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:
@@ -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
|
return client
|
||||||
.request(UpdateWatchStatusQuery, { titleId, watchStatus }, headers)
|
.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(UpdateWatchStatusMutation, { titleId, watchStatus }, headers)
|
||||||
.then((data) => !!data?.SaveMediaListEntry?.id);
|
.then((data) => !!data?.SaveMediaListEntry?.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
5
src/libs/errors/TitleNotFound.ts
Normal file
5
src/libs/errors/TitleNotFound.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export class AnilistTitleNotFoundError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Title not found in Anilist");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/mocks/anilist/deleteMediaListEntry.ts
Normal file
15
src/mocks/anilist/deleteMediaListEntry.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/mocks/anilist/mediaListEntry.ts
Normal file
35
src/mocks/anilist/mediaListEntry.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user