Files
sofa/packages/core/test/import-processor.test.ts
T

736 lines
22 KiB
TypeScript

import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
importJobs,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
import {
clearAllTables,
eq,
insertMovieWatch,
insertStatus,
insertTitle,
insertTvShow,
insertUser,
testClient,
testDb,
} from "@sofa/test/db";
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");
});
});
// ── Status Normalization ────────────────────────────────────────────
describe("processImportJob — status normalization", () => {
const watchlistOnly = {
importWatches: false,
importWatchlist: true,
importRatings: false,
};
test("stores TV 'completed' as 'in_progress'", async () => {
const userId = insertUser();
insertTvShowWithFetchedAt("tv-c", 500);
const payload: NormalizedImport = {
source: "simkl",
movies: [],
episodes: [],
watchlist: [{ tmdbId: 500, title: "Test Show", type: "tv", status: "completed" }],
ratings: [],
};
const jobId = createJob(userId, payload, watchlistOnly);
await processImportJob(jobId);
const rows = testDb
.select()
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
expect(rows).toHaveLength(1);
expect(rows[0].titleId).toBe("tv-c");
expect(rows[0].status).toBe("in_progress");
});
test("stores movie 'in_progress' as 'watchlist'", async () => {
const userId = insertUser();
insertMovieTitle("m-ip", 501);
const payload: NormalizedImport = {
source: "simkl",
movies: [],
episodes: [],
watchlist: [{ tmdbId: 501, title: "Test Movie", type: "movie", status: "in_progress" }],
ratings: [],
};
const jobId = createJob(userId, payload, watchlistOnly);
await processImportJob(jobId);
const rows = testDb
.select()
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
expect(rows).toHaveLength(1);
expect(rows[0].titleId).toBe("m-ip");
expect(rows[0].status).toBe("watchlist");
});
});
// ── Migration: normalize_imported_statuses ──────────────────────────
describe("normalize_imported_statuses migration", () => {
test("repairs TV 'completed' and movie 'in_progress' rows", () => {
const dir = fileURLToPath(new URL("../../db/drizzle", import.meta.url));
const folder = readdirSync(dir).find((d) => d.endsWith("_normalize_imported_statuses"));
expect(folder).toBeDefined();
const userId = insertUser();
insertTitle({ id: "mig-tv", tmdbId: 7001, type: "tv", title: "Mig Show" });
insertTitle({ id: "mig-watched", tmdbId: 7002, type: "movie", title: "Mig Watched" });
insertTitle({ id: "mig-unwatched", tmdbId: 7003, type: "movie", title: "Mig Unwatched" });
insertStatus(userId, "mig-tv", "completed");
insertStatus(userId, "mig-watched", "in_progress");
insertStatus(userId, "mig-unwatched", "in_progress");
insertMovieWatch(userId, "mig-watched");
testClient.exec(readFileSync(join(dir, folder!, "migration.sql"), "utf8"));
const statusOf = (titleId: string) =>
testDb.select().from(userTitleStatus).where(eq(userTitleStatus.titleId, titleId)).get()
?.status;
expect(statusOf("mig-tv")).toBe("in_progress");
expect(statusOf("mig-watched")).toBe("completed");
expect(statusOf("mig-unwatched")).toBe("watchlist");
});
});
// ── 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();
let duplicateActiveJobRejected = false;
try {
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();
} catch {
duplicateActiveJobRejected = true;
}
expect(duplicateActiveJobRejected).toBe(true);
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 = vi.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();
}
});
});
// ── Rewatches, added dates, late cancellation ───────────────────────
describe("processImportJob — rewatches and added dates", () => {
const movieWatches = (userId: string) =>
testDb.select().from(userMovieWatches).where(eq(userMovieWatches.userId, userId)).all();
const emptyPayload = (source: NormalizedImport["source"]): NormalizedImport => ({
source,
movies: [],
episodes: [],
watchlist: [],
ratings: [],
});
const twoPlays: NormalizedImport = {
...emptyPayload("trakt"),
movies: [
{ tmdbId: 550, title: "Fight Club", watchedAt: "2024-01-01T20:00:00Z" },
{ tmdbId: 550, title: "Fight Club", watchedAt: "2024-06-01T20:00:00Z" },
],
};
test("keeps rewatches with distinct timestamps", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
const jobId = createJob(userId, twoPlays);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.importedCount).toBe(2);
expect(movieWatches(userId)).toHaveLength(2);
});
test("skips a play already recorded at the same time", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
insertMovieWatch(userId, "movie-1", new Date("2024-01-01T20:30:00Z"));
const payload: NormalizedImport = {
...emptyPayload("trakt"),
movies: [{ tmdbId: 550, title: "Fight Club", watchedAt: "2024-01-01T20:00:00Z" }],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const job = readImportJob(jobId);
expect(job.skippedCount).toBe(1);
expect(job.importedCount).toBe(0);
expect(movieWatches(userId)).toHaveLength(1);
});
test("re-running the same import is idempotent", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
const firstJobId = createJob(userId, twoPlays);
await processImportJob(firstJobId);
expect(readImportJob(firstJobId).importedCount).toBe(2);
const secondJobId = createJob(userId, twoPlays);
await processImportJob(secondJobId);
const second = readImportJob(secondJobId);
expect(second.skippedCount).toBe(2);
expect(second.importedCount).toBe(0);
expect(movieWatches(userId)).toHaveLength(2);
});
test("keeps episode rewatches", async () => {
const userId = insertUser();
insertTvShowWithFetchedAt("tv-1", 1396);
const payload: NormalizedImport = {
...emptyPayload("trakt"),
episodes: [
{
showTmdbId: 1396,
showTitle: "Test Show",
seasonNumber: 1,
episodeNumber: 1,
watchedAt: "2024-01-01T20:00:00Z",
},
{
showTmdbId: 1396,
showTitle: "Test Show",
seasonNumber: 1,
episodeNumber: 1,
watchedAt: "2024-02-01T20:00:00Z",
},
],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
expect(readImportJob(jobId).importedCount).toBe(2);
const watches = testDb
.select()
.from(userEpisodeWatches)
.where(eq(userEpisodeWatches.userId, userId))
.all();
expect(watches).toHaveLength(2);
});
test("date-only diary entries on different dates are both kept", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
const payload: NormalizedImport = {
...emptyPayload("letterboxd"),
movies: [
{ tmdbId: 550, title: "Fight Club", watchedOn: "2024-01-01" },
{ tmdbId: 550, title: "Fight Club", watchedOn: "2024-01-10" },
],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
expect(readImportJob(jobId).importedCount).toBe(2);
expect(movieWatches(userId)).toHaveLength(2);
});
test("library item backdates addedAt after a watch created the row", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
const payload: NormalizedImport = {
...emptyPayload("trakt"),
movies: [{ tmdbId: 550, title: "Fight Club", watchedAt: "2024-06-01T20:00:00Z" }],
watchlist: [
{
tmdbId: 550,
title: "Fight Club",
type: "movie",
status: "completed",
addedAt: "2023-01-01T00:00:00Z",
},
],
};
const jobId = createJob(userId, payload);
await processImportJob(jobId);
const row = testDb
.select()
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.get();
expect(row).toBeDefined();
expect(row?.addedAt).toEqual(new Date("2023-01-01T00:00:00Z"));
});
test("cancel during the last item is not overwritten by success", async () => {
const userId = insertUser();
insertMovieTitle("movie-1", 550, "Fight Club");
const payload: NormalizedImport = {
...emptyPayload("letterboxd"),
movies: [{ imdbId: "tt0137523", title: "Fight Club" }],
};
let jobId = "";
const findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockImplementation(async () => {
testDb.update(importJobs).set({ status: "cancelled" }).where(eq(importJobs.id, jobId)).run();
return { movie_results: [{ id: 550 }] } as never;
});
try {
jobId = createJob(userId, payload);
await processImportJob(jobId);
expect(findSpy).toHaveBeenCalled();
const job = testDb.select().from(importJobs).where(eq(importJobs.id, jobId)).get();
expect(job?.status).toBe("cancelled");
expect(job?.finishedAt).not.toBeNull();
} finally {
findSpy.mockRestore();
}
});
});