Files
freno-dev/src/server/analytics-schema.test.ts

138 lines
4.1 KiB
TypeScript

import { describe, expect, it, beforeEach } from "bun:test";
import { Database } from "bun:sqlite";
import {
VISITOR_ANALYTICS_ADDED_COLUMNS,
VISITOR_ANALYTICS_COLUMNS,
VISITOR_ANALYTICS_DDL,
VISITOR_ANALYTICS_INSERT,
VISITOR_ANALYTICS_INDEXES
} from "./analytics-schema";
// Why an in-memory SQLite database: VisitorAnalytics lived only in the live
// Turso database, so nothing in the repo proved the DDL and the INSERT
// agreed. These tests apply the real DDL to a throwaway database and run the
// real INSERT statement against it — the exact class of drift (a column in
// the INSERT that the table does not have) fails here instead of silently in
// production, where the buffer swallows write errors.
let db: Database;
const sampleArgs = [
"row-1", // id
null, // user_id
"/nook/", // path
"GET", // method
"https://nook.freno.me/", // referrer
"Mozilla/5.0 (Macintosh)", // user_agent
"127.0.0.1", // ip_address
"GB", // country
"desktop", // device_type
"safari", // browser
"macos", // os
"hn", // utm_source
"post", // utm_medium
"launch", // utm_campaign
12, // duration_ms
100, // fcp
200, // lcp
0.01, // cls
1, // fid
2, // inp
3, // ttfb
4, // dom_load
5 // load_complete
];
beforeEach(() => {
db = new Database(":memory:");
db.run(VISITOR_ANALYTICS_DDL);
for (const index of VISITOR_ANALYTICS_INDEXES) db.run(index);
});
describe("VisitorAnalytics schema", () => {
it("creates the table idempotently", () => {
expect(() => db.run(VISITOR_ANALYTICS_DDL)).not.toThrow();
expect(() => {
for (const index of VISITOR_ANALYTICS_INDEXES) db.run(index);
}).not.toThrow();
});
it("defines every column the writers insert", () => {
const columns = (
db.prepare(`PRAGMA table_info(VisitorAnalytics)`).all() as {
name: string;
}[]
).map((c) => c.name);
for (const column of VISITOR_ANALYTICS_COLUMNS) {
expect(columns).toContain(column);
}
// The reverse direction matters too: a column the code writes but the
// DDL omits is the failure this whole file exists to catch.
expect(VISITOR_ANALYTICS_COLUMNS.length).toBe(23);
});
it("has one placeholder per inserted column", () => {
const placeholders = (VISITOR_ANALYTICS_INSERT.match(/\?/g) ?? []).length;
expect(placeholders).toBe(VISITOR_ANALYTICS_COLUMNS.length);
expect(sampleArgs.length).toBe(VISITOR_ANALYTICS_COLUMNS.length);
});
it("accepts a full row and returns the campaign columns", () => {
db.prepare(VISITOR_ANALYTICS_INSERT).run(...sampleArgs);
const row = db
.prepare(
`SELECT path, country, utm_source, utm_medium, utm_campaign
FROM VisitorAnalytics`
)
.get() as Record<string, unknown>;
expect(row).toEqual({
path: "/nook/",
country: "GB",
utm_source: "hn",
utm_medium: "post",
utm_campaign: "launch"
});
});
it("accepts a row with no campaign tags", () => {
const args = [...sampleArgs];
args[11] = args[12] = args[13] = null;
db.prepare(VISITOR_ANALYTICS_INSERT).run(...args);
const row = db
.prepare(`SELECT utm_source FROM VisitorAnalytics`)
.get() as Record<string, unknown>;
expect(row.utm_source).toBeNull();
});
it("adds the post-hoc columns to a table that predates them", () => {
// Simulate the older table: create it without the utm columns, then run
// the ALTER path the bootstrap uses and confirm the writers still work.
const old = new Database(":memory:");
const withoutUtm = VISITOR_ANALYTICS_COLUMNS.filter(
(c) => !VISITOR_ANALYTICS_ADDED_COLUMNS.some((a) => a.name === c)
);
old.run(
`CREATE TABLE VisitorAnalytics (${withoutUtm
.map((c) => `${c} TEXT`)
.join(", ")})`
);
const present = new Set(
(old.prepare(`PRAGMA table_info(VisitorAnalytics)`).all() as {
name: string;
}[]).map((c) => c.name)
);
for (const column of VISITOR_ANALYTICS_ADDED_COLUMNS) {
if (!present.has(column.name)) old.run(column.ddl);
}
expect(() =>
old.prepare(VISITOR_ANALYTICS_INSERT).run(...sampleArgs)
).not.toThrow();
});
});