test(db): cover platform seeding, session cleanup, and import recovery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-06 12:43:07 -04:00
1 parent 561a1b6361
commit c6e414ecdb
6 files changed
+296 -1

No files matched your search

+1
View File
@@ -16,6 +16,7 @@
"format": "oxfmt --config ../../.oxfmtrc.json", "format": "oxfmt --config ../../.oxfmtrc.json",
"format:check": "oxfmt --check --config ../../.oxfmtrc.json", "format:check": "oxfmt --check --config ../../.oxfmtrc.json",
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",
"test": "vitest run",
"db:generate": "bun --bun drizzle-kit generate", "db:generate": "bun --bun drizzle-kit generate",
"db:migrate": "bun --bun drizzle-kit migrate", "db:migrate": "bun --bun drizzle-kit migrate",
"db:push": "bun --bun drizzle-kit push", "db:push": "bun --bun drizzle-kit push",
+85
View File
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, test } from "vitest";
import { clearAllTables, insertUser, testDb } from "@sofa/test/db";
import { deleteExpiredSessions, deleteExpiredVerifications } from "../src/queries/auth-cleanup";
import { session, verification } from "../src/schema";
const HOUR = 60 * 60 * 1000;
beforeEach(() => {
clearAllTables();
});
describe("deleteExpiredSessions", () => {
test("deletes only expired sessions", () => {
insertUser("u1");
const now = new Date();
testDb
.insert(session)
.values([
{
id: "s-old",
userId: "u1",
token: "tok-old",
expiresAt: new Date(Date.now() - HOUR),
createdAt: now,
updatedAt: now,
},
{
id: "s-new",
userId: "u1",
token: "tok-new",
expiresAt: new Date(Date.now() + HOUR),
createdAt: now,
updatedAt: now,
},
])
.run();
expect(deleteExpiredSessions()).toBe(1);
expect(
testDb
.select()
.from(session)
.all()
.map((s) => s.id),
).toEqual(["s-new"]);
});
});
describe("deleteExpiredVerifications", () => {
test("deletes only expired verifications", () => {
const now = new Date();
testDb
.insert(verification)
.values([
{
id: "v-old",
identifier: "a@example.com",
value: "x",
expiresAt: new Date(Date.now() - HOUR),
createdAt: now,
updatedAt: now,
},
{
id: "v-new",
identifier: "b@example.com",
value: "y",
expiresAt: new Date(Date.now() + HOUR),
createdAt: now,
updatedAt: now,
},
])
.run();
expect(deleteExpiredVerifications()).toBe(1);
expect(
testDb
.select()
.from(verification)
.all()
.map((v) => v.id),
).toEqual(["v-new"]);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, test } from "vitest";
import { clearAllTables, eq, insertUser, testDb } from "@sofa/test/db";
import { recoverStaleImportJobs } from "../src/queries/imports";
import { importJobs } from "../src/schema";
beforeEach(() => {
clearAllTables();
});
function insertJob(
id: string,
userId: string,
status: "pending" | "running" | "success" | "cancelled",
) {
testDb
.insert(importJobs)
.values({
id,
userId,
source: "trakt",
status,
payload: "{}",
importWatches: true,
importWatchlist: true,
importRatings: true,
createdAt: new Date(),
})
.run();
}
function getJob(id: string) {
return testDb.select().from(importJobs).where(eq(importJobs.id, id)).get()!;
}
describe("recoverStaleImportJobs", () => {
test("errors out pending and running jobs only", () => {
insertUser("u1");
// importJobs_active_user allows one pending/running job per user
insertUser("u2");
insertJob("j-pending", "u1", "pending");
insertJob("j-running", "u2", "running");
insertJob("j-success", "u1", "success");
insertJob("j-cancelled", "u1", "cancelled");
expect(recoverStaleImportJobs()).toBe(2);
for (const id of ["j-pending", "j-running"]) {
const job = getJob(id);
expect(job.status).toBe("error");
expect(job.finishedAt).toBeInstanceOf(Date);
expect(job.errors).toContain("Import interrupted by server restart");
}
for (const [id, status] of [
["j-success", "success"],
["j-cancelled", "cancelled"],
] as const) {
const job = getJob(id);
expect(job.status).toBe(status);
expect(job.finishedAt).toBeNull();
expect(job.errors).toBeNull();
}
});
});
+136
View File
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, test } from "vitest";
import {
clearAllTables,
eq,
insertPlatform,
insertTitle,
insertTitleAvailability,
insertUser,
insertUserPlatform,
testDb,
} from "@sofa/test/db";
import { platformTmdbIds, platforms, titleAvailability, userPlatforms } from "../src/schema";
import { SEED_DATA, seedPlatforms } from "../src/seed-platforms";
beforeEach(() => {
clearAllTables();
});
describe("seedPlatforms", () => {
test("seeds a fresh database", () => {
seedPlatforms();
expect(testDb.select().from(platforms).all()).toHaveLength(SEED_DATA.length);
const totalIds = SEED_DATA.reduce((sum, s) => sum + s.tmdbProviderIds.length, 0);
expect(testDb.select().from(platformTmdbIds).all()).toHaveLength(totalIds);
});
test("is idempotent", () => {
seedPlatforms();
const firstIds = testDb
.select({ id: platforms.id })
.from(platforms)
.all()
.map((p) => p.id)
.toSorted();
const firstMappings = testDb.select().from(platformTmdbIds).all().length;
seedPlatforms();
const secondIds = testDb
.select({ id: platforms.id })
.from(platforms)
.all()
.map((p) => p.id)
.toSorted();
expect(secondIds).toEqual(firstIds);
expect(testDb.select().from(platformTmdbIds).all()).toHaveLength(firstMappings);
});
test("restores changed metadata without changing the id", () => {
seedPlatforms();
const seed = SEED_DATA[0]!;
const mapping = testDb
.select()
.from(platformTmdbIds)
.where(eq(platformTmdbIds.tmdbProviderId, seed.tmdbProviderIds[0]!))
.get()!;
testDb.update(platforms).set({ name: "Old" }).where(eq(platforms.id, mapping.platformId)).run();
seedPlatforms();
const row = testDb.select().from(platforms).where(eq(platforms.id, mapping.platformId)).get();
expect(row?.id).toBe(mapping.platformId);
expect(row?.name).toBe(seed.name);
});
test("merging duplicates keeps user choices and avoids duplicate availability", () => {
const netflix = SEED_DATA.find((s) => s.tmdbProviderIds.includes(8))!;
expect(netflix.tmdbProviderIds).toContain(175);
insertPlatform({ id: "p-a", name: "Netflix", tmdbProviderIds: [8] });
insertPlatform({ id: "p-b", name: "Netflix Ads", tmdbProviderIds: [175] });
insertUser("u1");
insertUser("u2");
insertUserPlatform("u1", "p-b");
insertUserPlatform("u2", "p-a");
insertUserPlatform("u2", "p-b");
insertTitle({ id: "t1", tmdbId: 1, type: "movie" });
insertTitleAvailability("t1", "p-a");
insertTitleAvailability("t1", "p-b");
seedPlatforms();
const mappedIds = testDb
.select()
.from(platformTmdbIds)
.all()
.filter((m) => netflix.tmdbProviderIds.includes(m.tmdbProviderId))
.map((m) => m.platformId);
const canonical = mappedIds[0]!;
expect(mappedIds).toHaveLength(netflix.tmdbProviderIds.length);
expect(new Set(mappedIds)).toEqual(new Set([canonical]));
expect(canonical).toBe("p-a");
expect(testDb.select().from(platforms).where(eq(platforms.id, "p-b")).all()).toHaveLength(0);
expect(testDb.select().from(platforms).where(eq(platforms.id, canonical)).all()).toHaveLength(
1,
);
const u1 = testDb.select().from(userPlatforms).where(eq(userPlatforms.userId, "u1")).all();
expect(u1).toHaveLength(1);
expect(u1[0]!.platformId).toBe(canonical);
const u2 = testDb.select().from(userPlatforms).where(eq(userPlatforms.userId, "u2")).all();
expect(u2).toHaveLength(1);
expect(u2[0]!.platformId).toBe(canonical);
const avail = testDb
.select()
.from(titleAvailability)
.where(eq(titleAvailability.titleId, "t1"))
.all();
expect(avail).toHaveLength(1);
expect(avail[0]!.platformId).toBe(canonical);
expect(avail[0]!.offerType).toBe("flatrate");
});
test("unrelated platforms survive seeding", () => {
const tmdbId = 999999;
expect(SEED_DATA.some((s) => s.tmdbProviderIds.includes(tmdbId))).toBe(false);
insertPlatform({ id: "p-x", name: "Tiny", tmdbProviderIds: [tmdbId] });
seedPlatforms();
expect(testDb.select().from(platforms).where(eq(platforms.id, "p-x")).all()).toHaveLength(1);
const mapping = testDb
.select()
.from(platformTmdbIds)
.where(eq(platformTmdbIds.tmdbProviderId, tmdbId))
.all();
expect(mapping).toHaveLength(1);
expect(mapping[0]!.platformId).toBe("p-x");
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"extends": "@sofa/tsconfig/base.json", "extends": "@sofa/tsconfig/base.json",
"include": ["src"] "include": ["src", "test"]
} }
+8
View File
@@ -0,0 +1,8 @@
import { defineProject } from "vitest/config";
export default defineProject({
test: {
include: ["test/**/*.test.ts"],
setupFiles: ["@sofa/test/setup"],
},
});