Add comprehensive test suite with CI workflow

- 211 tests across 15 files covering services (tracking, discovery,
  metadata, backup, credits, webhooks, settings, update-check, colors,
  person), utilities (config, cron, providers, title-theme), and TMDB
  image URL helpers
- `lib/test-preload.ts` + `bunfig.toml` wire up a global in-memory
  SQLite DB with migrations for all DB-backed tests
- `lib/test-utils.ts` provides `clearAllTables()` and seed helpers
  (insertUser, insertTitle, insertTvShow, insertMovieWatch, etc.)
- Export `getBackupSource`, `isKnownBackup`, `isValidBackupFilename`,
  `buildBackupCron`, `performUpdateCheck` internals for direct testing
- GitHub Actions workflow runs `bun test --coverage` on push/PR to main
This commit is contained in:
2026-03-06 13:42:58 -05:00
parent 965e8353ac
commit 4cf326fa00
25 changed files with 2737 additions and 9 deletions
+310
View File
@@ -0,0 +1,310 @@
import { describe, expect, test } from "bun:test";
import {
parseEmbyPayload,
parseJellyfinPayload,
parsePlexPayload,
toOptionalInt,
} from "./webhooks";
describe("toOptionalInt", () => {
test("returns integer number as-is", () => {
expect(toOptionalInt(42)).toBe(42);
});
test("returns 0 for zero", () => {
expect(toOptionalInt(0)).toBe(0);
});
test("returns undefined for float", () => {
expect(toOptionalInt(3.14)).toBeUndefined();
});
test("parses valid integer string", () => {
expect(toOptionalInt("7")).toBe(7);
});
test("parses string with whitespace", () => {
expect(toOptionalInt(" 12 ")).toBe(12);
});
test("returns undefined for non-numeric string", () => {
expect(toOptionalInt("abc")).toBeUndefined();
});
test("returns undefined for empty string", () => {
expect(toOptionalInt("")).toBeUndefined();
});
test("returns undefined for null", () => {
expect(toOptionalInt(null)).toBeUndefined();
});
test("returns undefined for undefined", () => {
expect(toOptionalInt(undefined)).toBeUndefined();
});
test("returns undefined for boolean", () => {
expect(toOptionalInt(true)).toBeUndefined();
});
});
describe("parsePlexPayload", () => {
function makeFormData(payload: unknown): FormData {
const fd = new FormData();
fd.set("payload", JSON.stringify(payload));
return fd;
}
test("returns null for missing payload field", () => {
expect(parsePlexPayload(new FormData())).toBeNull();
});
test("returns null for invalid JSON", () => {
const fd = new FormData();
fd.set("payload", "not json{");
expect(parsePlexPayload(fd)).toBeNull();
});
test("returns null for wrong event type", () => {
expect(parsePlexPayload(makeFormData({ event: "media.play" }))).toBeNull();
});
test("returns null when Metadata is missing", () => {
expect(
parsePlexPayload(makeFormData({ event: "media.scrobble" })),
).toBeNull();
});
test("returns null for unsupported media type", () => {
expect(
parsePlexPayload(
makeFormData({
event: "media.scrobble",
Metadata: { type: "track" },
}),
),
).toBeNull();
});
test("parses movie scrobble", () => {
const result = parsePlexPayload(
makeFormData({
event: "media.scrobble",
Metadata: {
type: "movie",
title: "Inception",
Guid: [{ id: "tmdb://27205" }, { id: "imdb://tt1375666" }],
},
}),
);
expect(result).toEqual({
provider: "plex",
mediaType: "movie",
title: "Inception",
tmdbId: 27205,
imdbId: "tt1375666",
tvdbId: undefined,
seasonNumber: undefined,
episodeNumber: undefined,
showTitle: undefined,
});
});
test("parses episode scrobble", () => {
const result = parsePlexPayload(
makeFormData({
event: "media.scrobble",
Metadata: {
type: "episode",
title: "Pilot",
grandparentTitle: "Breaking Bad",
parentIndex: 1,
index: 1,
Guid: [{ id: "tvdb://349232" }],
},
}),
);
expect(result).toEqual({
provider: "plex",
mediaType: "episode",
title: "Pilot",
tmdbId: undefined,
imdbId: undefined,
tvdbId: "349232",
seasonNumber: 1,
episodeNumber: 1,
showTitle: "Breaking Bad",
});
});
test("extracts GUIDs from lowercase guid field", () => {
const result = parsePlexPayload(
makeFormData({
event: "media.scrobble",
Metadata: {
type: "movie",
title: "Test",
guid: [{ id: "tmdb://12345" }],
},
}),
);
expect(result?.tmdbId).toBe(12345);
});
});
describe("parseJellyfinPayload", () => {
test("returns null for wrong NotificationType", () => {
expect(
parseJellyfinPayload({ NotificationType: "PlaybackStart" }),
).toBeNull();
});
test("returns null when PlayedToCompletion is false", () => {
expect(
parseJellyfinPayload({
NotificationType: "PlaybackStop",
PlayedToCompletion: false,
ItemType: "Movie",
}),
).toBeNull();
});
test("returns null for unsupported ItemType", () => {
expect(
parseJellyfinPayload({
NotificationType: "PlaybackStop",
PlayedToCompletion: true,
ItemType: "Audio",
}),
).toBeNull();
});
test("parses movie payload", () => {
const result = parseJellyfinPayload({
NotificationType: "PlaybackStop",
PlayedToCompletion: true,
ItemType: "Movie",
Name: "The Matrix",
Provider_tmdb: "603",
Provider_imdb: "tt0133093",
});
expect(result).toEqual({
provider: "jellyfin",
mediaType: "movie",
title: "The Matrix",
tmdbId: 603,
imdbId: "tt0133093",
tvdbId: undefined,
seasonNumber: undefined,
episodeNumber: undefined,
showTitle: undefined,
});
});
test("parses episode payload", () => {
const result = parseJellyfinPayload({
NotificationType: "PlaybackStop",
PlayedToCompletion: true,
ItemType: "Episode",
Name: "Ozymandias",
Provider_tmdb: "62161",
SeasonNumber: 5,
EpisodeNumber: 14,
SeriesName: "Breaking Bad",
});
expect(result).toEqual({
provider: "jellyfin",
mediaType: "episode",
title: "Ozymandias",
tmdbId: 62161,
imdbId: undefined,
tvdbId: undefined,
seasonNumber: 5,
episodeNumber: 14,
showTitle: "Breaking Bad",
});
});
});
describe("parseEmbyPayload", () => {
test("returns null for wrong event type", () => {
expect(
parseEmbyPayload({ Event: "playback.start", PlayedToCompletion: true }),
).toBeNull();
});
test("returns null when PlayedToCompletion is false", () => {
expect(
parseEmbyPayload({ Event: "playback.stop", PlayedToCompletion: false }),
).toBeNull();
});
test("returns null when Item is missing", () => {
expect(
parseEmbyPayload({ Event: "playback.stop", PlayedToCompletion: true }),
).toBeNull();
});
test("accepts 'playback.stop' event string", () => {
const result = parseEmbyPayload({
Event: "playback.stop",
PlayedToCompletion: true,
Item: {
Type: "Movie",
Name: "Interstellar",
ProviderIds: { Tmdb: "157336" },
},
});
expect(result?.provider).toBe("emby");
expect(result?.tmdbId).toBe(157336);
});
test("accepts 'PlaybackStop' event string", () => {
const result = parseEmbyPayload({
Event: "PlaybackStop",
PlayedToCompletion: true,
Item: {
Type: "Movie",
Name: "Interstellar",
ProviderIds: { Tmdb: "157336" },
},
});
expect(result?.provider).toBe("emby");
});
test("parses episode with ProviderIds", () => {
const result = parseEmbyPayload({
Event: "playback.stop",
PlayedToCompletion: true,
Item: {
Type: "Episode",
Name: "Pilot",
ParentIndexNumber: 1,
IndexNumber: 1,
SeriesName: "Lost",
ProviderIds: { Tmdb: "12345", Imdb: "tt0000001", Tvdb: "67890" },
},
});
expect(result).toEqual({
provider: "emby",
mediaType: "episode",
title: "Pilot",
tmdbId: 12345,
imdbId: "tt0000001",
tvdbId: "67890",
seasonNumber: 1,
episodeNumber: 1,
showTitle: "Lost",
});
});
test("handles missing ProviderIds gracefully", () => {
const result = parseEmbyPayload({
Event: "playback.stop",
PlayedToCompletion: true,
Item: { Type: "Movie", Name: "Unknown Movie" },
});
expect(result?.tmdbId).toBeUndefined();
expect(result?.imdbId).toBeUndefined();
});
});