Files
buzz/desktop/src/shared/api/relayClosedRecovery.test.mjs
Taylor Ho 83ca595ada fix(desktop): preserve theme when opening communities (#5266)
**Category:** fix
**User Impact:** Previously selected community themes are preserved when
opening a relay through onboarding, while first-time theme migration
still completes for communities with no saved theme.

**Problem:** During community initialization, desktop queried theme
history before establishing live delivery. If a replacement event
arrived while an empty history query was in flight, the client could
incorrectly treat the theme as absent and publish the default over the
user's saved selection.

**Solution:** Subscribe before fetching history, expose whether live
readiness reached EOSE, flush buffered live events before resolving
EOSE, and retain the newest delivered replacement through hydration.
Seed the inherited/default theme only when both live and history
snapshots reach EOSE with no valid or unreadable event; subscription
failures, CLOSED, readiness timeout, relay failure, and unreadable
events fail closed without publishing.

<details>
<summary>File changes</summary>

**desktop/src/shared/api/relayClientSession.ts / relayClientShared.ts /
relayClosedRecovery.ts**
Distinguish EOSE from CLOSED/timeout readiness and flush buffered events
before resolving an EOSE fence.

**desktop/src/shared/theme/CommunityThemeController.tsx**
Seed and complete first-community migration only for confirmed absence;
uncertain hydration remains non-publishing.

**desktop/src/shared/theme/communityThemePreference.ts**
Keep the inherited appearance for the first migrated community and the
stable default for later empty communities.

**desktop/src/shared/theme/communityThemeSync.ts**
Arbitrate live and history results into valid, confirmed-absent,
invalid, or unavailable hydration outcomes.

**Tests**
Cover EOSE/CLOSED readiness, subscription failure, timeout, unreadable
and live-racing events, no-op initialization, and first-to-later
community fallback isolation.

</details>

## Reproduction steps

1. Save a non-default appearance for a community relay.
2. Remove the community locally, then open the same relay again through
onboarding.
3. Arrange for the saved replacement event to arrive live while the
initial history query returns empty.
4. Confirm the saved appearance remains selected and the client does not
publish the default theme over it.
5. On an account with no theme records, open a first empty community and
confirm its inherited appearance is migrated; open a later empty
community and confirm it starts from the stable default.

## Validation

- Pre-push desktop checks, typecheck, and full desktop tests: passed at
`f79556b0e`
- Focused theme/relay readiness tests: 38 passed
- Desktop file-size ratchet and diff check: passed

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-11 08:21:34 -07:00

495 lines
12 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
handleRelayClosed,
handleSubscriptionEose,
} from "./relayClosedRecovery.ts";
import {
requestFirstEventGated,
requestHistoryGated,
} from "./relayGateBoundary.ts";
// ── Fake-timer setup ──────────────────────────────────────────────────────────
// The rate-limit gate and closed-retry logic use window.setTimeout/clearTimeout.
let fakeNow = 0;
const pendingTimers = new Map();
let nextTimerId = 1;
function fakeSetTimeout(fn, ms) {
const id = nextTimerId++;
pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
return id;
}
function fakeClearTimeout(id) {
pendingTimers.delete(id);
}
function tickTo(ms) {
fakeNow = ms;
for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) {
if (fireAt <= fakeNow) {
pendingTimers.delete(id);
fn();
}
}
}
const origDateNow = Date.now;
function setFakeNow(ms) {
fakeNow = ms;
Date.now = () => fakeNow;
}
globalThis.window = {
setTimeout: fakeSetTimeout,
clearTimeout: fakeClearTimeout,
};
// Import gate after window shim is installed.
const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import(
"./relayRateLimitGate.ts"
);
function resetAll(startMs = 0) {
pendingTimers.clear();
nextTimerId = 1;
setFakeNow(startMs);
resetRateLimitGate();
}
test("production CLOSED handler rejects history once and clears its timeout", () => {
const originalWindow = globalThis.window;
const clearedTimeouts = [];
globalThis.window = {
// Provide both setTimeout (needed by activateRateLimit in the F1 fix) and
// clearTimeout (the existing assertion target).
setTimeout: (_fn, _ms) => 0,
clearTimeout: (timeout) => clearedTimeouts.push(timeout),
};
try {
const errors = [];
const subscriptions = new Map([
[
"history-1",
{
mode: "history",
events: [],
resolve: () => assert.fail("CLOSED must not resolve history"),
reject: (error) => errors.push(error),
timeout: 42,
},
],
]);
const input = {
subscriptions,
subId: "history-1",
sendReq: () => Promise.resolve(),
};
handleRelayClosed({
...input,
message: "rate-limited: too many concurrent requests",
});
handleRelayClosed({ ...input, message: "late CLOSED" });
assert.equal(subscriptions.has("history-1"), false);
assert.deepEqual(clearedTimeouts, [42]);
assert.equal(errors.length, 1);
assert.equal(
errors[0].message,
"rate-limited: too many concurrent requests",
);
} finally {
globalThis.window = originalWindow;
}
});
test("rate-limited history CLOSED arms the shared gate for concurrent ops", () => {
resetAll(0);
const subscriptions = new Map([
[
"history-1",
{
mode: "history",
events: [],
resolve: () => {},
reject: () => {},
timeout: 0,
},
],
]);
handleRelayClosed({
subscriptions,
subId: "history-1",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => Promise.resolve(),
});
assert.equal(
isRateLimited(),
true,
"gate must be active after rate-limited history CLOSED",
);
// Gate expires after the hint duration.
tickTo(5_001);
assert.equal(isRateLimited(), false, "gate must clear after hint duration");
});
test("non-rate-limited history CLOSED does not arm the gate", () => {
resetAll(0);
const subscriptions = new Map([
[
"history-2",
{
mode: "history",
events: [],
resolve: () => {},
reject: () => {},
timeout: 0,
},
],
]);
handleRelayClosed({
subscriptions,
subId: "history-2",
message: "error: database unavailable",
sendReq: () => Promise.resolve(),
});
assert.equal(
isRateLimited(),
false,
"gate must remain inactive for non-rate-limited history CLOSED",
);
});
test("gate armed by rate-limited history CLOSED defers the next REQ until expiry then resumes", async () => {
// Simulate: rate-limited CLOSED arrives on a history sub → gate arms for 5s.
// A concurrent requestHistoryGated call must not issue the REQ before 5s,
// and must issue it (and resolve) once the gate clears.
resetAll(0);
const subscriptions = new Map([
[
"history-gate",
{
mode: "history",
events: [],
resolve: () => {},
reject: () => {},
timeout: 0,
},
],
]);
// Arm the gate via a rate-limited history CLOSED.
handleRelayClosed({
subscriptions,
subId: "history-gate",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => Promise.resolve(),
});
assert.equal(isRateLimited(), true, "gate must be armed before the test");
const sentAt = [];
const reqSubscriptions = new Map();
// requestHistoryGated will await the gate, so the REQ must not fire at t=0.
const historyPromise = requestHistoryGated(
reqSubscriptions,
async (payload) => {
// Record when the REQ fires. The test harness sets up the EOSE path by
// adding a history subscription to reqSubscriptions immediately after
// the REQ is recorded, then resolving it.
sentAt.push(fakeNow);
// Resolve the returned promise by completing the sub synchronously.
const subId = payload[1];
const sub = reqSubscriptions.get(subId);
if (sub) {
window.clearTimeout(sub.timeout);
reqSubscriptions.delete(subId);
sub.resolve([]);
}
},
async () => {},
{ kinds: [9], "#h": ["ch-test"], limit: 50 },
25_000,
);
// REQ must not have fired yet — gate is still active at t=0.
await Promise.resolve();
assert.equal(sentAt.length, 0, "REQ must not fire while gate is active");
// Expire the gate — the deferred REQ should fire.
tickTo(5_001);
await historyPromise;
assert.equal(
sentAt.length,
1,
"REQ must fire exactly once after gate clears",
);
assert.ok(sentAt[0] >= 5_001, "REQ must fire only after gate expiry");
});
test("first-event request resolves null when EOSE arrives without an event", async () => {
resetAll(0);
const subscriptions = new Map();
let requestedSubId = "";
const firstEventPromise = requestFirstEventGated(
subscriptions,
async (payload) => {
requestedSubId = payload[1];
},
async () => {},
{ kinds: [13_534], limit: 1 },
25_000,
);
await Promise.resolve();
assert.match(requestedSubId, /^first-/);
handleSubscriptionEose({
subscriptions,
subId: requestedSubId,
closeSubscription: async () => {},
});
assert.equal(await firstEventPromise, null);
assert.equal(subscriptions.has(requestedSubId), false);
});
test("live readiness distinguishes EOSE from CLOSED", () => {
const readiness = [];
const subscriptions = new Map([
[
"live-eose",
{
mode: "live",
filter: { kinds: [9], limit: 0 },
onEvent: () => {},
resolveReady: (result) => readiness.push(result),
},
],
[
"live-closed",
{
mode: "live",
filter: { kinds: [9], limit: 0 },
onEvent: () => {},
resolveReady: (result) => readiness.push(result),
},
],
]);
handleSubscriptionEose({
subscriptions,
subId: "live-eose",
closeSubscription: async () => {},
});
handleRelayClosed({
subscriptions,
subId: "live-closed",
message: "restricted: access revoked",
sendReq: () => Promise.resolve(),
});
assert.deepEqual(readiness, ["eose", "closed"]);
});
test("production CLOSED handler removes terminal live subscriptions", () => {
let readyCalls = 0;
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], limit: 50 },
onEvent: () => {},
resolveReady: () => {
readyCalls += 1;
},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "restricted: access revoked",
sendReq: () => Promise.resolve(),
});
assert.equal(subscriptions.has("live-1"), false);
assert.equal(readyCalls, 1);
});
// ── Rate-limited CLOSED core behaviour (F5) ───────────────────────────────────
test("rate-limited CLOSED keeps live subscription in the map", () => {
resetAll(0);
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => Promise.resolve(),
});
assert.equal(
subscriptions.has("live-1"),
true,
"subscription must survive rate-limited CLOSED",
);
});
test("rate-limited CLOSED activates the rate-limit gate with the parsed hint", () => {
resetAll(0);
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => Promise.resolve(),
});
assert.equal(
isRateLimited(),
true,
"gate must be active after rate-limited CLOSED",
);
// Gate should expire at 5s.
tickTo(5_001);
assert.equal(isRateLimited(), false);
});
test("rate-limited CLOSED retry delay is max(backoff, gate remaining), not just hint", () => {
resetAll(0);
// Activate a long gate first (20s), then send a shorter-hint CLOSED (5s).
// The retry delay must use the gate remaining time (20s), not the hint (5s).
activateRateLimit(20); // gate expires at 20_000 ms
const firedAt = [];
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => {
firedAt.push(fakeNow);
return Promise.resolve();
},
});
// Retry should NOT fire at 5s (hint) — the gate remaining is 20s.
tickTo(5_001);
assert.equal(
firedAt.length,
0,
"retry must not fire before gate remaining time",
);
// Should fire at 20s.
tickTo(20_001);
assert.equal(firedAt.length, 1, "retry must fire after gate remaining time");
});
test("non-rate-limited retryable CLOSED still schedules a retry", () => {
resetAll(0);
const firedAt = [];
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "error: database error",
sendReq: () => {
firedAt.push(fakeNow);
return Promise.resolve();
},
});
// Base delay is 1s for first attempt.
tickTo(1_001);
assert.equal(firedAt.length, 1, "retryable CLOSED must schedule a retry");
assert.equal(
subscriptions.has("live-1"),
true,
"subscription must survive retryable CLOSED",
);
});
test("terminal CLOSED deletes subscription and does not retry", () => {
resetAll(0);
const firedAt = [];
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "restricted: not a member",
sendReq: () => {
firedAt.push(fakeNow);
return Promise.resolve();
},
});
assert.equal(
subscriptions.has("live-1"),
false,
"terminal CLOSED must delete subscription",
);
tickTo(10_000);
assert.equal(firedAt.length, 0, "terminal CLOSED must not retry");
});
// ── Teardown ──────────────────────────────────────────────────────────────────
test("teardown — restore Date.now", () => {
Date.now = origDateNow;
assert.ok(true);
});