Files
STTracker/packages/core/test/export.test.ts
T
2026-10-30 20:17:25 +01:00

136 lines
8.0 KiB
TypeScript

import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { strFromU8, unzipSync, zipSync, strToU8 } from "fflate";
import { forEachDialect } from "@sttracker/db/testing";
import { storeAttachment } from "../src/attachments.ts";
import { ImportError } from "../src/errors.ts";
import { exportUserBundle, exportUserCsv, importUserBundle } from "../src/export.ts";
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
forEachDialect("export bundle", (getDb) => {
let dir: string;
let dir2: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "sttracker-exp-"));
dir2 = await mkdtemp(join(tmpdir(), "sttracker-exp-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
await rm(dir2, { recursive: true, force: true });
});
async function seed(db: ReturnType<typeof getDb>, u: string) {
const shared = await makeWork(db, { title: "Dune", mediaType: "book", year: 1965, externalIds: { hardcover: "dune" } });
const parent = await makeWork(db, { title: "Show", mediaType: "tv", externalIds: { trakt: "show" } });
const child = await makeWork(db, { title: "Pilot", mediaType: "tv", parentId: parent, externalIds: { trakt: "pilot" } });
const priv = await makeWork(db, { title: "My Zine", mediaType: "book", ownerUserId: u });
const now = "2024-01-02T03:04:05.000Z";
const ent = async (id: string, workId: string, extra: object = {}) =>
db.k.insertInto("entries").values({
id, user_id: u, work_id: workId, status: "completed", rating: 85, review: "great", private_notes: null, favorite: 1,
tags: '["a","b"]', custom_fields: "{}", visibility: "private", overrides: "{}", media_type_override: null,
field_sources: '{"rating":"user"}', created_at: now, updated_at: now, deleted_at: null, ...extra,
}).execute();
const e1 = crypto.randomUUID(), e2 = crypto.randomUUID(), e3 = crypto.randomUUID(), e4 = crypto.randomUUID();
await ent(e1, shared);
await ent(e2, child);
await ent(e3, priv, { rating: null });
await ent(e4, parent, { deleted_at: now });
const eng = crypto.randomUUID();
await db.k.insertInto("engagements").values({
id: eng, entry_id: e1, started_at: "1999-05", started_precision: "month", finished_at: "2000", finished_precision: "year",
progress: "{}", platform: "kindle", rating: 70, notes: "n", custom_fields: "{}", field_sources: "{}", created_at: now, updated_at: now, deleted_at: null,
}).execute();
const list = crypto.randomUUID();
await db.k.insertInto("lists").values({ id: list, user_id: u, name: "Faves", description: "d", kind: "list", visibility: "private", rating: null, review: null, created_at: now, updated_at: now, deleted_at: null }).execute();
await db.k.insertInto("list_items").values({ id: crypto.randomUUID(), list_id: list, work_id: shared, position: 0, note: "first" }).execute();
await db.k.insertInto("user_history").values({ id: crypto.randomUUID(), user_id: u, entity: "entry", entity_id: e1, before: "{}", after: '{"rating":85}', actor: "user", created_at: now }).execute();
await storeAttachment(db, dir, u, { bytes: new TextEncoder().encode("hello"), mime: "text/plain", filename: "n.txt" }, { entity: "entry", entityId: e1 });
return { e1, shared };
}
test("export then import into fresh user reproduces entries, ratings, engagements with precision, lists and attachments", async () => {
const db = getDb();
const u = await makeUser(db, "ada");
await seed(db, u);
const zip = await exportUserBundle(db, dir, u);
const v = await makeUser(db, "bob");
expect(await importUserBundle(db, dir2, v, zip)).toEqual({ works: 4, entries: 4 });
const rows = await db.k.selectFrom("entries").innerJoin("works", "works.id", "entries.work_id").select(["works.title", "entries.rating", "entries.favorite", "entries.field_sources", "entries.deleted_at", "entries.created_at", "works.owner_user_id", "works.parent_id", "works.id as wid"]).where("entries.user_id", "=", v).execute();
const byTitle = Object.fromEntries(rows.map((r) => [r.title, r]));
expect(rows).toHaveLength(4);
expect(byTitle["Dune"]!.rating).toBe(85);
expect(byTitle["Dune"]!.favorite).toBe(1);
expect(byTitle["Dune"]!.field_sources).toBe('{"rating":"user"}');
expect(byTitle["Dune"]!.created_at).toBe("2024-01-02T03:04:05.000Z");
expect(byTitle["Show"]!.deleted_at).not.toBeNull();
expect(byTitle["My Zine"]!.owner_user_id).toBe(v);
expect(byTitle["Pilot"]!.parent_id).toBe(byTitle["Show"]!.wid);
// shared works were re-matched, not duplicated
expect(await db.k.selectFrom("works").select("id").where("title", "=", "Dune").execute()).toHaveLength(1);
const eng = await db.k.selectFrom("engagements").innerJoin("entries", "entries.id", "engagements.entry_id").selectAll("engagements").where("entries.user_id", "=", v).execute();
expect(eng).toHaveLength(1);
expect(eng[0]).toMatchObject({ started_at: "1999-05", started_precision: "month", finished_precision: "year", platform: "kindle" });
const lists = await db.k.selectFrom("lists").selectAll().where("user_id", "=", v).execute();
expect(lists).toHaveLength(1);
const items = await db.k.selectFrom("list_items").selectAll().where("list_id", "=", lists[0]!.id).execute();
expect(items).toMatchObject([{ note: "first", work_id: byTitle["Dune"]!.wid }]);
const hist = await db.k.selectFrom("user_history").selectAll().where("user_id", "=", v).execute();
expect(hist).toHaveLength(1);
const dune = await db.k.selectFrom("entries").select("id").where("user_id", "=", v).where("work_id", "=", byTitle["Dune"]!.wid).executeTakeFirstOrThrow();
expect(hist[0]!.entity_id).toBe(dune.id);
const atts = await db.k.selectFrom("attachments").innerJoin("attachment_links", "attachment_links.attachment_id", "attachments.id").selectAll("attachments").select("attachment_links.entity_id").where("attachments.user_id", "=", v).execute();
expect(atts).toHaveLength(1);
expect(atts[0]!.entity_id).toBe(dune.id);
expect(await Bun.file(join(dir2, "attachments", atts[0]!.sha256.slice(0, 2), atts[0]!.sha256.slice(2, 4), atts[0]!.sha256)).text()).toBe("hello");
});
test("bundle has no credential fields", async () => {
const db = getDb();
const u = await makeUser(db, "ada");
await seed(db, u);
const files = unzipSync(await exportUserBundle(db, dir, u));
expect(Object.keys(files)).toContain("manifest.json");
const text = Object.entries(files).filter(([n]) => !n.startsWith("attachments/")).map(([, b]) => strFromU8(b)).join("\n").toLowerCase();
for (const w of ["password", "token", "credentials"]) expect(text).not.toContain(w);
expect(JSON.parse(strFromU8(files["manifest.json"]!)).format).toBe("sttracker-export/v1");
});
test("import into non-empty account refused", async () => {
const db = getDb();
const u = await makeUser(db, "ada");
await seed(db, u);
const zip = await exportUserBundle(db, dir, u);
const err = await importUserBundle(db, dir2, u, zip).catch((e) => e);
expect(err).toBeInstanceOf(ImportError);
expect(err.code).toBe("account_not_empty");
});
test("import of an unknown format is refused", async () => {
const db = getDb();
const v = await makeUser(db, "bob");
const zip = zipSync({ "manifest.json": strToU8('{"format":"other"}') });
const err = await importUserBundle(db, dir2, v, zip).catch((e) => e);
expect(err.code).toBe("unsupported_format");
});
test("csv export has entries, engagements and listens", async () => {
const db = getDb();
const u = await makeUser(db, "ada");
await seed(db, u);
const csv = await exportUserCsv(db, u);
expect(Object.keys(csv).sort()).toEqual(["engagements.csv", "entries.csv", "listens.csv"]);
expect(csv["entries.csv"]).toContain("Dune");
expect(csv["entries.csv"]).not.toContain("Show,");
expect(csv["engagements.csv"]).toContain("kindle");
});
});