mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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).
544 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|