Files
sofa/packages/core/test/import-processor.test.ts
T
jake 58cf2e689f fix: harden backup restore, first-user race, and several reliability issues
- Block all non-health API requests with 503 while a database restore is in progress (`withDatabaseAccessBlocked` in `@sofa/db/client`); pause and resume cron jobs around the restore window
- Replace the two-hook first-user admin promotion with an atomic `claimInitialAdmin` query that uses a DB-level unique constraint so concurrent sign-ups during the first-run window can't each see `userCount === 0`
- Fix `refreshAvailability` to always call `replaceAvailabilityTransaction` (clearing stale rows) even when TMDB returns no US providers, instead of returning early and leaving old data in place
- Fix `performUpdateCheck` to read `release_url` (snake_case) from the public API response instead of `releaseUrl`
- Fix `createJob` import handler to catch `SQLITE_CONSTRAINT_UNIQUE` and surface it as an `IMPORT_ALREADY_RUNNING` conflict error instead of a 500
- Relax public API telemetry schema to accept `string | number` for `users` and `titles` fields
- Add tests for availability clearing, import deduplication, and `claimInitialAdmin`
2026-03-21 16:16:37 -04:00

457 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, spyOn, test } from "bun:test";
import {
importJobs,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
import {
clearAllTables,
eq,
insertMovieWatch,
insertTvShow,
insertUser,
testDb,
} from "@sofa/db/test-utils";
import * as tmdbClient from "@sofa/tmdb/client";
import type { NormalizedImport } from "../src/imports/parsers";
import { processImportJob, readImportJob } from "../src/imports/processor";
// ── Helpers ─────────────────────────────────────────────────────────
/** Insert a fully-fetched movie title (lastFetchedAt set so metadata won't re-fetch). */
function insertMovieTitle(id: string, tmdbId: number, movieTitle = "Test Movie") {
testDb
.insert(titles)
.values({
id,
tmdbId,
type: "movie",
title: movieTitle,
lastFetchedAt: new Date(),
})
.run();
return id;
}
/** Insert a fully-fetched TV title with seasons/episodes (lastFetchedAt set). */
function insertTvShowWithFetchedAt(
titleId: string,
tmdbId: number,
seasonCount = 1,
epsPerSeason = 3,
) {
const result = insertTvShow(titleId, tmdbId, seasonCount, epsPerSeason);
// Mark as fully fetched so getOrFetchTitleByTmdbId skips TMDB API calls
testDb.update(titles).set({ lastFetchedAt: new Date() }).where(eq(titles.id, titleId)).run();
return result;
}
/** Create an import job row in the DB and return its ID. */
function createJob(
userId: string,
payload: NormalizedImport,
options: {
importWatches?: boolean;
importWatchlist?: boolean;
importRatings?: boolean;
} = {},
): string {
const id = `job-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
testDb
.insert(importJobs)
.values({
id,
userId,
source: payload.source,
status: "pending",
payload: JSON.stringify(payload),
importWatches: options.importWatches ?? true,
importWatchlist: options.importWatchlist ?? true,
importRatings: options.importRatings ?? true,
createdAt: new Date(),
})
.run();
return id;
}
beforeEach(() => {
clearAllTables();
});
// ── Movie Import ────────────────────────────────────────────────────
describe("processImportJob — movies", () => {
test("imports a movie with direct tmdbId", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
const payload: NormalizedImport = {
source: "trakt",
movies: [
{
tmdbId: 550,
title: "Fight Club",
year: 1999,
watchedAt: "2024-06-15T20:00:00Z",
},
],
episodes: [],
watchlist: [],
ratings: [],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(1);
expect(job.skippedCount).toBe(0);
expect(job.failedCount).toBe(0);
// Verify watch record created
const watches = testDb
.select()
.from(userMovieWatches)
.where(eq(userMovieWatches.userId, userId))
.all();
expect(watches).toHaveLength(1);
expect(watches[0].titleId).toBe("movie-1");
});
test("deduplicates existing movie watches", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
insertMovieWatch(userId, "movie-1");
const payload: NormalizedImport = {
source: "trakt",
movies: [{ tmdbId: 550, title: "Fight Club" }],
episodes: [],
watchlist: [],
ratings: [],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(0);
expect(job.skippedCount).toBe(1);
// Should still be just the original watch
const watches = testDb
.select()
.from(userMovieWatches)
.where(eq(userMovieWatches.userId, userId))
.all();
expect(watches).toHaveLength(1);
});
});
// ── Episode Import ──────────────────────────────────────────────────
describe("processImportJob — episodes", () => {
test("imports episodes for a pre-seeded TV show", async () => {
const userId = insertUser();
insertTvShowWithFetchedAt("tv-1", 1399, 1, 3);
const payload: NormalizedImport = {
source: "trakt",
movies: [],
episodes: [
{
showTmdbId: 1399,
showTitle: "Test Show",
seasonNumber: 1,
episodeNumber: 1,
watchedAt: "2024-01-10T20:00:00Z",
},
{
showTmdbId: 1399,
showTitle: "Test Show",
seasonNumber: 1,
episodeNumber: 2,
watchedAt: "2024-01-11T20:00:00Z",
},
],
watchlist: [],
ratings: [],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(2);
expect(job.failedCount).toBe(0);
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, userId))
.all();
expect(watches).toHaveLength(2);
});
});
// ── Watchlist Import ────────────────────────────────────────────────
describe("processImportJob — watchlist", () => {
test("sets title status to watchlist", async () => {
const userId = insertUser();
insertMovieTitle("movie-wl", 999, "Watchlist Movie");
const payload: NormalizedImport = {
source: "simkl",
movies: [],
episodes: [],
watchlist: [{ tmdbId: 999, title: "Watchlist Movie", type: "movie" }],
ratings: [],
};
const jobId = createJob(userId, payload, {
importWatches: false,
importWatchlist: true,
importRatings: false,
});
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(1);
const statusRow = testDb
.select()
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
expect(statusRow).toHaveLength(1);
expect(statusRow[0].status).toBe("watchlist");
expect(statusRow[0].titleId).toBe("movie-wl");
});
});
// ── Rating Import ───────────────────────────────────────────────────
describe("processImportJob — ratings", () => {
test("stores rating correctly", async () => {
const userId = insertUser();
insertMovieTitle("movie-r", 888, "Rated Movie");
const payload: NormalizedImport = {
source: "trakt",
movies: [],
episodes: [],
watchlist: [],
ratings: [
{
tmdbId: 888,
title: "Rated Movie",
type: "movie",
rating: 4,
ratedAt: "2024-03-01T12:00:00Z",
},
],
};
const jobId = createJob(userId, payload, {
importWatches: false,
importWatchlist: false,
importRatings: true,
});
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.importedCount).toBe(1);
const ratingRows = testDb
.select()
.from(userRatings)
.where(eq(userRatings.userId, userId))
.all();
expect(ratingRows).toHaveLength(1);
expect(ratingRows[0].ratingStars).toBe(4);
expect(ratingRows[0].titleId).toBe("movie-r");
});
});
// ── Job State Transitions ───────────────────────────────────────────
describe("processImportJob — state transitions", () => {
test("job starts as pending, ends as success", async () => {
const userId = insertUser();
insertMovieTitle("movie-st", 111, "State Test");
const payload: NormalizedImport = {
source: "letterboxd",
movies: [{ tmdbId: 111, title: "State Test" }],
episodes: [],
watchlist: [],
ratings: [],
};
const jobId = createJob(userId, payload);
// Before processing
const before = readImportJob(jobId);
expect(before.status).toBe("pending");
expect(before.startedAt).toBeNull();
expect(before.finishedAt).toBeNull();
await processImportJob(jobId);
// After processing
const after = readImportJob(jobId);
expect(after.status).toBe("success");
expect(after.startedAt).not.toBeNull();
expect(after.finishedAt).not.toBeNull();
});
test("empty import with no matching options succeeds with warning", async () => {
const userId = insertUser();
const payload: NormalizedImport = {
source: "trakt",
movies: [{ tmdbId: 111, title: "A Movie" }],
episodes: [],
watchlist: [],
ratings: [],
};
// Disable all import options — movies exist but importWatches is false
const jobId = createJob(userId, payload, {
importWatches: false,
importWatchlist: false,
importRatings: false,
});
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.warnings.length).toBeGreaterThan(0);
expect(job.warnings[0]).toContain("No items to import");
});
test("readImportJob throws for non-existent job", () => {
expect(() => readImportJob("non-existent-id")).toThrow("Import job non-existent-id not found");
});
test("schema allows only one active import job per user", () => {
const userId = insertUser();
const createdAt = new Date();
testDb
.insert(importJobs)
.values({
id: "job-1",
userId,
source: "trakt",
status: "pending",
payload: JSON.stringify({
source: "trakt",
movies: [],
episodes: [],
watchlist: [],
ratings: [],
}),
importWatches: true,
importWatchlist: true,
importRatings: true,
createdAt,
})
.run();
expect(() =>
testDb
.insert(importJobs)
.values({
id: "job-2",
userId,
source: "simkl",
status: "running",
payload: JSON.stringify({
source: "simkl",
movies: [],
episodes: [],
watchlist: [],
ratings: [],
}),
importWatches: true,
importWatchlist: true,
importRatings: true,
createdAt,
})
.run(),
).toThrow();
testDb.update(importJobs).set({ status: "success" }).where(eq(importJobs.id, "job-1")).run();
expect(() =>
testDb
.insert(importJobs)
.values({
id: "job-3",
userId,
source: "letterboxd",
status: "pending",
payload: JSON.stringify({
source: "letterboxd",
movies: [],
episodes: [],
watchlist: [],
ratings: [],
}),
importWatches: true,
importWatchlist: true,
importRatings: true,
createdAt,
})
.run(),
).not.toThrow();
});
});
// ── Failed Resolution ───────────────────────────────────────────────
describe("processImportJob — failed resolution", () => {
test("records failure when movie cannot be resolved", async () => {
const userId = insertUser();
const payload: NormalizedImport = {
source: "letterboxd",
movies: [{ title: "Completely Unknown Film ZZZZZ" }],
episodes: [],
watchlist: [],
ratings: [],
};
// Mock TMDB search to return empty results (no network call)
const searchSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [],
} as never);
try {
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.status).toBe("success");
expect(job.failedCount).toBe(1);
expect(job.importedCount).toBe(0);
expect(job.errors.length).toBeGreaterThan(0);
expect(job.errors[0]).toContain("Could not resolve movie");
} finally {
searchSpy.mockRestore();
}
});
});