mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -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:
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
|
||||
import * as schema from "@sofa/db/schema";
|
||||
|
||||
const {
|
||||
user,
|
||||
titles,
|
||||
seasons,
|
||||
episodes,
|
||||
userMovieWatches,
|
||||
userEpisodeWatches,
|
||||
userTitleStatus,
|
||||
userRatings,
|
||||
availabilityOffers,
|
||||
titleRecommendations,
|
||||
integrations,
|
||||
} = schema;
|
||||
|
||||
export const testClient = new Database(":memory:");
|
||||
testClient.pragma("foreign_keys = ON");
|
||||
export const testDb = drizzle({ client: testClient, schema });
|
||||
|
||||
export function applyMigrations() {
|
||||
const dbPkgDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"db",
|
||||
"drizzle",
|
||||
);
|
||||
migrate(testDb, { migrationsFolder: dbPkgDir });
|
||||
}
|
||||
|
||||
export function clearAllTables() {
|
||||
const tables = testClient
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__drizzle%'")
|
||||
.all() as { name: string }[];
|
||||
testClient.pragma("foreign_keys = OFF");
|
||||
for (const { name } of tables) {
|
||||
testClient.exec(`DELETE FROM "${name}"`);
|
||||
}
|
||||
testClient.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;
|
||||
tvdbId?: number;
|
||||
type?: "movie" | "tv";
|
||||
title?: string;
|
||||
releaseDate?: string;
|
||||
posterPath?: string;
|
||||
status?: string;
|
||||
} = {},
|
||||
) {
|
||||
const id = overrides.id ?? "title-1";
|
||||
testDb
|
||||
.insert(titles)
|
||||
.values({
|
||||
id,
|
||||
tmdbId: overrides.tmdbId ?? 12345,
|
||||
tvdbId: overrides.tvdbId,
|
||||
type: overrides.type ?? "movie",
|
||||
title: overrides.title ?? "Test Movie",
|
||||
releaseDate: overrides.releaseDate,
|
||||
posterPath: overrides.posterPath,
|
||||
status: overrides.status,
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function insertTvShow(
|
||||
titleId = "tv-1",
|
||||
tmdbId = 99999,
|
||||
seasonCount = 1,
|
||||
epsPerSeason = 3,
|
||||
options: { title?: string; airDates?: string[]; status?: string } = {},
|
||||
) {
|
||||
insertTitle({
|
||||
id: titleId,
|
||||
tmdbId,
|
||||
type: "tv",
|
||||
title: options.title ?? "Test Show",
|
||||
status: options.status,
|
||||
});
|
||||
const episodeIds: string[] = [];
|
||||
let airDateIdx = 0;
|
||||
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}`,
|
||||
airDate: options.airDates?.[airDateIdx],
|
||||
})
|
||||
.run();
|
||||
episodeIds.push(epId);
|
||||
airDateIdx++;
|
||||
}
|
||||
}
|
||||
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 timestamp = new Date();
|
||||
testDb
|
||||
.insert(userTitleStatus)
|
||||
.values({ userId, titleId, status, addedAt: timestamp, updatedAt: timestamp })
|
||||
.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 insertIntegration(userId: string, provider: string, token = "test-token") {
|
||||
const type = provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
|
||||
return testDb
|
||||
.insert(integrations)
|
||||
.values({
|
||||
userId,
|
||||
provider,
|
||||
type,
|
||||
token,
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export {
|
||||
and,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
lt,
|
||||
notInArray,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
@@ -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