Files
Oleg Isonen 96cc8d9c58 feat: real-time notification polling with toast and browser alerts
Add a universal topic-based polling system (polly) that replaces the
old notification-store with a more extensible architecture:

- Backoff, polling-client, polling-manager with full test coverage (86 tests)
- Server-side topic resolvers + tRPC subscription router
- Dashboard subscription wiring with loader-seeded notifications
- In-app toast as primary alert, browser Notification as supplement
- Notification popover reads from shared nanostore atom
- project-queue.ts uses polly backoff, tests migrated to vi.mock()
2026-03-18 08:03:33 +00:00

264 lines
7.5 KiB
TypeScript

import { describe, test, expect, beforeEach } from "vitest";
import { enqueue, dequeueAll } from "./command-queue";
const makeTx = (id: string) => ({
id,
object: "server",
payload: [{ namespace: "ns", patches: [], revisePatches: [] }],
});
describe("command-queue", () => {
beforeEach(() => {
// Drain any leftover commands from previous tests
dequeueAll();
});
// ── dequeueAll ────────────────────────────────────────────────
describe("dequeueAll", () => {
test("returns empty array when queue is empty", () => {
expect(dequeueAll()).toEqual([]);
});
test("returns all enqueued commands and clears the queue", () => {
enqueue({
type: "setDetails",
projectId: "p1",
version: 1,
buildId: "b1",
authToken: "tok",
});
const first = dequeueAll();
expect(first).toHaveLength(1);
const second = dequeueAll();
expect(second).toHaveLength(0);
});
});
// ── enqueue: setDetails ───────────────────────────────────────
describe("enqueue — setDetails", () => {
test("pushes setDetails without merging", () => {
enqueue({
type: "setDetails",
projectId: "p1",
version: 1,
buildId: "b1",
authToken: "tok",
});
enqueue({
type: "setDetails",
projectId: "p1",
version: 2,
buildId: "b1",
authToken: "tok",
});
const commands = dequeueAll();
expect(commands).toHaveLength(2);
expect(commands[0]).toEqual(
expect.objectContaining({ type: "setDetails", version: 1 })
);
expect(commands[1]).toEqual(
expect.objectContaining({ type: "setDetails", version: 2 })
);
});
test("setDetails for different projects are kept separate", () => {
enqueue({
type: "setDetails",
projectId: "p1",
version: 1,
buildId: "b1",
authToken: undefined,
});
enqueue({
type: "setDetails",
projectId: "p2",
version: 1,
buildId: "b2",
authToken: undefined,
});
const commands = dequeueAll();
expect(commands).toHaveLength(2);
});
});
// ── enqueue: transactions merging ─────────────────────────────
describe("enqueue — transactions merging", () => {
test("merges consecutive transactions for the same project", () => {
const tx1 = makeTx("t1");
const tx2 = makeTx("t2");
enqueue({ type: "transactions", projectId: "p1", transactions: [tx1] });
enqueue({ type: "transactions", projectId: "p1", transactions: [tx2] });
const commands = dequeueAll();
expect(commands).toHaveLength(1);
expect(commands[0].type).toBe("transactions");
if (commands[0].type === "transactions") {
expect(commands[0].transactions).toEqual([tx1, tx2]);
}
});
test("does not merge transactions for different projects", () => {
enqueue({
type: "transactions",
projectId: "p1",
transactions: [makeTx("t1")],
});
enqueue({
type: "transactions",
projectId: "p2",
transactions: [makeTx("t2")],
});
const commands = dequeueAll();
expect(commands).toHaveLength(2);
});
test("setDetails between transactions prevents merging", () => {
enqueue({
type: "transactions",
projectId: "p1",
transactions: [makeTx("t1")],
});
enqueue({
type: "setDetails",
projectId: "p1",
version: 1,
buildId: "b1",
authToken: undefined,
});
enqueue({
type: "transactions",
projectId: "p1",
transactions: [makeTx("t2")],
});
const commands = dequeueAll();
// The setDetails breaks the merge chain, so we get 3 commands
// (note: the internal reverse() may reorder them)
expect(commands.length).toBeGreaterThanOrEqual(3);
});
test("merges multiple transactions in a single batch", () => {
const tx1 = makeTx("t1");
const tx2 = makeTx("t2");
const tx3 = makeTx("t3");
enqueue({ type: "transactions", projectId: "p1", transactions: [tx1] });
enqueue({
type: "transactions",
projectId: "p1",
transactions: [tx2, tx3],
});
const commands = dequeueAll();
expect(commands).toHaveLength(1);
if (commands[0].type === "transactions") {
expect(commands[0].transactions).toEqual([tx1, tx2, tx3]);
}
});
test("merges with the last matching transactions command", () => {
// Enqueue transactions for two different projects, then another for p1.
// The second p1 batch should merge with the first p1 batch.
enqueue({
type: "transactions",
projectId: "p1",
transactions: [makeTx("t1")],
});
enqueue({
type: "transactions",
projectId: "p2",
transactions: [makeTx("t2")],
});
enqueue({
type: "transactions",
projectId: "p1",
transactions: [makeTx("t3")],
});
const commands = dequeueAll();
// After merging, p1's transactions should contain t1 and t3
const p1Commands = commands.filter(
(c) => c.type === "transactions" && c.projectId === "p1"
);
expect(p1Commands).toHaveLength(1);
if (p1Commands[0].type === "transactions") {
expect(p1Commands[0].transactions.map((t) => t.id)).toContain("t1");
expect(p1Commands[0].transactions.map((t) => t.id)).toContain("t3");
}
});
test("empty queue followed by a transactions command is not merged", () => {
enqueue({
type: "transactions",
projectId: "p1",
transactions: [makeTx("t1")],
});
const commands = dequeueAll();
expect(commands).toHaveLength(1);
if (commands[0].type === "transactions") {
expect(commands[0].transactions).toHaveLength(1);
}
});
});
// ── Mixed command ordering ────────────────────────────────────
describe("mixed commands", () => {
test("setDetails then transactions preserves both", () => {
enqueue({
type: "setDetails",
projectId: "p1",
version: 1,
buildId: "b1",
authToken: undefined,
});
enqueue({
type: "transactions",
projectId: "p1",
transactions: [makeTx("t1")],
});
const commands = dequeueAll();
expect(commands).toHaveLength(2);
expect(commands.some((c) => c.type === "setDetails")).toBe(true);
expect(commands.some((c) => c.type === "transactions")).toBe(true);
});
test("multiple dequeueAll calls return disjoint results", () => {
enqueue({
type: "transactions",
projectId: "p1",
transactions: [makeTx("t1")],
});
const first = dequeueAll();
enqueue({
type: "transactions",
projectId: "p1",
transactions: [makeTx("t2")],
});
const second = dequeueAll();
expect(first).toHaveLength(1);
expect(second).toHaveLength(1);
if (
first[0].type === "transactions" &&
second[0].type === "transactions"
) {
expect(first[0].transactions[0].id).toBe("t1");
expect(second[0].transactions[0].id).toBe("t2");
}
});
});
});