feat: Add unit tests for new episode job, title matching, and next episode scheduling
Also update Vitest exclude configuration
This commit is contained in:
92
src/jobs/new-episode.spec.ts
Normal file
92
src/jobs/new-episode.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
122
src/libs/findBestMatchingTitle.spec.ts
Normal file
122
src/libs/findBestMatchingTitle.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
72
src/libs/maybeScheduleNextAiringEpisode.spec.ts
Normal file
72
src/libs/maybeScheduleNextAiringEpisode.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ export default defineWorkersProject(async () => {
|
||||
"**/mocks/**",
|
||||
"drizzle.config.ts",
|
||||
"src/schema.ts",
|
||||
"src/libs/test",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user