mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
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:
23 files changed
+3513
-86
No files matched your search
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +9,8 @@ mock.module("@sofa/db/client", () => ({
|
||||
optimizeDatabase: () => {},
|
||||
vacuumDatabase: () => {},
|
||||
closeDatabase: () => {},
|
||||
isDatabaseAccessBlocked: () => false,
|
||||
withDatabaseAccessBlocked: async (fn: () => Promise<unknown> | unknown) => await fn(),
|
||||
}));
|
||||
|
||||
applyMigrations();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user