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:
2026-03-06 13:42:58 -05:00
parent 965e8353ac
commit 4cf326fa00
25 changed files with 2737 additions and 9 deletions
+28
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./lib/test-preload.ts"]
+178
View File
@@ -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);
});
});
+72
View File
@@ -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 * * *");
});
});
+50
View File
@@ -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",
);
});
});
+101
View File
@@ -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,
);
});
});
+6 -3
View File
@@ -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)
+36
View File
@@ -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();
});
});
+93
View File
@@ -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);
});
});
+344
View File
@@ -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);
});
});
+217
View File
@@ -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();
});
});
+6 -2
View File
@@ -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;
}
+92
View File
@@ -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"]);
});
});
+70
View File
@@ -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);
});
});
+668
View File
@@ -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);
});
});
+44
View File
@@ -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);
});
});
+2 -2
View File
@@ -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);
+310
View File
@@ -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();
});
});
+2 -1
View File
@@ -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;
}
+9
View File
@@ -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();
+200
View File
@@ -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();
}
+104
View File
@@ -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",
);
});
});
});
+99
View File
@@ -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");
});
});
+2 -1
View File
@@ -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;
+2
View File
@@ -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",