Files
ihasmail/web/src/lib/__tests__/shareTarget.test.ts
T
jcoffey-dev 191c4e7e68 Ask before opening a shared item in a message
The share address takes a plain form POST, which any website can make,
and the app opened whatever arrived straight into a composer. It now
shows what was shared -- the title, the start of the text and link, and
the file names -- and opens a message only when the reader chooses to.
Discarding drops it.

Confirm dialogs now put a message that is not plain text in a div, since
the summary has blocks of its own.

Three new strings, translated in all nine catalogs.
2026-09-16 12:23:27 -07:00

131 lines
5.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { shareSummary, collectShare, shareBody, SHARE_MAX_AGE_MS } from "@/lib/shareTarget";
import { SW_CACHE_NAME } from "@/lib/sw/swCache";
/**
* The handoff, from the tab's side. The worker's half cannot be exercised here
* -- sw.js is copied to the build rather than imported, and there is no service
* worker under a test runner -- so what is stood up below is the cache it
* writes into, keyed and shaped exactly as `stashShare` leaves it.
*
* That shape is the contract between two files that never see each other, and
* it is the thing worth pinning: a drift on either side is silent. Nothing
* errors, a share simply arrives at an empty composer.
*/
interface Entry {
body: BodyInit;
type: string;
}
function fakeCaches(entries: Record<string, Entry>) {
const store = new Map(Object.entries(entries));
const cache = {
match: vi.fn(async (key: string) => {
const e = store.get(key);
return e ? new Response(e.body, { headers: { "content-type": e.type } }) : undefined;
}),
delete: vi.fn(async (key: string) => store.delete(key)),
put: vi.fn(async () => undefined),
};
vi.stubGlobal("caches", { open: vi.fn(async (name: string) => (name === SW_CACHE_NAME ? cache : { match: async () => undefined })) });
return { cache, store };
}
/** What the worker writes, at the keys it writes them under. */
function stash(meta: Record<string, unknown>, files: { name: string; type: string; body: string }[] = []) {
const entries: Record<string, Entry> = {};
const index = files.map((f, i) => ({ key: `/ihasmail-share/${i}`, name: f.name, type: f.type }));
entries["/ihasmail-share"] = { body: JSON.stringify({ at: Date.now(), files: index, ...meta }), type: "application/json" };
for (const [i, f] of files.entries()) entries[`/ihasmail-share/${i}`] = { body: f.body, type: f.type };
return entries;
}
beforeEach(() => vi.unstubAllGlobals());
afterEach(() => vi.unstubAllGlobals());
describe("collecting a share", () => {
it("finds nothing on an ordinary start, which is almost every start", async () => {
fakeCaches({});
await expect(collectShare()).resolves.toBeNull();
});
it("survives a browser with no cache storage at all", async () => {
vi.stubGlobal("caches", undefined);
await expect(collectShare()).resolves.toBeNull();
});
it("rebuilds the files, with their names and types intact", async () => {
fakeCaches(stash({ title: "Holiday", text: "", url: "" }, [
{ name: "beach.png", type: "image/png", body: "pixels" },
{ name: "notes.txt", type: "text/plain", body: "later" },
]));
const share = await collectShare();
expect(share?.title).toBe("Holiday");
expect(share?.files.map((f) => [f.name, f.type])).toEqual([["beach.png", "image/png"], ["notes.txt", "text/plain"]]);
// The bytes made the trip, not just the index entry describing them.
expect(share!.files[0]!.size).toBe("pixels".length);
});
it("leaves nothing behind, so it cannot be collected twice", async () => {
const { store } = fakeCaches(stash({ text: "hello" }, [{ name: "a.txt", type: "text/plain", body: "x" }]));
await collectShare();
expect(store.size).toBe(0);
});
it("ignores one nobody came back for, and still clears it", async () => {
// A share to a signed-out ihasmail waits through the sign-in page, so it
// cannot expire quickly -- but it must expire, or it opens a composer full
// of a forgotten photo on some unrelated morning.
const { store } = fakeCaches(stash({ at: Date.now() - SHARE_MAX_AGE_MS - 1000, text: "stale" }));
await expect(collectShare()).resolves.toBeNull();
expect(store.size).toBe(0);
});
it("treats an empty share as no share", async () => {
fakeCaches(stash({ title: "", text: "", url: "" }));
await expect(collectShare()).resolves.toBeNull();
});
it("does not throw on a stash it cannot read", async () => {
fakeCaches({ "/ihasmail-share": { body: "not json", type: "application/json" } });
await expect(collectShare()).resolves.toBeNull();
});
});
describe("the body a share turns into", () => {
it("keeps the link when the text does not already carry it", () => {
expect(shareBody({ text: "Look at this", url: "https://example.com/a" })).toBe("Look at this\n\nhttps://example.com/a");
});
it("does not repeat a link the sharing app already put in the text", () => {
// Which field a link arrives in is up to whatever shared it, and they do
// not agree. Appending unconditionally would double it more often than not.
expect(shareBody({ text: "https://example.com/a", url: "https://example.com/a" })).toBe("https://example.com/a");
});
it("is just the link when that is all there was", () => {
expect(shareBody({ text: "", url: "https://example.com/a" })).toBe("https://example.com/a");
});
it("is just the text when there was no link", () => {
expect(shareBody({ text: "a thought", url: "" })).toBe("a thought");
});
});
describe("shareSummary", () => {
const file = (name: string) => new File(["x"], name);
it("gives the title, the text and link together, and the file names", () => {
expect(shareSummary({ title: " Trip ", text: "See this", url: "https://example.com", files: [file("a.jpg")] })).toEqual({
title: "Trip",
preview: "See this https://example.com",
files: ["a.jpg"],
});
});
it("shortens a long text rather than showing all of it", () => {
const { preview } = shareSummary({ title: "", text: "word ".repeat(100), url: "", files: [] });
expect(preview.length).toBeLessThanOrEqual(160);
expect(preview.endsWith("…")).toBe(true);
});
});