Files
inbuxa-webmail/web/src/lib/sw/__tests__/swFacts.test.ts
T
jcoffey-dev 9fcf4812f3
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m3s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
Notifications with the app closed, for every signed-in account
Second of three for background push across accounts (multi-account
spec, MA-8 part 2).

Turning on "Notify me even when inbuxa is closed" now registers this
browser in every signed-in account, each through its own session (the
route from the previous change), and renewal keeps them all current.
GET /api/auth/accounts says which mail account each session is, so the
subscription can name it. The remembered endpoint is kept per account,
and survives the clean-up that follows switching accounts.

The service worker is told who the other accounts are. A push for one
of them is titled with that account's address, shown even while a tab
is focused (the tab only shows the front account's mail), and its
Mark read and Archive act through that account's session. Clicking it
brings that account to the front and opens the message. While push is
on, the tab's own polling of other accounts stops notifying, so nothing
arrives twice.

Signing out of one account removes this device's subscription there
only; signing out of all, or turning push off, removes every one.

Also fixes where every background notification opened: the worker
linked to /mail/inbox/<thread>, and the route takes a mailbox id there,
so a click landed on the inbox list with "That folder no longer
exists". The worker now gets each account's inbox id and links to the
message.

Checked end to end in Chrome against a local server with two accounts:
both registered and verified, a message to the account not in front
showed a notification under its address, and clicking it switched
accounts and opened the message. No new strings. typecheck, tests
(web 1547, server 279) and build pass.
2026-10-05 20:38:44 -07:00

99 lines
4.1 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/sw/swFacts";
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
import { setCatalog } from "@/lib/i18n";
import { catalog as de } from "@/locales/de";
/**
* The briefing is the only thing standing between a notification action and a
* button labeled in a language the reader does not use — the worker is plain
* JavaScript outside the bundle and cannot reach a catalog.
*
* It is also the only place the archive mailbox is named, and getting that
* wrong does not fail visibly: a message would be filed somewhere, just not
* where Archive means.
*/
function fakeCaches() {
const store = new Map<string, string>();
const cache = {
put: vi.fn(async (key: string, res: Response) => void store.set(key, await res.text())),
match: vi.fn(async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined)),
delete: vi.fn(async () => true),
};
// Only the worker's own cache: a briefing put anywhere else is one the
// worker will never read.
const other = { put: vi.fn(), match: vi.fn(), delete: vi.fn() };
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : other)) });
return { store, cache };
}
const written = (store: Map<string, string>) => JSON.parse(store.get(FACTS_KEY)!) as WorkerFacts;
afterEach(() => {
vi.unstubAllGlobals();
setCatalog("en", { strings: {}, plurals: {} });
});
describe("the worker's briefing", () => {
it("names the account and the archive mailbox", async () => {
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive");
const facts = written(store);
expect(facts.accountId).toBe("a1");
expect(facts.archiveId).toBe("mb-archive");
});
it("names the inbox a notification opens in", async () => {
// The route takes a mailbox id. The worker used to put the word `inbox`
// there, and every click landed on "That folder no longer exists".
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive", "mb-inbox");
expect(written(store).inboxId).toBe("mb-inbox");
});
it("carries the worker's text in the language the tab is in", async () => {
// The worker has no catalog. Everything it will say has to be said here
// first, or a German reader gets English buttons on their lock screen.
setCatalog("de", de);
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive");
const facts = written(store);
expect(facts.strings.archive).toBe("Archivieren");
expect(facts.strings.markRead).toBe("Als gelesen markieren");
expect(facts.strings.newMail).toBe("Neue E-Mail");
expect(facts.strings.noSubject).toBe("(kein Betreff)");
expect(facts.strings.failed).not.toBe("");
});
it("says so when there is no archive folder, rather than inventing one", async () => {
// The worker draws no Archive button on a null. An account without an
// archive is not a reason to file mail somewhere else.
const { store } = fakeCaches();
await publishWorkerFacts("a1", null);
expect(written(store).archiveId).toBeNull();
});
it("writes nothing before there is an account", async () => {
const { cache } = fakeCaches();
await publishWorkerFacts(null, null);
expect(cache.put).not.toHaveBeenCalled();
});
it("does not throw where the browser has no cache storage", async () => {
vi.stubGlobal("caches", undefined);
await expect(publishWorkerFacts("a1", "mb-archive")).resolves.toBeUndefined();
});
it("carries every string the worker looks up", async () => {
// The worker reads these by name and shows `undefined` for a missing one,
// which is the kind of thing that only appears on somebody's lock screen.
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive");
const facts = written(store);
for (const k of ["newMail", "newMessage", "noSubject", "archive", "markRead", "failed"] as const) {
expect(facts.strings[k], `missing ${k}`).toBeTruthy();
}
});
});