feat: migrate test runner from Bun to Vitest

- Replace bun:test with Vitest across all 22 test files
- Create @sofa/test package with shared setup and DB test helpers
  - setup.ts: vi.mock for @sofa/db/client, Bun.randomUUIDv7 polyfill
  - db.ts: in-memory SQLite via better-sqlite3, seed helpers
- Add per-project vitest configs (packages/core, apps/web)
- Add root vitest.config.ts with projects and v8 coverage
- Set up @vitest/browser + Playwright for web component tests
- Move validateBackupDatabase from core/backup.ts to @sofa/db/client
- Derive required backup tables from schema instead of hard-coded list
- Update CI workflow with Playwright install and Codecov upload
- Update CLAUDE.md documentation for Vitest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 12:03:06 -04:00
co-authored by Claude Opus 4.6
parent 3f05e08fea
commit 5f3e14b415
45 changed files with 589 additions and 364 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"@better-auth/drizzle-adapter": "1.5.5",
"@better-auth/drizzle-adapter": "1.5.6",
"@better-auth/expo": "catalog:",
"@sofa/core": "workspace:*",
"@sofa/db": "workspace:*",
-2
View File
@@ -1,2 +0,0 @@
[test]
preload = ["./test/preload.ts"]
+3 -2
View File
@@ -33,7 +33,7 @@
"format": "oxfmt --config ../../.oxfmtrc.json",
"format:check": "oxfmt --check --config ../../.oxfmtrc.json",
"check-types": "tsc --noEmit",
"test": "bun test"
"test": "vitest run"
},
"dependencies": {
"@sofa/api": "workspace:*",
@@ -50,6 +50,7 @@
"devDependencies": {
"@types/adm-zip": "0.5.8",
"@types/bun": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
}
}
+7 -66
View File
@@ -1,10 +1,14 @@
import { Database } from "bun:sqlite";
import { renameSync, unlinkSync, closeSync, openSync, readSync } from "node:fs";
import { renameSync, unlinkSync } from "node:fs";
import { mkdir, readdir } from "node:fs/promises";
import path from "node:path";
import { BACKUP_DIR, DATABASE_URL } from "@sofa/config";
import { closeDatabase, vacuumDatabase, withDatabaseAccessBlocked } from "@sofa/db/client";
import {
closeDatabase,
vacuumDatabase,
validateBackupDatabase,
withDatabaseAccessBlocked,
} from "@sofa/db/client";
import { runMigrations } from "@sofa/db/migrate";
import { createLogger } from "@sofa/logger";
@@ -29,26 +33,6 @@ export interface BackupInfo {
source: BackupSource;
}
const REQUIRED_TABLES = [
"account",
"appSettings",
"availabilityOffers",
"cronRuns",
"episodes",
"seasons",
"session",
"titleRecommendations",
"titles",
"user",
"userEpisodeWatches",
"userMovieWatches",
"userRatings",
"userTitleStatus",
"verification",
"integrations",
"integrationEvents",
] as const;
let backupOpQueue: Promise<void> = Promise.resolve();
/** @internal */
@@ -96,49 +80,6 @@ function unlinkIfExistsSync(filePath: string): void {
}
}
const SQLITE_MAGIC = "SQLite format 3\0";
function validateBackupDatabase(filePath: string): void {
// Check SQLite magic bytes before opening with Database() to avoid
// passing arbitrary files to the SQLite parser.
const header = Buffer.alloc(16);
const fd = openSync(filePath, "r");
try {
readSync(fd, header, 0, 16, 0);
} finally {
closeSync(fd);
}
if (header.toString("ascii", 0, 16) !== SQLITE_MAGIC) {
throw new Error("Not a valid SQLite database file");
}
const testDb = new Database(filePath, { readonly: true });
try {
const integrityRows = testDb.query("PRAGMA integrity_check").all() as {
integrity_check: string;
}[];
if (integrityRows.length === 0 || integrityRows.some((row) => row.integrity_check !== "ok")) {
throw new Error("Database integrity check failed");
}
const foreignKeyErrors = testDb.query("PRAGMA foreign_key_check").all();
if (foreignKeyErrors.length > 0) {
throw new Error("Database foreign key check failed");
}
const tableRows = testDb.query("SELECT name FROM sqlite_master WHERE type='table'").all() as {
name: string;
}[];
const tableSet = new Set(tableRows.map((row) => row.name));
const missing = REQUIRED_TABLES.filter((table) => !tableSet.has(table));
if (missing.length > 0) {
throw new Error(`Invalid backup: missing required tables (${missing.join(", ")})`);
}
} finally {
testDb.close();
}
}
/** @internal */
export function isValidBackupFilename(filename: string): boolean {
const base = path.basename(filename);
+7 -13
View File
@@ -1,19 +1,13 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { availabilityOffers } from "@sofa/db/schema";
import {
clearAllTables,
eq,
insertAvailabilityOffer,
insertTitle,
testDb,
} from "@sofa/db/test-utils";
import { clearAllTables, eq, insertAvailabilityOffer, insertTitle, testDb } from "@sofa/test/db";
let watchProvidersResponse: { results: Record<string, unknown> } = { results: {} };
const { getWatchProviders } = vi.hoisted(() => ({
getWatchProviders: vi.fn(async () => ({ results: {} as Record<string, unknown> })),
}));
const getWatchProviders = mock(async () => watchProvidersResponse);
mock.module("@sofa/tmdb/client", () => ({
vi.mock("@sofa/tmdb/client", () => ({
getWatchProviders,
}));
@@ -21,7 +15,7 @@ import { refreshAvailability } from "../src/availability";
beforeEach(() => {
clearAllTables();
watchProvidersResponse = { results: {} };
getWatchProviders.mockImplementation(async () => ({ results: {} }));
});
describe("refreshAvailability", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { getBackupSource, isKnownBackup, isValidBackupFilename } from "../src/backup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { parseColorPalette } from "../src/colors";
+2 -2
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { beforeEach, describe, expect, test } from "vitest";
import { persons, titleCast } from "@sofa/db/schema";
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
import { clearAllTables, insertTitle, testDb } from "@sofa/test/db";
import { getCastForTitle } from "../src/credits";
+6 -6
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { beforeEach, describe, expect, test } from "vitest";
import {
clearAllTables,
@@ -11,7 +11,7 @@ import {
insertTitle,
insertTvShow,
insertUser,
} from "@sofa/db/test-utils";
} from "@sofa/test/db";
import {
getContinueWatchingFeed,
@@ -82,7 +82,7 @@ describe("getWatchHistory", () => {
insertMovieWatch("user-1", "m2");
const history = getWatchHistory("user-1", "movies", "this_week");
expect(history).toBeArrayOfSize(7);
expect(history).toHaveLength(7);
const totalCount = history.reduce((sum, b) => sum + b.count, 0);
expect(totalCount).toBe(2);
});
@@ -90,20 +90,20 @@ describe("getWatchHistory", () => {
test("returns all-zero buckets when no watches", () => {
insertUser();
const history = getWatchHistory("user-1", "movies", "this_month");
expect(history).toBeArrayOfSize(30);
expect(history).toHaveLength(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);
expect(history).toHaveLength(24);
});
test("returns correct bucket count for this_year period", () => {
insertUser();
const history = getWatchHistory("user-1", "movies", "this_year");
expect(history).toBeArrayOfSize(12);
expect(history).toHaveLength(12);
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { beforeEach, describe, expect, test } from "vitest";
import {
clearAllTables,
@@ -9,7 +9,7 @@ import {
insertTitle,
insertTvShow,
insertUser,
} from "@sofa/db/test-utils";
} from "@sofa/test/db";
import { generateUserExport } from "../src/export";
+2 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { loadImageBuffer } from "../src/image-cache";
@@ -13,7 +13,7 @@ afterEach(() => {
describe("loadImageBuffer", () => {
test("uses category-specific TMDB sizes when cache is disabled", async () => {
const urls: string[] = [];
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation((async (
input: string | URL | Request,
) => {
urls.push(String(input));
+1 -2
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test";
import AdmZip from "adm-zip";
import { describe, expect, test } from "vitest";
import {
type ParseResult,
+3 -3
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
importJobs,
@@ -15,7 +15,7 @@ import {
insertTvShow,
insertUser,
testDb,
} from "@sofa/db/test-utils";
} from "@sofa/test/db";
import * as tmdbClient from "@sofa/tmdb/client";
import type { NormalizedImport } from "../src/imports/parsers";
@@ -435,7 +435,7 @@ describe("processImportJob — failed resolution", () => {
};
// Mock TMDB search to return empty results (no network call)
const searchSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
const searchSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [],
} as never);
+27 -27
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test";
import { afterEach, describe, expect, test, vi } from "vitest";
import * as tmdbClient from "@sofa/tmdb/client";
@@ -9,9 +9,9 @@ import { resolveMovieTmdbId, resolveShowTmdbId } from "../src/imports/resolve";
// globalThis.fetch because openapi-fetch captures the fetch reference at
// module-init time, making globalThis.fetch mocking ineffective.
let findSpy: ReturnType<typeof spyOn>;
let searchMoviesSpy: ReturnType<typeof spyOn>;
let searchTvSpy: ReturnType<typeof spyOn>;
let findSpy: ReturnType<typeof vi.spyOn>;
let searchMoviesSpy: ReturnType<typeof vi.spyOn>;
let searchTvSpy: ReturnType<typeof vi.spyOn>;
afterEach(() => {
findSpy?.mockRestore();
@@ -23,8 +23,8 @@ afterEach(() => {
describe("resolveMovieTmdbId", () => {
test("returns tmdbId directly when provided", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId");
searchMoviesSpy = spyOn(tmdbClient, "searchMovies");
findSpy = vi.spyOn(tmdbClient, "findByExternalId");
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies");
const result = await resolveMovieTmdbId({ tmdbId: 123 });
expect(result).toBe(123);
@@ -33,7 +33,7 @@ describe("resolveMovieTmdbId", () => {
});
test("resolves via IMDB ID lookup", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [{ id: 456 }],
tv_results: [],
tv_episode_results: [],
@@ -45,7 +45,7 @@ describe("resolveMovieTmdbId", () => {
});
test("falls back to TVDB lookup when no IMDB ID", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [{ id: 789 }],
tv_results: [],
tv_episode_results: [],
@@ -58,7 +58,7 @@ describe("resolveMovieTmdbId", () => {
test("IMDB returns no movie, falls back to TVDB", async () => {
let callIndex = 0;
findSpy = spyOn(tmdbClient, "findByExternalId").mockImplementation(async () => {
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockImplementation(async () => {
callIndex++;
if (callIndex === 1) {
// IMDB lookup — no results
@@ -85,7 +85,7 @@ describe("resolveMovieTmdbId", () => {
});
test("falls back to title search when no IDs available", async () => {
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [{ id: 321, title: "Inception", release_date: "2010-07-16" }],
} as never);
@@ -95,7 +95,7 @@ describe("resolveMovieTmdbId", () => {
});
test("title search with year prefers matching year", async () => {
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [
{ id: 100, title: "Dune", release_date: "1984-12-14" },
{ id: 200, title: "Dune", release_date: "2021-10-22" },
@@ -107,7 +107,7 @@ describe("resolveMovieTmdbId", () => {
});
test("title search without year match returns null", async () => {
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [
{ id: 100, title: "Dune", release_date: "1984-12-14" },
{ id: 200, title: "Dune", release_date: "2021-10-22" },
@@ -120,12 +120,12 @@ describe("resolveMovieTmdbId", () => {
});
test("returns null when all methods fail", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [],
tv_episode_results: [],
} as never);
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [],
} as never);
@@ -137,8 +137,8 @@ describe("resolveMovieTmdbId", () => {
});
test("returns null when no identifiers at all", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId");
searchMoviesSpy = spyOn(tmdbClient, "searchMovies");
findSpy = vi.spyOn(tmdbClient, "findByExternalId");
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies");
const result = await resolveMovieTmdbId({});
expect(result).toBeNull();
@@ -147,7 +147,7 @@ describe("resolveMovieTmdbId", () => {
});
test("cache prevents duplicate lookups", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [{ id: 555 }],
tv_results: [],
tv_episode_results: [],
@@ -166,7 +166,7 @@ describe("resolveMovieTmdbId", () => {
});
test("cache stores null for unresolvable items", async () => {
searchMoviesSpy = spyOn(tmdbClient, "searchMovies").mockResolvedValue({
searchMoviesSpy = vi.spyOn(tmdbClient, "searchMovies").mockResolvedValue({
results: [],
} as never);
@@ -186,7 +186,7 @@ describe("resolveMovieTmdbId", () => {
describe("resolveShowTmdbId", () => {
test("returns tmdbId directly when provided", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId");
findSpy = vi.spyOn(tmdbClient, "findByExternalId");
const result = await resolveShowTmdbId({ tmdbId: 42 });
expect(result).toBe(42);
@@ -194,7 +194,7 @@ describe("resolveShowTmdbId", () => {
});
test("resolves via IMDB ID — show-level result", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [{ id: 600, name: "Breaking Bad" }],
tv_episode_results: [],
@@ -205,7 +205,7 @@ describe("resolveShowTmdbId", () => {
});
test("resolves via IMDB ID — episode-level result extracts show_id", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [],
tv_episode_results: [
@@ -224,7 +224,7 @@ describe("resolveShowTmdbId", () => {
});
test("falls back to TVDB lookup", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [{ id: 800 }],
tv_episode_results: [],
@@ -236,7 +236,7 @@ describe("resolveShowTmdbId", () => {
});
test("TVDB lookup extracts show_id from episode result", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [],
tv_episode_results: [
@@ -255,7 +255,7 @@ describe("resolveShowTmdbId", () => {
});
test("falls back to title search", async () => {
searchTvSpy = spyOn(tmdbClient, "searchTv").mockResolvedValue({
searchTvSpy = vi.spyOn(tmdbClient, "searchTv").mockResolvedValue({
results: [{ id: 900, name: "The Office" }],
} as never);
@@ -265,12 +265,12 @@ describe("resolveShowTmdbId", () => {
});
test("returns null when all methods fail", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [],
tv_episode_results: [],
} as never);
searchTvSpy = spyOn(tmdbClient, "searchTv").mockResolvedValue({
searchTvSpy = vi.spyOn(tmdbClient, "searchTv").mockResolvedValue({
results: [],
} as never);
@@ -282,7 +282,7 @@ describe("resolveShowTmdbId", () => {
});
test("cache prevents duplicate show lookups", async () => {
findSpy = spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
findSpy = vi.spyOn(tmdbClient, "findByExternalId").mockResolvedValue({
movie_results: [],
tv_results: [{ id: 950 }],
tv_episode_results: [],
+11 -9
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
clearAllTables,
@@ -6,19 +6,21 @@ import {
insertStatus,
insertTitle,
insertUser,
} from "@sofa/db/test-utils";
} from "@sofa/test/db";
import { getRadarrList, getSonarrList, parseStatusParam, resolveListToken } from "../src/lists";
const { mockGetTvExternalIds } = vi.hoisted(() => ({
mockGetTvExternalIds: vi.fn(
(): Promise<{ tvdb_id: number | null; imdb_id: string | null }> =>
Promise.resolve({ tvdb_id: 55555, imdb_id: "tt9999999" }),
),
}));
// Mock getTvExternalIds for lazy resolution tests
const mockGetTvExternalIds = mock(
(): Promise<{ tvdb_id: number | null; imdb_id: string | null }> =>
Promise.resolve({ tvdb_id: 55555, imdb_id: "tt9999999" }),
);
mock.module("@sofa/tmdb/client", () => ({
vi.mock("@sofa/tmdb/client", () => ({
getTvExternalIds: mockGetTvExternalIds,
}));
import { getRadarrList, getSonarrList, parseStatusParam, resolveListToken } from "../src/lists";
beforeEach(() => {
clearAllTables();
mockGetTvExternalIds.mockClear();
+16 -25
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { episodes, seasons, titles } from "@sofa/db/schema";
import { clearAllTables, eq, insertTitle, testDb } from "@sofa/db/test-utils";
import { clearAllTables, eq, insertTitle, testDb } from "@sofa/test/db";
interface MockSeasonEpisode {
episode_number: number;
@@ -21,7 +21,7 @@ interface MockSeasonDetails {
episodes: MockSeasonEpisode[];
}
let seasonDetails: MockSeasonDetails = {
const defaultSeasonDetails: MockSeasonDetails = {
season_number: 1,
name: "Season 1",
overview: null,
@@ -39,7 +39,11 @@ let seasonDetails: MockSeasonDetails = {
],
};
mock.module("@sofa/tmdb/client", () => ({
const { mockGetTvSeasonDetails } = vi.hoisted(() => ({
mockGetTvSeasonDetails: vi.fn(),
}));
vi.mock("@sofa/tmdb/client", () => ({
getMovieDetails: async () => {
throw new Error("not used");
},
@@ -48,7 +52,7 @@ mock.module("@sofa/tmdb/client", () => ({
getTvDetails: async () => {
throw new Error("not used");
},
getTvSeasonDetails: async () => seasonDetails,
getTvSeasonDetails: mockGetTvSeasonDetails,
getVideos: async () => ({ results: [] }),
}));
@@ -56,23 +60,10 @@ import { refreshTvChildren, updateTitleWithArtInvalidation } from "../src/metada
beforeEach(() => {
clearAllTables();
seasonDetails = {
season_number: 1,
name: "Season 1",
overview: null,
poster_path: "/new-season.png",
air_date: null,
episodes: [
{
episode_number: 1,
name: "Episode 1",
overview: null,
still_path: "/new-still.png",
air_date: null,
runtime: null,
},
],
};
mockGetTvSeasonDetails.mockImplementation(async () => ({
...defaultSeasonDetails,
episodes: [...defaultSeasonDetails.episodes],
}));
});
describe("refreshTvChildren", () => {
@@ -132,8 +123,8 @@ describe("refreshTvChildren", () => {
})
.run();
seasonDetails = {
...seasonDetails,
mockGetTvSeasonDetails.mockImplementation(async () => ({
...defaultSeasonDetails,
poster_path: "/season.png",
episodes: [
{
@@ -145,7 +136,7 @@ describe("refreshTvChildren", () => {
runtime: null,
},
],
};
}));
await refreshTvChildren("tv-1", 10, 1);
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
+24 -54
View File
@@ -1,15 +1,14 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { personFilmography, persons } from "@sofa/db/schema";
import { clearAllTables, eq, testDb } from "@sofa/db/test-utils";
import { clearAllTables, eq, testDb } from "@sofa/test/db";
const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
"base64",
);
let nextBuffer: Buffer | null = TINY_PNG;
let nextPersonDetails = {
const defaultPersonDetails = {
id: 100,
name: "Updated Person",
biography: "Bio",
@@ -21,8 +20,8 @@ let nextPersonDetails = {
popularity: 10,
imdb_id: null,
};
let combinedCreditsCalls = 0;
let nextCombinedCredits = {
const defaultCombinedCredits = {
cast: [
{
id: 501,
@@ -43,62 +42,34 @@ let nextCombinedCredits = {
crew: [],
};
mock.module("@sofa/tmdb/client", () => ({
getPersonDetails: async () => nextPersonDetails,
getPersonCombinedCredits: async () => {
combinedCreditsCalls++;
return nextCombinedCredits;
},
const { mockGetPersonDetails, mockGetPersonCombinedCredits } = vi.hoisted(() => ({
mockGetPersonDetails: vi.fn(),
mockGetPersonCombinedCredits: vi.fn(),
}));
vi.mock("@sofa/tmdb/client", () => ({
getPersonDetails: mockGetPersonDetails,
getPersonCombinedCredits: mockGetPersonCombinedCredits,
}));
import { fetchFullFilmography, getOrFetchPerson } from "../src/person";
let combinedCreditsCalls = 0;
beforeEach(() => {
clearAllTables();
process.env.IMAGE_CACHE_ENABLED = "false";
nextBuffer = TINY_PNG;
nextPersonDetails = {
id: 100,
name: "Updated Person",
biography: "Bio",
birthday: null,
deathday: null,
place_of_birth: null,
profile_path: "/new-profile.png",
known_for_department: "Acting",
popularity: 10,
imdb_id: null,
};
combinedCreditsCalls = 0;
nextCombinedCredits = {
cast: [
{
id: 501,
media_type: "movie",
title: "Cached Movie",
name: undefined,
overview: "Overview",
release_date: "2024-01-01",
first_air_date: undefined,
poster_path: "/poster.png",
backdrop_path: "/backdrop.png",
popularity: 5,
vote_average: 7.5,
vote_count: 42,
character: "Lead",
},
],
crew: [],
};
spyOn(globalThis, "fetch").mockImplementation((async (
mockGetPersonDetails.mockImplementation(async () => ({ ...defaultPersonDetails }));
mockGetPersonCombinedCredits.mockImplementation(async () => {
combinedCreditsCalls++;
return { cast: [...defaultCombinedCredits.cast], crew: [] };
});
vi.spyOn(globalThis, "fetch").mockImplementation((async (
_input: string | URL | Request,
_init?: RequestInit,
) => {
if (!nextBuffer) {
return new Response(null, { status: 404 });
}
return new Response(nextBuffer, {
return new Response(TINY_PNG, {
status: 200,
headers: { "content-type": "image/png" },
});
@@ -107,7 +78,6 @@ beforeEach(() => {
afterEach(() => {
delete process.env.IMAGE_CACHE_ENABLED;
mock.restore();
});
describe("getOrFetchPerson", () => {
@@ -127,7 +97,7 @@ describe("getOrFetchPerson", () => {
const person = await getOrFetchPerson("p1");
const stored = testDb.select().from(persons).where(eq(persons.id, "p1")).get();
expect(person?.profileThumbHash).toBeString();
expect(person?.profileThumbHash).toBeTypeOf("string");
expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
});
@@ -148,7 +118,7 @@ describe("getOrFetchPerson", () => {
const stored = testDb.select().from(persons).where(eq(persons.id, "p1")).get();
expect(stored?.profilePath).toBe("/new-profile.png");
expect(person?.profileThumbHash).toBeString();
expect(person?.profileThumbHash).toBeTypeOf("string");
expect(person?.profileThumbHash).not.toBe("stale-hash");
expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
});
+2 -2
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { beforeEach, describe, expect, test } from "vitest";
import { personFilmography, persons } from "@sofa/db/schema";
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
import { clearAllTables, insertTitle, testDb } from "@sofa/test/db";
import { getLocalFilmography } from "../src/person";
-20
View File
@@ -1,20 +0,0 @@
import { afterEach, mock } from "bun:test";
import { applyMigrations, testDb } from "@sofa/db/test-utils";
process.env.LOG_LEVEL ??= "error";
mock.module("@sofa/db/client", () => ({
db: testDb,
optimizeDatabase: () => {},
vacuumDatabase: () => {},
closeDatabase: () => {},
isDatabaseAccessBlocked: () => false,
withDatabaseAccessBlocked: async (fn: () => Promise<unknown> | unknown) => await fn(),
}));
applyMigrations();
afterEach(() => {
mock.restore();
});
+2 -2
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { beforeEach, describe, expect, test } from "vitest";
import { user } from "@sofa/db/schema";
import { clearAllTables, eq, insertUser, testDb } from "@sofa/db/test-utils";
import { clearAllTables, eq, insertUser, testDb } from "@sofa/test/db";
import {
claimInitialAdmin,
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { parseSofaExport } from "../src/imports/sofa-parser";
+4 -5
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { episodes, seasons, titles } from "@sofa/db/schema";
import { clearAllTables, eq, testDb } from "@sofa/db/test-utils";
import { clearAllTables, eq, testDb } from "@sofa/test/db";
const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
@@ -16,7 +16,7 @@ beforeEach(() => {
clearAllTables();
process.env.IMAGE_CACHE_ENABLED = "false";
nextBuffer = TINY_PNG;
spyOn(globalThis, "fetch").mockImplementation((async (
vi.spyOn(globalThis, "fetch").mockImplementation((async (
_input: string | URL | Request,
_init?: RequestInit,
) => {
@@ -33,13 +33,12 @@ beforeEach(() => {
afterEach(() => {
delete process.env.IMAGE_CACHE_ENABLED;
mock.restore();
});
describe("thumbhash generation", () => {
test("generates a thumbhash from a loaded image buffer", async () => {
const hash = await generateThumbHash("/poster.png", "posters");
expect(hash).toBeString();
expect(hash).toBeTypeOf("string");
expect(hash).not.toBe("");
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { beforeEach, describe, expect, test } from "vitest";
import {
userEpisodeWatches,
@@ -14,7 +14,7 @@ import {
insertTvShow,
insertUser,
testDb,
} from "@sofa/db/test-utils";
} from "@sofa/test/db";
import {
getUserTitleInfo,
+2 -2
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { beforeEach, describe, expect, test } from "vitest";
import {
clearAllTables,
@@ -8,7 +8,7 @@ import {
insertTitle,
insertTvShow,
insertUser,
} from "@sofa/db/test-utils";
} from "@sofa/test/db";
import { getUpcomingFeed } from "../src/discovery";
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test } from "vitest";
import { isNewerVersion } from "../src/update-check";
+8
View File
@@ -0,0 +1,8 @@
import { defineProject } from "vitest/config";
export default defineProject({
test: {
include: ["test/**/*.test.ts"],
setupFiles: ["@sofa/test/setup"],
},
});
+2 -3
View File
@@ -7,7 +7,6 @@
"./client": "./src/client.ts",
"./migrate": "./src/migrate.ts",
"./schema": "./src/schema.ts",
"./test-utils": "./src/test-utils.ts",
"./queries/*": "./src/queries/*.ts"
},
"scripts": {
@@ -23,11 +22,11 @@
"dependencies": {
"@sofa/config": "workspace:*",
"@sofa/logger": "workspace:*",
"drizzle-orm": "1.0.0-beta.17-67b1795"
"drizzle-orm": "catalog:"
},
"devDependencies": {
"@types/bun": "catalog:",
"drizzle-kit": "1.0.0-beta.17-67b1795",
"drizzle-kit": "catalog:",
"typescript": "catalog:"
}
}
+52
View File
@@ -1,8 +1,11 @@
import { Database } from "bun:sqlite";
import { AsyncLocalStorage } from "node:async_hooks";
import { closeSync, openSync, readSync } from "node:fs";
import type { Logger } from "drizzle-orm";
import { getTableName } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { SQLiteTable } from "drizzle-orm/sqlite-core";
import { DATABASE_URL } from "@sofa/config";
import { createLogger } from "@sofa/logger";
@@ -111,3 +114,52 @@ export function closeDatabase() {
globalForDb._client = undefined;
globalForDb._db = undefined;
}
// ─── Backup validation ──────────────────────────────────────────────
const SQLITE_MAGIC = "SQLite format 3\0";
const REQUIRED_TABLES = Object.values(schema)
.filter((v) => v instanceof SQLiteTable)
.map((t) => getTableName(t as SQLiteTable));
export function validateBackupDatabase(filePath: string): void {
// Check SQLite magic bytes before opening with Database() to avoid
// passing arbitrary files to the SQLite parser.
const header = Buffer.alloc(16);
const fd = openSync(filePath, "r");
try {
readSync(fd, header, 0, 16, 0);
} finally {
closeSync(fd);
}
if (header.toString("ascii", 0, 16) !== SQLITE_MAGIC) {
throw new Error("Not a valid SQLite database file");
}
const validationDb = new Database(filePath, { readonly: true });
try {
const integrityRows = validationDb.query("PRAGMA integrity_check").all() as {
integrity_check: string;
}[];
if (integrityRows.length === 0 || integrityRows.some((row) => row.integrity_check !== "ok")) {
throw new Error("Database integrity check failed");
}
const foreignKeyErrors = validationDb.query("PRAGMA foreign_key_check").all();
if (foreignKeyErrors.length > 0) {
throw new Error("Database foreign key check failed");
}
const tableRows = validationDb
.query("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[];
const tableSet = new Set(tableRows.map((row) => row.name));
const missing = REQUIRED_TABLES.filter((table) => !tableSet.has(table));
if (missing.length > 0) {
throw new Error(`Invalid backup: missing required tables (${missing.join(", ")})`);
}
} finally {
validationDb.close();
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@sofa/test",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
"./setup": "./src/setup.ts",
"./db": "./src/db.ts"
},
"dependencies": {
"@sofa/db": "workspace:*",
"better-sqlite3": "12.8.0",
"drizzle-orm": "catalog:"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -1,9 +1,11 @@
import { Database } from "bun:sqlite";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import * as schema from "./schema";
import * as schema from "@sofa/db/schema";
const {
user,
@@ -20,23 +22,29 @@ const {
} = schema;
export const testClient = new Database(":memory:");
testClient.run("PRAGMA foreign_keys = ON");
testClient.pragma("foreign_keys = ON");
export const testDb = drizzle({ client: testClient, schema });
export function applyMigrations() {
const migrationsFolder = `${import.meta.dir}/../drizzle`;
migrate(testDb, { migrationsFolder });
const dbPkgDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"db",
"drizzle",
);
migrate(testDb, { migrationsFolder: dbPkgDir });
}
export function clearAllTables() {
const tables = testClient
.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__drizzle%'")
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__drizzle%'")
.all() as { name: string }[];
testClient.run("PRAGMA foreign_keys = OFF");
testClient.pragma("foreign_keys = OFF");
for (const { name } of tables) {
testClient.run(`DELETE FROM "${name}"`);
testClient.exec(`DELETE FROM "${name}"`);
}
testClient.run("PRAGMA foreign_keys = ON");
testClient.pragma("foreign_keys = ON");
}
const now = new Date();
+30
View File
@@ -0,0 +1,30 @@
import crypto from "node:crypto";
import { afterEach, vi } from "vitest";
import { applyMigrations, testDb } from "./db";
// Polyfill Bun globals used by schema $defaultFn (tests run on Node.js, not Bun)
if (typeof globalThis.Bun === "undefined") {
(globalThis as Record<string, unknown>).Bun = {
randomUUIDv7: () => crypto.randomUUID(),
};
}
process.env.LOG_LEVEL ??= "error";
vi.mock("@sofa/db/client", () => ({
db: testDb,
optimizeDatabase: () => {},
vacuumDatabase: () => {},
validateBackupDatabase: () => {},
closeDatabase: () => {},
isDatabaseAccessBlocked: () => false,
withDatabaseAccessBlocked: async (fn: () => Promise<unknown> | unknown) => await fn(),
}));
applyMigrations();
afterEach(() => {
vi.restoreAllMocks();
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["esnext"],
"strict": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}