From 0b49b5748d702d54a29cc905e465f9cef78dab88 Mon Sep 17 00:00:00 2001 From: Houda135 Date: Mon, 27 Jul 2026 21:42:28 +1000 Subject: [PATCH] Add guard availability slot endpoints (Ticket #20) The Guard App (guard_app/src/api/availability.ts) already calls four calendar-slot endpoints that did not exist on the backend, so those requests returned 404. This implements them end to end. - New AvailabilitySlot model (per-date slots: guardId, date, fromTime, toTime, optional recurring). Distinct from the existing Availability model, which is left untouched. - availabilitySlot service + thin controller following the team's controller -> service -> model direction. guardId is always taken from the authenticated user, never the request body. - Routes: POST /slots, GET /slots/my-slots, DELETE /slots/clear-all, DELETE /slots/:id. Literal paths are registered before /slots/:id so they are not captured as an id param. - Input validation (date/time formats, from } + * - list : { availability: [, ...] } + * - delete : { message } + * - clear : { message, deletedCount } + */ + +import { ACTIONS } from "../middleware/logger.js"; +import * as slotService from "../services/availabilitySlot.service.js"; + +/** + * Resolve the authenticated guard id, or send 401 and return null. + */ +const requireGuardId = (req, res) => { + const guardId = req.user?.id || req.user?._id; + if (!guardId) { + res.status(401).json({ message: "Unauthorized" }); + return null; + } + return guardId; +}; + +/** + * Map a thrown service error to its HTTP response. Errors raised by the service + * carry a `.status`; anything else is an unexpected 500. + */ +const handleError = (res, err, context) => { + if (err?.status) { + return res.status(err.status).json({ message: err.message }); + } + console.error(`${context}:`, err); + return res.status(500).json({ message: "Server error", error: err.message }); +}; + +/** + * POST /api/v1/availability/slots + */ +export const createSlot = async (req, res) => { + const guardId = requireGuardId(req, res); + if (!guardId) return; + + try { + const slot = await slotService.createSlot(guardId, req.body); + + if (req.audit?.log) { + await req.audit.log(guardId, ACTIONS.AVAILABILITY_SLOT_CREATED, { + slotId: slot._id, + date: slot.date, + }); + } + + return res.status(201).json({ + message: "Availability slot created successfully.", + availability: slot, + }); + } catch (err) { + return handleError(res, err, "AvailabilitySlot CREATE error"); + } +}; + +/** + * GET /api/v1/availability/slots/my-slots + */ +export const getMySlots = async (req, res) => { + const guardId = requireGuardId(req, res); + if (!guardId) return; + + try { + const slots = await slotService.listMySlots(guardId, req.query); + return res.status(200).json({ availability: slots }); + } catch (err) { + return handleError(res, err, "AvailabilitySlot LIST error"); + } +}; + +/** + * DELETE /api/v1/availability/slots/:id + */ +export const deleteSlot = async (req, res) => { + const guardId = requireGuardId(req, res); + if (!guardId) return; + + try { + const slot = await slotService.deleteSlot(guardId, req.params.id); + + if (req.audit?.log) { + await req.audit.log(guardId, ACTIONS.AVAILABILITY_SLOT_DELETED, { + slotId: slot._id, + }); + } + + return res + .status(200) + .json({ message: "Availability slot deleted successfully." }); + } catch (err) { + return handleError(res, err, "AvailabilitySlot DELETE error"); + } +}; + +/** + * DELETE /api/v1/availability/slots/clear-all + */ +export const clearAllSlots = async (req, res) => { + const guardId = requireGuardId(req, res); + if (!guardId) return; + + try { + const deletedCount = await slotService.clearAllSlots(guardId); + + if (req.audit?.log) { + await req.audit.log(guardId, ACTIONS.AVAILABILITY_SLOTS_CLEARED, { + deletedCount, + }); + } + + return res.status(200).json({ + message: "All availability slots cleared successfully.", + deletedCount, + }); + } catch (err) { + return handleError(res, err, "AvailabilitySlot CLEAR error"); + } +}; diff --git a/app-backend/src/middleware/logger.js b/app-backend/src/middleware/logger.js index 89c1858e4..8a09b4cd6 100644 --- a/app-backend/src/middleware/logger.js +++ b/app-backend/src/middleware/logger.js @@ -25,6 +25,9 @@ export const ACTIONS = { USER_SOFT_DELETED: "USER_SOFT_DELETED", AVAILABILITY_UPDATED: "AVAILABILITY_UPDATED", + AVAILABILITY_SLOT_CREATED: "AVAILABILITY_SLOT_CREATED", + AVAILABILITY_SLOT_DELETED: "AVAILABILITY_SLOT_DELETED", + AVAILABILITY_SLOTS_CLEARED: "AVAILABILITY_SLOTS_CLEARED", RATINGS_SUBMITTED: "RATINGS_SUBMITTED", SITE_CREATED: "SITE_CREATED", diff --git a/app-backend/src/models/AvailabilitySlot.js b/app-backend/src/models/AvailabilitySlot.js new file mode 100644 index 000000000..96b691246 --- /dev/null +++ b/app-backend/src/models/AvailabilitySlot.js @@ -0,0 +1,91 @@ +/** + * models/AvailabilitySlot.js + * + * Calendar-based availability slots for the Guard App (Ticket #20). + * + * This is a DISTINCT concept from models/Availability.js. `Availability` is a + * single unique document per user describing recurring weekday preferences + * (days[] / timeSlots[] / live status). An `AvailabilitySlot` is one concrete + * date-bound window a guard is available; a guard owns MANY of them. + * + * Slot shape matches the Guard App contract (guard_app/src/api/availability.ts, + * AvailabilitySlotDto): + * { _id, guardId, date, fromTime, toTime, recurring?, createdAt, updatedAt } + */ + +import mongoose from "mongoose"; + +const { Schema } = mongoose; + +// "YYYY-MM-DD" zero-padded ISO calendar date. Sorts and range-filters lexically. +export const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +// "HH:MM" 24-hour clock. +export const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; + +export const RECURRING_PATTERNS = ["weekly", "daily"]; + +const RecurringSchema = new Schema( + { + enabled: { + type: Boolean, + default: false, + }, + pattern: { + type: String, + enum: RECURRING_PATTERNS, + }, + endDate: { + type: String, + match: ISO_DATE_REGEX, + }, + }, + { _id: false }, +); + +const AvailabilitySlotSchema = new Schema( + { + guardId: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + + date: { + type: String, + required: true, + match: [ISO_DATE_REGEX, 'date must be in "YYYY-MM-DD" format.'], + }, + + fromTime: { + type: String, + required: true, + match: [TIME_REGEX, 'fromTime must be in "HH:MM" 24-hour format.'], + }, + + toTime: { + type: String, + required: true, + match: [TIME_REGEX, 'toTime must be in "HH:MM" 24-hour format.'], + }, + + recurring: { + type: RecurringSchema, + default: undefined, + }, + }, + { + timestamps: true, + }, +); + +// Common access pattern: a guard's slots ordered along the calendar. +AvailabilitySlotSchema.index({ guardId: 1, date: 1, fromTime: 1 }); + +const AvailabilitySlot = mongoose.model( + "AvailabilitySlot", + AvailabilitySlotSchema, +); + +export default AvailabilitySlot; diff --git a/app-backend/src/routes/availability.routes.js b/app-backend/src/routes/availability.routes.js index a9fe8e0b1..6564ab458 100644 --- a/app-backend/src/routes/availability.routes.js +++ b/app-backend/src/routes/availability.routes.js @@ -1,5 +1,6 @@ import express from "express"; import * as availabilityController from "../controllers/availability.controller.js"; +import * as availabilitySlotController from "../controllers/availabilitySlot.controller.js"; import auth from "../middleware/auth.js"; const router = express.Router(); @@ -11,6 +12,189 @@ const router = express.Router(); * description: API to manage user availability */ +/** + * ========================================================================= + * CALENDAR AVAILABILITY SLOTS (Ticket #20) + * ========================================================================= + * Per-date slots owned by the authenticated guard. Registered BEFORE the + * "/:userId" route below so "/slots/..." is never captured as a userId param. + * Within the group, the literal "/slots/my-slots" and "/slots/clear-all" + * paths MUST come before the "/slots/:id" param route. + */ + +/** + * @swagger + * components: + * schemas: + * AvailabilitySlot: + * type: object + * properties: + * _id: + * type: string + * guardId: + * type: string + * date: + * type: string + * example: "2025-12-25" + * fromTime: + * type: string + * example: "09:00" + * toTime: + * type: string + * example: "17:00" + * recurring: + * type: object + * properties: + * enabled: + * type: boolean + * pattern: + * type: string + * enum: [weekly, daily] + * endDate: + * type: string + * example: "2026-01-31" + * createdAt: + * type: string + * updatedAt: + * type: string + */ + +/** + * @swagger + * /api/v1/availability/slots: + * post: + * summary: Create an availability slot for the authenticated guard + * tags: [Availability] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [date, fromTime, toTime] + * properties: + * date: + * type: string + * example: "2025-12-25" + * fromTime: + * type: string + * example: "09:00" + * toTime: + * type: string + * example: "17:00" + * recurring: + * type: object + * properties: + * enabled: + * type: boolean + * pattern: + * type: string + * enum: [weekly, daily] + * endDate: + * type: string + * responses: + * 201: + * description: Slot created + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * availability: + * $ref: "#/components/schemas/AvailabilitySlot" + * 400: + * description: Invalid input + * 401: + * description: Unauthorized + */ +router.post("/slots", auth, availabilitySlotController.createSlot); + +/** + * @swagger + * /api/v1/availability/slots/my-slots: + * get: + * summary: List the authenticated guard's availability slots + * tags: [Availability] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: startDate + * schema: + * type: string + * description: Optional ISO date (YYYY-MM-DD) lower bound + * - in: query + * name: endDate + * schema: + * type: string + * description: Optional ISO date (YYYY-MM-DD) upper bound + * responses: + * 200: + * description: List of slots + * content: + * application/json: + * schema: + * type: object + * properties: + * availability: + * type: array + * items: + * $ref: "#/components/schemas/AvailabilitySlot" + * 400: + * description: Invalid date range + * 401: + * description: Unauthorized + */ +router.get("/slots/my-slots", auth, availabilitySlotController.getMySlots); + +/** + * @swagger + * /api/v1/availability/slots/clear-all: + * delete: + * summary: Delete all of the authenticated guard's availability slots + * tags: [Availability] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Slots cleared + * 401: + * description: Unauthorized + */ +router.delete( + "/slots/clear-all", + auth, + availabilitySlotController.clearAllSlots, +); + +/** + * @swagger + * /api/v1/availability/slots/{id}: + * delete: + * summary: Delete one of the authenticated guard's availability slots + * tags: [Availability] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Slot deleted + * 401: + * description: Unauthorized + * 404: + * description: Slot not found or not owned by the guard + */ +router.delete("/slots/:id", auth, availabilitySlotController.deleteSlot); + /** * ========================= * CREATE / UPDATE AVAILABILITY diff --git a/app-backend/src/services/availabilitySlot.service.js b/app-backend/src/services/availabilitySlot.service.js new file mode 100644 index 000000000..cc7e972d4 --- /dev/null +++ b/app-backend/src/services/availabilitySlot.service.js @@ -0,0 +1,211 @@ +/** + * services/availabilitySlot.service.js + * + * Business logic for calendar-based availability slots (Ticket #20). + * + * Design rules (confirmed with Krisha Patel, Guard App Lead): + * - Every operation is scoped to ONE guard. The caller passes `guardId`, which + * the controller always derives from the authenticated user (req.user) + * and never from the request body, so a guard can only touch their own slots. + * - Validation errors throw an Error with `status: 400`; ownership misses on a + * single slot throw `status: 404`. The controller maps `.status` to the HTTP + * response (falling back to 500). + */ + +import mongoose from "mongoose"; +import AvailabilitySlot, { + ISO_DATE_REGEX, + TIME_REGEX, + RECURRING_PATTERNS, +} from "../models/AvailabilitySlot.js"; + +/** + * Build an Error that carries an HTTP status for the controller to surface. + * @param {number} status + * @param {string} message + */ +const httpError = (status, message) => { + const err = new Error(message); + err.status = status; + return err; +}; + +const toMinutes = (hhmm) => { + const [hh, mm] = hhmm.split(":").map(Number); + return hh * 60 + mm; +}; + +/** + * Validate and normalise the create-slot payload. + * Returns the clean fields to persist; throws a 400 on any bad input. + */ +const validateSlotInput = (body = {}) => { + const { date, fromTime, toTime, recurring } = body; + + if (typeof date !== "string" || !ISO_DATE_REGEX.test(date)) { + throw httpError(400, 'date is required in "YYYY-MM-DD" format.'); + } + + // Reject impossible calendar dates that still match the regex (e.g. 2025-13-40). + const parsed = new Date(`${date}T00:00:00.000Z`); + if ( + Number.isNaN(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== date + ) { + throw httpError(400, `date "${date}" is not a valid calendar date.`); + } + + if (typeof fromTime !== "string" || !TIME_REGEX.test(fromTime)) { + throw httpError(400, 'fromTime is required in "HH:MM" 24-hour format.'); + } + + if (typeof toTime !== "string" || !TIME_REGEX.test(toTime)) { + throw httpError(400, 'toTime is required in "HH:MM" 24-hour format.'); + } + + if (toMinutes(fromTime) >= toMinutes(toTime)) { + throw httpError(400, "fromTime must be earlier than toTime."); + } + + const clean = { date, fromTime, toTime }; + + if (recurring !== undefined && recurring !== null) { + if (typeof recurring !== "object" || Array.isArray(recurring)) { + throw httpError(400, "recurring must be an object."); + } + + if (typeof recurring.enabled !== "boolean") { + throw httpError(400, "recurring.enabled must be a boolean."); + } + + if (recurring.enabled) { + if (!RECURRING_PATTERNS.includes(recurring.pattern)) { + throw httpError( + 400, + `recurring.pattern must be one of: ${RECURRING_PATTERNS.join(", ")}.`, + ); + } + + if (recurring.endDate !== undefined && recurring.endDate !== null) { + if ( + typeof recurring.endDate !== "string" || + !ISO_DATE_REGEX.test(recurring.endDate) + ) { + throw httpError( + 400, + 'recurring.endDate must be in "YYYY-MM-DD" format.', + ); + } + if (recurring.endDate < date) { + throw httpError(400, "recurring.endDate cannot be before date."); + } + } + } + + clean.recurring = { + enabled: recurring.enabled, + ...(recurring.enabled + ? { + pattern: recurring.pattern, + ...(recurring.endDate ? { endDate: recurring.endDate } : {}), + } + : {}), + }; + } + + return clean; +}; + +/** + * Validate an optional {startDate,endDate} range for list filtering. + * Both are optional; each must be a valid ISO date if present. + */ +const buildDateRangeFilter = (query = {}) => { + const { startDate, endDate } = query; + const range = {}; + + if (startDate !== undefined) { + if (typeof startDate !== "string" || !ISO_DATE_REGEX.test(startDate)) { + throw httpError(400, 'startDate must be in "YYYY-MM-DD" format.'); + } + range.$gte = startDate; + } + + if (endDate !== undefined) { + if (typeof endDate !== "string" || !ISO_DATE_REGEX.test(endDate)) { + throw httpError(400, 'endDate must be in "YYYY-MM-DD" format.'); + } + range.$lte = endDate; + } + + if (range.$gte && range.$lte && range.$gte > range.$lte) { + throw httpError(400, "startDate cannot be after endDate."); + } + + return Object.keys(range).length ? range : null; +}; + +const assertValidGuardId = (guardId) => { + if (!guardId || !mongoose.Types.ObjectId.isValid(guardId)) { + throw httpError(400, "A valid authenticated guard id is required."); + } +}; + +/** + * POST /availability/slots: create a slot owned by the given guardId. + */ +export const createSlot = async (guardId, body) => { + assertValidGuardId(guardId); + const clean = validateSlotInput(body); + return AvailabilitySlot.create({ guardId, ...clean }); +}; + +/** + * GET /availability/slots/my-slots: list the guard's slots, optionally + * filtered to a [startDate, endDate] range. Sorted along the calendar. + */ +export const listMySlots = async (guardId, query) => { + assertValidGuardId(guardId); + const filter = { guardId }; + + const range = buildDateRangeFilter(query); + if (range) { + filter.date = range; + } + + return AvailabilitySlot.find(filter).sort({ date: 1, fromTime: 1 }); +}; + +/** + * DELETE /availability/slots/:id: delete one slot, only if it belongs to the + * guard. A non-existent id OR another guard's slot both surface as 404, so the + * endpoint never reveals the existence of slots the caller doesn't own. + */ +export const deleteSlot = async (guardId, slotId) => { + assertValidGuardId(guardId); + + if (!slotId || !mongoose.Types.ObjectId.isValid(slotId)) { + throw httpError(404, "Availability slot not found."); + } + + const deleted = await AvailabilitySlot.findOneAndDelete({ + _id: slotId, + guardId, + }); + + if (!deleted) { + throw httpError(404, "Availability slot not found."); + } + + return deleted; +}; + +/** + * DELETE /availability/slots/clear-all: remove all of the guard's slots. + * Returns the number deleted. + */ +export const clearAllSlots = async (guardId) => { + assertValidGuardId(guardId); + const result = await AvailabilitySlot.deleteMany({ guardId }); + return result?.deletedCount ?? 0; +}; diff --git a/app-backend/tests/availabilitySlot.controller.test.js b/app-backend/tests/availabilitySlot.controller.test.js new file mode 100644 index 000000000..13767a497 --- /dev/null +++ b/app-backend/tests/availabilitySlot.controller.test.js @@ -0,0 +1,383 @@ +import mongoose from "mongoose"; + +import { + createSlot, + getMySlots, + deleteSlot, + clearAllSlots, +} from "../src/controllers/availabilitySlot.controller.js"; + +import AvailabilitySlot from "../src/models/AvailabilitySlot.js"; + +// Keep the real named exports (regexes / patterns the service validates with), +// but replace the model's DB methods with mocks so tests never touch Mongo. +jest.mock("../src/models/AvailabilitySlot.js", () => { + const actual = jest.requireActual("../src/models/AvailabilitySlot.js"); + return { + __esModule: true, + ...actual, + default: { + create: jest.fn(), + find: jest.fn(), + findOneAndDelete: jest.fn(), + deleteMany: jest.fn(), + }, + }; +}); + +const GUARD_ID = new mongoose.Types.ObjectId().toString(); +const SLOT_ID = new mongoose.Types.ObjectId().toString(); + +const mockRes = () => { + const res = {}; + res.status = jest.fn().mockReturnThis(); + res.json = jest.fn(); + return res; +}; + +const mockReq = (overrides = {}) => ({ + user: { id: GUARD_ID, _id: GUARD_ID, role: "guard" }, + body: {}, + params: {}, + query: {}, + audit: { log: jest.fn() }, + ...overrides, +}); + +// find(...).sort(...) resolves to the given array +const mockFindReturning = (slots) => { + AvailabilitySlot.find.mockReturnValue({ + sort: jest.fn().mockResolvedValue(slots), + }); +}; + +describe("AvailabilitySlot Controller", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // --------------------------------------------------------------- + // POST /availability/slots + // --------------------------------------------------------------- + describe("createSlot", () => { + test("401 when unauthenticated", async () => { + const req = mockReq({ user: null }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(AvailabilitySlot.create).not.toHaveBeenCalled(); + }); + + test("400 when date is missing", async () => { + const req = mockReq({ body: { fromTime: "09:00", toTime: "17:00" } }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(AvailabilitySlot.create).not.toHaveBeenCalled(); + }); + + test("400 when date format is wrong", async () => { + const req = mockReq({ + body: { date: "25-12-2025", fromTime: "09:00", toTime: "17:00" }, + }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test("400 when date is not a real calendar date", async () => { + const req = mockReq({ + body: { date: "2025-13-40", fromTime: "09:00", toTime: "17:00" }, + }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test("400 when time format is wrong", async () => { + const req = mockReq({ + body: { date: "2025-12-25", fromTime: "9am", toTime: "17:00" }, + }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test("400 when fromTime is not before toTime", async () => { + const req = mockReq({ + body: { date: "2025-12-25", fromTime: "17:00", toTime: "09:00" }, + }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test("400 when recurring.enabled is not a boolean", async () => { + const req = mockReq({ + body: { + date: "2025-12-25", + fromTime: "09:00", + toTime: "17:00", + recurring: { enabled: "yes" }, + }, + }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test("400 when recurring is enabled with an invalid pattern", async () => { + const req = mockReq({ + body: { + date: "2025-12-25", + fromTime: "09:00", + toTime: "17:00", + recurring: { enabled: true, pattern: "monthly" }, + }, + }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test("400 when recurring.endDate is before date", async () => { + const req = mockReq({ + body: { + date: "2025-12-25", + fromTime: "09:00", + toTime: "17:00", + recurring: { + enabled: true, + pattern: "weekly", + endDate: "2025-12-01", + }, + }, + }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test("201 and returns the created slot under `availability`", async () => { + const created = { + _id: SLOT_ID, + guardId: GUARD_ID, + date: "2025-12-25", + fromTime: "09:00", + toTime: "17:00", + }; + AvailabilitySlot.create.mockResolvedValue(created); + + const req = mockReq({ + body: { date: "2025-12-25", fromTime: "09:00", toTime: "17:00" }, + }); + const res = mockRes(); + + await createSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ availability: created }), + ); + }); + + test("takes guardId from the token, never from the request body", async () => { + AvailabilitySlot.create.mockResolvedValue({ _id: SLOT_ID }); + + const req = mockReq({ + body: { + guardId: "attacker-supplied-id", + date: "2025-12-25", + fromTime: "09:00", + toTime: "17:00", + }, + }); + const res = mockRes(); + + await createSlot(req, res); + + expect(AvailabilitySlot.create).toHaveBeenCalledWith( + expect.objectContaining({ guardId: GUARD_ID }), + ); + }); + }); + + // --------------------------------------------------------------- + // GET /availability/slots/my-slots + // --------------------------------------------------------------- + describe("getMySlots", () => { + test("401 when unauthenticated", async () => { + const req = mockReq({ user: null }); + const res = mockRes(); + + await getMySlots(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test("200 and returns slots under `availability`", async () => { + const slots = [{ _id: SLOT_ID, guardId: GUARD_ID }]; + mockFindReturning(slots); + + const req = mockReq(); + const res = mockRes(); + + await getMySlots(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ availability: slots }); + }); + + test("scopes the query to the authenticated guard", async () => { + mockFindReturning([]); + + const req = mockReq(); + const res = mockRes(); + + await getMySlots(req, res); + + expect(AvailabilitySlot.find).toHaveBeenCalledWith( + expect.objectContaining({ guardId: GUARD_ID }), + ); + }); + + test("400 when startDate is malformed", async () => { + const req = mockReq({ query: { startDate: "not-a-date" } }); + const res = mockRes(); + + await getMySlots(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(AvailabilitySlot.find).not.toHaveBeenCalled(); + }); + + test("400 when startDate is after endDate", async () => { + const req = mockReq({ + query: { startDate: "2025-12-31", endDate: "2025-12-01" }, + }); + const res = mockRes(); + + await getMySlots(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test("applies a date range filter when provided", async () => { + mockFindReturning([]); + + const req = mockReq({ + query: { startDate: "2025-12-01", endDate: "2025-12-31" }, + }); + const res = mockRes(); + + await getMySlots(req, res); + + expect(AvailabilitySlot.find).toHaveBeenCalledWith( + expect.objectContaining({ + guardId: GUARD_ID, + date: { $gte: "2025-12-01", $lte: "2025-12-31" }, + }), + ); + }); + }); + + // --------------------------------------------------------------- + // DELETE /availability/slots/:id + // --------------------------------------------------------------- + describe("deleteSlot", () => { + test("401 when unauthenticated", async () => { + const req = mockReq({ user: null, params: { id: SLOT_ID } }); + const res = mockRes(); + + await deleteSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test("404 when the id is not a valid ObjectId", async () => { + const req = mockReq({ params: { id: "not-an-id" } }); + const res = mockRes(); + + await deleteSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(AvailabilitySlot.findOneAndDelete).not.toHaveBeenCalled(); + }); + + test("404 when the slot does not belong to the guard", async () => { + AvailabilitySlot.findOneAndDelete.mockResolvedValue(null); + + const req = mockReq({ params: { id: SLOT_ID } }); + const res = mockRes(); + + await deleteSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + // ownership is enforced in the query itself + expect(AvailabilitySlot.findOneAndDelete).toHaveBeenCalledWith({ + _id: SLOT_ID, + guardId: GUARD_ID, + }); + }); + + test("200 when the guard's own slot is deleted", async () => { + AvailabilitySlot.findOneAndDelete.mockResolvedValue({ _id: SLOT_ID }); + + const req = mockReq({ params: { id: SLOT_ID } }); + const res = mockRes(); + + await deleteSlot(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + }); + }); + + // --------------------------------------------------------------- + // DELETE /availability/slots/clear-all + // --------------------------------------------------------------- + describe("clearAllSlots", () => { + test("401 when unauthenticated", async () => { + const req = mockReq({ user: null }); + const res = mockRes(); + + await clearAllSlots(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + }); + + test("200 and returns the deleted count, scoped to the guard", async () => { + AvailabilitySlot.deleteMany.mockResolvedValue({ deletedCount: 3 }); + + const req = mockReq(); + const res = mockRes(); + + await clearAllSlots(req, res); + + expect(AvailabilitySlot.deleteMany).toHaveBeenCalledWith({ + guardId: GUARD_ID, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ deletedCount: 3 }), + ); + }); + }); +});