feat: Add unit tests for new episode job, title matching, and next episode scheduling

Also update Vitest exclude configuration
This commit is contained in:
2025-12-07 05:26:49 -05:00
parent 4b3354a5d6
commit c45a24febe
4 changed files with 287 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as getAdminSdkCredentials from "~/libs/gcloud/getAdminSdkCredentials";
import * as sendFcmMessage from "~/libs/gcloud/sendFcmMessage";
import * as maybeScheduleNextAiringEpisode from "~/libs/maybeScheduleNextAiringEpisode";
import * as token from "~/models/token";
import * as watchStatus from "~/models/watchStatus";
import * as aniwatch from "~/services/episodes/getByAniListId/aniwatch";
import * as getEpisodeUrl from "~/services/episodes/getEpisodeUrl";
import { onNewEpisode } from "./new-episode";
describe("onNewEpisode", () => {
beforeEach(() => {
vi.restoreAllMocks();
// Default mocks
vi.spyOn(getAdminSdkCredentials, "getAdminSdkCredentials").mockReturnValue(
{} as any,
);
vi.spyOn(watchStatus, "isWatchingTitle").mockResolvedValue(true);
vi.spyOn(aniwatch, "getEpisodesFromAniwatch").mockResolvedValue({
providerId: "test",
episodes: [],
} as any);
vi.spyOn(token, "getTokensSubscribedToTitle").mockResolvedValue([]);
vi.spyOn(
maybeScheduleNextAiringEpisode,
"maybeScheduleNextAiringEpisode",
).mockResolvedValue();
vi.spyOn(sendFcmMessage, "sendFcmMessage").mockResolvedValue({} as any);
// Mock Successful fetchUrlResult
vi.spyOn(getEpisodeUrl, "fetchEpisodeUrl").mockResolvedValue({
url: "http://example.com/stream",
headers: {},
} as any);
});
it("should return isNoLongerWatching if title is not being watched", async () => {
vi.spyOn(watchStatus, "isWatchingTitle").mockResolvedValue(false);
const result = await onNewEpisode(123, 10);
expect(result).toEqual({
success: true,
result: { isNoLongerWatching: true },
});
});
it("should return failure if fetching episode URL fails", async () => {
vi.spyOn(getEpisodeUrl, "fetchEpisodeUrl").mockResolvedValue(null);
const result = await onNewEpisode(123, 10);
expect(result).toEqual({
success: false,
message: "Failed to fetch episode URL",
});
});
it("should send FCM messages to subscribed tokens", async () => {
const tokens = ["token1", "token2"];
vi.spyOn(token, "getTokensSubscribedToTitle").mockResolvedValue(tokens);
const sendSpy = vi.spyOn(sendFcmMessage, "sendFcmMessage");
await onNewEpisode(123, 10);
expect(sendSpy).toHaveBeenCalledTimes(2);
// Verify arguments for one call
expect(sendSpy).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
token: "token1",
data: expect.objectContaining({
type: "new_episode",
aniListId: "123",
episodeNumber: "10",
}),
}),
);
});
it("should schedule next airing episode", async () => {
const scheduleSpy = vi.spyOn(
maybeScheduleNextAiringEpisode,
"maybeScheduleNextAiringEpisode",
);
await onNewEpisode(123, 10);
expect(scheduleSpy).toHaveBeenCalledWith(123);
});
});

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { findBestMatchingTitle } from "./findBestMatchingTitle";
describe("findBestMatchingTitle", () => {
it("should return the exact match for userPreferred title", () => {
const title = {
userPreferred: "One Piece",
english: "One Piece",
};
const titlesToSearch = [
{ userPreferred: "Naruto", english: "Naruto" },
{ userPreferred: "One Piece", english: "One Piece" },
{ userPreferred: "Bleach", english: "Bleach" },
];
const result = findBestMatchingTitle(title, titlesToSearch);
expect(result.title).toBe("one piece");
expect(result.score).toBeGreaterThan(0.8);
});
it("should return the exact match for english title if userPreferred is missing", () => {
const title = {
english: "Attack on Titan",
};
const titlesToSearch = [
{ userPreferred: "Shingeki no Kyojin", english: "Attack on Titan" },
{ userPreferred: "Naruto", english: "Naruto" },
];
const result = findBestMatchingTitle(title, titlesToSearch);
expect(result.title).toBe("attack on titan");
expect(result.score).toBeGreaterThan(0.8);
});
it("should favor userPreferred match over english match if score is higher", () => {
const title = {
userPreferred: "Fullmetal Alchemist: Brotherhood",
english: "Fullmetal Alchemist Brotherhood",
};
const titlesToSearch = [
{
userPreferred: "Fullmetal Alchemist: Brotherhood",
english: "Fullmetal Alchemist",
},
];
const result = findBestMatchingTitle(title, titlesToSearch);
expect(result.title).toBe("fullmetal alchemist: brotherhood");
});
it("should handle partial matches with high scores", () => {
const title = {
userPreferred: "My Hero Academia 2",
};
const titlesToSearch = [
{ userPreferred: "My Hero Academia" },
{ userPreferred: "My Hero Academia 2" },
{ userPreferred: "My Hero Academia 3" },
];
const result = findBestMatchingTitle(title, titlesToSearch);
expect(result.title).toBe("my hero academia 2");
});
it("should filter by suffix for 'My Hero Academia' logic", () => {
const title = {
english: "My Hero Academia 3",
};
// Expected suffix is "3"
const titlesToSearch = [
{ userPreferred: "Boku no Hero Academia", english: "My Hero Academia" },
{
userPreferred: "Boku no Hero Academia 2",
english: "My Hero Academia 2",
},
{
userPreferred: "Boku no Hero Academia 3",
english: "My Hero Academia 3",
},
];
const result = findBestMatchingTitle(title, titlesToSearch);
// It should match the one ending with 3
expect(result.title).toBe("my hero academia 3");
});
it("should return null/low score if no good match is found", () => {
const title = {
userPreferred: "Random Unknown Anime",
};
const titlesToSearch = [
{ userPreferred: "Naruto" },
{ userPreferred: "Bleach" },
];
const result = findBestMatchingTitle(title, titlesToSearch);
// It will return *some* match because valid targets > 0, but score should be low.
// However, the implementation always returns the "best" match from the list.
// If the list is not empty, it returns something.
expect(result.title).toBeTruthy();
expect(result.score).toBeLessThan(0.5);
});
it("should return null if titlesToSearch is empty", () => {
const title = {
userPreferred: "One Piece",
};
const titlesToSearch: any[] = [];
const result = findBestMatchingTitle(title, titlesToSearch);
expect(result.title).toBeNull();
expect(result.score).toBe(0);
});
});

