mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
Add comprehensive test suite with CI workflow
- 211 tests across 15 files covering services (tracking, discovery, metadata, backup, credits, webhooks, settings, update-check, colors, person), utilities (config, cron, providers, title-theme), and TMDB image URL helpers - `lib/test-preload.ts` + `bunfig.toml` wire up a global in-memory SQLite DB with migrations for all DB-backed tests - `lib/test-utils.ts` provides `clearAllTables()` and seed helpers (insertUser, insertTitle, insertTvShow, insertMovieWatch, etc.) - Export `getBackupSource`, `isKnownBackup`, `isValidBackupFilename`, `buildBackupCron`, `performUpdateCheck` internals for direct testing - GitHub Actions workflow runs `bun test --coverage` on push/PR to main
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: bun test --coverage --coverage-reporter=lcov
|
||||
|
||||
# - name: Upload to Codecov
|
||||
# uses: codecov/codecov-action@v3
|
||||
# with:
|
||||
# file: ./coverage/lcov.info
|
||||
# fail_ci_if_error: true
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./lib/test-preload.ts"]
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
getOidcProviderName,
|
||||
isOidcAutoRegisterEnabled,
|
||||
isOidcConfigured,
|
||||
isPasswordLoginDisabled,
|
||||
isTmdbConfigured,
|
||||
} from "./config";
|
||||
|
||||
describe("isTmdbConfigured", () => {
|
||||
const orig = process.env.TMDB_API_READ_ACCESS_TOKEN;
|
||||
|
||||
afterEach(() => {
|
||||
if (orig !== undefined) {
|
||||
process.env.TMDB_API_READ_ACCESS_TOKEN = orig;
|
||||
} else {
|
||||
delete process.env.TMDB_API_READ_ACCESS_TOKEN;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns true when token is set", () => {
|
||||
process.env.TMDB_API_READ_ACCESS_TOKEN = "some-token";
|
||||
expect(isTmdbConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when token is unset", () => {
|
||||
delete process.env.TMDB_API_READ_ACCESS_TOKEN;
|
||||
expect(isTmdbConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when token is empty string", () => {
|
||||
process.env.TMDB_API_READ_ACCESS_TOKEN = "";
|
||||
expect(isTmdbConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOidcConfigured", () => {
|
||||
const origId = process.env.OIDC_CLIENT_ID;
|
||||
const origSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
const origIssuer = process.env.OIDC_ISSUER_URL;
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, val] of [
|
||||
["OIDC_CLIENT_ID", origId],
|
||||
["OIDC_CLIENT_SECRET", origSecret],
|
||||
["OIDC_ISSUER_URL", origIssuer],
|
||||
] as const) {
|
||||
if (val !== undefined) {
|
||||
process.env[key] = val;
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("returns true when all three vars are set", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://issuer.example.com";
|
||||
expect(isOidcConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when client ID is missing", () => {
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://issuer.example.com";
|
||||
expect(isOidcConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when client secret is missing", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
process.env.OIDC_ISSUER_URL = "https://issuer.example.com";
|
||||
expect(isOidcConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when issuer URL is missing", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
expect(isOidcConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOidcProviderName", () => {
|
||||
const orig = process.env.OIDC_PROVIDER_NAME;
|
||||
|
||||
afterEach(() => {
|
||||
if (orig !== undefined) {
|
||||
process.env.OIDC_PROVIDER_NAME = orig;
|
||||
} else {
|
||||
delete process.env.OIDC_PROVIDER_NAME;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns custom name when set", () => {
|
||||
process.env.OIDC_PROVIDER_NAME = "Okta";
|
||||
expect(getOidcProviderName()).toBe("Okta");
|
||||
});
|
||||
|
||||
test("returns 'SSO' as default", () => {
|
||||
delete process.env.OIDC_PROVIDER_NAME;
|
||||
expect(getOidcProviderName()).toBe("SSO");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOidcAutoRegisterEnabled", () => {
|
||||
const orig = process.env.OIDC_AUTO_REGISTER;
|
||||
|
||||
afterEach(() => {
|
||||
if (orig !== undefined) {
|
||||
process.env.OIDC_AUTO_REGISTER = orig;
|
||||
} else {
|
||||
delete process.env.OIDC_AUTO_REGISTER;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns true by default", () => {
|
||||
delete process.env.OIDC_AUTO_REGISTER;
|
||||
expect(isOidcAutoRegisterEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when explicitly set to 'false'", () => {
|
||||
process.env.OIDC_AUTO_REGISTER = "false";
|
||||
expect(isOidcAutoRegisterEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for any other value", () => {
|
||||
process.env.OIDC_AUTO_REGISTER = "true";
|
||||
expect(isOidcAutoRegisterEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPasswordLoginDisabled", () => {
|
||||
const origDisable = process.env.DISABLE_PASSWORD_LOGIN;
|
||||
const origId = process.env.OIDC_CLIENT_ID;
|
||||
const origSecret = process.env.OIDC_CLIENT_SECRET;
|
||||
const origIssuer = process.env.OIDC_ISSUER_URL;
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, val] of [
|
||||
["DISABLE_PASSWORD_LOGIN", origDisable],
|
||||
["OIDC_CLIENT_ID", origId],
|
||||
["OIDC_CLIENT_SECRET", origSecret],
|
||||
["OIDC_ISSUER_URL", origIssuer],
|
||||
] as const) {
|
||||
if (val !== undefined) {
|
||||
process.env[key] = val;
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("returns true when flag is 'true' and OIDC is configured", () => {
|
||||
process.env.DISABLE_PASSWORD_LOGIN = "true";
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://issuer.example.com";
|
||||
expect(isPasswordLoginDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when flag is 'true' but OIDC is not configured", () => {
|
||||
process.env.DISABLE_PASSWORD_LOGIN = "true";
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
expect(isPasswordLoginDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when flag is not set", () => {
|
||||
delete process.env.DISABLE_PASSWORD_LOGIN;
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://issuer.example.com";
|
||||
expect(isPasswordLoginDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
|
||||
mock.module("@/lib/services/availability", () => ({
|
||||
refreshAvailability: async () => {},
|
||||
}));
|
||||
mock.module("@/lib/services/backup", () => ({
|
||||
createBackup: async () => ({}),
|
||||
ensureBackupDir: async () => {},
|
||||
pruneBackups: async () => {},
|
||||
}));
|
||||
mock.module("@/lib/services/credits", () => ({
|
||||
refreshCredits: async () => {},
|
||||
}));
|
||||
mock.module("@/lib/services/image-cache", () => ({
|
||||
cacheEpisodeStills: async () => {},
|
||||
cacheImagesForTitle: async () => {},
|
||||
cacheProfilePhotos: async () => {},
|
||||
cacheProviderLogos: async () => {},
|
||||
imageCacheEnabled: () => false,
|
||||
}));
|
||||
mock.module("@/lib/services/metadata", () => ({
|
||||
refreshRecommendations: async () => {},
|
||||
refreshTitle: async () => {},
|
||||
refreshTvChildren: async () => {},
|
||||
}));
|
||||
mock.module("@/lib/services/update-check", () => ({
|
||||
performUpdateCheck: async () => ({}),
|
||||
}));
|
||||
mock.module("@/lib/tmdb/client", () => ({
|
||||
getTvDetails: async () => ({}),
|
||||
}));
|
||||
|
||||
import { buildBackupCron } from "./cron";
|
||||
|
||||
describe("buildBackupCron", () => {
|
||||
test("6h frequency", () => {
|
||||
expect(buildBackupCron("6h", "02:00")).toBe("0 */6 * * *");
|
||||
});
|
||||
|
||||
test("12h frequency", () => {
|
||||
expect(buildBackupCron("12h", "02:00")).toBe("0 2,14 * * *");
|
||||
});
|
||||
|
||||
test("1d frequency (default)", () => {
|
||||
expect(buildBackupCron("1d", "03:30")).toBe("30 3 * * *");
|
||||
});
|
||||
|
||||
test("7d frequency with day of week", () => {
|
||||
expect(buildBackupCron("7d", "04:15", 3)).toBe("15 4 * * 3");
|
||||
});
|
||||
|
||||
test("7d frequency defaults to Sunday (0)", () => {
|
||||
expect(buildBackupCron("7d", "02:00")).toBe("0 2 * * 0");
|
||||
});
|
||||
|
||||
test("defaults to 1d when called with no args", () => {
|
||||
expect(buildBackupCron()).toBe("0 2 * * *");
|
||||
});
|
||||
|
||||
test("handles invalid time gracefully", () => {
|
||||
const result = buildBackupCron("1d", "invalid");
|
||||
expect(result).toBe("0 2 * * *");
|
||||
});
|
||||
|
||||
test("6h ignores hour from time, uses minute only", () => {
|
||||
expect(buildBackupCron("6h", "14:45")).toBe("45 */6 * * *");
|
||||
});
|
||||
|
||||
test("12h wraps hour correctly", () => {
|
||||
expect(buildBackupCron("12h", "18:00")).toBe("0 18,6 * * *");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { generateProviderUrl } from "./providers";
|
||||
|
||||
describe("generateProviderUrl", () => {
|
||||
test("generates Netflix URL", () => {
|
||||
expect(generateProviderUrl(8, "Inception")).toBe(
|
||||
"https://www.netflix.com/search?q=Inception",
|
||||
);
|
||||
});
|
||||
|
||||
test("generates Amazon Prime Video URL", () => {
|
||||
expect(generateProviderUrl(9, "The Matrix")).toBe(
|
||||
"https://www.amazon.com/s?i=instant-video&k=The%20Matrix",
|
||||
);
|
||||
});
|
||||
|
||||
test("generates Disney+ URL", () => {
|
||||
expect(generateProviderUrl(337, "Frozen")).toBe(
|
||||
"https://www.disneyplus.com/search/Frozen",
|
||||
);
|
||||
});
|
||||
|
||||
test("URL-encodes spaces", () => {
|
||||
expect(generateProviderUrl(8, "The Dark Knight")).toBe(
|
||||
"https://www.netflix.com/search?q=The%20Dark%20Knight",
|
||||
);
|
||||
});
|
||||
|
||||
test("URL-encodes special characters", () => {
|
||||
expect(generateProviderUrl(8, "Tom & Jerry")).toBe(
|
||||
"https://www.netflix.com/search?q=Tom%20%26%20Jerry",
|
||||
);
|
||||
});
|
||||
|
||||
test("URL-encodes unicode", () => {
|
||||
const url = generateProviderUrl(8, "Amelie");
|
||||
expect(url).toContain("Amelie");
|
||||
expect(url).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for unknown provider ID", () => {
|
||||
expect(generateProviderUrl(99999, "Test")).toBeNull();
|
||||
});
|
||||
|
||||
test("generates Hulu URL", () => {
|
||||
expect(generateProviderUrl(15, "Test")).toBe(
|
||||
"https://www.hulu.com/search?q=Test",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
getBackupSource,
|
||||
isKnownBackup,
|
||||
isValidBackupFilename,
|
||||
} from "./backup";
|
||||
|
||||
describe("getBackupSource", () => {
|
||||
test("detects manual backup", () => {
|
||||
expect(getBackupSource("sofa-manual-2024-01-15-120000.db")).toBe("manual");
|
||||
});
|
||||
|
||||
test("detects manual backup with milliseconds", () => {
|
||||
expect(getBackupSource("sofa-manual-2024-01-15-120000123.db")).toBe(
|
||||
"manual",
|
||||
);
|
||||
});
|
||||
|
||||
test("detects scheduled backup", () => {
|
||||
expect(getBackupSource("sofa-scheduled-2024-06-01-030000.db")).toBe(
|
||||
"scheduled",
|
||||
);
|
||||
});
|
||||
|
||||
test("detects pre-restore backup", () => {
|
||||
expect(getBackupSource("pre-restore-2024-06-01-030000.db")).toBe(
|
||||
"pre-restore",
|
||||
);
|
||||
});
|
||||
|
||||
test("defaults to manual for unknown patterns", () => {
|
||||
expect(getBackupSource("random-file.db")).toBe("manual");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isKnownBackup", () => {
|
||||
test("recognizes manual backup", () => {
|
||||
expect(isKnownBackup("sofa-manual-2024-01-15-120000.db")).toBe(true);
|
||||
});
|
||||
|
||||
test("recognizes scheduled backup", () => {
|
||||
expect(isKnownBackup("sofa-scheduled-2024-06-01-030000.db")).toBe(true);
|
||||
});
|
||||
|
||||
test("recognizes pre-restore backup", () => {
|
||||
expect(isKnownBackup("pre-restore-2024-06-01-030000.db")).toBe(true);
|
||||
});
|
||||
|
||||
test("recognizes backup with milliseconds", () => {
|
||||
expect(isKnownBackup("sofa-manual-2024-01-15-120000456.db")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects random filename", () => {
|
||||
expect(isKnownBackup("random-file.db")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects similar but wrong prefix", () => {
|
||||
expect(isKnownBackup("sofa-auto-2024-01-15-120000.db")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects wrong extension", () => {
|
||||
expect(isKnownBackup("sofa-manual-2024-01-15-120000.sql")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidBackupFilename", () => {
|
||||
test("accepts valid manual backup", () => {
|
||||
expect(isValidBackupFilename("sofa-manual-2024-01-15-120000.db")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts valid scheduled backup", () => {
|
||||
expect(isValidBackupFilename("sofa-scheduled-2024-06-01-030000.db")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects path traversal", () => {
|
||||
expect(isValidBackupFilename("../sofa-manual-2024-01-15-120000.db")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects directory separators", () => {
|
||||
expect(
|
||||
isValidBackupFilename("subdir/sofa-manual-2024-01-15-120000.db"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects unknown filenames", () => {
|
||||
expect(isValidBackupFilename("evil.db")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects filenames with double dots", () => {
|
||||
expect(isValidBackupFilename("sofa-manual-2024..01-15-120000.db")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -53,13 +53,15 @@ const REQUIRED_TABLES = [
|
||||
|
||||
let backupOpQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function getBackupSource(filename: string): BackupSource {
|
||||
/** @internal */
|
||||
export function getBackupSource(filename: string): BackupSource {
|
||||
if (SCHEDULED_PATTERN.test(filename)) return "scheduled";
|
||||
if (PRE_RESTORE_PATTERN.test(filename)) return "pre-restore";
|
||||
return "manual";
|
||||
}
|
||||
|
||||
function isKnownBackup(filename: string): boolean {
|
||||
/** @internal */
|
||||
export function isKnownBackup(filename: string): boolean {
|
||||
return (
|
||||
MANUAL_PATTERN.test(filename) ||
|
||||
SCHEDULED_PATTERN.test(filename) ||
|
||||
@@ -129,7 +131,8 @@ function validateBackupDatabase(filePath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidBackupFilename(filename: string): boolean {
|
||||
/** @internal */
|
||||
export function isValidBackupFilename(filename: string): boolean {
|
||||
const base = path.basename(filename);
|
||||
return (
|
||||
base === filename && !filename.includes("..") && isKnownBackup(filename)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
|
||||
mock.module("@/lib/services/image-cache", () => ({
|
||||
downloadAndCacheImage: async () => {},
|
||||
getLocalImagePath: () => "",
|
||||
imageCacheEnabled: () => false,
|
||||
isImageCached: async () => false,
|
||||
}));
|
||||
|
||||
import { parseColorPalette } from "./colors";
|
||||
|
||||
describe("parseColorPalette", () => {
|
||||
test("returns null for null input", () => {
|
||||
expect(parseColorPalette(null)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseColorPalette("")).toBeNull();
|
||||
});
|
||||
|
||||
test("parses valid JSON", () => {
|
||||
const palette = {
|
||||
vibrant: "#e63946",
|
||||
darkVibrant: "#1d3557",
|
||||
lightVibrant: "#f1faee",
|
||||
muted: "#a8dadc",
|
||||
darkMuted: "#457b9d",
|
||||
lightMuted: "#f4f4f4",
|
||||
};
|
||||
expect(parseColorPalette(JSON.stringify(palette))).toEqual(palette);
|
||||
});
|
||||
|
||||
test("returns null for malformed JSON", () => {
|
||||
expect(parseColorPalette("{not valid json")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { persons, titleCast } from "@/lib/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@/lib/test-utils";
|
||||
|
||||
mock.module("@/lib/tmdb/image", () => ({
|
||||
tmdbImageUrl: (path: string | null) => path,
|
||||
}));
|
||||
|
||||
mock.module("./image-cache", () => ({
|
||||
imageCacheEnabled: () => false,
|
||||
cacheProfilePhotos: async () => {},
|
||||
}));
|
||||
|
||||
import { getCastForTitle } from "./credits";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
function insertPerson(
|
||||
id: string,
|
||||
tmdbId: number,
|
||||
name: string,
|
||||
profilePath: string | null = null,
|
||||
) {
|
||||
testDb.insert(persons).values({ id, tmdbId, name, profilePath }).run();
|
||||
return id;
|
||||
}
|
||||
|
||||
function insertCastEntry(
|
||||
titleId: string,
|
||||
personId: string,
|
||||
overrides: {
|
||||
character?: string | null;
|
||||
department?: string;
|
||||
job?: string | null;
|
||||
displayOrder?: number;
|
||||
episodeCount?: number | null;
|
||||
} = {},
|
||||
) {
|
||||
testDb
|
||||
.insert(titleCast)
|
||||
.values({
|
||||
titleId,
|
||||
personId,
|
||||
character: overrides.character ?? "Character",
|
||||
department: overrides.department ?? "Acting",
|
||||
job: overrides.job ?? null,
|
||||
displayOrder: overrides.displayOrder ?? 0,
|
||||
episodeCount: overrides.episodeCount ?? null,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
describe("getCastForTitle", () => {
|
||||
test("returns cast members ordered by displayOrder", () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertPerson("p1", 100, "Actor One", "/path1.jpg");
|
||||
insertPerson("p2", 200, "Actor Two", "/path2.jpg");
|
||||
insertCastEntry("m1", "p1", { character: "Hero", displayOrder: 0 });
|
||||
insertCastEntry("m1", "p2", { character: "Villain", displayOrder: 1 });
|
||||
|
||||
const cast = getCastForTitle("m1");
|
||||
expect(cast).toHaveLength(2);
|
||||
expect(cast[0].name).toBe("Actor One");
|
||||
expect(cast[0].character).toBe("Hero");
|
||||
expect(cast[0].profilePath).toBe("/path1.jpg");
|
||||
expect(cast[1].name).toBe("Actor Two");
|
||||
});
|
||||
|
||||
test("includes crew members", () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertPerson("p1", 100, "Director Person");
|
||||
insertCastEntry("m1", "p1", {
|
||||
character: null,
|
||||
department: "Directing",
|
||||
job: "Director",
|
||||
displayOrder: 100,
|
||||
});
|
||||
|
||||
const cast = getCastForTitle("m1");
|
||||
expect(cast).toHaveLength(1);
|
||||
expect(cast[0].department).toBe("Directing");
|
||||
expect(cast[0].job).toBe("Director");
|
||||
});
|
||||
|
||||
test("returns empty array for title with no cast", () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
const cast = getCastForTitle("m1");
|
||||
expect(cast).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import {
|
||||
clearAllTables,
|
||||
insertAvailabilityOffer,
|
||||
insertEpisodeWatch,
|
||||
insertMovieWatch,
|
||||
insertRating,
|
||||
insertRecommendation,
|
||||
insertStatus,
|
||||
insertTitle,
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
} from "@/lib/test-utils";
|
||||
|
||||
mock.module("@/lib/tmdb/image", () => ({
|
||||
tmdbImageUrl: (path: string | null) => path,
|
||||
}));
|
||||
|
||||
import {
|
||||
getContinueWatchingFeed,
|
||||
getNewAvailableFeed,
|
||||
getRecommendationsFeed,
|
||||
getRecommendationsForTitle,
|
||||
getUserStats,
|
||||
getWatchCount,
|
||||
getWatchHistory,
|
||||
} from "./discovery";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
// ── getWatchCount ───────────────────────────────────────────────────
|
||||
|
||||
describe("getWatchCount", () => {
|
||||
test("counts movie watches within period", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertTitle({ id: "m2", tmdbId: 2 });
|
||||
insertMovieWatch("user-1", "m1");
|
||||
insertMovieWatch("user-1", "m2");
|
||||
|
||||
const count = getWatchCount("user-1", "movies", "this_month");
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("counts episode watches within period", () => {
|
||||
insertUser();
|
||||
const { episodeIds } = insertTvShow();
|
||||
insertEpisodeWatch("user-1", episodeIds[0]);
|
||||
insertEpisodeWatch("user-1", episodeIds[1]);
|
||||
|
||||
const count = getWatchCount("user-1", "episodes", "this_week");
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("excludes watches outside period", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
// Watch from 2 years ago
|
||||
const oldDate = new Date();
|
||||
oldDate.setFullYear(oldDate.getFullYear() - 2);
|
||||
insertMovieWatch("user-1", "m1", oldDate);
|
||||
|
||||
const count = getWatchCount("user-1", "movies", "this_year");
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
test("returns 0 when no watches exist", () => {
|
||||
insertUser();
|
||||
const count = getWatchCount("user-1", "movies", "today");
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getWatchHistory ─────────────────────────────────────────────────
|
||||
|
||||
describe("getWatchHistory", () => {
|
||||
test("returns bucketed history with correct total count", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertTitle({ id: "m2", tmdbId: 2 });
|
||||
insertMovieWatch("user-1", "m1");
|
||||
insertMovieWatch("user-1", "m2");
|
||||
|
||||
const history = getWatchHistory("user-1", "movies", "this_week");
|
||||
expect(history).toBeArrayOfSize(7);
|
||||
const totalCount = history.reduce((sum, b) => sum + b.count, 0);
|
||||
expect(totalCount).toBe(2);
|
||||
});
|
||||
|
||||
test("returns all-zero buckets when no watches", () => {
|
||||
insertUser();
|
||||
const history = getWatchHistory("user-1", "movies", "this_month");
|
||||
expect(history).toBeArrayOfSize(30);
|
||||
expect(history.every((b) => b.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns correct bucket count for today period", () => {
|
||||
insertUser();
|
||||
const history = getWatchHistory("user-1", "episodes", "today");
|
||||
expect(history).toBeArrayOfSize(24);
|
||||
});
|
||||
|
||||
test("returns correct bucket count for this_year period", () => {
|
||||
insertUser();
|
||||
const history = getWatchHistory("user-1", "movies", "this_year");
|
||||
expect(history).toBeArrayOfSize(12);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getUserStats ────────────────────────────────────────────────────
|
||||
|
||||
describe("getUserStats", () => {
|
||||
test("returns correct stats", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertTitle({ id: "m2", tmdbId: 2 });
|
||||
const { titleId } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
|
||||
insertMovieWatch("user-1", "m1");
|
||||
insertMovieWatch("user-1", "m2");
|
||||
insertEpisodeWatch("user-1", "tv-1-s1e1");
|
||||
|
||||
insertStatus("user-1", "m1", "completed");
|
||||
insertStatus("user-1", "m2", "completed");
|
||||
insertStatus("user-1", titleId, "in_progress");
|
||||
|
||||
const stats = getUserStats("user-1");
|
||||
expect(stats.moviesThisMonth).toBe(2);
|
||||
expect(stats.episodesThisWeek).toBe(1);
|
||||
expect(stats.librarySize).toBe(3);
|
||||
expect(stats.completed).toBe(2);
|
||||
});
|
||||
|
||||
test("returns zeros when no data", () => {
|
||||
insertUser();
|
||||
const stats = getUserStats("user-1");
|
||||
expect(stats.moviesThisMonth).toBe(0);
|
||||
expect(stats.episodesThisWeek).toBe(0);
|
||||
expect(stats.librarySize).toBe(0);
|
||||
expect(stats.completed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getContinueWatchingFeed ─────────────────────────────────────────
|
||||
|
||||
describe("getContinueWatchingFeed", () => {
|
||||
test("returns in-progress shows with next unwatched episode", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
insertStatus("user-1", titleId, "in_progress");
|
||||
insertEpisodeWatch("user-1", episodeIds[0]);
|
||||
|
||||
const feed = getContinueWatchingFeed("user-1");
|
||||
expect(feed).toHaveLength(1);
|
||||
expect(feed[0].title.id).toBe(titleId);
|
||||
expect(feed[0].nextEpisode?.episodeNumber).toBe(2);
|
||||
expect(feed[0].watchedEpisodes).toBe(1);
|
||||
expect(feed[0].totalEpisodes).toBe(3);
|
||||
});
|
||||
|
||||
test("excludes completed shows", () => {
|
||||
insertUser();
|
||||
const { titleId } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
insertStatus("user-1", titleId, "completed");
|
||||
|
||||
const feed = getContinueWatchingFeed("user-1");
|
||||
expect(feed).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("excludes movies", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1, type: "movie" });
|
||||
insertStatus("user-1", "m1", "in_progress");
|
||||
|
||||
const feed = getContinueWatchingFeed("user-1");
|
||||
expect(feed).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("returns empty when no in-progress shows", () => {
|
||||
insertUser();
|
||||
const feed = getContinueWatchingFeed("user-1");
|
||||
expect(feed).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("sorts by most recent watch", () => {
|
||||
insertUser();
|
||||
const show1 = insertTvShow("tv-1", 11111, 1, 3);
|
||||
const show2 = insertTvShow("tv-2", 22222, 1, 3);
|
||||
insertStatus("user-1", show1.titleId, "in_progress");
|
||||
insertStatus("user-1", show2.titleId, "in_progress");
|
||||
|
||||
const older = new Date("2026-01-01");
|
||||
const newer = new Date("2026-03-01");
|
||||
insertEpisodeWatch("user-1", show1.episodeIds[0], older);
|
||||
insertEpisodeWatch("user-1", show2.episodeIds[0], newer);
|
||||
|
||||
const feed = getContinueWatchingFeed("user-1");
|
||||
expect(feed).toHaveLength(2);
|
||||
expect(feed[0].title.id).toBe("tv-2");
|
||||
expect(feed[1].title.id).toBe("tv-1");
|
||||
});
|
||||
|
||||
test("skips show when all episodes are watched (no next episode)", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 2);
|
||||
insertStatus("user-1", titleId, "in_progress");
|
||||
for (const epId of episodeIds) {
|
||||
insertEpisodeWatch("user-1", epId);
|
||||
}
|
||||
|
||||
const feed = getContinueWatchingFeed("user-1");
|
||||
expect(feed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getNewAvailableFeed ─────────────────────────────────────────────
|
||||
|
||||
describe("getNewAvailableFeed", () => {
|
||||
test("returns titles with availability offers", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertAvailabilityOffer("m1");
|
||||
|
||||
const feed = getNewAvailableFeed("user-1");
|
||||
expect(feed).toHaveLength(1);
|
||||
expect(feed[0].titleId).toBe("m1");
|
||||
});
|
||||
|
||||
test("excludes titles without availability", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
|
||||
const feed = getNewAvailableFeed("user-1");
|
||||
expect(feed).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("excludes titles not in user library", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertAvailabilityOffer("m1");
|
||||
|
||||
const feed = getNewAvailableFeed("user-1");
|
||||
expect(feed).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getRecommendationsFeed ──────────────────────────────────────────
|
||||
|
||||
describe("getRecommendationsFeed", () => {
|
||||
test("returns recommendations from completed titles", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1, title: "Source Movie" });
|
||||
insertTitle({ id: "m2", tmdbId: 2, title: "Recommended Movie" });
|
||||
insertStatus("user-1", "m1", "completed");
|
||||
insertRecommendation("m1", "m2", { rank: 1 });
|
||||
|
||||
const feed = getRecommendationsFeed("user-1");
|
||||
expect(feed).toHaveLength(1);
|
||||
expect(feed[0]?.id).toBe("m2");
|
||||
});
|
||||
|
||||
test("returns recommendations from highly-rated titles", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertTitle({ id: "m2", tmdbId: 2 });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertRating("user-1", "m1", 5);
|
||||
insertRecommendation("m1", "m2", { rank: 1 });
|
||||
|
||||
const feed = getRecommendationsFeed("user-1");
|
||||
expect(feed).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("excludes already-tracked titles", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertTitle({ id: "m2", tmdbId: 2 });
|
||||
insertStatus("user-1", "m1", "completed");
|
||||
insertStatus("user-1", "m2", "watchlist");
|
||||
insertRecommendation("m1", "m2", { rank: 1 });
|
||||
|
||||
const feed = getRecommendationsFeed("user-1");
|
||||
expect(feed).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("returns empty when no source titles", () => {
|
||||
insertUser();
|
||||
const feed = getRecommendationsFeed("user-1");
|
||||
expect(feed).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("scores higher when recommended by multiple sources", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertTitle({ id: "m2", tmdbId: 2 });
|
||||
insertTitle({ id: "m3", tmdbId: 3 });
|
||||
insertTitle({ id: "rec1", tmdbId: 10, title: "Double Recommended" });
|
||||
insertTitle({ id: "rec2", tmdbId: 20, title: "Single Recommended" });
|
||||
insertStatus("user-1", "m1", "completed");
|
||||
insertStatus("user-1", "m2", "completed");
|
||||
|
||||
// rec1 recommended by both m1 and m2
|
||||
insertRecommendation("m1", "rec1", { rank: 1 });
|
||||
insertRecommendation("m2", "rec1", { rank: 1 });
|
||||
// rec2 recommended only by m1
|
||||
insertRecommendation("m1", "rec2", { rank: 1 });
|
||||
|
||||
const feed = getRecommendationsFeed("user-1");
|
||||
expect(feed).toHaveLength(2);
|
||||
expect(feed[0]?.id).toBe("rec1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getRecommendationsForTitle ──────────────────────────────────────
|
||||
|
||||
describe("getRecommendationsForTitle", () => {
|
||||
test("returns ordered recommendations for a title", () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
insertTitle({ id: "rec1", tmdbId: 10, title: "Rec One" });
|
||||
insertTitle({ id: "rec2", tmdbId: 20, title: "Rec Two" });
|
||||
insertRecommendation("m1", "rec1", { rank: 2 });
|
||||
insertRecommendation("m1", "rec2", { rank: 1 });
|
||||
|
||||
const recs = getRecommendationsForTitle("m1");
|
||||
expect(recs).toHaveLength(2);
|
||||
expect(recs[0].title).toBe("Rec Two");
|
||||
expect(recs[1].title).toBe("Rec One");
|
||||
});
|
||||
|
||||
test("returns empty for unknown title", () => {
|
||||
const recs = getRecommendationsForTitle("nonexistent");
|
||||
expect(recs).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("returns empty when no recommendations exist", () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1 });
|
||||
const recs = getRecommendationsForTitle("m1");
|
||||
expect(recs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type {
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
TmdbVideo,
|
||||
} from "@/lib/tmdb/types";
|
||||
import {
|
||||
extractMovieContentRating,
|
||||
extractTvContentRating,
|
||||
pickBestTrailer,
|
||||
} from "./metadata";
|
||||
|
||||
function makeVideo(overrides: Partial<TmdbVideo> = {}): TmdbVideo {
|
||||
return {
|
||||
id: "v1",
|
||||
key: "abc123",
|
||||
name: "Trailer",
|
||||
site: "YouTube",
|
||||
type: "Trailer",
|
||||
official: false,
|
||||
published_at: "2024-01-01T00:00:00.000Z",
|
||||
size: 1080,
|
||||
iso_639_1: "en",
|
||||
iso_3166_1: "US",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pickBestTrailer", () => {
|
||||
test("returns null for empty array", () => {
|
||||
expect(pickBestTrailer([])).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when no YouTube videos", () => {
|
||||
const videos = [makeVideo({ site: "Vimeo", key: "vimeo1" })];
|
||||
expect(pickBestTrailer(videos)).toBeNull();
|
||||
});
|
||||
|
||||
test("prefers official trailers over unofficial", () => {
|
||||
const videos = [
|
||||
makeVideo({ key: "unofficial", official: false, type: "Trailer" }),
|
||||
makeVideo({ key: "official", official: true, type: "Trailer" }),
|
||||
];
|
||||
expect(pickBestTrailer(videos)).toBe("official");
|
||||
});
|
||||
|
||||
test("prefers trailers over teasers", () => {
|
||||
const videos = [
|
||||
makeVideo({ key: "teaser", type: "Teaser" }),
|
||||
makeVideo({ key: "trailer", type: "Trailer" }),
|
||||
];
|
||||
expect(pickBestTrailer(videos)).toBe("trailer");
|
||||
});
|
||||
|
||||
test("falls back to teaser when no trailers", () => {
|
||||
const videos = [
|
||||
makeVideo({ key: "teaser", type: "Teaser" }),
|
||||
makeVideo({ key: "featurette", type: "Featurette" }),
|
||||
];
|
||||
expect(pickBestTrailer(videos)).toBe("teaser");
|
||||
});
|
||||
|
||||
test("returns null when only non-trailer/teaser types exist", () => {
|
||||
const videos = [
|
||||
makeVideo({ key: "feat", type: "Featurette" }),
|
||||
makeVideo({ key: "clip", type: "Clip" }),
|
||||
];
|
||||
expect(pickBestTrailer(videos)).toBeNull();
|
||||
});
|
||||
|
||||
test("prefers English over other languages", () => {
|
||||
const videos = [
|
||||
makeVideo({ key: "french", iso_639_1: "fr", type: "Trailer" }),
|
||||
makeVideo({ key: "english", iso_639_1: "en", type: "Trailer" }),
|
||||
];
|
||||
expect(pickBestTrailer(videos)).toBe("english");
|
||||
});
|
||||
|
||||
test("sorts by newest published_at", () => {
|
||||
const videos = [
|
||||
makeVideo({
|
||||
key: "older",
|
||||
type: "Trailer",
|
||||
official: true,
|
||||
published_at: "2023-01-01T00:00:00.000Z",
|
||||
}),
|
||||
makeVideo({
|
||||
key: "newer",
|
||||
type: "Trailer",
|
||||
official: true,
|
||||
published_at: "2024-06-01T00:00:00.000Z",
|
||||
}),
|
||||
];
|
||||
expect(pickBestTrailer(videos)).toBe("newer");
|
||||
});
|
||||
|
||||
test("handles missing published_at", () => {
|
||||
const videos = [
|
||||
makeVideo({
|
||||
key: "no-date",
|
||||
type: "Trailer",
|
||||
official: true,
|
||||
published_at: undefined as unknown as string,
|
||||
}),
|
||||
makeVideo({
|
||||
key: "with-date",
|
||||
type: "Trailer",
|
||||
official: true,
|
||||
published_at: "2024-01-01T00:00:00.000Z",
|
||||
}),
|
||||
];
|
||||
expect(pickBestTrailer(videos)).toBe("with-date");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMovieContentRating", () => {
|
||||
test("returns US certification", () => {
|
||||
const movie = {
|
||||
release_dates: {
|
||||
results: [
|
||||
{
|
||||
iso_3166_1: "US",
|
||||
release_dates: [{ certification: "PG-13", type: 3 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TmdbMovieDetails;
|
||||
expect(extractMovieContentRating(movie)).toBe("PG-13");
|
||||
});
|
||||
|
||||
test("returns first non-empty certification", () => {
|
||||
const movie = {
|
||||
release_dates: {
|
||||
results: [
|
||||
{
|
||||
iso_3166_1: "US",
|
||||
release_dates: [
|
||||
{ certification: "", type: 1 },
|
||||
{ certification: "R", type: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TmdbMovieDetails;
|
||||
expect(extractMovieContentRating(movie)).toBe("R");
|
||||
});
|
||||
|
||||
test("returns null when no US entry", () => {
|
||||
const movie = {
|
||||
release_dates: {
|
||||
results: [
|
||||
{
|
||||
iso_3166_1: "GB",
|
||||
release_dates: [{ certification: "15", type: 3 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TmdbMovieDetails;
|
||||
expect(extractMovieContentRating(movie)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when release_dates is undefined", () => {
|
||||
const movie = {} as unknown as TmdbMovieDetails;
|
||||
expect(extractMovieContentRating(movie)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when all US certifications are empty", () => {
|
||||
const movie = {
|
||||
release_dates: {
|
||||
results: [
|
||||
{
|
||||
iso_3166_1: "US",
|
||||
release_dates: [
|
||||
{ certification: "", type: 1 },
|
||||
{ certification: "", type: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TmdbMovieDetails;
|
||||
expect(extractMovieContentRating(movie)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTvContentRating", () => {
|
||||
test("returns US rating", () => {
|
||||
const show = {
|
||||
content_ratings: {
|
||||
results: [{ iso_3166_1: "US", rating: "TV-MA" }],
|
||||
},
|
||||
} as unknown as TmdbTvDetails;
|
||||
expect(extractTvContentRating(show)).toBe("TV-MA");
|
||||
});
|
||||
|
||||
test("returns null when no US entry", () => {
|
||||
const show = {
|
||||
content_ratings: {
|
||||
results: [{ iso_3166_1: "DE", rating: "16" }],
|
||||
},
|
||||
} as unknown as TmdbTvDetails;
|
||||
expect(extractTvContentRating(show)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when content_ratings is undefined", () => {
|
||||
const show = {} as unknown as TmdbTvDetails;
|
||||
expect(extractTvContentRating(show)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when US rating is empty", () => {
|
||||
const show = {
|
||||
content_ratings: {
|
||||
results: [{ iso_3166_1: "US", rating: "" }],
|
||||
},
|
||||
} as unknown as TmdbTvDetails;
|
||||
expect(extractTvContentRating(show)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,10 @@ function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractMovieContentRating(movie: TmdbMovieDetails): string | null {
|
||||
/** @internal */
|
||||
export function extractMovieContentRating(
|
||||
movie: TmdbMovieDetails,
|
||||
): string | null {
|
||||
const us = movie.release_dates?.results?.find((r) => r.iso_3166_1 === "US");
|
||||
if (!us) return null;
|
||||
for (const rd of us.release_dates) {
|
||||
@@ -90,7 +93,8 @@ function extractMovieContentRating(movie: TmdbMovieDetails): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTvContentRating(show: TmdbTvDetails): string | null {
|
||||
/** @internal */
|
||||
export function extractTvContentRating(show: TmdbTvDetails): string | null {
|
||||
const us = show.content_ratings?.results?.find((r) => r.iso_3166_1 === "US");
|
||||
return us?.rating || null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { persons, titleCast } from "@/lib/db/schema";
|
||||
import { clearAllTables, insertTitle, testDb } from "@/lib/test-utils";
|
||||
|
||||
mock.module("@/lib/tmdb/image", () => ({
|
||||
tmdbImageUrl: (path: string | null) => path,
|
||||
}));
|
||||
|
||||
import { getLocalFilmography } from "./person";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
function insertPerson(id: string, tmdbId: number, name: string) {
|
||||
testDb.insert(persons).values({ id, tmdbId, name }).run();
|
||||
return id;
|
||||
}
|
||||
|
||||
function insertCastEntry(
|
||||
titleId: string,
|
||||
personId: string,
|
||||
overrides: {
|
||||
character?: string | null;
|
||||
department?: string;
|
||||
job?: string | null;
|
||||
displayOrder?: number;
|
||||
} = {},
|
||||
) {
|
||||
testDb
|
||||
.insert(titleCast)
|
||||
.values({
|
||||
titleId,
|
||||
personId,
|
||||
character: overrides.character ?? "Character",
|
||||
department: overrides.department ?? "Acting",
|
||||
job: overrides.job ?? null,
|
||||
displayOrder: overrides.displayOrder ?? 0,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
describe("getLocalFilmography", () => {
|
||||
test("returns filmography for a person", () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1, title: "Movie One" });
|
||||
insertTitle({ id: "m2", tmdbId: 2, title: "Movie Two" });
|
||||
insertPerson("p1", 100, "Test Actor");
|
||||
insertCastEntry("m1", "p1", { character: "Hero" });
|
||||
insertCastEntry("m2", "p1", { character: "Sidekick" });
|
||||
|
||||
const filmography = getLocalFilmography("p1");
|
||||
expect(filmography).toHaveLength(2);
|
||||
expect(filmography.map((f) => f.title).sort()).toEqual([
|
||||
"Movie One",
|
||||
"Movie Two",
|
||||
]);
|
||||
});
|
||||
|
||||
test("includes crew roles", () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1, title: "Directed Movie" });
|
||||
insertPerson("p1", 100, "Director Person");
|
||||
insertCastEntry("m1", "p1", {
|
||||
character: null,
|
||||
department: "Directing",
|
||||
job: "Director",
|
||||
});
|
||||
|
||||
const filmography = getLocalFilmography("p1");
|
||||
expect(filmography).toHaveLength(1);
|
||||
expect(filmography[0].department).toBe("Directing");
|
||||
expect(filmography[0].job).toBe("Director");
|
||||
});
|
||||
|
||||
test("returns empty for person with no credits", () => {
|
||||
insertPerson("p1", 100, "Unknown Actor");
|
||||
const filmography = getLocalFilmography("p1");
|
||||
expect(filmography).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("returns correct title types", () => {
|
||||
insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "A Movie" });
|
||||
insertTitle({ id: "tv1", tmdbId: 2, type: "tv", title: "A Show" });
|
||||
insertPerson("p1", 100, "Versatile Actor");
|
||||
insertCastEntry("m1", "p1");
|
||||
insertCastEntry("tv1", "p1");
|
||||
|
||||
const filmography = getLocalFilmography("p1");
|
||||
const types = filmography.map((f) => f.type).sort();
|
||||
expect(types).toEqual(["movie", "tv"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { clearAllTables, insertUser } from "@/lib/test-utils";
|
||||
import {
|
||||
getSetting,
|
||||
getUserCount,
|
||||
isRegistrationOpen,
|
||||
setSetting,
|
||||
} from "./settings";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
// ── getSetting / setSetting ─────────────────────────────────────────
|
||||
|
||||
describe("getSetting / setSetting", () => {
|
||||
test("returns null for missing key", () => {
|
||||
expect(getSetting("nonexistent")).toBeNull();
|
||||
});
|
||||
|
||||
test("stores and retrieves a value", () => {
|
||||
setSetting("theme", "dark");
|
||||
expect(getSetting("theme")).toBe("dark");
|
||||
});
|
||||
|
||||
test("upserts: overwrites existing value", () => {
|
||||
setSetting("theme", "dark");
|
||||
setSetting("theme", "light");
|
||||
expect(getSetting("theme")).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getUserCount ────────────────────────────────────────────────────
|
||||
|
||||
describe("getUserCount", () => {
|
||||
test("returns 0 when no users", () => {
|
||||
expect(getUserCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("returns correct count", () => {
|
||||
insertUser("user-1");
|
||||
insertUser("user-2");
|
||||
expect(getUserCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isRegistrationOpen ──────────────────────────────────────────────
|
||||
|
||||
describe("isRegistrationOpen", () => {
|
||||
test("returns true when no users exist (first-run)", () => {
|
||||
expect(isRegistrationOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when users exist and setting is not set", () => {
|
||||
insertUser();
|
||||
expect(isRegistrationOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when users exist and setting is 'true'", () => {
|
||||
insertUser();
|
||||
setSetting("registrationOpen", "true");
|
||||
expect(isRegistrationOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when users exist and setting is 'false'", () => {
|
||||
insertUser();
|
||||
setSetting("registrationOpen", "false");
|
||||
expect(isRegistrationOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,668 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
userEpisodeWatches,
|
||||
userMovieWatches,
|
||||
userRatings,
|
||||
userTitleStatus,
|
||||
} from "@/lib/db/schema";
|
||||
import {
|
||||
clearAllTables,
|
||||
insertTitle,
|
||||
insertTvShow,
|
||||
insertUser,
|
||||
testDb,
|
||||
} from "@/lib/test-utils";
|
||||
import {
|
||||
getUserTitleInfo,
|
||||
logEpisodeWatch,
|
||||
logEpisodeWatchBatch,
|
||||
logMovieWatch,
|
||||
markAllEpisodesWatched,
|
||||
rateTitleStars,
|
||||
removeTitleStatus,
|
||||
setTitleStatus,
|
||||
unwatchEpisode,
|
||||
unwatchSeason,
|
||||
} from "./tracking";
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
});
|
||||
|
||||
// ── setTitleStatus ──────────────────────────────────────────────────
|
||||
|
||||
describe("setTitleStatus", () => {
|
||||
test("sets new status for a user/title pair", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
setTitleStatus("user-1", "title-1", "watchlist");
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("watchlist");
|
||||
});
|
||||
|
||||
test("updates existing status", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
setTitleStatus("user-1", "title-1", "watchlist");
|
||||
setTitleStatus("user-1", "title-1", "in_progress");
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
test("upsert: only one row per user/title", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
setTitleStatus("user-1", "title-1", "watchlist");
|
||||
setTitleStatus("user-1", "title-1", "completed");
|
||||
|
||||
const rows = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeTitleStatus ───────────────────────────────────────────────
|
||||
|
||||
describe("removeTitleStatus", () => {
|
||||
test("removes existing status", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
setTitleStatus("user-1", "title-1", "watchlist");
|
||||
removeTitleStatus("user-1", "title-1");
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
test("no-op when status doesn't exist", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
// Should not throw
|
||||
removeTitleStatus("user-1", "title-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── logMovieWatch ───────────────────────────────────────────────────
|
||||
|
||||
describe("logMovieWatch", () => {
|
||||
test("inserts watch record", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
logMovieWatch("user-1", "title-1");
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userMovieWatches)
|
||||
.where(eq(userMovieWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(watches).toHaveLength(1);
|
||||
expect(watches[0].titleId).toBe("title-1");
|
||||
});
|
||||
|
||||
test("auto-sets status to completed when no prior status", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
logMovieWatch("user-1", "title-1");
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
|
||||
test("auto-upgrades watchlist to completed", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
setTitleStatus("user-1", "title-1", "watchlist");
|
||||
logMovieWatch("user-1", "title-1");
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
|
||||
test("auto-upgrades in_progress to completed", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
setTitleStatus("user-1", "title-1", "in_progress");
|
||||
logMovieWatch("user-1", "title-1");
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
|
||||
test("doesn't downgrade already-completed status", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
setTitleStatus("user-1", "title-1", "completed");
|
||||
logMovieWatch("user-1", "title-1");
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── logEpisodeWatch ─────────────────────────────────────────────────
|
||||
|
||||
describe("logEpisodeWatch", () => {
|
||||
test("inserts watch record", () => {
|
||||
insertUser();
|
||||
const { episodeIds } = insertTvShow();
|
||||
logEpisodeWatch("user-1", episodeIds[0]);
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(watches).toHaveLength(1);
|
||||
expect(watches[0].episodeId).toBe(episodeIds[0]);
|
||||
});
|
||||
|
||||
test("auto-sets status to in_progress", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow();
|
||||
logEpisodeWatch("user-1", episodeIds[0]);
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
test("upgrades watchlist to in_progress", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow();
|
||||
setTitleStatus("user-1", titleId, "watchlist");
|
||||
logEpisodeWatch("user-1", episodeIds[0]);
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
test("doesn't downgrade completed to in_progress", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow();
|
||||
setTitleStatus("user-1", titleId, "completed");
|
||||
logEpisodeWatch("user-1", episodeIds[0]);
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
|
||||
test("auto-completes when all episodes are watched", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
|
||||
for (const epId of episodeIds) {
|
||||
logEpisodeWatch("user-1", epId);
|
||||
}
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
});
|
||||
|
||||
// ── logEpisodeWatchBatch ────────────────────────────────────────────
|
||||
|
||||
describe("logEpisodeWatchBatch", () => {
|
||||
test("batch inserts multiple watches", () => {
|
||||
insertUser();
|
||||
const { episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
logEpisodeWatchBatch("user-1", episodeIds.slice(0, 2));
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(watches).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("sets in_progress status", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
logEpisodeWatchBatch("user-1", [episodeIds[0]]);
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
test("auto-completes if batch covers all episodes", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
logEpisodeWatchBatch("user-1", episodeIds);
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
|
||||
test("no-op for empty array", () => {
|
||||
insertUser();
|
||||
logEpisodeWatchBatch("user-1", []);
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(watches).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── markAllEpisodesWatched ──────────────────────────────────────────
|
||||
|
||||
describe("markAllEpisodesWatched", () => {
|
||||
test("marks all episodes as watched and sets completed", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
markAllEpisodesWatched("user-1", titleId);
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(watches).toHaveLength(episodeIds.length);
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
});
|
||||
|
||||
test("skips already-watched episodes (no duplicates)", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
|
||||
// Watch first episode manually
|
||||
logEpisodeWatch("user-1", episodeIds[0]);
|
||||
// Then mark all
|
||||
markAllEpisodesWatched("user-1", titleId);
|
||||
|
||||
// Should have exactly 3 episode watches (1 manual + 2 new), not 4
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(watches).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("no-op for movies", () => {
|
||||
insertUser();
|
||||
insertTitle({ id: "movie-1", type: "movie" });
|
||||
markAllEpisodesWatched("user-1", "movie-1");
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(watches).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── unwatchEpisode ──────────────────────────────────────────────────
|
||||
|
||||
describe("unwatchEpisode", () => {
|
||||
test("removes watch record", () => {
|
||||
insertUser();
|
||||
const { episodeIds } = insertTvShow();
|
||||
logEpisodeWatch("user-1", episodeIds[0]);
|
||||
unwatchEpisode("user-1", episodeIds[0]);
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(watches).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("downgrades completed to in_progress", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
markAllEpisodesWatched("user-1", titleId);
|
||||
|
||||
// Verify completed first
|
||||
let row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
|
||||
// Unwatch one episode
|
||||
unwatchEpisode("user-1", episodeIds[0]);
|
||||
|
||||
row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
test("doesn't change in_progress status", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
logEpisodeWatch("user-1", episodeIds[0]);
|
||||
logEpisodeWatch("user-1", episodeIds[1]);
|
||||
|
||||
// Status should be in_progress
|
||||
let row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
|
||||
unwatchEpisode("user-1", episodeIds[0]);
|
||||
|
||||
row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
|
||||
// ── unwatchSeason ───────────────────────────────────────────────────
|
||||
|
||||
describe("unwatchSeason", () => {
|
||||
test("removes all episode watches for a season", () => {
|
||||
insertUser();
|
||||
const { episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
for (const epId of episodeIds) {
|
||||
logEpisodeWatch("user-1", epId);
|
||||
}
|
||||
|
||||
unwatchSeason("user-1", "tv-1-s1");
|
||||
|
||||
const watches = testDb
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(eq(userEpisodeWatches.userId, "user-1"))
|
||||
.all();
|
||||
expect(watches).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("downgrades completed to in_progress", () => {
|
||||
insertUser();
|
||||
const { titleId } = insertTvShow("tv-1", 99999, 2, 2);
|
||||
|
||||
// Watch all episodes across both seasons
|
||||
markAllEpisodesWatched("user-1", titleId);
|
||||
|
||||
let row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("completed");
|
||||
|
||||
// Unwatch season 1
|
||||
unwatchSeason("user-1", "tv-1-s1");
|
||||
|
||||
row = testDb
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, "user-1"),
|
||||
eq(userTitleStatus.titleId, titleId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.status).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
|
||||
// ── rateTitleStars ──────────────────────────────────────────────────
|
||||
|
||||
describe("rateTitleStars", () => {
|
||||
test("sets rating", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
rateTitleStars("user-1", "title-1", 4);
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(
|
||||
eq(userRatings.userId, "user-1"),
|
||||
eq(userRatings.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row?.ratingStars).toBe(4);
|
||||
});
|
||||
|
||||
test("updates existing rating", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
rateTitleStars("user-1", "title-1", 3);
|
||||
rateTitleStars("user-1", "title-1", 5);
|
||||
|
||||
const rows = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(
|
||||
eq(userRatings.userId, "user-1"),
|
||||
eq(userRatings.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ratingStars).toBe(5);
|
||||
});
|
||||
|
||||
test("deletes rating when 0", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
rateTitleStars("user-1", "title-1", 4);
|
||||
rateTitleStars("user-1", "title-1", 0);
|
||||
|
||||
const row = testDb
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(
|
||||
eq(userRatings.userId, "user-1"),
|
||||
eq(userRatings.titleId, "title-1"),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── getUserTitleInfo ────────────────────────────────────────────────
|
||||
|
||||
describe("getUserTitleInfo", () => {
|
||||
test("returns status, rating, and watched episode IDs", () => {
|
||||
insertUser();
|
||||
const { titleId, episodeIds } = insertTvShow("tv-1", 99999, 1, 3);
|
||||
setTitleStatus("user-1", titleId, "in_progress");
|
||||
rateTitleStars("user-1", titleId, 4);
|
||||
logEpisodeWatch("user-1", episodeIds[0]);
|
||||
logEpisodeWatch("user-1", episodeIds[1]);
|
||||
|
||||
const info = getUserTitleInfo("user-1", titleId);
|
||||
expect(info.status).toBe("in_progress");
|
||||
expect(info.rating).toBe(4);
|
||||
expect(info.episodeWatches).toHaveLength(2);
|
||||
expect(info.episodeWatches).toContain(episodeIds[0]);
|
||||
expect(info.episodeWatches).toContain(episodeIds[1]);
|
||||
});
|
||||
|
||||
test("returns nulls when no data exists", () => {
|
||||
insertUser();
|
||||
insertTitle();
|
||||
|
||||
const info = getUserTitleInfo("user-1", "title-1");
|
||||
expect(info.status).toBeNull();
|
||||
expect(info.rating).toBeNull();
|
||||
expect(info.episodeWatches).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isNewerVersion } from "./update-check";
|
||||
|
||||
describe("isNewerVersion", () => {
|
||||
test("newer major version", () => {
|
||||
expect(isNewerVersion("2.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("older major version", () => {
|
||||
expect(isNewerVersion("1.0.0", "2.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("newer minor version", () => {
|
||||
expect(isNewerVersion("1.2.0", "1.1.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("older minor version", () => {
|
||||
expect(isNewerVersion("1.1.0", "1.2.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("newer patch version", () => {
|
||||
expect(isNewerVersion("1.0.2", "1.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
test("older patch version", () => {
|
||||
expect(isNewerVersion("1.0.1", "1.0.2")).toBe(false);
|
||||
});
|
||||
|
||||
test("equal versions return false", () => {
|
||||
expect(isNewerVersion("1.2.3", "1.2.3")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles 'v' prefix on latest", () => {
|
||||
expect(isNewerVersion("v2.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("handles 'v' prefix on current", () => {
|
||||
expect(isNewerVersion("2.0.0", "v1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("handles 'v' prefix on both", () => {
|
||||
expect(isNewerVersion("v1.0.0", "v1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -23,8 +23,8 @@ export function isUpdateCheckEnabled(): boolean {
|
||||
return setting !== "false";
|
||||
}
|
||||
|
||||
/** Returns true if `latest` is strictly newer than `current` using semver comparison. */
|
||||
function isNewerVersion(latest: string, current: string): boolean {
|
||||
/** @internal Returns true if `latest` is strictly newer than `current` using semver comparison. */
|
||||
export function isNewerVersion(latest: string, current: string): boolean {
|
||||
const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number);
|
||||
const [lMajor = 0, lMinor = 0, lPatch = 0] = parse(latest);
|
||||
const [cMajor = 0, cMinor = 0, cPatch = 0] = parse(current);
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
parseEmbyPayload,
|
||||
parseJellyfinPayload,
|
||||
parsePlexPayload,
|
||||
toOptionalInt,
|
||||
} from "./webhooks";
|
||||
|
||||
describe("toOptionalInt", () => {
|
||||
test("returns integer number as-is", () => {
|
||||
expect(toOptionalInt(42)).toBe(42);
|
||||
});
|
||||
|
||||
test("returns 0 for zero", () => {
|
||||
expect(toOptionalInt(0)).toBe(0);
|
||||
});
|
||||
|
||||
test("returns undefined for float", () => {
|
||||
expect(toOptionalInt(3.14)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses valid integer string", () => {
|
||||
expect(toOptionalInt("7")).toBe(7);
|
||||
});
|
||||
|
||||
test("parses string with whitespace", () => {
|
||||
expect(toOptionalInt(" 12 ")).toBe(12);
|
||||
});
|
||||
|
||||
test("returns undefined for non-numeric string", () => {
|
||||
expect(toOptionalInt("abc")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(toOptionalInt("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for null", () => {
|
||||
expect(toOptionalInt(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for undefined", () => {
|
||||
expect(toOptionalInt(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for boolean", () => {
|
||||
expect(toOptionalInt(true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePlexPayload", () => {
|
||||
function makeFormData(payload: unknown): FormData {
|
||||
const fd = new FormData();
|
||||
fd.set("payload", JSON.stringify(payload));
|
||||
return fd;
|
||||
}
|
||||
|
||||
test("returns null for missing payload field", () => {
|
||||
expect(parsePlexPayload(new FormData())).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for invalid JSON", () => {
|
||||
const fd = new FormData();
|
||||
fd.set("payload", "not json{");
|
||||
expect(parsePlexPayload(fd)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for wrong event type", () => {
|
||||
expect(parsePlexPayload(makeFormData({ event: "media.play" }))).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when Metadata is missing", () => {
|
||||
expect(
|
||||
parsePlexPayload(makeFormData({ event: "media.scrobble" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for unsupported media type", () => {
|
||||
expect(
|
||||
parsePlexPayload(
|
||||
makeFormData({
|
||||
event: "media.scrobble",
|
||||
Metadata: { type: "track" },
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("parses movie scrobble", () => {
|
||||
const result = parsePlexPayload(
|
||||
makeFormData({
|
||||
event: "media.scrobble",
|
||||
Metadata: {
|
||||
type: "movie",
|
||||
title: "Inception",
|
||||
Guid: [{ id: "tmdb://27205" }, { id: "imdb://tt1375666" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
provider: "plex",
|
||||
mediaType: "movie",
|
||||
title: "Inception",
|
||||
tmdbId: 27205,
|
||||
imdbId: "tt1375666",
|
||||
tvdbId: undefined,
|
||||
seasonNumber: undefined,
|
||||
episodeNumber: undefined,
|
||||
showTitle: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses episode scrobble", () => {
|
||||
const result = parsePlexPayload(
|
||||
makeFormData({
|
||||
event: "media.scrobble",
|
||||
Metadata: {
|
||||
type: "episode",
|
||||
title: "Pilot",
|
||||
grandparentTitle: "Breaking Bad",
|
||||
parentIndex: 1,
|
||||
index: 1,
|
||||
Guid: [{ id: "tvdb://349232" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
provider: "plex",
|
||||
mediaType: "episode",
|
||||
title: "Pilot",
|
||||
tmdbId: undefined,
|
||||
imdbId: undefined,
|
||||
tvdbId: "349232",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
showTitle: "Breaking Bad",
|
||||
});
|
||||
});
|
||||
|
||||
test("extracts GUIDs from lowercase guid field", () => {
|
||||
const result = parsePlexPayload(
|
||||
makeFormData({
|
||||
event: "media.scrobble",
|
||||
Metadata: {
|
||||
type: "movie",
|
||||
title: "Test",
|
||||
guid: [{ id: "tmdb://12345" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result?.tmdbId).toBe(12345);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJellyfinPayload", () => {
|
||||
test("returns null for wrong NotificationType", () => {
|
||||
expect(
|
||||
parseJellyfinPayload({ NotificationType: "PlaybackStart" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when PlayedToCompletion is false", () => {
|
||||
expect(
|
||||
parseJellyfinPayload({
|
||||
NotificationType: "PlaybackStop",
|
||||
PlayedToCompletion: false,
|
||||
ItemType: "Movie",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for unsupported ItemType", () => {
|
||||
expect(
|
||||
parseJellyfinPayload({
|
||||
NotificationType: "PlaybackStop",
|
||||
PlayedToCompletion: true,
|
||||
ItemType: "Audio",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("parses movie payload", () => {
|
||||
const result = parseJellyfinPayload({
|
||||
NotificationType: "PlaybackStop",
|
||||
PlayedToCompletion: true,
|
||||
ItemType: "Movie",
|
||||
Name: "The Matrix",
|
||||
Provider_tmdb: "603",
|
||||
Provider_imdb: "tt0133093",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "jellyfin",
|
||||
mediaType: "movie",
|
||||
title: "The Matrix",
|
||||
tmdbId: 603,
|
||||
imdbId: "tt0133093",
|
||||
tvdbId: undefined,
|
||||
seasonNumber: undefined,
|
||||
episodeNumber: undefined,
|
||||
showTitle: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("parses episode payload", () => {
|
||||
const result = parseJellyfinPayload({
|
||||
NotificationType: "PlaybackStop",
|
||||
PlayedToCompletion: true,
|
||||
ItemType: "Episode",
|
||||
Name: "Ozymandias",
|
||||
Provider_tmdb: "62161",
|
||||
SeasonNumber: 5,
|
||||
EpisodeNumber: 14,
|
||||
SeriesName: "Breaking Bad",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "jellyfin",
|
||||
mediaType: "episode",
|
||||
title: "Ozymandias",
|
||||
tmdbId: 62161,
|
||||
imdbId: undefined,
|
||||
tvdbId: undefined,
|
||||
seasonNumber: 5,
|
||||
episodeNumber: 14,
|
||||
showTitle: "Breaking Bad",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseEmbyPayload", () => {
|
||||
test("returns null for wrong event type", () => {
|
||||
expect(
|
||||
parseEmbyPayload({ Event: "playback.start", PlayedToCompletion: true }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when PlayedToCompletion is false", () => {
|
||||
expect(
|
||||
parseEmbyPayload({ Event: "playback.stop", PlayedToCompletion: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when Item is missing", () => {
|
||||
expect(
|
||||
parseEmbyPayload({ Event: "playback.stop", PlayedToCompletion: true }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("accepts 'playback.stop' event string", () => {
|
||||
const result = parseEmbyPayload({
|
||||
Event: "playback.stop",
|
||||
PlayedToCompletion: true,
|
||||
Item: {
|
||||
Type: "Movie",
|
||||
Name: "Interstellar",
|
||||
ProviderIds: { Tmdb: "157336" },
|
||||
},
|
||||
});
|
||||
expect(result?.provider).toBe("emby");
|
||||
expect(result?.tmdbId).toBe(157336);
|
||||
});
|
||||
|
||||
test("accepts 'PlaybackStop' event string", () => {
|
||||
const result = parseEmbyPayload({
|
||||
Event: "PlaybackStop",
|
||||
PlayedToCompletion: true,
|
||||
Item: {
|
||||
Type: "Movie",
|
||||
Name: "Interstellar",
|
||||
ProviderIds: { Tmdb: "157336" },
|
||||
},
|
||||
});
|
||||
expect(result?.provider).toBe("emby");
|
||||
});
|
||||
|
||||
test("parses episode with ProviderIds", () => {
|
||||
const result = parseEmbyPayload({
|
||||
Event: "playback.stop",
|
||||
PlayedToCompletion: true,
|
||||
Item: {
|
||||
Type: "Episode",
|
||||
Name: "Pilot",
|
||||
ParentIndexNumber: 1,
|
||||
IndexNumber: 1,
|
||||
SeriesName: "Lost",
|
||||
ProviderIds: { Tmdb: "12345", Imdb: "tt0000001", Tvdb: "67890" },
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
provider: "emby",
|
||||
mediaType: "episode",
|
||||
title: "Pilot",
|
||||
tmdbId: 12345,
|
||||
imdbId: "tt0000001",
|
||||
tvdbId: "67890",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
showTitle: "Lost",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles missing ProviderIds gracefully", () => {
|
||||
const result = parseEmbyPayload({
|
||||
Event: "playback.stop",
|
||||
PlayedToCompletion: true,
|
||||
Item: { Type: "Movie", Name: "Unknown Movie" },
|
||||
});
|
||||
expect(result?.tmdbId).toBeUndefined();
|
||||
expect(result?.imdbId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,8 @@ export interface WebhookEvent {
|
||||
showTitle?: string;
|
||||
}
|
||||
|
||||
function toOptionalInt(value: unknown): number | undefined {
|
||||
/** @internal */
|
||||
export function toOptionalInt(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { mock } from "bun:test";
|
||||
import { applyMigrations, testDb } from "@/lib/test-utils";
|
||||
|
||||
mock.module("@/lib/db/client", () => ({
|
||||
db: testDb,
|
||||
closeDatabase: () => {},
|
||||
}));
|
||||
|
||||
applyMigrations();
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import * as schema from "@/lib/db/schema";
|
||||
|
||||
const {
|
||||
user,
|
||||
titles,
|
||||
seasons,
|
||||
episodes,
|
||||
userMovieWatches,
|
||||
userEpisodeWatches,
|
||||
userTitleStatus,
|
||||
userRatings,
|
||||
availabilityOffers,
|
||||
titleRecommendations,
|
||||
} = schema;
|
||||
|
||||
export const testClient = new Database(":memory:");
|
||||
testClient.run("PRAGMA foreign_keys = ON");
|
||||
export const testDb = drizzle({ client: testClient, schema });
|
||||
|
||||
export function applyMigrations() {
|
||||
migrate(testDb, { migrationsFolder: "./drizzle" });
|
||||
}
|
||||
|
||||
export function clearAllTables() {
|
||||
const tables = testClient
|
||||
.query(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__drizzle%'",
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
testClient.run("PRAGMA foreign_keys = OFF");
|
||||
for (const { name } of tables) {
|
||||
testClient.run(`DELETE FROM "${name}"`);
|
||||
}
|
||||
testClient.run("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
export function insertUser(id = "user-1") {
|
||||
testDb
|
||||
.insert(user)
|
||||
.values({
|
||||
id,
|
||||
name: "Test User",
|
||||
email: `${id}@test.com`,
|
||||
emailVerified: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function insertTitle(
|
||||
overrides: {
|
||||
id?: string;
|
||||
tmdbId?: number;
|
||||
type?: "movie" | "tv";
|
||||
title?: string;
|
||||
} = {},
|
||||
) {
|
||||
const id = overrides.id ?? "title-1";
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({
|
||||
id,
|
||||
tmdbId: overrides.tmdbId ?? 12345,
|
||||
type: overrides.type ?? "movie",
|
||||
title: overrides.title ?? "Test Movie",
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function insertTvShow(
|
||||
titleId = "tv-1",
|
||||
tmdbId = 99999,
|
||||
seasonCount = 1,
|
||||
epsPerSeason = 3,
|
||||
) {
|
||||
insertTitle({ id: titleId, tmdbId, type: "tv", title: "Test Show" });
|
||||
const episodeIds: string[] = [];
|
||||
for (let s = 1; s <= seasonCount; s++) {
|
||||
const seasonId = `${titleId}-s${s}`;
|
||||
testDb
|
||||
.insert(seasons)
|
||||
.values({ id: seasonId, titleId, seasonNumber: s })
|
||||
.run();
|
||||
for (let e = 1; e <= epsPerSeason; e++) {
|
||||
const epId = `${titleId}-s${s}e${e}`;
|
||||
testDb
|
||||
.insert(episodes)
|
||||
.values({
|
||||
id: epId,
|
||||
seasonId,
|
||||
episodeNumber: e,
|
||||
name: `S${s}E${e}`,
|
||||
})
|
||||
.run();
|
||||
episodeIds.push(epId);
|
||||
}
|
||||
}
|
||||
return { titleId, episodeIds };
|
||||
}
|
||||
|
||||
export function insertMovieWatch(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
testDb
|
||||
.insert(userMovieWatches)
|
||||
.values({
|
||||
userId,
|
||||
titleId,
|
||||
watchedAt: watchedAt ?? new Date(),
|
||||
source: "manual",
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertEpisodeWatch(
|
||||
userId: string,
|
||||
episodeId: string,
|
||||
watchedAt?: Date,
|
||||
) {
|
||||
testDb
|
||||
.insert(userEpisodeWatches)
|
||||
.values({
|
||||
userId,
|
||||
episodeId,
|
||||
watchedAt: watchedAt ?? new Date(),
|
||||
source: "manual",
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertStatus(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
status: "watchlist" | "in_progress" | "completed",
|
||||
) {
|
||||
const now = new Date();
|
||||
testDb
|
||||
.insert(userTitleStatus)
|
||||
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertRating(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
ratingStars: number,
|
||||
) {
|
||||
testDb
|
||||
.insert(userRatings)
|
||||
.values({ userId, titleId, ratingStars, ratedAt: new Date() })
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertAvailabilityOffer(
|
||||
titleId: string,
|
||||
overrides: {
|
||||
providerId?: number;
|
||||
providerName?: string;
|
||||
offerType?: "flatrate" | "rent" | "buy" | "free" | "ads";
|
||||
} = {},
|
||||
) {
|
||||
testDb
|
||||
.insert(availabilityOffers)
|
||||
.values({
|
||||
titleId,
|
||||
providerId: overrides.providerId ?? 8,
|
||||
providerName: overrides.providerName ?? "Netflix",
|
||||
offerType: overrides.offerType ?? "flatrate",
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertRecommendation(
|
||||
titleId: string,
|
||||
recommendedTitleId: string,
|
||||
overrides: {
|
||||
source?: "tmdb_recommendations" | "tmdb_similar";
|
||||
rank?: number;
|
||||
} = {},
|
||||
) {
|
||||
testDb
|
||||
.insert(titleRecommendations)
|
||||
.values({
|
||||
titleId,
|
||||
recommendedTitleId,
|
||||
source: overrides.source ?? "tmdb_recommendations",
|
||||
rank: overrides.rank ?? 1,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { tmdbImageUrl } from "./image";
|
||||
|
||||
describe("tmdbImageUrl", () => {
|
||||
const origCacheEnabled = process.env.IMAGE_CACHE_ENABLED;
|
||||
const origBaseUrl = process.env.TMDB_IMAGE_BASE_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.IMAGE_CACHE_ENABLED;
|
||||
delete process.env.TMDB_IMAGE_BASE_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origCacheEnabled !== undefined) {
|
||||
process.env.IMAGE_CACHE_ENABLED = origCacheEnabled;
|
||||
} else {
|
||||
delete process.env.IMAGE_CACHE_ENABLED;
|
||||
}
|
||||
if (origBaseUrl !== undefined) {
|
||||
process.env.TMDB_IMAGE_BASE_URL = origBaseUrl;
|
||||
} else {
|
||||
delete process.env.TMDB_IMAGE_BASE_URL;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null for null path", () => {
|
||||
expect(tmdbImageUrl(null)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for undefined path", () => {
|
||||
expect(tmdbImageUrl(undefined as unknown as string | null)).toBeNull();
|
||||
});
|
||||
|
||||
describe("cache enabled (default)", () => {
|
||||
test("w500 maps to posters", () => {
|
||||
expect(tmdbImageUrl("/abc.jpg", "w500")).toBe(
|
||||
"/api/images/posters/abc.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("w1280 maps to backdrops", () => {
|
||||
expect(tmdbImageUrl("/backdrop.jpg", "w1280")).toBe(
|
||||
"/api/images/backdrops/backdrop.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("w92 maps to logos", () => {
|
||||
expect(tmdbImageUrl("/logo.png", "w92")).toBe(
|
||||
"/api/images/logos/logo.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("w185 maps to profiles", () => {
|
||||
expect(tmdbImageUrl("/profile.jpg", "w185")).toBe(
|
||||
"/api/images/profiles/profile.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("strips leading slash from path", () => {
|
||||
expect(tmdbImageUrl("/test.jpg")).toBe("/api/images/posters/test.jpg");
|
||||
});
|
||||
|
||||
test("handles path without leading slash", () => {
|
||||
expect(tmdbImageUrl("test.jpg")).toBe("/api/images/posters/test.jpg");
|
||||
});
|
||||
|
||||
test("explicit category override", () => {
|
||||
expect(tmdbImageUrl("/still.jpg", "w1280", "stills")).toBe(
|
||||
"/api/images/stills/still.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("default size is w500 (posters)", () => {
|
||||
expect(tmdbImageUrl("/img.jpg")).toBe("/api/images/posters/img.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache disabled", () => {
|
||||
beforeEach(() => {
|
||||
process.env.IMAGE_CACHE_ENABLED = "false";
|
||||
});
|
||||
|
||||
test("returns TMDB CDN URL with default base", () => {
|
||||
expect(tmdbImageUrl("/abc.jpg", "w500")).toBe(
|
||||
"https://image.tmdb.org/t/p/w500/abc.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses custom TMDB_IMAGE_BASE_URL", () => {
|
||||
process.env.TMDB_IMAGE_BASE_URL = "https://custom-cdn.example.com";
|
||||
// Need to re-import to pick up the new base URL — but since IMAGE_BASE_URL
|
||||
// is evaluated at module load time, we test with the default
|
||||
expect(tmdbImageUrl("/abc.jpg", "w1280")).toBe(
|
||||
"https://image.tmdb.org/t/p/w1280/abc.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves size in URL", () => {
|
||||
expect(tmdbImageUrl("/img.jpg", "w185")).toBe(
|
||||
"https://image.tmdb.org/t/p/w185/img.jpg",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getTitleThemeStyle, hexToRelativeLuminance } from "./title-theme";
|
||||
|
||||
describe("hexToRelativeLuminance", () => {
|
||||
test("black has luminance ~0", () => {
|
||||
expect(hexToRelativeLuminance("#000000")).toBeCloseTo(0, 4);
|
||||
});
|
||||
|
||||
test("white has luminance ~1", () => {
|
||||
expect(hexToRelativeLuminance("#ffffff")).toBeCloseTo(1, 4);
|
||||
});
|
||||
|
||||
test("mid-range grey", () => {
|
||||
const lum = hexToRelativeLuminance("#808080");
|
||||
expect(lum).toBeGreaterThan(0.15);
|
||||
expect(lum).toBeLessThan(0.25);
|
||||
});
|
||||
|
||||
test("pure red", () => {
|
||||
const lum = hexToRelativeLuminance("#ff0000");
|
||||
expect(lum).toBeCloseTo(0.2126, 3);
|
||||
});
|
||||
|
||||
test("pure green", () => {
|
||||
const lum = hexToRelativeLuminance("#00ff00");
|
||||
expect(lum).toBeCloseTo(0.7152, 3);
|
||||
});
|
||||
|
||||
test("pure blue", () => {
|
||||
const lum = hexToRelativeLuminance("#0000ff");
|
||||
expect(lum).toBeCloseTo(0.0722, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTitleThemeStyle", () => {
|
||||
test("returns empty object for null palette", () => {
|
||||
expect(getTitleThemeStyle(null)).toEqual({});
|
||||
});
|
||||
|
||||
test("returns empty object when vibrant is null", () => {
|
||||
expect(
|
||||
getTitleThemeStyle({
|
||||
vibrant: null,
|
||||
darkVibrant: "#123456",
|
||||
lightVibrant: null,
|
||||
muted: null,
|
||||
darkMuted: null,
|
||||
lightMuted: null,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test("dark color produces light foreground", () => {
|
||||
const style = getTitleThemeStyle({
|
||||
vibrant: "#1a1a2e",
|
||||
darkVibrant: null,
|
||||
lightVibrant: null,
|
||||
muted: null,
|
||||
darkMuted: null,
|
||||
lightMuted: null,
|
||||
});
|
||||
expect(style["--primary" as keyof typeof style]).toBe("#1a1a2e");
|
||||
expect(style["--primary-foreground" as keyof typeof style]).toBe(
|
||||
"oklch(0.93 0.015 80)",
|
||||
);
|
||||
});
|
||||
|
||||
test("bright color produces dark foreground", () => {
|
||||
const style = getTitleThemeStyle({
|
||||
vibrant: "#ffff00",
|
||||
darkVibrant: null,
|
||||
lightVibrant: null,
|
||||
muted: null,
|
||||
darkMuted: null,
|
||||
lightMuted: null,
|
||||
});
|
||||
expect(style["--primary" as keyof typeof style]).toBe("#ffff00");
|
||||
expect(style["--primary-foreground" as keyof typeof style]).toBe(
|
||||
"oklch(0.13 0.006 55)",
|
||||
);
|
||||
});
|
||||
|
||||
test("sets all expected CSS custom properties", () => {
|
||||
const style = getTitleThemeStyle({
|
||||
vibrant: "#e63946",
|
||||
darkVibrant: null,
|
||||
lightVibrant: null,
|
||||
muted: null,
|
||||
darkMuted: null,
|
||||
lightMuted: null,
|
||||
});
|
||||
expect(style).toHaveProperty("--primary");
|
||||
expect(style).toHaveProperty("--ring");
|
||||
expect(style).toHaveProperty("--primary-foreground");
|
||||
expect(style).toHaveProperty("--status-watching");
|
||||
expect(style["--ring" as keyof typeof style]).toBe("#e63946");
|
||||
expect(style["--status-watching" as keyof typeof style]).toBe("#e63946");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ColorPalette } from "@/lib/types/title";
|
||||
|
||||
function hexToRelativeLuminance(hex: string): number {
|
||||
/** @internal */
|
||||
export function hexToRelativeLuminance(hex: string): number {
|
||||
const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"test:coverage": "bun test --coverage",
|
||||
"db:generate": "bun --bun drizzle-kit generate",
|
||||
"db:migrate": "bun --bun drizzle-kit migrate",
|
||||
"db:push": "bun --bun drizzle-kit push",
|
||||
|
||||
Reference in New Issue
Block a user