From c8c6cccf6af9d31decf3b200ec584b3068a5bb5c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 6 Dec 2024 20:55:51 +0100 Subject: [PATCH] Handle and test slug reconciliation & conflicts --- api/src/controllers/seed/index.ts | 11 +++- api/src/controllers/seed/movies.ts | 87 ++++++++++++++++++++---------- api/tests/seed-movies.test.ts | 44 +++++++++++---- 3 files changed, 104 insertions(+), 38 deletions(-) diff --git a/api/src/controllers/seed/index.ts b/api/src/controllers/seed/index.ts index 11de1774..645835fe 100644 --- a/api/src/controllers/seed/index.ts +++ b/api/src/controllers/seed/index.ts @@ -1,6 +1,8 @@ import Elysia, { t } from "elysia"; import { Movie, SeedMovie } from "~/models/movie"; import { seedMovie, SeedMovieResponse } from "./movies"; +import { Resource } from "~/models/utils"; +import { comment } from "~/utils"; export const seed = new Elysia() .model({ @@ -13,7 +15,7 @@ export const seed = new Elysia() "/movies", async ({ body, error }) => { const { status, ...ret } = await seedMovie(body); - return error(status === "created" ? 201 : 200, ret); + return error(status, ret); }, { body: "seed-movie", @@ -24,6 +26,13 @@ export const seed = new Elysia() }, 201: { ...SeedMovieResponse, description: "Created a new movie." }, 400: "error", + 409: { + ...Resource, + description: comment` + A movie with the same slug but a different air date already exists. + Change the slug and re-run the request. + `, + }, }, detail: { tags: ["movies"], diff --git a/api/src/controllers/seed/movies.ts b/api/src/controllers/seed/movies.ts index 16daf805..08ac4d35 100644 --- a/api/src/controllers/seed/movies.ts +++ b/api/src/controllers/seed/movies.ts @@ -1,4 +1,4 @@ -import { inArray, sql } from "drizzle-orm"; +import { inArray, sql, eq } from "drizzle-orm"; import { t } from "elysia"; import { db } from "~/db"; import { @@ -29,7 +29,9 @@ export type SeedMovieResponse = typeof SeedMovieResponse.static; export const seedMovie = async ( seed: SeedMovie, -): Promise => { +): Promise< + SeedMovieResponse & { status: "Created" | "OK" | "Conflict" } +> => { const { translations, videos: vids, ...bMovie } = seed; const ret = await db.transaction(async (tx) => { @@ -39,29 +41,57 @@ export const seedMovie = async ( nextRefresh: guessNextRefresh(bMovie.airDate ?? new Date()), ...bMovie, }; - const [ret] = await tx - .insert(shows) - .values(movie) - .onConflictDoUpdate({ - target: shows.slug, - set: conflictUpdateAllExcept(shows, ["pk", "id", "slug", "createdAt"]), - // if year is different, this is not an update but a conflict (ex: dune-1984 vs dune-2021) - setWhere: sql`date_part('year', ${shows.startAir}) = date_part('year', excluded."start_air")`, - }) - .returning({ - pk: shows.pk, - id: shows.id, - slug: shows.slug, - startAir: shows.startAir, - // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 - updated: sql`(xmax <> 0)`.as("updated"), - }); - if (ret.updated) { - // TODO: if updated, differenciates updates with conflicts. - // if the start year is different or external ids, it's a conflict. - // if (getYear(ret.startAir) === getYear(movie.startAir)) { - // return; - // } + + const insert = () => + tx + .insert(shows) + .values(movie) + .onConflictDoUpdate({ + target: shows.slug, + set: conflictUpdateAllExcept(shows, [ + "pk", + "id", + "slug", + "createdAt", + ]), + // if year is different, this is not an update but a conflict (ex: dune-1984 vs dune-2021) + setWhere: sql`date_part('year', ${shows.startAir}) = date_part('year', excluded."start_air")`, + }) + .returning({ + pk: shows.pk, + id: shows.id, + slug: shows.slug, + // https://stackoverflow.com/questions/39058213/differentiate-inserted-and-updated-rows-in-upsert-using-system-columns/39204667#39204667 + updated: sql`(xmax <> 0)`.as("updated"), + }); + let [ret] = await insert(); + if (!ret) { + // ret is undefined when the conflict's where return false (meaning we have + // a conflicting slug but a different air year. + // try to insert adding the year at the end of the slug. + if ( + movie.startAir && + !movie.slug.endsWith(`${getYear(movie.startAir)}`) + ) { + movie.slug = `${movie.slug}-${getYear(movie.startAir)}`; + [ret] = await insert(); + } + + // if at this point ret is still undefined, we could not reconciliate. + // simply bail and let the caller handle this. + if (!ret) { + const [{ id }] = await db + .select({ id: shows.id }) + .from(shows) + .where(eq(shows.slug, movie.slug)) + .limit(1); + return { + status: "Conflict" as const, + id, + slug: movie.slug, + videos: [], + }; + } } // even if never shown to the user, a movie still has an entry. @@ -112,6 +142,8 @@ export const seedMovie = async ( return { ...ret, entry: entry.pk }; }); + if (ret.status === "Conflict") return ret; + let retVideos: { slug: string }[] = []; if (vids) { retVideos = await db @@ -139,14 +171,13 @@ export const seedMovie = async ( } return { - status: ret.updated ? "updated" : "created", + status: ret.updated ? "Ok" : "Created", id: ret.id, slug: ret.slug, videos: retVideos, }; }; -function getYear(date?: string | null) { - if (!date) return null; +function getYear(date: string) { return new Date(date).getUTCFullYear(); } diff --git a/api/tests/seed-movies.test.ts b/api/tests/seed-movies.test.ts index df2d717a..49e384a4 100644 --- a/api/tests/seed-movies.test.ts +++ b/api/tests/seed-movies.test.ts @@ -54,7 +54,7 @@ describe("Movie seeding", () => { const [resp, body] = await createMovie({ ...dune, - airDate: "2159-12-09", + runtime: 200_000, translations: { ...dune.translations, en: { ...dune.translations.en, description: "edited translation" }, @@ -85,23 +85,49 @@ describe("Movie seeding", () => { expectStatus(resp, body).toBe(200); expect(body.id).toBeString(); expect(body.slug).toBe("dune"); - expect(body.videos).toBe([]); - expect(edited.startAir).toBe("2159-12-09"); + expect(body.videos).toBeArrayOfSize(0); + expect(edited.runtime).toBe(200_000); expect(edited.status).toBe(dune.status); - expect(translations).toMatchObject({ - language: "en", + expect(translations.find((x) => x.language === "en")).toMatchObject({ name: dune.translations.en.name, description: "edited translation", }); - expect(translations).toMatchObject({ - language: "fr", + expect(translations.find((x) => x.language === "fr")).toMatchObject({ name: "dune-but-in-french", description: null, }); }); - test.todo("Conflicting slug auto-correct", async () => {}); - test.todo("Conflict in slug+year fails", async () => {}); + it("Conflicting slug auto-correct", async () => { + // confirm that db is in the correct state (from previous tests) + const [existing] = await db + .select() + .from(shows) + .where(eq(shows.slug, dune.slug)) + .limit(1); + expect(existing).toMatchObject({ slug: dune.slug, startAir: dune.airDate }); + + const [resp, body] = await createMovie({ ...dune, airDate: "2158-12-13" }); + expectStatus(resp, body).toBe(200); + expect(body.id).toBeString(); + expect(body.slug).toBe("dune-2158"); + }); + + it("Conflict in slug w/out year fails", async () => { + // confirm that db is in the correct state (from conflict auto-correct test) + const [existing] = await db + .select() + .from(shows) + .where(eq(shows.slug, dune.slug)) + .limit(1); + expect(existing).toMatchObject({ slug: dune.slug, startAir: dune.airDate }); + + const [resp, body] = await createMovie({ ...dune, airDate: null }); + expectStatus(resp, body).toBe(409); + expect(body.id).toBe(existing.id); + expect(body.slug).toBe(existing.slug); + }); + test.todo("Missing videos send info", async () => {}); test.todo("Schema error", async () => {}); test.todo("Invalid translation name", async () => {});