Files
OwnCord/Client/tauri-client/tests/unit/message-controller.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

287 lines
9.2 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const {
mockSetMessages,
mockPrependMessages,
mockIsChannelLoaded,
mockGetChannelMessages,
mockSetChannelLoading,
mockSetChannelLoadError,
} = vi.hoisted(() => ({
mockSetMessages: vi.fn(),
mockPrependMessages: vi.fn(),
mockIsChannelLoaded: vi.fn((): boolean => false),
mockGetChannelMessages: vi.fn((): Array<{ id: number; content?: string }> => []),
mockSetChannelLoading: vi.fn(),
mockSetChannelLoadError: vi.fn(),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@stores/messages.store", () => ({
setMessages: mockSetMessages,
prependMessages: mockPrependMessages,
isChannelLoaded: mockIsChannelLoaded,
getChannelMessages: mockGetChannelMessages,
setChannelLoading: mockSetChannelLoading,
setChannelLoadError: mockSetChannelLoadError,
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import {
createMessageController,
createPendingDeleteManager,
} from "../../src/pages/main-page/MessageController";
import type { MessageControllerOptions } from "../../src/pages/main-page/MessageController";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeApi(overrides: Partial<MessageControllerOptions["api"]> = {}) {
return {
getMessages: vi.fn().mockResolvedValue({
messages: [{ id: 1, content: "hi" }],
has_more: false,
}),
...overrides,
} as unknown as MessageControllerOptions["api"];
}
function makeAbort(): { signal: AbortSignal; abort: () => void } {
const ctrl = new AbortController();
return { signal: ctrl.signal, abort: () => ctrl.abort() };
}
// ---------------------------------------------------------------------------
// MessageController
// ---------------------------------------------------------------------------
describe("createMessageController", () => {
let showError: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
showError = vi.fn();
mockIsChannelLoaded.mockReturnValue(false);
mockGetChannelMessages.mockReturnValue([]);
});
describe("loadMessages", () => {
it("loads messages and stores them", async () => {
const api = makeApi();
const ctrl = createMessageController({ api, showError });
const { signal } = makeAbort();
await ctrl.loadMessages(42, signal);
expect(api.getMessages).toHaveBeenCalledWith(42, { limit: 50 }, signal);
expect(mockSetMessages).toHaveBeenCalledWith(42, [{ id: 1, content: "hi" }], false);
});
it("marks the channel loading before the fetch resolves", async () => {
const api = makeApi();
const ctrl = createMessageController({ api, showError });
const pending = ctrl.loadMessages(42, makeAbort().signal);
// Synchronous prefix: the loading placeholder is visible from the
// first render, before the first await.
expect(mockSetChannelLoading).toHaveBeenCalledWith(42);
await pending;
});
it("skips fetch when channel is already loaded", async () => {
mockIsChannelLoaded.mockReturnValue(true);
const api = makeApi();
const ctrl = createMessageController({ api, showError });
await ctrl.loadMessages(42, makeAbort().signal);
expect(api.getMessages).not.toHaveBeenCalled();
expect(mockSetMessages).not.toHaveBeenCalled();
expect(mockSetChannelLoading).not.toHaveBeenCalled();
});
it("does not store messages after abort", async () => {
const { signal, abort } = makeAbort();
const api = makeApi({
getMessages: vi.fn().mockImplementation(async () => {
abort();
return { messages: [{ id: 1 }], has_more: false };
}),
});
const ctrl = createMessageController({ api, showError });
await ctrl.loadMessages(42, signal);
expect(mockSetMessages).not.toHaveBeenCalled();
});
it("marks the channel load-errored on fetch failure (inline error, not a toast)", async () => {
const api = makeApi({
getMessages: vi.fn().mockRejectedValue(new Error("network error")),
});
const ctrl = createMessageController({ api, showError });
await ctrl.loadMessages(42, makeAbort().signal);
// The region renders an inline error + Retry (UX spec §2); no toast.
expect(mockSetChannelLoadError).toHaveBeenCalledWith(42);
expect(showError).not.toHaveBeenCalled();
});
it("does not mark an error when aborted before failure", async () => {
const { signal, abort } = makeAbort();
abort();
const api = makeApi({
getMessages: vi.fn().mockRejectedValue(new Error("aborted")),
});
const ctrl = createMessageController({ api, showError });
await ctrl.loadMessages(42, signal);
expect(mockSetChannelLoadError).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
});
});
describe("loadOlderMessages", () => {
it("prepends older messages using oldest id", async () => {
mockGetChannelMessages.mockReturnValue([
{ id: 10, content: "oldest" },
{ id: 20, content: "newest" },
]);
const api = makeApi({
getMessages: vi.fn().mockResolvedValue({
messages: [{ id: 5, content: "older" }],
has_more: true,
}),
});
const ctrl = createMessageController({ api, showError });
const { signal } = makeAbort();
await ctrl.loadOlderMessages(42, signal);
expect(api.getMessages).toHaveBeenCalledWith(42, { before: 10, limit: 50 }, signal);
expect(mockPrependMessages).toHaveBeenCalledWith(42, [{ id: 5, content: "older" }], true);
});
it("does nothing when channel has no messages", async () => {
mockGetChannelMessages.mockReturnValue([]);
const api = makeApi();
const ctrl = createMessageController({ api, showError });
await ctrl.loadOlderMessages(42, makeAbort().signal);
expect(api.getMessages).not.toHaveBeenCalled();
});
it("shows error on fetch failure", async () => {
mockGetChannelMessages.mockReturnValue([{ id: 1 }]);
const api = makeApi({
getMessages: vi.fn().mockRejectedValue(new Error("fail")),
});
const ctrl = createMessageController({ api, showError });
await ctrl.loadOlderMessages(42, makeAbort().signal);
expect(showError).toHaveBeenCalledWith("Failed to load older messages");
});
it("does not show error when aborted before failure", async () => {
mockGetChannelMessages.mockReturnValue([{ id: 1 }]);
const { signal, abort } = makeAbort();
abort();
const api = makeApi({
getMessages: vi.fn().mockRejectedValue(new Error("aborted")),
});
const ctrl = createMessageController({ api, showError });
await ctrl.loadOlderMessages(42, signal);
expect(showError).not.toHaveBeenCalled();
});
it("does not prepend after abort", async () => {
mockGetChannelMessages.mockReturnValue([{ id: 1 }]);
const { signal, abort } = makeAbort();
const api = makeApi({
getMessages: vi.fn().mockImplementation(async () => {
abort();
return { messages: [{ id: 0 }], has_more: false };
}),
});
const ctrl = createMessageController({ api, showError });
await ctrl.loadOlderMessages(42, signal);
expect(mockPrependMessages).not.toHaveBeenCalled();
});
});
});
// ---------------------------------------------------------------------------
// PendingDeleteManager
// ---------------------------------------------------------------------------
describe("createPendingDeleteManager", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("returns 'pending' on first click", () => {
const mgr = createPendingDeleteManager();
expect(mgr.tryDelete(1)).toBe("pending");
});
it("returns 'confirmed' on second click within timeout", () => {
const mgr = createPendingDeleteManager();
mgr.tryDelete(1);
expect(mgr.tryDelete(1)).toBe("confirmed");
});
it("returns 'pending' again after timeout expires", () => {
const mgr = createPendingDeleteManager();
mgr.tryDelete(1);
vi.advanceTimersByTime(5001); // just past the 5000ms pending timeout
expect(mgr.tryDelete(1)).toBe("pending");
});
it("tracks multiple messages independently", () => {
const mgr = createPendingDeleteManager();
mgr.tryDelete(1);
mgr.tryDelete(2);
expect(mgr.tryDelete(1)).toBe("confirmed");
expect(mgr.tryDelete(2)).toBe("confirmed");
});
it("cleanup clears all pending timeouts", () => {
const mgr = createPendingDeleteManager();
mgr.tryDelete(1);
mgr.tryDelete(2);
mgr.cleanup();
// After cleanup, both should be fresh "pending" again
expect(mgr.tryDelete(1)).toBe("pending");
expect(mgr.tryDelete(2)).toBe("pending");
});
afterEach(() => {
vi.useRealTimers();
});
});