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/**",
|
"**/mocks/**",
|
||||||
"drizzle.config.ts",
|
"drizzle.config.ts",
|
||||||
"src/schema.ts",
|
"src/schema.ts",
|
||||||
|
"src/libs/test",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user