Both happen in the background. The phone stays where it is. This was twice described as impossible, here and in FEATURES.md: the service worker was said to have no session, so anything touching mail had to open the app. That is wrong, and checking it rather than repeating it is the whole of this change. ihasmail's session is an httpOnly cookie against its own origin and the only other thing the API asks for is a fixed `x-requested-with` header, which is not a secret and is not held anywhere. A same-origin fetch from the worker carries the cookie like any other. Confirmed against the mock: logging in with curl and then issuing `Email/set` with nothing but that cookie and the static headers marked a message read and moved it to Archive, HTTP 200. Nothing the tab holds in memory is involved, because the API asks for none of it. Two actions, because `maxActions` is two on Android and anything past it is dropped without a word. Archive and Mark as read are the two worth having: they are what somebody does to a notification they have already read the whole of. Reply is not among them -- it would have to open the app, which is what tapping the notification does already. The worker still cannot reach a catalogue. It is plain JavaScript copied into the build, outside the bundle, with no i18n and no idea which mailbox is the archive. So the app writes both down in the same cache it already uses for handoffs, and rewrites them whenever the language, the account or the folder list changes. Where there is no such note -- between installing this worker and next opening ihasmail -- the notification appears with no buttons at all, rather than English ones over a mailbox guessed by name. That also fixes two strings the worker had always shown in English regardless: "New mail" and "(no subject)". A session can be gone by the time a button is pressed. That comes back as a refusal and the notification says so, rather than vanishing as though it had worked. It does not open the app to recover: being interrupted is what the button existed to avoid. The two claims that were wrong are corrected rather than quietly deleted, including the one about push renewal -- which still needs a tab, but for a different reason than the one given. The reason is when the worker runs, not what it may do: it wakes only for a push, and the push stops when the subscription lapses. Two new strings, in all nine catalogues.
91 lines
3.7 KiB
TypeScript
91 lines
3.7 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { publishWorkerFacts, FACTS_KEY, type WorkerFacts } from "@/lib/swFacts";
|
|
import { SW_CACHE_NAME } from "@/lib/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 labelled in a language the reader does not use — the worker is plain
|
|
* JavaScript outside the bundle and cannot reach a catalogue.
|
|
*
|
|
* 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("carries the worker's text in the language the tab is in", async () => {
|
|
// The worker has no catalogue. 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();
|
|
}
|
|
});
|
|
});
|