Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions desktop/src/features/sidebar/lib/channelSortSync.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";

import { relayClient } from "@/shared/api/relayClient";
import { ChannelSortSyncManager } from "./channelSortSync.ts";

function makeStore(groups = {}) {
return { version: 1, groups };
}

// ─── destroy() must cancel pending publish, not flush ─────────────────────────

// Regression guard for the workspace-switch cross-relay publish vector:
// change a sort mode in relay A → destroy() is called (relayUrl dep change) →
// no publish should fire. The scoped localStorage write is durable; when the
// user returns to relay A the seed-publish path handles it.
test("destroy: cancels pending publish without flushing to the relay", () => {
const publishCalls = [];
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});

let timerCallback = null;
const fakeTimers = [];
let nextId = 1;
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const originalSetTimeout = globalThis.window.setTimeout;
const originalClearTimeout = globalThis.window.clearTimeout;
globalThis.window.setTimeout = (fn, _ms) => {
const id = nextId++;
fakeTimers.push({ id, fn });
timerCallback = fn;
return id;
};
globalThis.window.clearTimeout = (id) => {
const idx = fakeTimers.findIndex((t) => t.id === id);
if (idx !== -1) {
fakeTimers.splice(idx, 1);
timerCallback = null;
}
};

try {
const manager = new ChannelSortSyncManager("pk-test");
const store = makeStore({ channels: "recent" });

manager.publishSortPrefs(store);
assert.ok(timerCallback !== null, "debounce timer should be set");

manager.destroy();

assert.ok(
timerCallback === null,
"debounce timer should be cleared on destroy",
);
assert.equal(
publishCalls.length,
0,
"no publish event should have been sent after destroy",
);
} finally {
if (originalSetTimeout !== undefined) {
globalThis.window.setTimeout = originalSetTimeout;
}
if (originalClearTimeout !== undefined) {
globalThis.window.clearTimeout = originalClearTimeout;
}
mock.reset();
}
});

// Regression guard for the timer-fired race: debounce fires → doPublish starts
// awaiting fetchOwnBlobBeforePublish → destroy() is called (relayUrl dep
// change) → publishEvent must never be called even though the timer already
// fired and cleared itself before destroy() ran.
test("destroy: aborts in-flight doPublish after fetchOwnBlobBeforePublish resolves", async () => {
let releaseFetch = null;
const publishCalls = [];

mock.method(relayClient, "fetchEvents", () => {
return new Promise((resolve) => {
releaseFetch = () => resolve([]);
});
});
mock.method(relayClient, "publishEvent", (...args) => {
publishCalls.push(args);
return Promise.resolve();
});

if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
let capturedCallback = null;
let nextId = 1;
const origSetTimeout = globalThis.window.setTimeout;
const origClearTimeout = globalThis.window.clearTimeout;
globalThis.window.setTimeout = (fn, _ms) => {
capturedCallback = fn;
return nextId++;
};
globalThis.window.clearTimeout = (_id) => {
capturedCallback = null;
};

try {
const manager = new ChannelSortSyncManager("pk-race");
const store = makeStore({ dms: "recent" });

manager.publishSortPrefs(store);
assert.ok(capturedCallback !== null, "debounce timer should be set");

const timerFn = capturedCallback;
capturedCallback = null; // timer cleared itself inside the callback
timerFn();

manager.destroy();

releaseFetch();

await new Promise((resolve) => setTimeout(resolve, 0));

assert.equal(
publishCalls.length,
0,
"publishEvent must not be called after destroy() even when timer already fired",
);
} finally {
globalThis.window.setTimeout = origSetTimeout;
globalThis.window.clearTimeout = origClearTimeout;
mock.reset();
}
});

test("destroy: is safe to call with no pending publish", () => {
const manager = new ChannelSortSyncManager("pk-no-pending");
assert.doesNotThrow(() => manager.destroy());
});

test("destroy: cancelPendingPublish clears pendingStore", () => {
let timerCallback = null;
let nextId = 1;
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
const orig = globalThis.window.setTimeout;
const origClear = globalThis.window.clearTimeout;
globalThis.window.setTimeout = (fn, _ms) => {
timerCallback = fn;
return nextId++;
};
globalThis.window.clearTimeout = (_id) => {
timerCallback = null;
};

try {
const manager = new ChannelSortSyncManager("pk-pending-null");
const store = makeStore({ starred: "recent" });
manager.publishSortPrefs(store);
assert.deepEqual(manager.getPendingStore(), store);

manager.destroy();
assert.equal(
manager.getPendingStore(),
null,
"pendingStore must be null after destroy",
);
assert.ok(timerCallback === null, "timer must be cleared after destroy");
} finally {
globalThis.window.setTimeout = orig;
globalThis.window.clearTimeout = origClear;
}
});
Loading
Loading