diff --git a/apps/backend/src/app/api/admin/game-session-schedules/[id]/route.test.ts b/apps/backend/src/app/api/admin/game-session-schedules/[id]/route.test.ts index d80426356..2deed4cf6 100644 --- a/apps/backend/src/app/api/admin/game-session-schedules/[id]/route.test.ts +++ b/apps/backend/src/app/api/admin/game-session-schedules/[id]/route.test.ts @@ -1,8 +1,12 @@ import { AUTH_COOKIE_NAME, type UpdateGameSessionScheduleData } from "@repo/shared" import { getReasonPhrase, StatusCodes } from "http-status-codes" import { cookies } from "next/headers" +import { payload } from "@/data-layer/adapters/Payload" +import BookingDataService from "@/data-layer/services/BookingDataService" import GameSessionDataService from "@/data-layer/services/GameSessionDataService" import { createMockNextRequest } from "@/test-config/backend-utils" +import { bookingCreateMock } from "@/test-config/mocks/Booking.mock" +import { gameSessionCreateMock } from "@/test-config/mocks/GameSession.mock" import { gameSessionScheduleCreateMock } from "@/test-config/mocks/GameSessionSchedule.mock" import { adminToken, casualToken, memberToken } from "@/test-config/vitest.setup" import { DELETE, GET, PATCH } from "./route" @@ -179,64 +183,196 @@ describe("/api/admin/game-session-schedules/[id]", async () => { }) describe("DELETE", () => { - it("should return 401 if user is a casual", async () => { - cookieStore.set(AUTH_COOKIE_NAME, casualToken) + const bookingDataService = new BookingDataService() + const gameSessionDataService = new GameSessionDataService() - const res = await DELETE(createMockNextRequest("", "DELETE"), { - params: Promise.resolve({ id: "some-id" }), - }) + it("should delete game session schedule and its game session and bookings when delateRelatedDocs is true", async () => { + cookieStore.set(AUTH_COOKIE_NAME, adminToken) - expect(res.status).toBe(StatusCodes.UNAUTHORIZED) - expect(await res.json()).toStrictEqual({ error: "No scope" }) - }) + const newGameSessionSchedule = await gameSessionDataService.createGameSessionSchedule( + gameSessionScheduleCreateMock, + ) + const newGameSession = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + gameSessionSchedule: newGameSessionSchedule, + }) + const booking1 = await bookingDataService.createBooking({ + ...bookingCreateMock, + gameSession: newGameSession, + }) + const booking2 = await bookingDataService.createBooking({ + ...bookingCreateMock, + gameSession: newGameSession, + }) - it("should return 401 if user is member", async () => { - cookieStore.set(AUTH_COOKIE_NAME, memberToken) + const res = await DELETE( + createMockNextRequest("/api/admin/game-session-schedules?delateRelatedDocs=true", "DELETE"), + { + params: Promise.resolve({ + id: newGameSessionSchedule.id, + }), + }, + ) - const res = await DELETE(createMockNextRequest("", "DELETE"), { - params: Promise.resolve({ id: "some-id" }), - }) + expect(res.status).toBe(StatusCodes.NO_CONTENT) - expect(res.status).toBe(StatusCodes.UNAUTHORIZED) - expect(await res.json()).toStrictEqual({ error: "No scope" }) + await expect( + gameSessionDataService.getGameSessionScheduleById(newGameSessionSchedule.id), + ).rejects.toThrow("Not Found") + await expect(gameSessionDataService.getGameSessionById(newGameSession.id)).rejects.toThrow( + "Not Found", + ) + await expect(bookingDataService.getBookingById(booking1.id)).rejects.toThrow("Not Found") + await expect(bookingDataService.getBookingById(booking2.id)).rejects.toThrow("Not Found") }) - it("should delete gameSessionSchedule if user is admin", async () => { + it.for([ + // Test case 1: Explicit false boolean parameter + "/api/admin/game-session-schedules?delateRelatedDocs=false", + // Test case 2: Flag parameter without value (equivalent to true in query params) + "/api/admin/game-session-schedules?delateRelatedDocs", + // Test case 3: Unrelated query parameter (testing irrelevant params) + "/api/admin/game-session-schedules?straightZhao", + // Test case 4: Base URL with no query parameters + "/api/admin/game-session-schedules", + ] as const)( + "should default to not delete related game session and bookings when cascade is false or not specified", + async (route) => { + cookieStore.set(AUTH_COOKIE_NAME, adminToken) + + const newGameSessionSchedule = await gameSessionDataService.createGameSessionSchedule( + gameSessionScheduleCreateMock, + ) + const newGameSession = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + gameSessionSchedule: newGameSessionSchedule, + }) + const booking1 = await bookingDataService.createBooking({ + ...bookingCreateMock, + gameSession: newGameSession, + }) + const booking2 = await bookingDataService.createBooking({ + ...bookingCreateMock, + gameSession: newGameSession, + }) + + const res = await DELETE(createMockNextRequest(route, "DELETE"), { + params: Promise.resolve({ + id: newGameSessionSchedule.id, + }), + }) + + expect(res.status).toBe(StatusCodes.NO_CONTENT) + + await expect( + gameSessionDataService.getGameSessionScheduleById(newGameSessionSchedule.id), + ).rejects.toThrow("Not Found") + expect(await gameSessionDataService.getGameSessionById(newGameSession.id)).toBeDefined() + expect(await bookingDataService.getBookingById(booking1.id)).toBeDefined() + expect(await bookingDataService.getBookingById(booking2.id)).toBeDefined() + }, + ) + + it("should rollback transaction if error occurs during deleteRelatedDocs and return 500", async () => { cookieStore.set(AUTH_COOKIE_NAME, adminToken) + const newGameSessionSchedule = await gameSessionDataService.createGameSessionSchedule( gameSessionScheduleCreateMock, ) + const newGameSession = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + gameSessionSchedule: newGameSessionSchedule, + }) + const booking = await bookingDataService.createBooking({ + ...bookingCreateMock, + gameSession: newGameSession, + }) - const res = await DELETE(createMockNextRequest("", "DELETE"), { - params: Promise.resolve({ id: newGameSessionSchedule.id }), + const mockDeleteBookings = vi + .spyOn(GameSessionDataService.prototype, "deleteGameSessionSchedule") + .mockRejectedValueOnce(new Error("Delete failed")) + + const res = await DELETE( + createMockNextRequest("/api/admin/game-session-schedules?cascade=true", "DELETE"), + { + params: Promise.resolve({ + id: newGameSessionSchedule.id, + }), + }, + ) + + expect(res.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR) + expect(await res.json()).toStrictEqual({ + error: getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), }) - expect(res.status).toBe(StatusCodes.NO_CONTENT) - await expect( - gameSessionDataService.getGameSessionScheduleById(newGameSessionSchedule.id), - ).rejects.toThrow("Not Found") + // Verify game session schedule, game session and booking still exist (transaction rolled back) + expect( + await gameSessionDataService.getGameSessionScheduleById(newGameSessionSchedule.id), + ).toBeDefined() + expect(await gameSessionDataService.getGameSessionById(newGameSession.id)).toBeDefined() + expect(await bookingDataService.getBookingById(booking.id)).toBeDefined() + + mockDeleteBookings.mockRestore() }) - it("should return 404 if gameSessionSchedule is non-existent", async () => { + it("should handle transaction management correctly", async () => { cookieStore.set(AUTH_COOKIE_NAME, adminToken) - const res = await DELETE(createMockNextRequest("", "DELETE"), { - params: Promise.resolve({ id: "non-existent" }), + // In a test environment beginTransaction does not return a transaction ID + vi.spyOn(payload.db, "beginTransaction").mockResolvedValue("transaction-id") + vi.spyOn(payload.db, "commitTransaction").mockResolvedValue(undefined) + vi.spyOn(payload.db, "rollbackTransaction").mockResolvedValue(undefined) + + // Spy on transaction methods + const beginTransactionSpy = vi.spyOn(payload.db, "beginTransaction") + const commitTransactionSpy = vi.spyOn(payload.db, "commitTransaction") + const rollbackTransactionSpy = vi.spyOn(payload.db, "rollbackTransaction") + + const newGameSessionSchedule = await gameSessionDataService.createGameSessionSchedule( + gameSessionScheduleCreateMock, + ) + const newGameSession = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + gameSessionSchedule: newGameSessionSchedule, + }) + await bookingDataService.createBooking({ + ...bookingCreateMock, + gameSession: newGameSession, }) - expect(res.status).toBe(StatusCodes.NOT_FOUND) + const res = await DELETE( + createMockNextRequest("/api/admin/game-session-schedules?cascade=true", "DELETE"), + { + params: Promise.resolve({ + id: newGameSessionSchedule.id, + }), + }, + ) + + expect(res.status).toBe(StatusCodes.NO_CONTENT) + expect(beginTransactionSpy).toHaveBeenCalled() + expect(commitTransactionSpy).toHaveBeenCalled() + expect(rollbackTransactionSpy).not.toHaveBeenCalled() + + beginTransactionSpy.mockRestore() + commitTransactionSpy.mockRestore() + rollbackTransactionSpy.mockRestore() }) - it("should return a 500 error for internal server error", async () => { + it("should return 404 if game session schedule is not found", async () => { cookieStore.set(AUTH_COOKIE_NAME, adminToken) - const res = await DELETE(createMockNextRequest("", "DELETE"), { - params: Promise.reject(new Error("Param parsing failed")), - }) + const res = await DELETE( + createMockNextRequest("/api/admin/game-session-schedules?deleteRelatedDocs=true", "DELETE"), + { + params: Promise.resolve({ id: "invalid-id" }), + }, + ) - expect(res.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR) + expect(res.status).toBe(StatusCodes.NOT_FOUND) const json = await res.json() - expect(json.error).toEqual(getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR)) + expect(json.error).toEqual("Game session schedule not found") }) }) }) diff --git a/apps/backend/src/app/api/admin/game-session-schedules/[id]/route.ts b/apps/backend/src/app/api/admin/game-session-schedules/[id]/route.ts index b2ebd1e48..50c291a97 100644 --- a/apps/backend/src/app/api/admin/game-session-schedules/[id]/route.ts +++ b/apps/backend/src/app/api/admin/game-session-schedules/[id]/route.ts @@ -4,6 +4,12 @@ import { type NextRequest, NextResponse } from "next/server" import { NotFound } from "payload" import { ZodError } from "zod" import { Security } from "@/business-layer/middleware/Security" +import { + commitCascadeTransaction, + createTransactionId, + rollbackCascadeTransaction, +} from "@/data-layer/adapters/Transaction" +import BookingDataService from "@/data-layer/services/BookingDataService" import GameSessionDataService from "@/data-layer/services/GameSessionDataService" class RouteWrapper { @@ -77,16 +83,32 @@ class RouteWrapper { /** * DELETE method to delete a game session schedule. * - * @param _req The request object + * @param req The request object * @param params Route parameters containing the GameSessionSchedule ID * @returns No content status code */ @Security("jwt", ["admin"]) - static async DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + static async DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params + const cascade = req.nextUrl.searchParams.get("delateRelatedDocs") === "true" const gameSessionDataService = new GameSessionDataService() - await gameSessionDataService.deleteGameSessionSchedule(id) + const bookingDataService = new BookingDataService() + const transactionID = cascade && (await createTransactionId()) + + if (transactionID) { + try { + await gameSessionDataService.deleteGameSessionSchedule(id, transactionID) + await gameSessionDataService.deleteAllGameSessionsByScheduleId(id, transactionID) + await bookingDataService.deleteRelatedBookingsByScheduleId(id, transactionID) + await commitCascadeTransaction(transactionID) + } catch { + await rollbackCascadeTransaction(transactionID) + } + } else { + await gameSessionDataService.deleteGameSessionSchedule(id) + } + return new NextResponse(null, { status: StatusCodes.NO_CONTENT }) } catch (error) { if (error instanceof NotFound) { diff --git a/apps/backend/src/data-layer/services/BookingDataService.ts b/apps/backend/src/data-layer/services/BookingDataService.ts index 4dd851c82..8c34b71d6 100644 --- a/apps/backend/src/data-layer/services/BookingDataService.ts +++ b/apps/backend/src/data-layer/services/BookingDataService.ts @@ -286,7 +286,7 @@ export default class BookingDataService { * * @param userId The ID of the user to bulk delete bookings for * @param transactionId an optional transaction ID for the request, useful for tracing - * @returns the deleted {@link Booking} documents if it exists, otherwise returns an empty array + */ public async deleteBookingsByUserId( userId: string, @@ -304,4 +304,27 @@ export default class BookingDataService { }) ).docs } + + /** + * Deletes all bookings related to a game session schedule. + * + * @param scheduleId the ID of the game session schedule whose bookings are to be deleted + * @param transactionID an optional transaction ID for the request, useful for tracing + */ + public async deleteRelatedBookingsByScheduleId( + scheduleId: string, + transactionID?: string | number, + ): Promise { + await payload.delete({ + collection: "booking", + where: { + "gameSession.gameSessionSchedule": { + equals: scheduleId, + }, + }, + req: { + transactionID, + }, + }) + } } diff --git a/apps/backend/src/data-layer/services/GameSessionDataService.test.ts b/apps/backend/src/data-layer/services/GameSessionDataService.test.ts index c2e376fd5..6ac6c6c27 100644 --- a/apps/backend/src/data-layer/services/GameSessionDataService.test.ts +++ b/apps/backend/src/data-layer/services/GameSessionDataService.test.ts @@ -505,4 +505,46 @@ describe("GameSessionDataService", () => { ).toStrictEqual([]) }) }) + + describe("deleteAllGameSessionsByScheduleId", () => { + it("should delete all game sessions by a game session schedule ID", async () => { + const createdGameSessionSchedule = await gameSessionDataService.createGameSessionSchedule( + gameSessionScheduleCreateMock, + ) + const createdGameSession1 = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + gameSessionSchedule: createdGameSessionSchedule.id, + }) + const createdGameSession2 = await gameSessionDataService.createGameSession({ + ...gameSessionCreateMock, + gameSessionSchedule: createdGameSessionSchedule.id, + }) + const createdGameSession3 = + await gameSessionDataService.createGameSession(gameSessionCreateMock) + + const deletedGameSessions = await gameSessionDataService.deleteAllGameSessionsByScheduleId( + createdGameSessionSchedule.id, + ) + expect(deletedGameSessions.length).toEqual(2) + expect(deletedGameSessions).toEqual( + expect.arrayContaining([createdGameSession1, createdGameSession2]), + ) + + expect(await gameSessionDataService.getGameSessionById(createdGameSession3.id)).toBeDefined() + await expect( + gameSessionDataService.getGameSessionById(createdGameSession1.id), + ).rejects.toThrowError("Not Found") + await expect( + gameSessionDataService.getGameSessionById(createdGameSession2.id), + ).rejects.toThrowError("Not Found") + }) + + it("should return an empty array if no game session exist when searching by a schedule ID", async () => { + expect( + await gameSessionDataService.deleteAllGameSessionsByScheduleId( + "Not a valid game session schedule ID", + ), + ).toStrictEqual([]) + }) + }) }) diff --git a/apps/backend/src/data-layer/services/GameSessionDataService.ts b/apps/backend/src/data-layer/services/GameSessionDataService.ts index ea79d58d7..7f8ca9914 100644 --- a/apps/backend/src/data-layer/services/GameSessionDataService.ts +++ b/apps/backend/src/data-layer/services/GameSessionDataService.ts @@ -285,12 +285,17 @@ export default class GameSessionDataService { * Deletes a {@link GameSessionSchedule} given its ID * * @param id the ID of the {@link GameSessionSchedule} to delete + * @param transactionId An optional transaction ID for the request, useful for tracing * @returns the deleted {@link GameSessionSchedule} document if it exists, otherwise throws a {@link NotFound} error */ - public async deleteGameSessionSchedule(id: string): Promise { + public async deleteGameSessionSchedule( + id: string, + transactionId?: string | number, + ): Promise { return await payload.delete({ collection: "gameSessionSchedule", id, + req: { transactionID: transactionId }, }) } @@ -317,4 +322,28 @@ export default class GameSessionDataService { }) ).docs } + + /** + * Deletes all {@link GameSession} documents for a {@link GameSessionSchedule} + * + * @param scheduleId the ID of the {@link GameSessionSchedule} with game sessions to be deleted + * @param transactionId An optional transaction ID for the request, useful for tracing + * @returns the deleted {@link GameSession} documents if it exists, otherwise returns an empty array + */ + public async deleteAllGameSessionsByScheduleId( + scheduleId: string, + transactionId?: string | number, + ): Promise { + return ( + await payload.delete({ + collection: "gameSession", + where: { + gameSessionSchedule: { + equals: scheduleId, + }, + }, + req: { transactionID: transactionId }, + }) + ).docs + } }