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).
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import {
|
|
uiStore,
|
|
toggleSidebar,
|
|
toggleMemberList,
|
|
openSettings,
|
|
closeSettings,
|
|
openModal,
|
|
closeModal,
|
|
setTheme,
|
|
setConnectionStatus,
|
|
setTransientError,
|
|
setPersistentError,
|
|
loadCollapsedCategories,
|
|
toggleCategory,
|
|
isCategoryCollapsed,
|
|
setSidebarMode,
|
|
setActiveDmUser,
|
|
} from "../../src/stores/ui.store";
|
|
|
|
function resetStore(): void {
|
|
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,
|
|
}));
|
|
}
|
|
|
|
describe("ui store", () => {
|
|
beforeEach(() => {
|
|
resetStore();
|
|
});
|
|
|
|
describe("initial state", () => {
|
|
it("has dark theme", () => {
|
|
expect(uiStore.getState().theme).toBe("dark");
|
|
});
|
|
|
|
it("has sidebar not collapsed", () => {
|
|
expect(uiStore.getState().sidebarCollapsed).toBe(false);
|
|
});
|
|
|
|
it("has member list visible", () => {
|
|
expect(uiStore.getState().memberListVisible).toBe(true);
|
|
});
|
|
|
|
it("has settings closed", () => {
|
|
expect(uiStore.getState().settingsOpen).toBe(false);
|
|
});
|
|
|
|
it("has no active modal", () => {
|
|
expect(uiStore.getState().activeModal).toBeNull();
|
|
});
|
|
|
|
it("has no collapsed categories", () => {
|
|
expect(uiStore.getState().collapsedCategories.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("toggleSidebar", () => {
|
|
it("collapses sidebar when expanded", () => {
|
|
toggleSidebar();
|
|
expect(uiStore.getState().sidebarCollapsed).toBe(true);
|
|
});
|
|
|
|
it("expands sidebar when collapsed", () => {
|
|
toggleSidebar();
|
|
toggleSidebar();
|
|
expect(uiStore.getState().sidebarCollapsed).toBe(false);
|
|
});
|
|
|
|
it("produces a new state object", () => {
|
|
const before = uiStore.getState();
|
|
toggleSidebar();
|
|
expect(uiStore.getState()).not.toBe(before);
|
|
});
|
|
});
|
|
|
|
describe("toggleMemberList", () => {
|
|
it("hides member list when visible", () => {
|
|
toggleMemberList();
|
|
expect(uiStore.getState().memberListVisible).toBe(false);
|
|
});
|
|
|
|
it("shows member list when hidden", () => {
|
|
toggleMemberList();
|
|
toggleMemberList();
|
|
expect(uiStore.getState().memberListVisible).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("openSettings / closeSettings", () => {
|
|
it("openSettings sets settingsOpen to true", () => {
|
|
openSettings();
|
|
expect(uiStore.getState().settingsOpen).toBe(true);
|
|
});
|
|
|
|
it("closeSettings sets settingsOpen to false", () => {
|
|
openSettings();
|
|
closeSettings();
|
|
expect(uiStore.getState().settingsOpen).toBe(false);
|
|
});
|
|
|
|
it("closeSettings is safe when already closed", () => {
|
|
closeSettings();
|
|
expect(uiStore.getState().settingsOpen).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("openModal / closeModal", () => {
|
|
it("openModal sets activeModal to given name", () => {
|
|
openModal("invite");
|
|
expect(uiStore.getState().activeModal).toBe("invite");
|
|
});
|
|
|
|
it("openModal overwrites existing modal", () => {
|
|
openModal("invite");
|
|
openModal("confirm-delete");
|
|
expect(uiStore.getState().activeModal).toBe("confirm-delete");
|
|
});
|
|
|
|
it("closeModal clears activeModal", () => {
|
|
openModal("invite");
|
|
closeModal();
|
|
expect(uiStore.getState().activeModal).toBeNull();
|
|
});
|
|
|
|
it("closeModal is safe when no modal is open", () => {
|
|
closeModal();
|
|
expect(uiStore.getState().activeModal).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("setTheme", () => {
|
|
it("sets theme to light", () => {
|
|
setTheme("light");
|
|
expect(uiStore.getState().theme).toBe("light");
|
|
});
|
|
|
|
it("sets theme back to dark", () => {
|
|
setTheme("light");
|
|
setTheme("dark");
|
|
expect(uiStore.getState().theme).toBe("dark");
|
|
});
|
|
});
|
|
|
|
describe("toggleCategory / isCategoryCollapsed", () => {
|
|
it("collapses a category that is expanded", () => {
|
|
toggleCategory("general");
|
|
expect(isCategoryCollapsed("general")).toBe(true);
|
|
});
|
|
|
|
it("expands a category that is collapsed", () => {
|
|
toggleCategory("general");
|
|
toggleCategory("general");
|
|
expect(isCategoryCollapsed("general")).toBe(false);
|
|
});
|
|
|
|
it("supports multiple independent categories", () => {
|
|
toggleCategory("general");
|
|
toggleCategory("voice");
|
|
expect(isCategoryCollapsed("general")).toBe(true);
|
|
expect(isCategoryCollapsed("voice")).toBe(true);
|
|
expect(isCategoryCollapsed("other")).toBe(false);
|
|
});
|
|
|
|
it("produces a new Set on each toggle", () => {
|
|
const before = uiStore.getState().collapsedCategories;
|
|
toggleCategory("general");
|
|
const after = uiStore.getState().collapsedCategories;
|
|
expect(before).not.toBe(after);
|
|
});
|
|
});
|
|
|
|
describe("subscribe", () => {
|
|
it("notifies on state changes", () => {
|
|
const listener = vi.fn();
|
|
const unsub = uiStore.subscribe(listener);
|
|
toggleSidebar();
|
|
uiStore.flush();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
unsub();
|
|
});
|
|
|
|
it("does not notify after unsubscribe", () => {
|
|
const listener = vi.fn();
|
|
const unsub = uiStore.subscribe(listener);
|
|
unsub();
|
|
toggleSidebar();
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("sidebar mode", () => {
|
|
beforeEach(() => {
|
|
uiStore.setState((prev) => ({
|
|
...prev,
|
|
sidebarMode: "channels",
|
|
activeDmUserId: null,
|
|
}));
|
|
});
|
|
|
|
it("defaults to channels mode", () => {
|
|
expect(uiStore.getState().sidebarMode).toBe("channels");
|
|
});
|
|
|
|
it("switches to DM mode with user ID", () => {
|
|
setSidebarMode("dms");
|
|
setActiveDmUser(42);
|
|
const state = uiStore.getState();
|
|
expect(state.sidebarMode).toBe("dms");
|
|
expect(state.activeDmUserId).toBe(42);
|
|
});
|
|
|
|
it("clears DM user when switching back to channels", () => {
|
|
setActiveDmUser(42);
|
|
setSidebarMode("channels");
|
|
expect(uiStore.getState().activeDmUserId).toBeNull();
|
|
});
|
|
|
|
it("preserves activeDmUserId when switching to dms mode", () => {
|
|
setActiveDmUser(42);
|
|
setSidebarMode("dms");
|
|
expect(uiStore.getState().activeDmUserId).toBe(42);
|
|
});
|
|
|
|
it("setActiveDmUser sets userId to null", () => {
|
|
setActiveDmUser(42);
|
|
setActiveDmUser(null);
|
|
expect(uiStore.getState().activeDmUserId).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── Connection status ─────────────────────────────────
|
|
|
|
describe("setConnectionStatus", () => {
|
|
it("sets status to connected", () => {
|
|
setConnectionStatus("connected");
|
|
expect(uiStore.getState().connectionStatus).toBe("connected");
|
|
});
|
|
|
|
it("sets status to reconnecting", () => {
|
|
setConnectionStatus("reconnecting");
|
|
expect(uiStore.getState().connectionStatus).toBe("reconnecting");
|
|
});
|
|
|
|
it("sets status to disconnected", () => {
|
|
setConnectionStatus("connected");
|
|
setConnectionStatus("disconnected");
|
|
expect(uiStore.getState().connectionStatus).toBe("disconnected");
|
|
});
|
|
});
|
|
|
|
// ── Error messages ────────────────────────────────────
|
|
|
|
describe("setTransientError", () => {
|
|
it("sets a transient error message", () => {
|
|
setTransientError("Connection lost");
|
|
expect(uiStore.getState().transientError).toBe("Connection lost");
|
|
});
|
|
|
|
it("clears transient error with null", () => {
|
|
setTransientError("Some error");
|
|
setTransientError(null);
|
|
expect(uiStore.getState().transientError).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("setPersistentError", () => {
|
|
it("sets a persistent error message", () => {
|
|
setPersistentError("Authentication failed");
|
|
expect(uiStore.getState().persistentError).toBe("Authentication failed");
|
|
});
|
|
|
|
it("clears persistent error with null", () => {
|
|
setPersistentError("Some error");
|
|
setPersistentError(null);
|
|
expect(uiStore.getState().persistentError).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── loadCollapsedCategories ───────────────────────────
|
|
|
|
describe("loadCollapsedCategories", () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
it("loads empty set when no saved data exists", () => {
|
|
loadCollapsedCategories("test-server:443");
|
|
expect(uiStore.getState().collapsedCategories.size).toBe(0);
|
|
});
|
|
|
|
it("loads categories from localStorage", () => {
|
|
localStorage.setItem(
|
|
"owncord:collapsed:test-server:443",
|
|
JSON.stringify(["general", "voice"]),
|
|
);
|
|
loadCollapsedCategories("test-server:443");
|
|
const cats = uiStore.getState().collapsedCategories;
|
|
expect(cats.has("general")).toBe(true);
|
|
expect(cats.has("voice")).toBe(true);
|
|
expect(cats.size).toBe(2);
|
|
});
|
|
|
|
it("resets to empty set for invalid JSON", () => {
|
|
localStorage.setItem("owncord:collapsed:test-server:443", "not-json");
|
|
loadCollapsedCategories("test-server:443");
|
|
expect(uiStore.getState().collapsedCategories.size).toBe(0);
|
|
});
|
|
|
|
it("resets to empty set for non-array data", () => {
|
|
localStorage.setItem("owncord:collapsed:test-server:443", JSON.stringify({ foo: "bar" }));
|
|
loadCollapsedCategories("test-server:443");
|
|
expect(uiStore.getState().collapsedCategories.size).toBe(0);
|
|
});
|
|
|
|
it("resets to empty set for array with non-string elements", () => {
|
|
localStorage.setItem("owncord:collapsed:test-server:443", JSON.stringify([1, 2, 3]));
|
|
loadCollapsedCategories("test-server:443");
|
|
expect(uiStore.getState().collapsedCategories.size).toBe(0);
|
|
});
|
|
|
|
it("persists collapsed categories to localStorage on toggle", () => {
|
|
loadCollapsedCategories("test-server:443");
|
|
toggleCategory("voice");
|
|
const raw = localStorage.getItem("owncord:collapsed:test-server:443");
|
|
expect(raw).not.toBeNull();
|
|
const parsed = JSON.parse(raw!);
|
|
expect(parsed).toContain("voice");
|
|
});
|
|
|
|
it("removes category from localStorage when toggled back", () => {
|
|
loadCollapsedCategories("test-server:443");
|
|
toggleCategory("voice");
|
|
toggleCategory("voice");
|
|
const raw = localStorage.getItem("owncord:collapsed:test-server:443");
|
|
expect(raw).not.toBeNull();
|
|
const parsed = JSON.parse(raw!);
|
|
expect(parsed).not.toContain("voice");
|
|
});
|
|
|
|
it("handles localStorage.setItem failure gracefully", () => {
|
|
loadCollapsedCategories("test-server:443");
|
|
|
|
// Make localStorage.setItem throw (simulating quota exceeded)
|
|
const originalSetItem = localStorage.setItem.bind(localStorage);
|
|
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
|
throw new Error("QuotaExceededError");
|
|
});
|
|
|
|
// toggleCategory calls saveCollapsedCategories which should catch the error
|
|
expect(() => toggleCategory("fail-safe")).not.toThrow();
|
|
|
|
// State should still be updated even though persistence failed
|
|
expect(isCategoryCollapsed("fail-safe")).toBe(true);
|
|
|
|
// Restore
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("does not save when no server host is loaded", () => {
|
|
// Reset the internal currentServerHost by not calling loadCollapsedCategories
|
|
// Instead, directly test that toggleCategory works without crashing
|
|
// when called before loadCollapsedCategories
|
|
resetStore();
|
|
// The module-level currentServerHost is set by loadCollapsedCategories.
|
|
// If we loaded for a server and then toggle, it should save.
|
|
loadCollapsedCategories("another-server:443");
|
|
toggleCategory("test-cat");
|
|
const raw = localStorage.getItem("owncord:collapsed:another-server:443");
|
|
expect(raw).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── setTheme additional values ────────────────────────
|
|
|
|
describe("setTheme — all variants", () => {
|
|
it("sets theme to neon-glow", () => {
|
|
setTheme("neon-glow");
|
|
expect(uiStore.getState().theme).toBe("neon-glow");
|
|
});
|
|
|
|
it("sets theme to midnight", () => {
|
|
setTheme("midnight");
|
|
expect(uiStore.getState().theme).toBe("midnight");
|
|
});
|
|
});
|
|
});
|