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
This commit is contained in:
2024-07-04 18:18:57 -04:00
parent ad84175d6b
commit 2becf1aa3b
8 changed files with 222 additions and 11 deletions

View File

@@ -1,9 +1,10 @@
import { graphql } from "gql.tada"; import { graphql } from "gql.tada";
import { GraphQLClient } from "graphql-request"; 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!) { mutation UpdateWatchStatus($titleId: Int!, $watchStatus: MediaListStatus!) {
SaveMediaListEntry(mediaId: $titleId, status: $watchStatus) { SaveMediaListEntry(mediaId: $titleId, status: $watchStatus) {
id 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( export async function maybeUpdateWatchStatusOnAnilist(
titleId: number, titleId: number,
watchStatus: WatchStatus, watchStatus: WatchStatus | null,
aniListToken: string | undefined, aniListToken: string | undefined,
) { ) {
if (!aniListToken) { if (!aniListToken) {
return; return true;
} }
const client = new GraphQLClient("https://graphql.anilist.co/"); const client = new GraphQLClient("https://graphql.anilist.co/");
const headers = new Headers({ Authorization: `Bearer ${aniListToken}` }); 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 return client
.request(UpdateWatchStatusQuery, { titleId, watchStatus }, headers) .request(UpdateWatchStatusMutation, { titleId, watchStatus }, headers)
.then((data) => !!data?.SaveMediaListEntry?.id); .then((data) => !!data?.SaveMediaListEntry?.id);
} }

View File

@@ -1,3 +1,5 @@
import { eq } from "drizzle-orm";
import { beforeEach, describe, expect, it } from "bun:test"; import { beforeEach, describe, expect, it } from "bun:test";
import app from "~/index"; import app from "~/index";
@@ -5,7 +7,7 @@ import { getTestDb } from "~/libs/test/getTestDb";
import { getTestEnv } from "~/libs/test/getTestEnv"; import { getTestEnv } from "~/libs/test/getTestEnv";
import { resetTestDb } from "~/libs/test/resetTestDb"; import { resetTestDb } from "~/libs/test/resetTestDb";
import { server } from "~/mocks"; import { server } from "~/mocks";
import { deviceTokensTable } from "~/models/schema"; import { deviceTokensTable, watchStatusTable } from "~/models/schema";
server.listen(); server.listen();
@@ -89,4 +91,109 @@ describe("requests the /watch-status route", () => {
expect(res.json()).resolves.toEqual({ success: true }); expect(res.json()).resolves.toEqual({ success: true });
expect(res.status).toBe(200); 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();
});
}); });

View File

@@ -16,7 +16,7 @@ const app = new OpenAPIHono<Env>();
const UpdateWatchStatusRequest = z.object({ const UpdateWatchStatusRequest = z.object({
deviceId: z.string(), deviceId: z.string(),
watchStatus: WatchStatus, watchStatus: WatchStatus.nullable(),
titleId: AniListIdSchema, titleId: AniListIdSchema,
isRetrying: z.boolean().optional().default(false), isRetrying: z.boolean().optional().default(false),
}); });
@@ -60,8 +60,12 @@ const route = createRoute({
}); });
app.openapi(route, async (c) => { app.openapi(route, async (c) => {
const { deviceId, watchStatus, titleId, isRetrying } = const {
await c.req.json<typeof UpdateWatchStatusRequest._type>(); deviceId,
watchStatus,
titleId,
isRetrying = false,
} = await c.req.json<typeof UpdateWatchStatusRequest._type>();
const aniListToken = c.req.header("X-AniList-Token"); const aniListToken = c.req.header("X-AniList-Token");
if (!isRetrying) { if (!isRetrying) {

View File

@@ -0,0 +1,5 @@
export class AnilistTitleNotFoundError extends Error {
constructor() {
super("Title not found in Anilist");
}
}

View File

@@ -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,
},
},
}),
);
}

View File

@@ -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,
},
},
});
});
}

View File

@@ -5,12 +5,16 @@ import { getAmvstrmTitle } from "./amvstrm/title";
import { getAnifyEpisodes } from "./anify/episodes"; import { getAnifyEpisodes } from "./anify/episodes";
import { getAnifySources } from "./anify/sources"; import { getAnifySources } from "./anify/sources";
import { getAnifyTitle } from "./anify/title"; import { getAnifyTitle } from "./anify/title";
import { deleteAnilistMediaListEntry } from "./anilist/deleteMediaListEntry";
import { getAnilistMediaListEntry } from "./anilist/mediaListEntry";
import { getAnilistSearchResults } from "./anilist/search"; import { getAnilistSearchResults } from "./anilist/search";
import { getAnilistTitle } from "./anilist/title"; import { getAnilistTitle } from "./anilist/title";
import { updateAnilistWatchStatus } from "./anilist/updateWatchStatus"; import { updateAnilistWatchStatus } from "./anilist/updateWatchStatus";
import { mockFcmMessageResponse } from "./fcm"; import { mockFcmMessageResponse } from "./fcm";
export const handlers = [ export const handlers = [
deleteAnilistMediaListEntry(),
getAnilistMediaListEntry(),
getAnilistSearchResults(), getAnilistSearchResults(),
getAnilistTitle(), getAnilistTitle(),
updateAnilistWatchStatus(), updateAnilistWatchStatus(),

View File

@@ -16,7 +16,7 @@ export function setWatchStatus(
env: Env, env: Env,
deviceId: string, deviceId: string,
titleId: number, titleId: number,
watchStatus: (typeof WatchStatusValues)[number], watchStatus: (typeof WatchStatusValues)[number] | null,
) { ) {
let dbAction; let dbAction;
const isSavingTitle = watchStatus === "CURRENT"; const isSavingTitle = watchStatus === "CURRENT";