mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
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:
@@ -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:*",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getBackupSource, isKnownBackup, isValidBackupFilename } from "../src/backup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseColorPalette } from "../src/colors";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import AdmZip from "adm-zip";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
type ParseResult,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { parseSofaExport } from "../src/imports/sofa-parser";
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { isNewerVersion } from "../src/update-check";
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineProject } from "vitest/config";
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
setupFiles: ["@sofa/test/setup"],
|
||||
},
|
||||
});
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user