View File

@@ -0,0 +1,72 @@
import { DateTime } from "luxon";
import { describe, expect, it, vi } from "vitest";
import * as unreleasedTitles from "~/models/unreleasedTitles";
import * as getNextEpisodeAiringAt from "./anilist/getNextEpisodeAiringAt";
import { maybeScheduleNextAiringEpisode } from "./maybeScheduleNextAiringEpisode";
import * as queueTask from "./tasks/queueTask";
describe("maybeScheduleNextAiringEpisode", () => {
it("should add to unreleased titles if status is NOT_YET_RELEASED and no airing time", async () => {
vi.spyOn(
getNextEpisodeAiringAt,
"getNextEpisodeTimeUntilAiring",
).mockResolvedValue({
status: "NOT_YET_RELEASED",
} as any);
const addSpy = vi
.spyOn(unreleasedTitles, "addUnreleasedTitle")
.mockResolvedValue();
const queueSpy = vi.spyOn(queueTask, "queueTask");
await maybeScheduleNextAiringEpisode(12345);
expect(addSpy).toHaveBeenCalledWith(12345);
expect(queueSpy).not.toHaveBeenCalled();
});
it("should return early if airing time is too far in the future (> 720 hours)", async () => {
const futureTime = DateTime.now().plus({ hours: 721 }).toSeconds();
vi.spyOn(
getNextEpisodeAiringAt,
"getNextEpisodeTimeUntilAiring",
).mockResolvedValue({
nextAiring: { airingAt: futureTime },
status: "RELEASING",
} as any);
const addSpy = vi.spyOn(unreleasedTitles, "addUnreleasedTitle");
const queueSpy = vi.spyOn(queueTask, "queueTask");
await maybeScheduleNextAiringEpisode(12345);
expect(addSpy).not.toHaveBeenCalled();
expect(queueSpy).not.toHaveBeenCalled();
});
it("should queue task and remove unreleased title if airing soon", async () => {
const nearFutureTime = DateTime.now().plus({ hours: 24 }).toSeconds();
vi.spyOn(
getNextEpisodeAiringAt,
"getNextEpisodeTimeUntilAiring",
).mockResolvedValue({
nextAiring: { airingAt: nearFutureTime, episode: 12 },
status: "RELEASING",
} as any);
const removeSpy = vi
.spyOn(unreleasedTitles, "removeUnreleasedTitle")
.mockResolvedValue();
const queueSpy = vi
.spyOn(queueTask, "queueTask")
.mockResolvedValue({} as any);
await maybeScheduleNextAiringEpisode(12345);
expect(queueSpy).toHaveBeenCalledWith(
"NEW_EPISODE",
{ aniListId: 12345, episodeNumber: 12 },
{ scheduleConfig: { epochTime: nearFutureTime } },
);
expect(removeSpy).toHaveBeenCalledWith(12345);
});
});

View File

@@ -40,6 +40,7 @@ export default defineWorkersProject(async () => {
"**/mocks/**", "**/mocks/**",
"drizzle.config.ts", "drizzle.config.ts",
"src/schema.ts", "src/schema.ts",
"src/libs/test",
], ],
}, },
}, },