diff --git a/src/jobs/new-episode.spec.ts b/src/jobs/new-episode.spec.ts new file mode 100644 index 0000000..291fac2 --- /dev/null +++ b/src/jobs/new-episode.spec.ts @@ -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); + }); +}); diff --git a/src/libs/findBestMatchingTitle.spec.ts b/src/libs/findBestMatchingTitle.spec.ts new file mode 100644 index 0000000..a1c62be --- /dev/null +++ b/src/libs/findBestMatchingTitle.spec.ts @@ -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); + }); +}); diff --git a/src/libs/maybeScheduleNextAiringEpisode.spec.ts b/src/libs/maybeScheduleNextAiringEpisode.spec.ts new file mode 100644 index 0000000..3068b56 --- /dev/null +++ b/src/libs/maybeScheduleNextAiringEpisode.spec.ts @@ -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); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a7b9f2c..0e555b8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,6 +40,7 @@ export default defineWorkersProject(async () => { "**/mocks/**", "drizzle.config.ts", "src/schema.ts", + "src/libs/test", ], }, },