Files
OwnCord/Client/tauri-client/tests/unit/user-bar.test.ts
T
Claude fc5a94cb50 feat(client): connection-status store + no-silent-failure batch
Implements the next four gaps from the client UX spec (docs/architecture/ux).

Connection status as single source of truth (spec §3):
- main.ts registers the one writer: ws.onStateChange → toConnectionStatus
  (new 5→3 state mapper exported from ws.ts) → ui.store.connectionStatus.
- Consumers now subscribe to the store instead of ad-hoc ws wirings: the
  MainPage reconnect banner (which also gains a "Disconnected" state via
  ServerBanner.showDisconnected instead of going stale), ChannelController
  composer gating + per-click send guard, and the UserBar presence picker.
- Fixes a latent production bug: SidebarArea never passed ws to UserBar, so
  the status picker was permanently disabled and its presence_update path
  dead. It now gates on the store and receives the ws send path.
- The one-shot connected-overlay wiring stays on ws.onStateChange by design
  (it needs the exact internal transition); LiveKit voice reconnection stays
  independent ("retrying underneath").

Transport backpressure surfaced (spec §5):
- ws.ts sendRaw no longer drops local send failures silently: send() passes
  the envelope id, and failures notify a new onSendFailure(id, code)
  listener — channel full → NETWORK, closed/not-open → OFFLINE (deferred a
  microtask on the not-open path so the optimistic row registers first).
- The dispatcher fails the matching pending row via markSendFailed, exactly
  like a server error reply; id-less sends (heartbeat) and fire-and-forget
  sends (typing, presence) stay silent by design. MessageList renders the
  new NETWORK reason ("Connection problem — message not sent").

uploadFile honors global 401 handling (spec §5):
- api.uploadFile now calls onUnauthorized and throws ApiClientError(401)
  like every other REST call; main.ts sets the "Your session expired — sign
  in again." transient error so the connect page shows the reason.

History fetch loading/error states (messaging.md §1):
- messages.store gains per-channel historyLoadState (loading/error, absent
  = idle) with setChannelLoading/setChannelLoadError; setMessages and
  clearChannelMessages clear it.
- MessageController.loadMessages sets loading synchronously before the
  fetch and marks error inline instead of a toast; MessageList renders an
  in-region spinner placeholder or an inline error + Retry (onRetryLoad
  re-invokes loadMessages via ChannelController).

Also fixes two pre-existing eslint errors in api.ts (redundant assertions).

Docs: the corresponding gap callouts in docs/architecture/ux are updated
(README §3/§5, messaging.md §1/§3/§6, channels-members-dms.md block-gating
note no longer claims the composer lacks a read-only mode).

Verified: tsc + full client unit suite (3225 tests) + oxlint/eslint +
prettier all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 19:08:19 +00:00

