forked from Mike/freno-dev
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
187 lines
5.7 KiB
TypeScript
187 lines
5.7 KiB
TypeScript
import { beforeEach, describe, expect, it } from "bun:test";
|
|
import { Database } from "bun:sqlite";
|
|
import {
|
|
VISITOR_ANALYTICS_COLUMNS,
|
|
VISITOR_ANALYTICS_DDL,
|
|
VISITOR_ANALYTICS_INDEXES,
|
|
VISITOR_ANALYTICS_INSERT
|
|
} from "./analytics-schema";
|
|
import { summariseDownloads } from "~/lib/download-asset";
|
|
import { assetFromLegacyPath, backfillDownloads } from "./backfill-downloads";
|
|
|
|
// Why a real in-memory SQLite database rather than a stub: the backfill runs
|
|
// real SQL against the live table shape, and a stub would happily accept a
|
|
// column list the real table does not have. Same reasoning as
|
|
// analytics-schema.test.ts.
|
|
//
|
|
// Nothing is module-mocked here on purpose: bun's `mock.module` is process-wide,
|
|
// so stubbing the connection factory would leak into the rate-limit and CSRF
|
|
// suites that share the same database module.
|
|
|
|
let db: Database;
|
|
|
|
/** Adapt bun:sqlite to the libsql `execute(stmt | sql)` contract. */
|
|
const conn = {
|
|
async execute(query: string | { sql: string; args?: unknown[] }) {
|
|
const sql = typeof query === "string" ? query : query.sql;
|
|
const args = (
|
|
typeof query === "string" ? [] : (query.args ?? [])
|
|
) as never[];
|
|
const statement = db.query(sql);
|
|
if (/^\s*(select|pragma|with)\b/i.test(sql)) {
|
|
return { rows: statement.all(...args) as unknown[], rowsAffected: 0 };
|
|
}
|
|
return {
|
|
rows: [] as unknown[],
|
|
rowsAffected: statement.run(...args).changes
|
|
};
|
|
}
|
|
};
|
|
|
|
const LEGACY_NOOK = "/api/the-nook/download";
|
|
|
|
const trpcClick = (asset: string) =>
|
|
`/api/trpc/downloads.getDownloadUrl?batch=1&input=%7B%220%22%3A%7B%22asset_name%22%3A%22${asset}%22%7D%7D`;
|
|
|
|
/** Columns whose value varies per row; everything else stays NULL. */
|
|
const fixedColumns: Record<string, string> = {
|
|
method: "GET",
|
|
referrer: "https://gaze.freno.me/",
|
|
user_agent: "Mozilla/5.0 (Macintosh)",
|
|
ip_address: "203.0.113.7",
|
|
country: "GB"
|
|
};
|
|
|
|
function insertLegacy(id: string, path: string) {
|
|
const values = VISITOR_ANALYTICS_COLUMNS.map((column) => {
|
|
if (column === "id") return id;
|
|
if (column === "path") return path;
|
|
return fixedColumns[column] ?? null;
|
|
});
|
|
db.query(VISITOR_ANALYTICS_INSERT).run(...(values as never[]));
|
|
}
|
|
|
|
function canonicalPaths(): string[] {
|
|
return db
|
|
.query<{ path: string }, []>(
|
|
`SELECT path FROM VisitorAnalytics WHERE path LIKE '/api/downloads/%' ORDER BY path`
|
|
)
|
|
.all()
|
|
.map((r: { path: string }) => r.path);
|
|
}
|
|
|
|
function legacyRowCount(): number {
|
|
return db
|
|
.query<
|
|
{ n: number },
|
|
[string]
|
|
>(`SELECT COUNT(*) AS n FROM VisitorAnalytics WHERE path = ?`)
|
|
.get(LEGACY_NOOK)!.n;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
db = new Database(":memory:");
|
|
db.exec(VISITOR_ANALYTICS_DDL);
|
|
for (const index of VISITOR_ANALYTICS_INDEXES) db.exec(index);
|
|
});
|
|
|
|
describe("backfillDownloads", () => {
|
|
it("derives a canonical row from each recoverable kind of history", async () => {
|
|
insertLegacy("nook-1", LEGACY_NOOK);
|
|
insertLegacy("gaze-1", trpcClick("gaze"));
|
|
insertLegacy("halo-1", trpcClick("inputhalo"));
|
|
insertLegacy("page-1", "/nook/");
|
|
|
|
const added = Object.fromEntries(
|
|
(await backfillDownloads(conn)).map((r) => [r.asset, r.added])
|
|
);
|
|
|
|
expect(added).toEqual({ thenook: 1, gaze: 1, inputhalo: 1 });
|
|
expect(canonicalPaths()).toEqual([
|
|
"/api/downloads/gaze",
|
|
"/api/downloads/inputhalo",
|
|
"/api/downloads/thenook"
|
|
]);
|
|
// A page visit is not download history and gains no derived row.
|
|
expect(
|
|
db
|
|
.query(`SELECT * FROM VisitorAnalytics WHERE id = 'backfill-page-1'`)
|
|
.all()
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it("keeps the visitor detail of the row it derives from", async () => {
|
|
insertLegacy("nook-1", LEGACY_NOOK);
|
|
|
|
await backfillDownloads(conn);
|
|
|
|
expect(
|
|
db
|
|
.query<
|
|
{ country: string; referrer: string },
|
|
[]
|
|
>(`SELECT country, referrer FROM VisitorAnalytics WHERE id = 'backfill-nook-1'`)
|
|
.get()
|
|
).toEqual({ country: "GB", referrer: "https://gaze.freno.me/" });
|
|
});
|
|
|
|
it("is idempotent and never rewrites the source rows", async () => {
|
|
insertLegacy("nook-1", LEGACY_NOOK);
|
|
|
|
await backfillDownloads(conn);
|
|
const second = await backfillDownloads(conn);
|
|
|
|
expect(second).toEqual([]);
|
|
expect(canonicalPaths()).toHaveLength(1);
|
|
expect(legacyRowCount()).toBe(1);
|
|
});
|
|
|
|
it("derives nothing when there is no legacy history", async () => {
|
|
expect(await backfillDownloads(conn)).toEqual([]);
|
|
expect(canonicalPaths()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("assetFromLegacyPath", () => {
|
|
it("maps the Nook door to its canonical asset", () => {
|
|
expect(assetFromLegacyPath(LEGACY_NOOK)).toBe("thenook");
|
|
});
|
|
|
|
it("reads the asset out of an encoded or a plain tRPC request URL", () => {
|
|
expect(assetFromLegacyPath(trpcClick("gaze"))).toBe("gaze");
|
|
expect(
|
|
assetFromLegacyPath(
|
|
'/api/trpc/downloads.getDownloadUrl?batch=1&input={"0":{"asset_name":"lineage"}}'
|
|
)
|
|
).toBe("lineage");
|
|
});
|
|
|
|
it("returns null when there is nothing to derive", () => {
|
|
expect(assetFromLegacyPath("/nook/")).toBeNull();
|
|
expect(
|
|
assetFromLegacyPath("/api/trpc/downloads.getDownloadUrl")
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("summariseDownloads", () => {
|
|
it("labels each row by the asset in its path and totals them", () => {
|
|
expect(
|
|
summariseDownloads([
|
|
{ path: "/api/downloads/gaze", count: 4 },
|
|
{ path: "/api/downloads/thenook", count: 1 }
|
|
])
|
|
).toEqual({
|
|
total: 5,
|
|
byAsset: [
|
|
{ asset: "gaze", count: 4 },
|
|
{ asset: "thenook", count: 1 }
|
|
]
|
|
});
|
|
});
|
|
|
|
it("reports nothing rather than throwing on an empty table", () => {
|
|
expect(summariseDownloads([])).toEqual({ total: 0, byAsset: [] });
|
|
});
|
|
});
|