Chapter 10 of 13

Test Chat, Streaming, and a Login Bug That Wasn't About Login

By this point the gateway had been tested with curl and a temporary mock Azure server, but never from inside the actual dashboard, with a real virtual key, against real Azure infrastructure. The test chat page exists to close that gap: a small panel where I paste a virtual key, pick from the models it’s allowed to use, and chat, streaming, exactly like any consumer of the gateway would.

A thin client for the gateway’s own API

GatewayData in @gatify/data-access talks to /v1, not /api, since it’s exercising the same OpenAI-compatible surface any external app would use, not the admin dashboard API:

async streamChat(
  apiKey: string,
  model: string,
  messages: ChatMessage[],
  onToken: (token: string) => void,
  signal?: AbortSignal,
): Promise<void> {
  const response = await fetch(`${this.baseUrl}/chat/completions`, {
    method: 'POST',
    signal,
    headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ model, messages, stream: true }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() ?? '';

    for (const line of lines) {
      if (!line.startsWith('data: ') || line.slice(6).trim() === '[DONE]') continue;
      try {
        const chunk = JSON.parse(line.slice(6));
        const token = chunk.choices?.[0]?.delta?.content;
        if (token) onToken(token);
      } catch {
        continue;
      }
    }
  }
}

Plain fetch, not HttpClient, on purpose. Streaming a ReadableStream and reading it incrementally is awkward to express through Angular’s HTTP client, and this is a case where reaching for the platform API directly was simpler than working around a library’s abstraction.

TestChatPage appends a placeholder assistant message with status: 'streaming', then updates its content incrementally as tokens arrive, flipping to status: 'done' once the stream ends or status: 'error' if it fails. There’s an AbortController wired to a stop button, and aborting is treated as a normal completion, not an error, since the user asked for that.

Testing this against real Azure AI Foundry, not a mock, for the first time, and watching tokens actually stream into the chat panel from a virtual key I’d generated through my own dashboard, was the moment this stopped feeling like a backend exercise and started feeling like a real product, even a small one only I use.

Then login broke, for no reason I could find

Right after this, logging in started failing every time, “Invalid email or password,” even though the network tab showed a clean 200 from /api/auth/login. My first assumption was obviously wrong: I assumed it was auth. It wasn’t.

The actual bug was in how the login page’s own error handling worked:

try {
  await this.authService.login(this.email(), this.password());
  await this.router.navigateByUrl('/dashboard');
} catch {
  this.errorMessage.set('Invalid email or password.');
} finally {
  this.isSubmitting.set(false);
}

navigateByUrl was inside the same try block as login. If navigation threw for any reason, that catch block would mislabel it as bad credentials, even though the actual login call had already succeeded. That’s a real design smell I should have caught in review, two unrelated failure modes sharing one error message. But it still didn’t explain why navigation was throwing in the first place. The login call itself, confirmed via the network tab, was returning a real token.

I bisected it by calling the app’s own fetch directly from the browser console (page.evaluate style, but just manually in devtools) to prove the HTTP layer was completely fine independent of Angular. It was. So the failure had to be somewhere in rendering the dashboard after a successful login, not in auth at all.

Checking performance.getEntriesByType('resource') pointed at the actual broken chunk: @gatify_data-access.js. Fetching its raw source directly showed the real problem, invalid, unexecutable JavaScript syntax, specifically an experimental class decorator form that browsers can’t run natively:

var X = @Injectable(...) class {...}

Here’s what caused it. @gatify/contracts and @gatify/data-access were being resolved by the frontend purely through the npm workspace symlink in node_modules, with no explicit TypeScript path mapping. Vite’s dependency optimizer treats anything resolved that way as an external npm package and pre-bundles it with esbuild ahead of time, completely separately from the rest of the Angular compilation pipeline. Esbuild, working in isolation, emitted raw decorator syntax for the @Injectable() on UsageData instead of Angular’s compiler handling it properly as part of the same program. The dashboard chunk imports UsageData. The moment that chunk loaded post-login, the browser hit a hard syntax error and the whole navigation blew up, which the login page’s overly broad catch block then mislabeled as a credentials problem.

The fix is two lines in tsconfig.base.json:

"paths": {
  "@gatify/contracts": ["./libs/shared/contracts/src/index.ts"],
  "@gatify/data-access": ["./libs/frontend/data-access/src/index.ts"]
}

That makes Angular’s own builder compile these libraries as part of the same TypeScript program as the app source, with proper decorator handling, instead of letting Vite treat them as opaque external dependencies. I also had to clear the stale .angular/cache afterward, since Vite’s dependency cache doesn’t automatically know the resolution strategy changed underneath it. And I split the login page’s error handling so navigation failures and auth failures can never share a message again:

try {
  await this.authService.login(this.email(), this.password());
} catch {
  this.errorMessage.set('Invalid email or password.');
  this.isSubmitting.set(false);
  return;
}

this.isSubmitting.set(false);
await this.router.navigateByUrl('/dashboard');

This is probably the single bug in this whole project that took the longest to actually pin down, not because the fix was complicated, it’s two config lines, but because every symptom pointed somewhere other than the real cause. Worth remembering next time something “obviously” auth-related turns out to be a bundler decision three layers removed from anything I’d normally think to check first.

Next: a full visual overhaul, moving the entire frontend onto a component library I’ve been building on the side, and the Signal Forms edge cases that came with it.