diff --git a/.gitignore b/.gitignore index c24511d..2c8a1e0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,3 @@ db-data/ coverage/ **/.DS_Store - -package-lock.json diff --git a/migrations/0001_dry_ikaris.sql b/migrations/0001_dry_ikaris.sql deleted file mode 100644 index cfd2c78..0000000 --- a/migrations/0001_dry_ikaris.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE "formQuestion" RENAME COLUMN "formQuestionId" TO "formQuestionRef";--> statement-breakpoint -ALTER TABLE "formQuestion" DROP CONSTRAINT "formQuestion_formQuestionId_formId_seasonCode_pk";--> statement-breakpoint -ALTER TABLE "formQuestion" ADD CONSTRAINT "formQuestion_formQuestionRef_formId_seasonCode_pk" PRIMARY KEY("formQuestionRef","formId","seasonCode"); \ No newline at end of file diff --git a/package.json b/package.json index 4cab2c0..1e76c64 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,6 @@ "vitest": "^4.0.8" }, "lint-staged": { - "*": [ - "eslint --fix" - ] + "*": ["eslint --fix"] } } diff --git a/scripts/seed-data/questions.ts b/scripts/seed-data/questions.ts index 0663120..bb342e2 100644 --- a/scripts/seed-data/questions.ts +++ b/scripts/seed-data/questions.ts @@ -6,21 +6,21 @@ import { FORM_1_ID, FORM_2_ID } from "./forms"; export const questions = [ // Form 1 questions (Hacker Application) { - formQuestionRef: "firstName", + formQuestionId: "firstName", formId: FORM_1_ID, seasonCode: "S26", questionType: "text", tags: ["required", "profile"], }, { - formQuestionRef: "lastName", + formQuestionId: "lastName", formId: FORM_1_ID, seasonCode: "S26", questionType: "text", tags: ["required", "profile"], }, { - formQuestionRef: "email", + formQuestionId: "email", formId: FORM_1_ID, seasonCode: "S26", questionType: "text", @@ -29,21 +29,21 @@ export const questions = [ // Form 2 questions (Workshop Feedback) { - formQuestionRef: "workshopName", + formQuestionId: "workshopName", formId: FORM_2_ID, seasonCode: "S26", questionType: "text", tags: ["required"], }, { - formQuestionRef: "rating", + formQuestionId: "rating", formId: FORM_2_ID, seasonCode: "S26", questionType: "number", tags: ["required"], }, { - formQuestionRef: "comments", + formQuestionId: "comments", formId: FORM_2_ID, seasonCode: "S26", questionType: "text", diff --git a/src/db/schema/formQuestion.ts b/src/db/schema/formQuestion.ts index 16227b3..7aa86ba 100644 --- a/src/db/schema/formQuestion.ts +++ b/src/db/schema/formQuestion.ts @@ -14,7 +14,7 @@ import { form } from "./form"; export const formQuestion = pgTable( "formQuestion", { - formQuestionRef: varchar("formQuestionRef", { length: 80 }).notNull(), + formQuestionId: varchar("formQuestionId", { length: 80 }), formId: uuid("formId") .notNull() .default(sql`uuidv7()`), @@ -28,7 +28,7 @@ export const formQuestion = pgTable( .default(sql`ARRAY[]::text[]`), }, (t) => [ - primaryKey({ columns: [t.formQuestionRef, t.formId, t.seasonCode] }), + primaryKey({ columns: [t.formQuestionId, t.formId, t.seasonCode] }), foreignKey({ columns: [t.seasonCode, t.formId], foreignColumns: [form.seasonCode, form.formId], // form(seasonCode, eventId) diff --git a/src/resources/form/forms.routes.test.ts b/src/resources/form/forms.routes.test.ts index 1fbb83d..2837eaf 100644 --- a/src/resources/form/forms.routes.test.ts +++ b/src/resources/form/forms.routes.test.ts @@ -163,7 +163,7 @@ describe("Forms routes", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ questions: [ - { formQuestionRef: "q1", questionType: "text", tags: ["required"] }, + { formQuestionId: "q1", questionType: "text", tags: ["required"] }, ], }), }); @@ -175,7 +175,7 @@ describe("Forms routes", () => { closeTime: null, tags: [], questions: [ - { formQuestionRef: "q1", questionType: "text", tags: ["required"] }, + { formQuestionId: "q1", questionType: "text", tags: ["required"] }, ], }); }); @@ -211,33 +211,17 @@ describe("Forms routes", () => { closeTime: null, tags: ["registration", "updated"], }; - - // Make absolutely sure the mock is set up correctly - const cloneFormMock = vi.fn(); - cloneFormMock.mockResolvedValue(mockCloned); - - // Replace the mock in the module - vi.mocked(cloneForm).mockImplementation(cloneFormMock); + (cloneForm as Mock).mockResolvedValue(mockCloned); const res = await app.request(`/seasons/S26/forms/${FORM_ID}/clone`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - newQuestionRefs: ["new_q1"], - }), + body: JSON.stringify({}), }); expect(res.status).toBe(201); - }); - it("returns 400 for invalid request", async () => { - // Test one validation case - missing required field - const res = await app.request(`/seasons/S26/forms/${FORM_ID}/clone`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), // Missing newQuestionRefs - }); - - expect(res.status).toBe(400); + expect(await res.json()).toEqual(mockCloned); + expect(cloneForm).toHaveBeenCalledWith("S26", FORM_ID); }); }); diff --git a/src/resources/form/forms.routes.ts b/src/resources/form/forms.routes.ts index 9ca7c93..f85f851 100644 --- a/src/resources/form/forms.routes.ts +++ b/src/resources/form/forms.routes.ts @@ -11,7 +11,7 @@ import { genericErrorResponse } from "@/config/openapi"; import { requireRoles, UserType } from "@/lib/auth"; const questionSchema = z.object({ - formQuestionRef: z.string().max(80), + formQuestionId: z.string().max(80), questionType: z.string(), tags: z.array(z.string()).optional().default([]), }); @@ -38,10 +38,6 @@ const formSchema = z.object({ tags: z.array(z.string()), }); -const cloneFormBodySchema = z.object({ - newQuestionRefs: z.array(z.string().max(80)), -}); - const formsRoute = new Hono(); // --- CREATE --- @@ -111,17 +107,11 @@ formsRoute.post( "param", z.object({ seasonCode: z.string().length(3), formId: z.string().uuid() }), ), - validator("json", cloneFormBodySchema), // VALIDATOR INFERS REQUEST BODY (CONSISTENT WITH CREATE) requireRoles(UserType.Admin), async (c) => { const params = c.req.valid("param"); - const body = c.req.valid("json"); - const cloned = await cloneForm( - params.seasonCode, - params.formId, - body.newQuestionRefs, - ); + const cloned = await cloneForm(params.seasonCode, params.formId); return c.json( { diff --git a/src/resources/form/forms.service.ts b/src/resources/form/forms.service.ts index 28f4964..aa47508 100644 --- a/src/resources/form/forms.service.ts +++ b/src/resources/form/forms.service.ts @@ -5,7 +5,7 @@ import { and, eq } from "drizzle-orm"; import { ApiError } from "@/lib/errors"; export interface CreateFormQuestionInput { - formQuestionRef: string; + formQuestionId: string; questionType: string; tags?: string[]; } @@ -34,7 +34,7 @@ export const createForm = async (input: CreateFormInput) => { if (input.questions && input.questions.length > 0) { await tx.insert(formQuestion).values( input.questions.map((q) => ({ - formQuestionRef: q.formQuestionRef, + formQuestionId: q.formQuestionId, formId: createdForm.formId, seasonCode: input.seasonCode, questionType: q.questionType, @@ -115,7 +115,7 @@ export const updateForm = async (input: UpdateFormInput) => { if (input.questions.length > 0) { await tx.insert(formQuestion).values( input.questions.map((q) => ({ - formQuestionRef: q.formQuestionRef, + formQuestionId: q.formQuestionId, formId: input.formId, seasonCode: input.seasonCode, questionType: q.questionType, @@ -158,14 +158,10 @@ export const deleteForm = async (seasonCode: string, formId: string) => { } }; -export const cloneForm = async ( - seasonCode: string, - formId: string, - newQuestionRefs: string[], // Array of new refs in SAME ORDER as source -) => { +export const cloneForm = async (seasonCode: string, formId: string) => { try { return await db.transaction(async (tx) => { - // Load source form + // Load existing form const [src] = await tx .select() .from(form) @@ -179,7 +175,7 @@ export const cloneForm = async ( }); } - // Load source questions + // Load existing questions const srcQuestions = await tx .select() .from(formQuestion) @@ -190,16 +186,7 @@ export const cloneForm = async ( ), ); - // Validate: must provide refs for ALL questions - if (newQuestionRefs.length !== srcQuestions.length) { - throw new ApiError(400, { - code: "QUESTION_COUNT_MISMATCH", - message: "Must provide new refs for all questions", - suggestion: `Source has ${srcQuestions.length} questions, got ${newQuestionRefs.length} refs`, - }); - } - - // Create cloned form (identical except new ID) + // Create new form (same fields) const [cloned] = await tx .insert(form) .values({ @@ -210,13 +197,13 @@ export const cloneForm = async ( }) .returning(); - // Clone questions with NEW refs (in same order) + // Clone questions (new formId; new question ids to avoid collisions) if (srcQuestions.length > 0) { await tx.insert(formQuestion).values( - srcQuestions.map((q, index) => ({ - formQuestionRef: newQuestionRefs[index], // Use provided new ref + srcQuestions.map((q) => ({ + formQuestionId: crypto.randomUUID(), formId: cloned.formId, - seasonCode: src.seasonCode, + seasonCode, questionType: q.questionType, tags: q.tags ?? [], })), diff --git a/src/resources/form/forms.test.ts b/src/resources/form/forms.test.ts index c4f77da..82508cc 100644 --- a/src/resources/form/forms.test.ts +++ b/src/resources/form/forms.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +// Mock env first vi.mock("@/config/env", () => ({ default: { NODE_ENV: "test", @@ -50,6 +51,7 @@ vi.mock("@/db/schema", () => ({ }, })); +// 🔹 Mock auth so errors.ts can import it without touching real @/db/schema vi.mock("@/lib/auth", () => ({ isUserType: vi.fn(), getUserId: vi.fn(), @@ -159,8 +161,8 @@ describe("forms.service", () => { openTime: null, closeTime: null, questions: [ - { formQuestionRef: "q1", questionType: "text", tags: ["required"] }, - { formQuestionRef: "q2", questionType: "number" }, + { formQuestionId: "q1", questionType: "text", tags: ["required"] }, + { formQuestionId: "q2", questionType: "number" }, ], }); @@ -170,14 +172,14 @@ describe("forms.service", () => { expect(questionValuesMock).toHaveBeenCalledWith([ { - formQuestionRef: "q1", + formQuestionId: "q1", formId: FORM_ID, seasonCode: "S26", questionType: "text", tags: ["required"], }, { - formQuestionRef: "q2", + formQuestionId: "q2", formId: FORM_ID, seasonCode: "S26", questionType: "number", @@ -294,13 +296,13 @@ describe("forms.service", () => { openTime: null, closeTime: null, formId: FORM_ID, - questions: [{ formQuestionRef: "q1", questionType: "text" }], + questions: [{ formQuestionId: "q1", questionType: "text" }], }); expect(db.delete).toHaveBeenCalledWith(formQuestion); expect(qValuesMock).toHaveBeenCalledWith([ { - formQuestionRef: "q1", + formQuestionId: "q1", formId: FORM_ID, seasonCode: "S26", questionType: "text", @@ -345,14 +347,12 @@ describe("forms.service", () => { const fromMock = vi.fn(() => ({ where: whereMock })); (db.select as Mock).mockReturnValue({ from: fromMock }); - await expect( - cloneForm("S26", FORM_ID, ["new_q1", "new_q2"]), - ).rejects.toMatchObject({ + await expect(cloneForm("S26", FORM_ID)).rejects.toMatchObject({ status: 404, }); }); - it("clones form and questions with provided new refs", async () => { + it("clones form and clones questions (new formId)", async () => { // 1) select src form const formWhereMock = vi.fn().mockResolvedValue([ { @@ -368,19 +368,12 @@ describe("forms.service", () => { // 2) select src questions const qWhereMock = vi.fn().mockResolvedValue([ { - formQuestionRef: "qOld1", + formQuestionId: "qOld1", formId: FORM_ID, seasonCode: "S26", questionType: "text", tags: ["a"], }, - { - formQuestionRef: "qOld2", - formId: FORM_ID, - seasonCode: "S26", - questionType: "number", - tags: ["b"], - }, ]); const qFromMock = vi.fn(() => ({ where: qWhereMock })); @@ -412,9 +405,13 @@ describe("forms.service", () => { }, ); - const newQuestionRefs = ["new_q1", "new_q2"]; + const NEW_Q_ID = + "new-q-id-0000-0000-0000-000000000000" as `${string}-${string}-${string}-${string}-${string}`; - const result = await cloneForm("S26", FORM_ID, newQuestionRefs); + // mock crypto.randomUUID so test is deterministic + vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(NEW_Q_ID); + + const result = await cloneForm("S26", FORM_ID); expect(result.formId).toBe(FORM_ID_2); @@ -423,19 +420,12 @@ describe("forms.service", () => { expect(qValuesInsertMock).toHaveBeenCalledWith([ { - formQuestionRef: "new_q1", + formQuestionId: NEW_Q_ID, formId: FORM_ID_2, seasonCode: "S26", questionType: "text", tags: ["a"], }, - { - formQuestionRef: "new_q2", - formId: FORM_ID_2, - seasonCode: "S26", - questionType: "number", - tags: ["b"], - }, ]); }); });