mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
- 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`
457 lines
13 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|