Files
freno-dev/src/routes/api/the-nook/download.test.ts
Michael Freno a524f23be2 downloads: track every door and surface them on /analytics
The analytics page could not answer "how many downloads, of which
product". Three of the four download doors recorded nothing: the tRPC
click proxy only left the generic request row, whose path is the raw URL
with the asset buried in `?batch=1&input={"asset_name":...}`, and the S3
file route that serves Sparkle updates and the Nook app logged nothing
at all. The one door that did log (the Nook redirect) kept a private copy
of the logVisit/enrich/flush sequence, under its own path.

All doors now go through trackDownload, which writes one row per event
under /api/downloads/<asset> and flushes it inside the request — a click
is low-volume and serverless is not guaranteed to run the 30s batch
timer after the response. The file route maps its prefix allowlist to
asset keys, so a version bump stays one asset rather than one per
release. getAnalyticsSummary groups those rows into a downloads block and
the freno.me panel renders it under the overview cards.

History is recovered to the degree it exists: the Nook door's rows and
the tRPC clicks still carry enough to derive from (asset name in the URL).
scripts/backfill-downloads.ts inserts those derived rows with a
`backfill-<source id>` primary key and INSERT OR IGNORE, leaving the
originals untouched, so re-running is a no-op. Run against the live table
it added 66 rows: gaze 42, thenook 19, inputhalo 4, lineage 1.

Tests: new backfill suite (derivation, visitor detail carried over,
idempotency, empty history) and summariseDownloads cases; the Nook route
test now pins the canonical path, and the NOOK-337 allowlist audit reads
the map keys instead of quoted strings. Red-checked by stubbing the
downloads block back to zeros — the summary case fails, then passes.
Full suite matches master.

Co-Authored-By: nw-flash <noreply@nw-flash.local>
MISC-48
2026-10-22 12:59:48 -04:00

203 lines
5.9 KiB
TypeScript

