Files
OwnCord/Client/tests/unit/sidebar-dm-helpers.test.ts
T
J3vb 9c9b8be669 feat(b2-2): protocol epoch and negotiation (slim) (#1438)
* feat(b2-2): declare protocol_epoch in the schema and generate both constants

protocol/schema.json gains protocol_epoch (1). genprotocol emits
ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go
constant to the schema so a stale regeneration fails the required check.

* feat(b2-2): check the client's protocol epoch in the auth handshake

The auth payload gains epoch (absent = 0). Outside [minClientEpoch,
ProtocolEpoch] the server answers one auth_error with code
protocol_epoch_unsupported, the client/server/min epochs, and a message
naming which side to update, then closes 1008 like every other handshake
failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep
connecting; the epoch-1 fixtures are unchanged.

* feat(b2-2): send the protocol epoch and offer the update on a refused connect

ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended
on purpose). On auth_error code protocol_epoch_unsupported with a newer
server the dispatcher records the host in ui.store.updateRequiredHost and
main.ts mounts the UpdateNotifier on the connect page, so a refused client
gets the same Update Now banner it would have had on the main page.

* feat(b2-2): withhold client releases newer than the server's protocol epoch

The signed server-update manifest gains protocol_epoch (release.yml reads it
from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the
manifest and reads it; the client-update endpoint answers 204 when the
release's epoch is newer than ws.ProtocolEpoch or the manifest does not
verify. Releases without a manifest are epoch 0 and advertised as before.
Docs: protocol.md Compatibility section, api.md, deployment.md, protocol
README, CHANGELOG Unreleased.

* docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it

* ci: prove the protocol_epoch manifest read on every PR, not only at tag time

* fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal

Codex P1: on a first login or startup auto-login no overlay exists before
auth_ok, so a refusal never re-rendered the connect page and the one-time
read of updateRequiredHost missed it. The connect page now subscribes to
it, and a later refusal replaces the banner.

Codex P2: a refusal on reconnect went through the generic logout and
deleted the stored credential although the token is still valid.
clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it
(the skip-auto-login flag is still set and, being sessionStorage, does not
survive the relaunch the update triggers).
2026-08-29 07:23:06 +02:00

544 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import {
selectDmConversation,
addDmToChannelsStore,
handleCreateDm,
buildDmConversations,
type DmHelperDeps,
} from "../../src/pages/main-page/SidebarDmHelpers";
import { channelsStore, setActiveChannel } from "../../src/stores/channels.store";
import { dmStore, addDmChannel } from "../../src/stores/dm.store";
import { membersStore } from "../../src/stores/members.store";
import { uiStore } from "../../src/stores/ui.store";
import type { DmChannel } from "../../src/stores/dm.store";
// ---------------------------------------------------------------------------
// Store reset
// ---------------------------------------------------------------------------
function resetStores(): void {
channelsStore.setState(() => ({
channels: new Map(),
activeChannelId: null,
roles: [],
}));
dmStore.setState(() => ({ channels: [] }));
membersStore.setState(() => ({
members: new Map(),
typingUsers: new Map(),
}));
uiStore.setState(() => ({
sidebarCollapsed: false,
memberListVisible: true,
settingsOpen: false,
activeModal: null,
theme: "dark" as const,
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
}));
}
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeDmChannel(overrides: Partial<DmChannel> = {}): DmChannel {
const base: DmChannel = {
channelId: 100,
recipient: {
id: 10,
username: "Alice",
avatar: "",
status: "online",
},
participants: [],
name: "",
isGroup: false,
lastMessageId: null,
lastMessage: "",
lastMessageAt: "",
unreadCount: 0,
mentionCount: 0,
...overrides,
};
// A 1:1 DM's participant list IS its recipient, so a fixture that overrides
// only `recipient` should not silently keep the default's participants.
return base.participants.length > 0 ? base : { ...base, participants: [base.recipient] };
}
function makeDeps(overrides: Partial<DmHelperDeps> = {}): DmHelperDeps {
return {
api: {
createDm: vi.fn().mockResolvedValue({
channel_id: 200,
recipient: { id: 20, username: "Bob", avatar: "", status: "online" },
}),
} as unknown as DmHelperDeps["api"],
getToast: vi.fn().mockReturnValue({ show: vi.fn() }),
getChannelBeforeDm: vi.fn().mockReturnValue(null),
setChannelBeforeDm: vi.fn(),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("SidebarDmHelpers", () => {
beforeEach(() => {
resetStores();
});
// -------------------------------------------------------------------------
// addDmToChannelsStore
// -------------------------------------------------------------------------
describe("addDmToChannelsStore", () => {
it("adds a DM channel to channelsStore when it does not exist", () => {
const dm = makeDmChannel({ channelId: 100, unreadCount: 3 });
addDmToChannelsStore(dm);
const ch = channelsStore.getState().channels.get(100);
expect(ch).toBeDefined();
expect(ch!.id).toBe(100);
expect(ch!.name).toBe("Alice");
expect(ch!.type).toBe("dm");
expect(ch!.category).toBeNull();
expect(ch!.position).toBe(0);
expect(ch!.unreadCount).toBe(3);
});
it("does not rewrite an existing channel whose name already matches", () => {
// Pre-populate with a channel that already carries the DM's display name
channelsStore.setState((prev) => {
const next = new Map(prev.channels);
next.set(100, {
id: 100,
name: "Alice",
type: "dm",
category: null,
position: 0,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels: next };
});
const dm = makeDmChannel({ channelId: 100 });
addDmToChannelsStore(dm);
// Name should remain unchanged
const ch = channelsStore.getState().channels.get(100);
expect(ch!.name).toBe("Alice");
});
it("overwrites an existing channel with an empty name", () => {
// Pre-populate with a channel that has an empty name (server sends DMs with name='')
channelsStore.setState((prev) => {
const next = new Map(prev.channels);
next.set(100, {
id: 100,
name: "",
type: "dm",
category: null,
position: 0,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels: next };
});
const dm = makeDmChannel({ channelId: 100 });
addDmToChannelsStore(dm);
// Name should be updated to recipient username
const ch = channelsStore.getState().channels.get(100);
expect(ch!.name).toBe("Alice");
});
});
// -------------------------------------------------------------------------
// selectDmConversation
// -------------------------------------------------------------------------
describe("selectDmConversation", () => {
it("saves current non-DM channel before switching", () => {
// Set a text channel as active
channelsStore.setState((prev) => {
const next = new Map(prev.channels);
next.set(1, {
id: 1,
name: "general",
type: "text",
category: null,
position: 0,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels: next, activeChannelId: 1 };
});
const deps = makeDeps();
const dm = makeDmChannel();
selectDmConversation(dm, deps);
expect(deps.setChannelBeforeDm).toHaveBeenCalledWith(1);
});
it("does not save channel if current channel is a DM", () => {
// Set a DM channel as active
channelsStore.setState((prev) => {
const next = new Map(prev.channels);
next.set(50, {
id: 50,
name: "OtherDm",
type: "dm",
category: null,
position: 0,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels: next, activeChannelId: 50 };
});
const deps = makeDeps();
const dm = makeDmChannel();
selectDmConversation(dm, deps);
expect(deps.setChannelBeforeDm).not.toHaveBeenCalled();
});
it("does not save channel when no active channel", () => {
const deps = makeDeps();
const dm = makeDmChannel();
selectDmConversation(dm, deps);
expect(deps.setChannelBeforeDm).not.toHaveBeenCalled();
});
it("sets activeDmUserId in UI store", () => {
const deps = makeDeps();
const dm = makeDmChannel({
recipient: { id: 10, username: "Alice", avatar: "", status: "online" },
});
selectDmConversation(dm, deps);
expect(uiStore.getState().activeDmUserId).toBe(10);
});
it("switches sidebar mode to dms", () => {
const deps = makeDeps();
const dm = makeDmChannel();
selectDmConversation(dm, deps);
expect(uiStore.getState().sidebarMode).toBe("dms");
});
it("clears DM unread count", () => {
// Add a DM channel with unreads
addDmChannel(makeDmChannel({ channelId: 100, unreadCount: 5 }));
const deps = makeDeps();
selectDmConversation(makeDmChannel({ channelId: 100, unreadCount: 5 }), deps);
const dmChannels = dmStore.getState().channels;
const dm = dmChannels.find((c) => c.channelId === 100);
expect(dm!.unreadCount).toBe(0);
});
it("adds DM channel to channelsStore and sets it as active", () => {
const deps = makeDeps();
const dm = makeDmChannel({ channelId: 100 });
selectDmConversation(dm, deps);
const ch = channelsStore.getState().channels.get(100);
expect(ch).toBeDefined();
expect(channelsStore.getState().activeChannelId).toBe(100);
});
});
// -------------------------------------------------------------------------
// handleCreateDm
// -------------------------------------------------------------------------
describe("handleCreateDm", () => {
it("creates a DM via API and switches to it", async () => {
const deps = makeDeps();
await handleCreateDm(20, deps);
expect(deps.api.createDm).toHaveBeenCalledWith(20);
// Should have added to DM store
const dmChannels = dmStore.getState().channels;
expect(dmChannels.length).toBe(1);
expect(dmChannels[0]!.channelId).toBe(200);
expect(dmChannels[0]!.recipient.username).toBe("Bob");
// Should have switched sidebar mode to dms
expect(uiStore.getState().sidebarMode).toBe("dms");
});
it("uses member store status as fallback when API returns no status", async () => {
// Add a member with a known status
membersStore.setState((prev) => ({
...prev,
members: new Map([
[20, { id: 20, username: "Bob", avatar: null, role: "member", status: "idle" as const }],
]),
}));
const mockApi = {
createDm: vi.fn().mockResolvedValue({
channel_id: 200,
recipient: { id: 20, username: "Bob", avatar: "", status: undefined },
}),
};
const deps = makeDeps({ api: mockApi as unknown as DmHelperDeps["api"] });
await handleCreateDm(20, deps);
const dmChannels = dmStore.getState().channels;
expect(dmChannels[0]!.recipient.status).toBe("idle");
});
it("falls back to 'offline' when neither API nor member store has status", async () => {
const mockApi = {
createDm: vi.fn().mockResolvedValue({
channel_id: 200,
recipient: { id: 999, username: "Unknown", avatar: "", status: undefined },
}),
};
const deps = makeDeps({ api: mockApi as unknown as DmHelperDeps["api"] });
await handleCreateDm(999, deps);
const dmChannels = dmStore.getState().channels;
expect(dmChannels[0]!.recipient.status).toBe("offline");
});
it("shows error toast on API failure", async () => {
const mockShow = vi.fn();
const mockApi = {
createDm: vi.fn().mockRejectedValue(new Error("Network error")),
};
const deps = makeDeps({
api: mockApi as unknown as DmHelperDeps["api"],
getToast: vi.fn().mockReturnValue({ show: mockShow }),
});
await handleCreateDm(20, deps);
expect(mockShow).toHaveBeenCalledWith("Network error", "error");
});
it("shows generic error toast for non-Error exceptions", async () => {
const mockShow = vi.fn();
const mockApi = {
createDm: vi.fn().mockRejectedValue("string error"),
};
const deps = makeDeps({
api: mockApi as unknown as DmHelperDeps["api"],
getToast: vi.fn().mockReturnValue({ show: mockShow }),
});
await handleCreateDm(20, deps);
expect(mockShow).toHaveBeenCalledWith("Failed to create DM", "error");
});
it("handles null toast gracefully on error", async () => {
const mockApi = {
createDm: vi.fn().mockRejectedValue(new Error("fail")),
};
const deps = makeDeps({
api: mockApi as unknown as DmHelperDeps["api"],
getToast: vi.fn().mockReturnValue(null),
});
// Should not throw
await handleCreateDm(20, deps);
});
});
// -------------------------------------------------------------------------
// buildDmConversations
// -------------------------------------------------------------------------
describe("buildDmConversations", () => {
it("returns empty array when no DM channels exist", () => {
const result = buildDmConversations(null);
expect(result).toEqual([]);
});
it("maps DM channels to DmConversation objects", () => {
addDmChannel(
makeDmChannel({
channelId: 100,
recipient: { id: 10, username: "Alice", avatar: "alice.png", status: "online" },
lastMessage: "Hello!",
lastMessageAt: "2025-01-01T00:00:00Z",
unreadCount: 3,
mentionCount: 1,
}),
);
const result = buildDmConversations(null);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
channelId: 100,
userId: 10,
username: "Alice",
avatar: "alice.png",
status: "online",
isGroup: false,
participants: [{ id: 10, username: "Alice", avatar: "alice.png" }],
lastMessage: "Hello!",
timestamp: "2025-01-01T00:00:00Z",
unread: true,
// The real counts ride along so the sidebar can render badges rather
// than a bare dot, and so DM mentions survive a reconnect.
unreadCount: 3,
mentionCount: 1,
muted: false,
active: false,
});
});
// Active is keyed on the CHANNEL, not the recipient: a group DM has no
// single recipient, and the same person can be in both a 1:1 and a group.
it("marks conversation as active when the channel is the active one", () => {
addDmChannel(
makeDmChannel({
channelId: 100,
recipient: { id: 10, username: "Alice", avatar: "", status: "online" },
}),
);
const result = buildDmConversations(100);
expect(result[0]!.active).toBe(true);
});
it("does not mark conversation as active when the channel does not match", () => {
addDmChannel(
makeDmChannel({
channelId: 100,
recipient: { id: 10, username: "Alice", avatar: "", status: "online" },
}),
);
const result = buildDmConversations(999);
expect(result[0]!.active).toBe(false);
});
it("uses 'No messages yet' when lastMessage is empty", () => {
addDmChannel(
makeDmChannel({
channelId: 100,
lastMessage: "",
}),
);
const result = buildDmConversations(null);
expect(result[0]!.lastMessage).toBe("No messages yet");
});
it("sets unread to false when unreadCount is 0", () => {
addDmChannel(
makeDmChannel({
channelId: 100,
unreadCount: 0,
}),
);
const result = buildDmConversations(null);
expect(result[0]!.unread).toBe(false);
});
it("uses avatar null when avatar is empty string", () => {
addDmChannel(
makeDmChannel({
channelId: 100,
recipient: { id: 10, username: "Alice", avatar: "", status: "online" },
}),
);
const result = buildDmConversations(null);
expect(result[0]!.avatar).toBeNull();
});
it("defaults status to 'offline' when status is undefined", () => {
addDmChannel(
makeDmChannel({
channelId: 100,
recipient: {
id: 10,
username: "Alice",
avatar: "",
status: undefined as unknown as string,
},
}),
);
const result = buildDmConversations(null);
expect(result[0]!.status).toBe("offline");
});
it("handles multiple DM channels", () => {
addDmChannel(
makeDmChannel({
channelId: 100,
recipient: { id: 10, username: "Alice", avatar: "", status: "online" },
}),
);
addDmChannel(
makeDmChannel({
channelId: 101,
recipient: { id: 11, username: "Bob", avatar: "", status: "idle" },
}),
);
const result = buildDmConversations(101);
expect(result).toHaveLength(2);
// Bob was added second so goes first (addDmChannel prepends)
const bob = result.find((c) => c.username === "Bob");
const alice = result.find((c) => c.username === "Alice");
expect(bob!.active).toBe(true);
expect(alice!.active).toBe(false);
});
});
});