fix: harden backup restore, first-user race, and several reliability issues

- Block all non-health API requests with 503 while a database restore is in progress (`withDatabaseAccessBlocked` in `@sofa/db/client`); pause and resume cron jobs around the restore window
- Replace the two-hook first-user admin promotion with an atomic `claimInitialAdmin` query that uses a DB-level unique constraint so concurrent sign-ups during the first-run window can't each see `userCount === 0`
- Fix `refreshAvailability` to always call `replaceAvailabilityTransaction` (clearing stale rows) even when TMDB returns no US providers, instead of returning early and leaving old data in place
- Fix `performUpdateCheck` to read `release_url` (snake_case) from the public API response instead of `releaseUrl`
- Fix `createJob` import handler to catch `SQLITE_CONSTRAINT_UNIQUE` and surface it as an `IMPORT_ALREADY_RUNNING` conflict error instead of a 500
- Relax public API telemetry schema to accept `string | number` for `users` and `titles` fields
- Add tests for availability clearing, import deduplication, and `claimInitialAdmin`
This commit is contained in:
jake committed 2026-03-21 16:16:37 -04:00
1 parent f676cea237
commit 58cf2e689f
23 files changed
+3513 -86

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { availabilityOffers } from "@sofa/db/schema";
import {
clearAllTables,
eq,
insertAvailabilityOffer,
insertTitle,
testDb,
} from "@sofa/db/test-utils";
let watchProvidersResponse: { results: Record<string, unknown> } = { results: {} };
const getWatchProviders = mock(async () => watchProvidersResponse);
mock.module("@sofa/tmdb/client", () => ({
getWatchProviders,
}));
import { refreshAvailability } from "../src/availability";
beforeEach(() => {
clearAllTables();
watchProvidersResponse = { results: {} };
});
describe("refreshAvailability", () => {
test("clears stale US offers when TMDB returns no US availability", async () => {
insertTitle({ id: "movie-1", tmdbId: 101, type: "movie", title: "Movie" });
insertAvailabilityOffer("movie-1");
await refreshAvailability("movie-1");
const offers = testDb
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, "movie-1"))
.all();
expect(offers).toHaveLength(0);
});
});
@@ -344,6 +344,80 @@ describe("processImportJob — state transitions", () => {
test("readImportJob throws for non-existent job", () => {
expect(() => readImportJob("non-existent-id")).toThrow("Import job non-existent-id not found");
});
test("schema allows only one active import job per user", () => {
const userId = insertUser();
const createdAt = new Date();
testDb
.insert(importJobs)
.values({
id: "job-1",
userId,
source: "trakt",
status: "pending",
payload: JSON.stringify({
source: "trakt",
movies: [],
episodes: [],
watchlist: [],
ratings: [],
}),
importWatches: true,
importWatchlist: true,
importRatings: true,
createdAt,
})
.run();
expect(() =>
testDb
.insert(importJobs)
.values({
id: "job-2",
userId,
source: "simkl",
status: "running",
payload: JSON.stringify({
source: "simkl",
movies: [],
episodes: [],
watchlist: [],
ratings: [],
}),
importWatches: true,
importWatchlist: true,
importRatings: true,
createdAt,
})
.run(),
).toThrow();
testDb.update(importJobs).set({ status: "success" }).where(eq(importJobs.id, "job-1")).run();
expect(() =>
testDb
.insert(importJobs)
.values({
id: "job-3",
userId,
source: "letterboxd",
status: "pending",
payload: JSON.stringify({
source: "letterboxd",
movies: [],
episodes: [],
watchlist: [],
ratings: [],
}),
importWatches: true,
importWatchlist: true,
importRatings: true,
createdAt,
})
.run(),
).not.toThrow();
});
});
// ── Failed Resolution ───────────────────────────────────────────────
+2
View File
@@ -9,6 +9,8 @@ mock.module("@sofa/db/client", () => ({
optimizeDatabase: () => {},
vacuumDatabase: () => {},
closeDatabase: () => {},
isDatabaseAccessBlocked: () => false,
withDatabaseAccessBlocked: async (fn: () => Promise<unknown> | unknown) => await fn(),
}));
applyMigrations();
+27 -2
View File
@@ -1,8 +1,15 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { clearAllTables, insertUser } from "@sofa/db/test-utils";
import { user } from "@sofa/db/schema";
import { clearAllTables, eq, insertUser, testDb } from "@sofa/db/test-utils";
import { getSetting, getUserCount, isRegistrationOpen, setSetting } from "../src/settings";
import {
claimInitialAdmin,
getSetting,
getUserCount,
isRegistrationOpen,
setSetting,
} from "../src/settings";
beforeEach(() => {
clearAllTables();
@@ -65,3 +72,21 @@ describe("isRegistrationOpen", () => {
expect(isRegistrationOpen()).toBe(false);
});
});
describe("claimInitialAdmin", () => {
test("promotes the earliest user to admin and closes registration", () => {
insertUser("user-1");
insertUser("user-2");
expect(claimInitialAdmin("user-2")).toBe(false);
expect(claimInitialAdmin("user-1")).toBe(true);
expect(claimInitialAdmin("user-1")).toBe(false);
const firstUser = testDb.select().from(user).where(eq(user.id, "user-1")).get();
const secondUser = testDb.select().from(user).where(eq(user.id, "user-2")).get();
expect(firstUser?.role).toBe("admin");
expect(secondUser?.role).toBe("user");
expect(getSetting("registrationOpen")).toBe("false");
});
});