From 2becf1aa3b3a1fd3f8b68c214dc63229dbefb587 Mon Sep 17 00:00:00 2001 From: Rushil Perera Date: Thu, 4 Jul 2024 18:18:57 -0400 Subject: [PATCH] feat: support removing watch status when null A user can choose to remove a show from being in their media list completely, by setting the watch status to null --- src/controllers/watch-status/anilist.ts | 53 ++++++++-- src/controllers/watch-status/index.spec.ts | 109 ++++++++++++++++++++- src/controllers/watch-status/index.ts | 10 +- src/libs/errors/TitleNotFound.ts | 5 + src/mocks/anilist/deleteMediaListEntry.ts | 15 +++ src/mocks/anilist/mediaListEntry.ts | 35 +++++++ src/mocks/handlers.ts | 4 + src/models/watchStatus.ts | 2 +- 8 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 src/libs/errors/TitleNotFound.ts create mode 100644 src/mocks/anilist/deleteMediaListEntry.ts create mode 100644 src/mocks/anilist/mediaListEntry.ts diff --git a/src/controllers/watch-status/anilist.ts b/src/controllers/watch-status/anilist.ts index 9e568dd..dc65e73 100644 --- a/src/controllers/watch-status/anilist.ts +++ b/src/controllers/watch-status/anilist.ts @@ -1,9 +1,10 @@ import { graphql } from "gql.tada"; import { GraphQLClient } from "graphql-request"; -import type { WatchStatus } from "~/types/title"; +import { AnilistTitleNotFoundError } from "~/libs/errors/TitleNotFound"; +import type { WatchStatus } from "~/types/title/watchStatus"; -const UpdateWatchStatusQuery = graphql(` +const UpdateWatchStatusMutation = graphql(` mutation UpdateWatchStatus($titleId: Int!, $watchStatus: MediaListStatus!) { SaveMediaListEntry(mediaId: $titleId, status: $watchStatus) { id @@ -11,20 +12,60 @@ const UpdateWatchStatusQuery = graphql(` } `); -/** Updates the watch status for a title on Anilist. If the token is null, the watch status will not be updated. */ +const GetMediaListEntryQuery = graphql(` + query GetMediaListEntry($titleId: Int!) { + Media(id: $titleId) { + mediaListEntry { + id + } + } + } +`); + +const DeleteMediaListEntryMutation = graphql(` + mutation DeleteMediaListEntry($entryId: Int!) { + DeleteMediaListEntry(id: $entryId) { + deleted + } + } +`); + +/** Updates the watch status for a title on Anilist. If the token is null, the watch status will not be updated. + * + * @returns true if the watch status was updated or if the token was null, false if it was not + */ export async function maybeUpdateWatchStatusOnAnilist( titleId: number, - watchStatus: WatchStatus, + watchStatus: WatchStatus | null, aniListToken: string | undefined, ) { if (!aniListToken) { - return; + return true; } const client = new GraphQLClient("https://graphql.anilist.co/"); const headers = new Headers({ Authorization: `Bearer ${aniListToken}` }); + if (!watchStatus) { + return client + .request(GetMediaListEntryQuery, { titleId }, headers) + .then((data) => data?.Media?.mediaListEntry?.id) + .then((mediaListEntryId) => { + if (!mediaListEntryId) { + throw new AnilistTitleNotFoundError(); + } + + return client + .request( + DeleteMediaListEntryMutation, + { entryId: mediaListEntryId }, + headers, + ) + .then((data) => !!data?.DeleteMediaListEntry?.deleted); + }); + } + return client - .request(UpdateWatchStatusQuery, { titleId, watchStatus }, headers) + .request(UpdateWatchStatusMutation, { titleId, watchStatus }, headers) .then((data) => !!data?.SaveMediaListEntry?.id); } diff --git a/src/controllers/watch-status/index.spec.ts b/src/controllers/watch-status/index.spec.ts index 5e51592..c278b26 100644 --- a/src/controllers/watch-status/index.spec.ts +++ b/src/controllers/watch-status/index.spec.ts @@ -1,3 +1,5 @@ +import { eq } from "drizzle-orm"; + import { beforeEach, describe, expect, it } from "bun:test"; import app from "~/index"; @@ -5,7 +7,7 @@ import { getTestDb } from "~/libs/test/getTestDb"; import { getTestEnv } from "~/libs/test/getTestEnv"; import { resetTestDb } from "~/libs/test/resetTestDb"; import { server } from "~/mocks"; -import { deviceTokensTable } from "~/models/schema"; +import { deviceTokensTable, watchStatusTable } from "~/models/schema"; server.listen(); @@ -89,4 +91,109 @@ describe("requests the /watch-status route", () => { expect(res.json()).resolves.toEqual({ success: true }); expect(res.status).toBe(200); }); + + it("watch status is null, should succeed", async () => { + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "asd" }); + + const res = await app.request( + "/watch-status", + { + method: "POST", + headers: new Headers({ + "x-anilist-token": "asd", + "Content-Type": "application/json", + }), + body: JSON.stringify({ + deviceId: "123", + watchStatus: null, + titleId: 10, + }), + }, + getTestEnv(), + ); + + expect(res.json()).resolves.toEqual({ success: true }); + expect(res.status).toBe(200); + }); + + it("watch status is null, title does not exist, should succeed", async () => { + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "asd" }); + + const res = await app.request( + "/watch-status", + { + method: "POST", + headers: new Headers({ + "x-anilist-token": "asd", + "Content-Type": "application/json", + }), + body: JSON.stringify({ + deviceId: "123", + watchStatus: null, + titleId: -1, + }), + }, + getTestEnv(), + ); + + expect(res.json()).resolves.toEqual({ success: true }); + expect(res.status).toBe(200); + }); + + it("watch status is null, title exists, fails to delete entry, should succeed", async () => { + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "asd" }); + + const res = await app.request("/watch-status", { + method: "POST", + headers: new Headers({ + "x-anilist-token": "asd", + "Content-Type": "application/json", + }), + body: JSON.stringify({ + deviceId: "123", + watchStatus: null, + titleId: 139518, + }), + }); + + expect(res.json()).resolves.toEqual({ success: true }); + expect(res.status).toBe(200); + }); + + it("watch status is null, should delete entry", async () => { + await db + .insert(deviceTokensTable) + .values({ deviceId: "123", token: "asd" }); + await db.insert(watchStatusTable).values({ deviceId: "123", titleId: 10 }); + + await app.request( + "/watch-status", + { + method: "POST", + headers: new Headers({ + "x-anilist-token": "asd", + "Content-Type": "application/json", + }), + body: JSON.stringify({ + deviceId: "123", + watchStatus: null, + titleId: 10, + }), + }, + getTestEnv(), + ); + const row = await db + .select() + .from(watchStatusTable) + .where(eq(watchStatusTable.titleId, 10)) + .get(); + + expect(row).toBeUndefined(); + }); }); diff --git a/src/controllers/watch-status/index.ts b/src/controllers/watch-status/index.ts index 9b8a4d1..1905891 100644 --- a/src/controllers/watch-status/index.ts +++ b/src/controllers/watch-status/index.ts @@ -16,7 +16,7 @@ const app = new OpenAPIHono(); const UpdateWatchStatusRequest = z.object({ deviceId: z.string(), - watchStatus: WatchStatus, + watchStatus: WatchStatus.nullable(), titleId: AniListIdSchema, isRetrying: z.boolean().optional().default(false), }); @@ -60,8 +60,12 @@ const route = createRoute({ }); app.openapi(route, async (c) => { - const { deviceId, watchStatus, titleId, isRetrying } = - await c.req.json(); + const { + deviceId, + watchStatus, + titleId, + isRetrying = false, + } = await c.req.json(); const aniListToken = c.req.header("X-AniList-Token"); if (!isRetrying) { diff --git a/src/libs/errors/TitleNotFound.ts b/src/libs/errors/TitleNotFound.ts new file mode 100644 index 0000000..805c486 --- /dev/null +++ b/src/libs/errors/TitleNotFound.ts @@ -0,0 +1,5 @@ +export class AnilistTitleNotFoundError extends Error { + constructor() { + super("Title not found in Anilist"); + } +} diff --git a/src/mocks/anilist/deleteMediaListEntry.ts b/src/mocks/anilist/deleteMediaListEntry.ts new file mode 100644 index 0000000..3a289cf --- /dev/null +++ b/src/mocks/anilist/deleteMediaListEntry.ts @@ -0,0 +1,15 @@ +import { HttpResponse, graphql } from "msw"; + +export function deleteAnilistMediaListEntry() { + return graphql.mutation( + "DeleteMediaListEntry", + ({ variables: { entryId } }) => + HttpResponse.json({ + data: { + DeleteMediaListEntry: { + deleted: entryId > 0, + }, + }, + }), + ); +} diff --git a/src/mocks/anilist/mediaListEntry.ts b/src/mocks/anilist/mediaListEntry.ts new file mode 100644 index 0000000..05843ea --- /dev/null +++ b/src/mocks/anilist/mediaListEntry.ts @@ -0,0 +1,35 @@ +import { HttpResponse, graphql } from "msw"; + +export function getAnilistMediaListEntry() { + return graphql.query("GetMediaListEntry", ({ variables: { titleId } }) => { + if (titleId === 10) { + return HttpResponse.json({ + data: { + Media: { + mediaListEntry: { + id: 123456, + }, + }, + }, + }); + } else if (titleId === 139518) { + return HttpResponse.json({ + data: { + Media: { + mediaListEntry: { + id: 123457, + }, + }, + }, + }); + } + + return HttpResponse.json({ + data: { + Media: { + mediaListEntry: null, + }, + }, + }); + }); +} diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 864982b..4835d65 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -5,12 +5,16 @@ import { getAmvstrmTitle } from "./amvstrm/title"; import { getAnifyEpisodes } from "./anify/episodes"; import { getAnifySources } from "./anify/sources"; import { getAnifyTitle } from "./anify/title"; +import { deleteAnilistMediaListEntry } from "./anilist/deleteMediaListEntry"; +import { getAnilistMediaListEntry } from "./anilist/mediaListEntry"; import { getAnilistSearchResults } from "./anilist/search"; import { getAnilistTitle } from "./anilist/title"; import { updateAnilistWatchStatus } from "./anilist/updateWatchStatus"; import { mockFcmMessageResponse } from "./fcm"; export const handlers = [ + deleteAnilistMediaListEntry(), + getAnilistMediaListEntry(), getAnilistSearchResults(), getAnilistTitle(), updateAnilistWatchStatus(), diff --git a/src/models/watchStatus.ts b/src/models/watchStatus.ts index a32ac6f..9995e5c 100644 --- a/src/models/watchStatus.ts +++ b/src/models/watchStatus.ts @@ -16,7 +16,7 @@ export function setWatchStatus( env: Env, deviceId: string, titleId: number, - watchStatus: (typeof WatchStatusValues)[number], + watchStatus: (typeof WatchStatusValues)[number] | null, ) { let dbAction; const isSavingTitle = watchStatus === "CURRENT";