234 lines
6.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { authStore } from "@stores/auth.store";
import { openSettings } from "@stores/ui.store";
import { createUserBar } from "@components/UserBar";
vi.mock("@stores/ui.store", () => ({
openSettings: vi.fn(),
uiStore: {
getState: () => ({ connectionStatus: "connected" }),
subscribe: () => () => {},
subscribeSelector: () => () => {},
},
}));
function setAuthState(user: { username: string } | null, isAuthenticated: boolean): void {
authStore.setState(() => ({
token: isAuthenticated ? "tok" : null,
user: user !== null ? { id: 1, username: user.username, avatar: null, role: "member" } : null,
serverName: "TestServer",
motd: null,
isAuthenticated,
}));
}
describe("UserBar", () => {
let container: HTMLDivElement;
let comp: ReturnType<typeof createUserBar>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
vi.clearAllMocks();
});
afterEach(() => {
comp?.destroy?.();
container.remove();
// Reset auth store
authStore.setState(() => ({
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
});
it("mounts with user-bar class", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
expect(container.querySelector(".user-bar")).not.toBeNull();
});
it("shows username from authStore", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
const name = container.querySelector(".ub-name");
expect(name?.textContent).toBe("alice");
});
it("shows first letter as avatar", () => {
setAuthState({ username: "bob" }, true);
comp = createUserBar();
comp.mount(container);
const avatar = container.querySelector(".ub-avatar span");
expect(avatar?.textContent).toBe("B");
});
it('shows "Online" when authenticated', () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
const status = container.querySelector(".ub-status");
expect(status?.textContent).toBe("Online");
});
it('shows "Offline" when not authenticated', () => {
setAuthState(null, false);
comp = createUserBar();
comp.mount(container);
const status = container.querySelector(".ub-status");
expect(status?.textContent).toBe("Offline");
});
it("settings button calls openSettings", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
const settingsBtn = container.querySelector('[title="Settings"]') as HTMLButtonElement;
settingsBtn.click();
expect(openSettings).toHaveBeenCalledOnce();
});
it("does not render mute or deafen buttons", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
expect(container.querySelector('[title="Mute"]')).toBeNull();
expect(container.querySelector('[title="Deafen"]')).toBeNull();
});
it("destroy removes DOM and unsubscribes", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
expect(container.querySelector(".user-bar")).not.toBeNull();
comp.destroy?.();
expect(container.querySelector(".user-bar")).toBeNull();
});
it("renders disconnect button when onDisconnect is provided", () => {
setAuthState({ username: "alice" }, true);
const onDisconnect = vi.fn();
comp = createUserBar({ onDisconnect });
comp.mount(container);
const disconnectBtn = container.querySelector(
'[data-testid="disconnect-btn"]',
) as HTMLButtonElement;
expect(disconnectBtn).not.toBeNull();
expect(disconnectBtn.getAttribute("aria-label")).toBe("Switch server");
});
it("calls onDisconnect when disconnect button is clicked", () => {
setAuthState({ username: "alice" }, true);
const onDisconnect = vi.fn();
comp = createUserBar({ onDisconnect });
comp.mount(container);
const disconnectBtn = container.querySelector(
'[data-testid="disconnect-btn"]',
) as HTMLButtonElement;
disconnectBtn.click();
expect(onDisconnect).toHaveBeenCalledOnce();
});
it("does not render disconnect button when onDisconnect is not provided", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
const disconnectBtn = container.querySelector('[data-testid="disconnect-btn"]');
expect(disconnectBtn).toBeNull();
});
it("updates username reactively when auth store changes", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
expect(container.querySelector(".ub-name")?.textContent).toBe("alice");
// Update auth store with new username
authStore.setState((prev) => ({
...prev,
user: prev.user ? { ...prev.user, username: "bob" } : null,
}));
authStore.flush();
expect(container.querySelector(".ub-name")?.textContent).toBe("bob");
});
it('shows "Unknown" and "U" avatar when user is null', () => {
setAuthState(null, false);
comp = createUserBar();
comp.mount(container);
const name = container.querySelector(".ub-name");
expect(name?.textContent).toBe("Unknown");
// "Unknown".charAt(0).toUpperCase() = "U"
const avatarSpan = container.querySelector(".ub-avatar span");
expect(avatarSpan?.textContent).toBe("U");
});
it("has data-testid on root element", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
const root = container.querySelector('[data-testid="user-bar"]');
expect(root).not.toBeNull();
});
it("status changes from Online to Offline when logged out", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
expect(container.querySelector(".ub-status")?.textContent).toBe("Online");
// Simulate logout
authStore.setState(() => ({
token: null,
user: null,
serverName: null,
motd: null,
isAuthenticated: false,
}));
authStore.flush();
expect(container.querySelector(".ub-status")?.textContent).toBe("Offline");
});
it("avatar initial updates when username changes", () => {
setAuthState({ username: "alice" }, true);
comp = createUserBar();
comp.mount(container);
expect(container.querySelector(".ub-avatar span")?.textContent).toBe("A");
authStore.setState((prev) => ({
...prev,
user: prev.user ? { ...prev.user, username: "zara" } : null,
}));
authStore.flush();
expect(container.querySelector(".ub-avatar span")?.textContent).toBe("Z");
});
});