import { describe, expect, it, mock, beforeEach } from "bun:test";
// The route's contracts:
// 1. Every click is recorded through the shared analytics buffer with the
// path, referrer, country and device type the funnel needs.
// 2. The response is a no-store 302 to the newest release enclosure, so a
// CDN can never serve a later click without it reaching this handler.
// 3. An unreadable appcast degrades to 503 — but the click is still logged,
// because a failed download is exactly the event worth seeing.
type Entry = Record<string, unknown>;
const logged: Entry[] = [];
let appcastXml = "";
let s3ShouldThrow = false;
let flushCalls = 0;
mock.module("~/env/server", () => ({
env: {
AWS_REGION: "us-east-1",
MY_AWS_ACCESS_KEY: "test-key",
MY_AWS_SECRET_KEY: "test-secret",
VITE_DOWNLOAD_BUCKET_STRING: "test-bucket"
}
}));
mock.module("@aws-sdk/client-s3", () => ({
S3Client: class {
async send() {
if (s3ShouldThrow) throw new Error("s3 unavailable");
return {
Body: appcastXml
? { transformToString: async () => appcastXml }
: null
};
}
},
GetObjectCommand: class {
input: unknown;
constructor(input: unknown) {
this.input = input;
}
}
}));
mock.module("vinxi/http", () => ({
getRequestIP: () => null
}));
mock.module("~/server/analytics", () => ({
logVisit: async (entry: Entry) => {
logged.push(entry);
},
// The route must flush, not merely buffer: the batch timer is not
// guaranteed to survive the response on serverless.
flushAnalytics: async () => {
flushCalls += 1;
},
// Mirrors the real enrichment contract: deviceType is derived from the UA.
enrichAnalyticsEntry: (entry: Entry) => ({
...entry,
deviceType: String(entry.userAgent ?? "").includes("Macintosh")
? "desktop"
: null
})
}));
const { GET } = await import("./download");
const { pickEnclosure, invalidateDownloadCache } = await import("./_download");
const DMG = "https://cdn.example/TheNook-0.6.3.dmg";
const ZIP = "https://cdn.example/TheNook-0.6.3.zip";
const appcast = (urls: string[]) =>
`<?xml version="1.0"?><rss><channel><item>` +
urls
.map(
(u) =>
`<enclosure url="${u}" sparkle:version="1" length="1" type="application/octet-stream"/>`
)
.join("") +
`</item></channel></rss>`;
function request(headers: Record<string, string> = {}, query = "") {
return {
request: new Request(
`https://nook.freno.me/api/the-nook/download${query}`,
{ headers }
),
nativeEvent: {}
} as never;
}
const MAC_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15";
beforeEach(() => {
logged.length = 0;
appcastXml = appcast([DMG, ZIP]);
s3ShouldThrow = false;
flushCalls = 0;
invalidateDownloadCache();
});
describe("GET /api/the-nook/download", () => {
it("logs the click and 302s to the newest dmg", async () => {
const res = await GET(request({ referer: "https://nook.freno.me/" }));
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe(DMG);
expect(res.headers.get("Cache-Control")).toBe("no-store");
expect(logged.length).toBe(1);
expect(logged[0]).toMatchObject({
path: "/api/downloads/thenook",
method: "GET",
referrer: "https://nook.freno.me/"
});
// Flushed within the request, not left to the batch timer.
expect(flushCalls).toBe(1);
});
it("derives the device type from the user agent", async () => {
await GET(request({ "user-agent": MAC_UA }));
expect(logged[0]!.deviceType).toBe("desktop");
});
it("records the edge country, and lets ?c= override it", async () => {
await GET(request({ "x-vercel-ip-country": "GB" }));
expect(logged[0]!.country).toBe("GB");
invalidateDownloadCache();
await GET(request({ "cf-ipcountry": "DE" }));
expect(logged[1]!.country).toBe("DE");
invalidateDownloadCache();
await GET(request({ "x-vercel-ip-country": "GB" }, "?c=JP"));
expect(logged[2]!.country).toBe("JP");
});
it("falls back to the forwarded address when no request IP is available", async () => {
await GET(request({ "x-forwarded-for": "198.51.100.9, 10.0.0.1" }));
expect(logged[0]!.ipAddress).toBe("198.51.100.9");
});
it("still logs the click when the appcast cannot be read", async () => {
s3ShouldThrow = true;
const res = await GET(request());
expect(res.status).toBe(503);
expect(res.headers.get("Cache-Control")).toBe("no-store");
expect(logged.length).toBe(1);
});
it("503s when the appcast carries no enclosure", async () => {
appcastXml = "<?xml version='1.0'?><rss><channel></channel></rss>";
invalidateDownloadCache();
const res = await GET(request());
expect(res.status).toBe(503);
});
it("captures campaign tags from the click URL and the referer", async () => {
await GET(
request(
{ referer: "https://nook.freno.me/?utm_source=hn&utm_campaign=launch" },
"?utm_medium=post"
)
);
expect(logged[0]).toMatchObject({
utmSource: "hn",
utmMedium: "post",
utmCampaign: "launch"
});
});
it("logs nulls when nothing is tagged", async () => {
await GET(request({ referer: "https://nook.freno.me/" }));
expect(logged[0]).toMatchObject({
utmSource: null,
utmMedium: null,
utmCampaign: null
});
});
});
describe("pickEnclosure", () => {
it("prefers a dmg over a zip even when the zip comes first", () => {
expect(pickEnclosure(appcast([ZIP, DMG]))).toBe(DMG);
});
it("falls back to a zip, then to any enclosure", () => {
expect(pickEnclosure(appcast([ZIP]))).toBe(ZIP);
expect(pickEnclosure(appcast(["https://cdn.example/notes.txt"]))).toBe(
"https://cdn.example/notes.txt"
);
});
it("returns null when there is no enclosure", () => {
expect(pickEnclosure("<rss><channel></channel></rss>")).toBeNull();
});
});