From 9f7b4ebf9e8acd8626d56ebd8b4abd2b121bea0e Mon Sep 17 00:00:00 2001 From: tinak250 Date: Sat, 16 May 2026 18:07:51 +1000 Subject: [PATCH 1/3] Implement adoptions API with tests and documentation --- docs/API.md | 393 +++++++++++++++++- src/modules/adoptions/adoptions.controller.ts | 88 ++++ src/modules/adoptions/adoptions.routes.ts | 66 +++ src/modules/adoptions/adoptions.service.ts | 198 +++++++++ src/modules/adoptions/index.ts | 3 + src/routes/index.ts | 2 + tests/integration/adoptions.test.ts | 149 ++++++- tests/unit/adoptions.test.ts | 258 +++++++++++- 8 files changed, 1148 insertions(+), 9 deletions(-) diff --git a/docs/API.md b/docs/API.md index 6735950..b7f83c1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3098,4 +3098,395 @@ The Tree Scans API follows the TreeO2 backend engineering standard: - Swagger documentation - Automated testing - Scalable backend structure ---- \ No newline at end of file +--- + +## 16. Adoptions API + +This module manages adoption records in the TreeO2 platform. It provides full CRUD operations for recording, retrieving, updating, and deleting tree adoption records. + +**Module Path:** `src/modules/adoptions/` + +### Files + +- `adoptions.routes.ts` +- `adoptions.controller.ts` +- `adoptions.service.ts` +- `index.ts` + +### 16.1 Purpose + +The Adoptions API is responsible for managing adoption records linked to adopters and tree FOB identifiers. + +An adoption record stores: +- the adopter linked to the adoption +- the tree FOB ID +- the adoption date +- the creation timestamp + +### 16.2 Architecture Flow + +Every request follows the standard backend module structure: + +```text +Route -> Controller -> Service -> Prisma ORM -> PostgreSQL -> Response +``` + +### Responsibilities + +#### Routes +- Define endpoints +- Apply authentication middleware +- Apply role-based authorization +- Contain Swagger documentation + +#### Controller +- Receive request data +- Read params and body +- Call service methods +- Return HTTP response + +#### Service +- Validate user input +- Apply business rules +- Execute database queries +- Throw structured errors + +### 16.3 Security + +All endpoints are protected using Bearer Token authentication. + +Middleware used: +- `authMiddleware` +- `roleMiddleware` + +### 16.4 Access Control Matrix + +| Endpoint | ADMIN | MANAGER | INSPECTOR | FARMER | DEVELOPER | +|---|---|---|---|---|---| +| GET /adoptions | Yes | Yes | No | No | No | +| GET /adoptions/{id} | Yes | Yes | No | No | No | +| POST /adoptions | Yes | No | No | No | No | +| PUT /adoptions/{id} | Yes | No | No | No | No | +| DELETE /adoptions/{id} | Yes | No | No | No | No | + +### 16.5 Endpoints + +#### GET /adoptions + +Retrieve paginated adoption records. + +##### Query Parameters + +| Name | Type | Required | +|---|---|---| +| page | integer | No | +| limit | integer | No | + +##### Response + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "adopter_id": 1, + "fob_id": "NFC-001", + "adopted_at": "2026-05-14T00:00:00.000Z" + } + ] +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid pagination parameters +- `401` Authentication required +- `403` Insufficient permissions + +--- + +#### GET /adoptions/{id} + +Retrieve a single adoption by ID. + +##### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "adopter_id": 1, + "fob_id": "NFC-001", + "adopted_at": "2026-05-14T00:00:00.000Z" + } +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid adoption ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adoption not found + +--- + +#### POST /adoptions + +Create a new adoption record. + +##### Request Body + +```json +{ + "adopter_id": 1, + "fob_id": "NFC-001", + "adopted_at": "2026-05-14" +} +``` + +##### Required Fields +- `adopter_id` +- `fob_id` +- `adopted_at` + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "adopter_id": 1, + "fob_id": "NFC-001", + "adopted_at": "2026-05-14T00:00:00.000Z" + } +} +``` + +##### Status Codes +- `201` Created +- `400` Invalid payload or missing required fields +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adopter not found + +--- + +#### PUT /adoptions/{id} + +Update an existing adoption record. + +##### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +##### Request Body + +Any subset of fields may be provided. + +```json +{ + "fob_id": "NFC-UPDATED" +} +``` + +##### Response + +```json +{ + "success": true, + "data": { + "id": 1, + "adopter_id": 1, + "fob_id": "NFC-UPDATED", + "adopted_at": "2026-05-14T00:00:00.000Z" + } +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid request, invalid ID, empty payload, or future adoption date +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adoption or adopter not found + +--- + +#### DELETE /adoptions/{id} + +Delete an adoption record. + +##### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +##### Response + +```json +{ + "success": true, + "data": { + "message": "Adoption deleted successfully" + } +} +``` + +##### Status Codes +- `200` Success +- `400` Invalid adoption ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Adoption not found + +### 16.6 Validation Rules + +#### Create Validation +- `adopter_id` must be a positive integer +- `fob_id` must be a non-empty string +- `adopted_at` must be a valid date +- `adopted_at` cannot be in the future +- adopter must exist before adoption is created + +#### Update Validation +- adoption ID must be a positive integer +- at least one field must be provided +- if `adopter_id` is provided, it must be a positive integer and existing adopter +- if `fob_id` is provided, it must be non-empty +- if `adopted_at` is provided, it must be valid and not in the future + +#### Delete Validation +- adoption must exist before deletion + +### 16.7 Error Handling + +Uses centralised error middleware. + +#### Standard Error Response + +```json +{ + "success": false, + "message": "Adoption not found" +} +``` + +#### Common Errors +- Authentication required +- Insufficient permissions +- Invalid adoption ID +- Missing required fields +- Invalid adoption date +- Adoption date cannot be in the future +- Adopter not found +- Adoption not found +- Empty update payload +- Internal server error + +### 16.8 Swagger Documentation + +All endpoints are documented in: + +`adoptions.routes.ts` + +Available at: + +`http://localhost:3000/api-docs` + +Swagger supports: +- Interactive testing +- Request examples +- Response definitions +- Security schemas + +### 16.9 Testing + +#### Test Files +- `tests/unit/adoptions.test.ts` +- `tests/integration/adoptions.test.ts` + +#### Covered Scenarios + +##### Authentication +- No token returns `401` + +##### Authorization +- Admin and Manager can access GET endpoints +- Only Admin can create, update, and delete +- Other roles return `403` + +##### Read +- Get all adoptions returns list +- Get adoption by ID returns correct record +- Missing adoption returns `404` +- Invalid ID returns `400` + +##### Create +- Valid adoption created with `201` +- Missing fields rejected with `400` +- Invalid adoption date rejected with `400` +- Missing adopter rejected with `404` + +##### Update +- Valid update succeeds with `200` +- Empty payload rejected with `400` +- Invalid ID rejected with `400` +- Missing adoption returns `404` + +##### Delete +- Valid delete succeeds with `200` +- Missing adoption returns `404` +- Invalid ID returns `400` + +### 16.10 How to Run Adoptions Tests + +Run unit tests only: + +```bash +npm test -- --runInBand tests/unit/adoptions.test.ts +``` + +Run integration tests only: + +```bash +npm test -- --runInBand tests/integration/adoptions.test.ts +``` + +Run both: + +```bash +npm test -- --runInBand tests/unit/adoptions.test.ts tests/integration/adoptions.test.ts +``` + +### 16.11 Current Limitations + +- auth and role checks depend on the existing scaffold and are not fully production-complete yet +- adoption records currently rely on FOB identifiers without deeper tree validation logic + +### 16.12 Summary + +The Adoptions API follows the TreeO2 backend engineering standard: + +- Modular architecture +- Secure authentication +- Role-based access control +- Clean separation of concerns +- Strong validation +- Full CRUD support +- Prisma-backed data access +- Swagger documentation +- Automated tests +- Scalable structure for future enhancements \ No newline at end of file diff --git a/src/modules/adoptions/adoptions.controller.ts b/src/modules/adoptions/adoptions.controller.ts index e69de29..12743b6 100644 --- a/src/modules/adoptions/adoptions.controller.ts +++ b/src/modules/adoptions/adoptions.controller.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from "express"; +import { + adoptionsService, + type CreateAdoptionInput, + type UpdateAdoptionInput, +} from "./adoptions.service"; + +export class AdoptionsController { + async createAdoption(req: Request, res: Response, next: NextFunction) { + try { + const payload = req.body as CreateAdoptionInput; + + const result = await adoptionsService.createAdoption(payload); + + return res.status(201).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } + + async listAdoptions(req: Request, res: Response, next: NextFunction) { + try { + const page = Number(req.query.page) || 1; + const limit = Number(req.query.limit) || 10; + + const result = await adoptionsService.listAdoptions(page, limit); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } + + async getAdoptionById(req: Request, res: Response, next: NextFunction) { + try { + const id = Number(req.params.id); + + const result = await adoptionsService.getAdoptionById(id); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } + + async updateAdoption(req: Request, res: Response, next: NextFunction) { + try { + const id = Number(req.params.id); + + const payload = req.body as UpdateAdoptionInput; + + const result = await adoptionsService.updateAdoption(id, payload); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } + + async deleteAdoption(req: Request, res: Response, next: NextFunction) { + try { + const id = Number(req.params.id); + + const result = await adoptionsService.deleteAdoption(id); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } +} + +export const adoptionsController = new AdoptionsController(); \ No newline at end of file diff --git a/src/modules/adoptions/adoptions.routes.ts b/src/modules/adoptions/adoptions.routes.ts index e69de29..b332815 100644 --- a/src/modules/adoptions/adoptions.routes.ts +++ b/src/modules/adoptions/adoptions.routes.ts @@ -0,0 +1,66 @@ +import { Router } from "express"; +import { adoptionsController } from "./adoptions.controller"; +import { authMiddleware } from "../../middleware/auth.middleware"; +import { roleMiddleware } from "../../middleware/role.middleware"; + +const router = Router(); + +/** + * CREATE ADOPTION (ADMIN ONLY) + */ +router.post( + "/", + authMiddleware, + roleMiddleware(["ADMIN"]), + (req, res, next) => void adoptionsController.createAdoption(req, res, next), +); + +/** + * LIST ADOPTIONS (ADMIN + MANAGER) + */ +router.get( + "/", + authMiddleware, + roleMiddleware(["ADMIN", "MANAGER"]), + (req, res, next) => { + void adoptionsController.listAdoptions(req, res, next); + }, +); + +/** + * GET BY ID (ADMIN + MANAGER) + */ +router.get( + "/:id", + authMiddleware, + roleMiddleware(["ADMIN", "MANAGER"]), + (req, res, next) => { + void adoptionsController.getAdoptionById(req, res, next); + }, +); + +/** + * UPDATE (ADMIN ONLY) + */ +router.put( + "/:id", + authMiddleware, + roleMiddleware(["ADMIN"]), + (req, res, next) => { + void adoptionsController.updateAdoption(req, res, next); + }, +); + +/** + * DELETE (ADMIN ONLY) + */ +router.delete( + "/:id", + authMiddleware, + roleMiddleware(["ADMIN"]), + (req, res, next) => { + void adoptionsController.deleteAdoption(req, res, next); + }, +); + +export default router; \ No newline at end of file diff --git a/src/modules/adoptions/adoptions.service.ts b/src/modules/adoptions/adoptions.service.ts index e69de29..5a314f0 100644 --- a/src/modules/adoptions/adoptions.service.ts +++ b/src/modules/adoptions/adoptions.service.ts @@ -0,0 +1,198 @@ +import { prisma } from "../../lib/prisma"; +import { AppError } from "../../middleware/errorHandler"; +import { ERROR_CODES } from "../../utils/errorCodes"; + +interface CreateAdoptionInput { + adopter_id: number; + fob_id: string; + adopted_at: string; +} + +interface UpdateAdoptionInput { + adopter_id?: number; + fob_id?: string; + adopted_at?: string; +} + +const assertValidId = (id: number) => { + if (!Number.isInteger(id) || id <= 0) { + throw new AppError(400, ERROR_CODES.VAL_002, ERROR_CODES.VAL_002); + } +}; + +const assertValidPagination = (page: number, limit: number) => { + if ( + !Number.isInteger(page) || + !Number.isInteger(limit) || + page <= 0 || + limit <= 0 + ) { + throw new AppError( + 400, + "Invalid pagination parameters", + ERROR_CODES.VAL_002, + ); + } +}; + +const assertValidDate = (date: string) => { + const parsedDate = new Date(date); + + if (Number.isNaN(parsedDate.getTime())) { + throw new AppError(400, "Invalid adoption date", ERROR_CODES.VAL_002); + } + + if (parsedDate > new Date()) { + throw new AppError( + 400, + "Adoption date cannot be in the future", + ERROR_CODES.VAL_003, + ); + } + + return parsedDate; +}; + +const assertCreatePayload = (data: CreateAdoptionInput) => { + assertValidId(Number(data.adopter_id)); + + if (!data.fob_id?.trim()) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + + if (!data.adopted_at) { + throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + } + + assertValidDate(data.adopted_at); +}; + +const assertUpdatePayload = (data: UpdateAdoptionInput) => { + if (Object.keys(data).length === 0) { + throw new AppError( + 400, + "No fields provided for update", + ERROR_CODES.VAL_003, + ); + } + + if (data.adopter_id !== undefined) { + assertValidId(Number(data.adopter_id)); + } + + if (data.fob_id !== undefined && !data.fob_id.trim()) { + throw new AppError(400, "Invalid fob_id", ERROR_CODES.VAL_002); + } + + if (data.adopted_at !== undefined) { + assertValidDate(data.adopted_at); + } +}; + +export class AdoptionsService { + async listAdoptions(page = 1, limit = 10) { + assertValidPagination(page, limit); + + const skip = (page - 1) * limit; + + const [data, total] = await Promise.all([ + prisma.adoption.findMany({ + skip, + take: limit, + orderBy: { id: "desc" }, + }), + prisma.adoption.count(), + ]); + + return { + data, + meta: { + page, + limit, + total, + }, + }; + } + + async createAdoption(data: CreateAdoptionInput) { + assertCreatePayload(data); + + const adopter = await prisma.adopter.findUnique({ + where: { id: Number(data.adopter_id) }, + }); + + if (!adopter) { + throw new AppError(404, "Adopter not found", ERROR_CODES.DATA_001); + } + + return prisma.adoption.create({ + data: { + adopterId: Number(data.adopter_id), + fobId: data.fob_id.trim(), + adoptedAt: assertValidDate(data.adopted_at), + }, + }); + } + + async getAdoptionById(id: number) { + assertValidId(id); + + const adoption = await prisma.adoption.findUnique({ + where: { id }, + }); + + if (!adoption) { + throw new AppError(404, "Adoption not found", ERROR_CODES.DATA_001); + } + + return adoption; + } + + async updateAdoption(id: number, data: UpdateAdoptionInput) { + assertValidId(id); + assertUpdatePayload(data); + + await this.getAdoptionById(id); + + if (data.adopter_id !== undefined) { + const adopter = await prisma.adopter.findUnique({ + where: { id: Number(data.adopter_id) }, + }); + + if (!adopter) { + throw new AppError(404, "Adopter not found", ERROR_CODES.DATA_001); + } + } + + return prisma.adoption.update({ + where: { id }, + data: { + ...(data.adopter_id !== undefined + ? { adopterId: Number(data.adopter_id) } + : {}), + ...(data.fob_id !== undefined ? { fobId: data.fob_id.trim() } : {}), + ...(data.adopted_at !== undefined + ? { adoptedAt: assertValidDate(data.adopted_at) } + : {}), + }, + }); + } + + async deleteAdoption(id: number) { + assertValidId(id); + + await this.getAdoptionById(id); + + await prisma.adoption.delete({ + where: { id }, + }); + + return { + message: "Adoption deleted successfully", + }; + } +} + +export const adoptionsService = new AdoptionsService(); + +export type { CreateAdoptionInput, UpdateAdoptionInput }; \ No newline at end of file diff --git a/src/modules/adoptions/index.ts b/src/modules/adoptions/index.ts index e69de29..a6f0df2 100644 --- a/src/modules/adoptions/index.ts +++ b/src/modules/adoptions/index.ts @@ -0,0 +1,3 @@ +export { default as adoptionsRoutes } from "./adoptions.routes"; +export { adoptionsController } from "./adoptions.controller"; +export { adoptionsService } from "./adoptions.service"; \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index 3ad61b6..3453d34 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -7,6 +7,7 @@ import { treeTypesRoutes } from "../modules/tree-types"; import { projectManagementRoutes } from "../modules/project-management"; import { localizationRoutes } from "../modules/localization"; import { adoptersRouter } from "../modules/adopters"; +import { adoptionsRoutes } from "../modules/adoptions"; import { userProjectAssignmentRoutes } from "../modules/user-project-assignment"; import { partnersRoutes } from "../modules/partners"; @@ -17,6 +18,7 @@ const router = Router(); router.use("/health", healthRoutes); router.use("/auth", authRoutes); router.use("/adopters", adoptersRouter); +router.use("/adoptions", adoptionsRoutes); router.use("/users", userRoutes); router.use("/tree-types", treeTypesRoutes); router.use("/projects", projectManagementRoutes); diff --git a/tests/integration/adoptions.test.ts b/tests/integration/adoptions.test.ts index 73e2381..b87c13d 100644 --- a/tests/integration/adoptions.test.ts +++ b/tests/integration/adoptions.test.ts @@ -1,5 +1,146 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); +process.env.NODE_ENV = "development"; +process.env.AUTH_DEV_MODE = "true"; + +import { describe, expect, it, beforeAll } from "@jest/globals"; +import request from "supertest"; +import app from "../../src/app"; + +const TOKENS = { + ADMIN: process.env.AUTH_DEV_ADMIN_TOKEN!, + MANAGER: process.env.AUTH_DEV_MANAGER_TOKEN!, + INSPECTOR: process.env.AUTH_DEV_INSPECTOR_TOKEN!, + FARMER: process.env.AUTH_DEV_FARMER_TOKEN!, +}; + +describe("Adoptions API Integration Tests", () => { + let adopterId: number; + let adoptionId: number; + + beforeAll(async () => { + const adopter = await request(app) + .post("/adopters") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") + .send({ + name: "Integration Adopter", + email: "integration@gmail.com", + }); + + adopterId = adopter.body.data.id; }); -}); + + it("POST /adoptions - should create adoption", async () => { + const res = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") + .send({ + adopter_id: adopterId, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("data.id"); + + adoptionId = res.body.data.id; + }); + + it("POST /adoptions - should return 400 when fob_id missing", async () => { + const res = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ + adopter_id: adopterId, + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(400); + }); + + it("GET /adoptions - should return list", async () => { + const res = await request(app) + .get("/adoptions?page=1&limit=10") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + }); + + it("GET /adoptions/:id - should return 404", async () => { + const res = await request(app) + .get("/adoptions/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(404); + }); + + it("PUT /adoptions/:id - should update adoption", async () => { + const res = await request(app) + .put(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ + fob_id: "NFC-UPDATED", + }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("DELETE /adoptions/:id - should delete adoption", async () => { + const created = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ + adopter_id: adopterId, + fob_id: "NFC-DELETE", + adopted_at: "2026-05-14", + }); + + const res = await request(app) + .delete(`/adoptions/${created.body.data.id}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + }); + + it("POST /adoptions - should return 401 when no token", async () => { + const res = await request(app) + .post("/adoptions") + .send({ + adopter_id: adopterId, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(401); + }); + + it("POST /adoptions - should return 403 for FARMER", async () => { + const res = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.FARMER}`) + .send({ + adopter_id: adopterId, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(403); + }); + + it("GET /adoptions - MANAGER should access list", async () => { + const res = await request(app) + .get("/adoptions") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(200); + }); + + it("DELETE cleanup created adoption", async () => { + const res = await request(app) + .delete(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + }); +}); \ No newline at end of file diff --git a/tests/unit/adoptions.test.ts b/tests/unit/adoptions.test.ts index 73e2381..d564cc0 100644 --- a/tests/unit/adoptions.test.ts +++ b/tests/unit/adoptions.test.ts @@ -1,5 +1,255 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); +import { describe, expect, it, beforeEach, jest } from "@jest/globals"; +import { adoptionsService } from "../../src/modules/adoptions/adoptions.service"; +import { prisma } from "../../src/lib/prisma"; +import { AppError } from "../../src/middleware/errorHandler"; + +jest.mock("../../src/lib/prisma", () => ({ + prisma: { + adoption: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }, + adopter: { + findUnique: jest.fn(), + }, + }, +})); + +const mockedPrismaAdoption = prisma.adoption as { + create: jest.MockedFunction; + findMany: jest.MockedFunction; + findUnique: jest.MockedFunction; + update: jest.MockedFunction; + delete: jest.MockedFunction; + count: jest.MockedFunction; +}; + +const mockedPrismaAdopter = prisma.adopter as { + findUnique: jest.MockedFunction; +}; + +describe("AdoptionsService - Unit Tests", () => { + beforeEach(() => { + jest.clearAllMocks(); }); -}); + + describe("createAdoption", () => { + it("should create adoption successfully", async () => { + mockedPrismaAdopter.findUnique.mockResolvedValue({ + id: 1, + name: "Adam", + }); + + mockedPrismaAdoption.create.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + const result = await adoptionsService.createAdoption({ + adopter_id: 1, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }); + + expect(result.id).toBe(1); + expect(result.fobId).toBe("NFC-001"); + expect(mockedPrismaAdopter.findUnique).toHaveBeenCalledTimes(1); + expect(mockedPrismaAdoption.create).toHaveBeenCalledTimes(1); + }); + + it("should throw 400 when fob_id is empty", async () => { + await expect( + adoptionsService.createAdoption({ + adopter_id: 1, + fob_id: "", + adopted_at: "2026-05-14", + }), + ).rejects.toThrow(AppError); + }); + + it("should throw 400 when adopted_at is invalid", async () => { + await expect( + adoptionsService.createAdoption({ + adopter_id: 1, + fob_id: "NFC-001", + adopted_at: "invalid-date", + }), + ).rejects.toThrow(AppError); + }); + + it("should throw 400 when adopted_at is in the future", async () => { + await expect( + adoptionsService.createAdoption({ + adopter_id: 1, + fob_id: "NFC-001", + adopted_at: "2099-01-01", + }), + ).rejects.toThrow(AppError); + }); + + it("should throw 404 when adopter does not exist", async () => { + mockedPrismaAdopter.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.createAdoption({ + adopter_id: 999, + fob_id: "NFC-001", + adopted_at: "2026-05-14", + }), + ).rejects.toThrow(AppError); + }); + }); + + describe("listAdoptions", () => { + it("should return paginated adoptions", async () => { + mockedPrismaAdoption.findMany.mockResolvedValue([ + { + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }, + ]); + + mockedPrismaAdoption.count.mockResolvedValue(1); + + const result = await adoptionsService.listAdoptions(1, 10); + + expect(result.data.length).toBe(1); + expect(result.meta.total).toBe(1); + expect(mockedPrismaAdoption.findMany).toHaveBeenCalled(); + }); + + it("should throw 400 for invalid pagination", async () => { + await expect(adoptionsService.listAdoptions(0, 10)).rejects.toThrow( + AppError, + ); + }); + }); + + describe("getAdoptionById", () => { + it("should return adoption when exists", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + const result = await adoptionsService.getAdoptionById(1); + + expect(result.id).toBe(1); + }); + + it("should throw 404 when adoption not found", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.getAdoptionById(999), + ).rejects.toThrow(AppError); + }); + + it("should throw 400 for invalid id", async () => { + await expect(adoptionsService.getAdoptionById(0)).rejects.toThrow( + AppError, + ); + }); + }); + + describe("updateAdoption", () => { + it("should update adoption successfully", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + mockedPrismaAdoption.update.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-UPDATED", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + const result = await adoptionsService.updateAdoption(1, { + fob_id: "NFC-UPDATED", + }); + + expect(result.fobId).toBe("NFC-UPDATED"); + expect(mockedPrismaAdoption.update).toHaveBeenCalledTimes(1); + }); + + it("should throw 400 when no fields provided", async () => { + await expect(adoptionsService.updateAdoption(1, {})).rejects.toThrow( + AppError, + ); + }); + + it("should throw 404 when updating non-existing adoption", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.updateAdoption(999, { + fob_id: "NFC-UPDATED", + }), + ).rejects.toThrow(AppError); + }); + + it("should throw 404 when updated adopter does not exist", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue({ + id: 1, + adopterId: 1, + fobId: "NFC-001", + adoptedAt: new Date("2026-05-14"), + createdAt: new Date("2026-05-14"), + }); + + mockedPrismaAdopter.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.updateAdoption(1, { + adopter_id: 999, + }), + ).rejects.toThrow(AppError); + }); + }); + + describe("deleteAdoption", () => { + it("should delete adoption successfully", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue({ + id: 1, + }); + + mockedPrismaAdoption.delete.mockResolvedValue({ + id: 1, + }); + + const result = await adoptionsService.deleteAdoption(1); + + expect(result.message).toBe("Adoption deleted successfully"); + expect(mockedPrismaAdoption.delete).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + }); + + it("should throw 404 when deleting non-existing adoption", async () => { + mockedPrismaAdoption.findUnique.mockResolvedValue(null); + + await expect( + adoptionsService.deleteAdoption(999), + ).rejects.toThrow(AppError); + }); + }); +}); \ No newline at end of file From 9eb2780ef22214ef6d4ee7393f489030388bbb8c Mon Sep 17 00:00:00 2001 From: tinak250 Date: Sat, 16 May 2026 18:13:22 +1000 Subject: [PATCH 2/3] Format adoptions module --- src/modules/adoptions/adoptions.controller.ts | 2 +- src/modules/adoptions/adoptions.routes.ts | 2 +- src/modules/adoptions/adoptions.service.ts | 2 +- src/modules/adoptions/index.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/adoptions/adoptions.controller.ts b/src/modules/adoptions/adoptions.controller.ts index 12743b6..64da5bc 100644 --- a/src/modules/adoptions/adoptions.controller.ts +++ b/src/modules/adoptions/adoptions.controller.ts @@ -85,4 +85,4 @@ export class AdoptionsController { } } -export const adoptionsController = new AdoptionsController(); \ No newline at end of file +export const adoptionsController = new AdoptionsController(); diff --git a/src/modules/adoptions/adoptions.routes.ts b/src/modules/adoptions/adoptions.routes.ts index b332815..ca93637 100644 --- a/src/modules/adoptions/adoptions.routes.ts +++ b/src/modules/adoptions/adoptions.routes.ts @@ -63,4 +63,4 @@ router.delete( }, ); -export default router; \ No newline at end of file +export default router; diff --git a/src/modules/adoptions/adoptions.service.ts b/src/modules/adoptions/adoptions.service.ts index 5a314f0..6825135 100644 --- a/src/modules/adoptions/adoptions.service.ts +++ b/src/modules/adoptions/adoptions.service.ts @@ -195,4 +195,4 @@ export class AdoptionsService { export const adoptionsService = new AdoptionsService(); -export type { CreateAdoptionInput, UpdateAdoptionInput }; \ No newline at end of file +export type { CreateAdoptionInput, UpdateAdoptionInput }; diff --git a/src/modules/adoptions/index.ts b/src/modules/adoptions/index.ts index a6f0df2..2a0de59 100644 --- a/src/modules/adoptions/index.ts +++ b/src/modules/adoptions/index.ts @@ -1,3 +1,3 @@ export { default as adoptionsRoutes } from "./adoptions.routes"; export { adoptionsController } from "./adoptions.controller"; -export { adoptionsService } from "./adoptions.service"; \ No newline at end of file +export { adoptionsService } from "./adoptions.service"; From 53078ca3b9eff269efe0b872b4eeeb653071e0b3 Mon Sep 17 00:00:00 2001 From: tinak250 Date: Sat, 16 May 2026 23:12:09 +1000 Subject: [PATCH 3/3] Implement adoptions API with filters and tests --- src/modules/adoptions/adoptions.controller.ts | 58 ++--- src/modules/adoptions/adoptions.service.ts | 139 ++++++++++-- tests/integration/adoptions.test.ts | 214 ++++++++++++++++-- tests/unit/adoptions.test.ts | 91 +++++++- 4 files changed, 427 insertions(+), 75 deletions(-) diff --git a/src/modules/adoptions/adoptions.controller.ts b/src/modules/adoptions/adoptions.controller.ts index 64da5bc..5742de1 100644 --- a/src/modules/adoptions/adoptions.controller.ts +++ b/src/modules/adoptions/adoptions.controller.ts @@ -3,19 +3,24 @@ import { adoptionsService, type CreateAdoptionInput, type UpdateAdoptionInput, + type ListAdoptionsFilters, } from "./adoptions.service"; +const parseOptionalNumber = (value: unknown): number | undefined => { + if (value === undefined) { + return undefined; + } + const parsed = Number(value); + return parsed; +}; + export class AdoptionsController { async createAdoption(req: Request, res: Response, next: NextFunction) { try { const payload = req.body as CreateAdoptionInput; - const result = await adoptionsService.createAdoption(payload); - return res.status(201).json({ - success: true, - data: result, - }); + return res.status(201).json({ success: true, data: result }); } catch (error) { return next(error); } @@ -23,15 +28,22 @@ export class AdoptionsController { async listAdoptions(req: Request, res: Response, next: NextFunction) { try { - const page = Number(req.query.page) || 1; - const limit = Number(req.query.limit) || 10; - - const result = await adoptionsService.listAdoptions(page, limit); - - return res.status(200).json({ - success: true, - data: result, - }); + const filters: ListAdoptionsFilters = { + page: parseOptionalNumber(req.query.page) ?? 1, + limit: parseOptionalNumber(req.query.limit) ?? 10, + fob_id: + req.query.fob_id !== undefined ? String(req.query.fob_id) : undefined, + adopter_id: parseOptionalNumber(req.query.adopter_id), + adopter: + req.query.adopter !== undefined + ? String(req.query.adopter) + : undefined, + year: parseOptionalNumber(req.query.year), + }; + + const result = await adoptionsService.listAdoptions(filters); + + return res.status(200).json({ success: true, data: result }); } catch (error) { return next(error); } @@ -40,13 +52,9 @@ export class AdoptionsController { async getAdoptionById(req: Request, res: Response, next: NextFunction) { try { const id = Number(req.params.id); - const result = await adoptionsService.getAdoptionById(id); - return res.status(200).json({ - success: true, - data: result, - }); + return res.status(200).json({ success: true, data: result }); } catch (error) { return next(error); } @@ -55,15 +63,11 @@ export class AdoptionsController { async updateAdoption(req: Request, res: Response, next: NextFunction) { try { const id = Number(req.params.id); - const payload = req.body as UpdateAdoptionInput; const result = await adoptionsService.updateAdoption(id, payload); - return res.status(200).json({ - success: true, - data: result, - }); + return res.status(200).json({ success: true, data: result }); } catch (error) { return next(error); } @@ -72,13 +76,9 @@ export class AdoptionsController { async deleteAdoption(req: Request, res: Response, next: NextFunction) { try { const id = Number(req.params.id); - const result = await adoptionsService.deleteAdoption(id); - return res.status(200).json({ - success: true, - data: result, - }); + return res.status(200).json({ success: true, data: result }); } catch (error) { return next(error); } diff --git a/src/modules/adoptions/adoptions.service.ts b/src/modules/adoptions/adoptions.service.ts index 6825135..1a38e9e 100644 --- a/src/modules/adoptions/adoptions.service.ts +++ b/src/modules/adoptions/adoptions.service.ts @@ -1,3 +1,4 @@ +import { Prisma } from "@prisma/client"; import { prisma } from "../../lib/prisma"; import { AppError } from "../../middleware/errorHandler"; import { ERROR_CODES } from "../../utils/errorCodes"; @@ -14,9 +15,18 @@ interface UpdateAdoptionInput { adopted_at?: string; } +interface ListAdoptionsFilters { + page?: number; + limit?: number; + fob_id?: string; + adopter_id?: number; + adopter?: string; + year?: number; +} + const assertValidId = (id: number) => { if (!Number.isInteger(id) || id <= 0) { - throw new AppError(400, ERROR_CODES.VAL_002, ERROR_CODES.VAL_002); + throw new AppError(400, "Invalid ID", ERROR_CODES.VAL_002); } }; @@ -35,18 +45,54 @@ const assertValidPagination = (page: number, limit: number) => { } }; -const assertValidDate = (date: string) => { - const parsedDate = new Date(date); +const assertValidYear = (year: number) => { + const currentYear = new Date().getFullYear(); + + if (!Number.isInteger(year) || year < 1900 || year > currentYear) { + throw new AppError(400, "Invalid year filter", ERROR_CODES.VAL_002); + } +}; + +const parseStrictDate = (date: string) => { + if (typeof date !== "string" || !date.trim()) { + throw new AppError(400, "adopted_at is required", ERROR_CODES.VAL_003); + } + + const trimmedDate = date.trim(); + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + + if (!dateRegex.test(trimmedDate)) { + throw new AppError( + 400, + "Adoption date must use YYYY-MM-DD format", + ERROR_CODES.VAL_002, + ); + } + + const parsedDate = new Date(`${trimmedDate}T00:00:00.000Z`); if (Number.isNaN(parsedDate.getTime())) { throw new AppError(400, "Invalid adoption date", ERROR_CODES.VAL_002); } - if (parsedDate > new Date()) { + const [year, month, day] = trimmedDate.split("-").map(Number); + + if ( + parsedDate.getUTCFullYear() !== year || + parsedDate.getUTCMonth() + 1 !== month || + parsedDate.getUTCDate() !== day + ) { + throw new AppError(400, "Invalid adoption date", ERROR_CODES.VAL_002); + } + + const today = new Date(); + today.setUTCHours(23, 59, 59, 999); + + if (parsedDate > today) { throw new AppError( 400, "Adoption date cannot be in the future", - ERROR_CODES.VAL_003, + ERROR_CODES.VAL_002, ); } @@ -54,17 +100,17 @@ const assertValidDate = (date: string) => { }; const assertCreatePayload = (data: CreateAdoptionInput) => { + if (data.adopter_id === undefined || data.adopter_id === null) { + throw new AppError(400, "adopter_id is required", ERROR_CODES.VAL_003); + } + assertValidId(Number(data.adopter_id)); if (!data.fob_id?.trim()) { - throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); - } - - if (!data.adopted_at) { - throw new AppError(400, ERROR_CODES.VAL_003, ERROR_CODES.VAL_003); + throw new AppError(400, "fob_id is required", ERROR_CODES.VAL_003); } - assertValidDate(data.adopted_at); + parseStrictDate(data.adopted_at); }; const assertUpdatePayload = (data: UpdateAdoptionInput) => { @@ -85,23 +131,75 @@ const assertUpdatePayload = (data: UpdateAdoptionInput) => { } if (data.adopted_at !== undefined) { - assertValidDate(data.adopted_at); + parseStrictDate(data.adopted_at); } }; export class AdoptionsService { - async listAdoptions(page = 1, limit = 10) { + async listAdoptions(filters: ListAdoptionsFilters = {}) { + const page = filters.page === undefined ? 1 : Number(filters.page); + + const limit = filters.limit === undefined ? 10 : Number(filters.limit); + assertValidPagination(page, limit); const skip = (page - 1) * limit; + const where: Prisma.AdoptionWhereInput = {}; + + if (filters.fob_id !== undefined) { + if (!filters.fob_id.trim()) { + throw new AppError(400, "Invalid fob_id filter", ERROR_CODES.VAL_002); + } + + where.fobId = { + contains: filters.fob_id.trim(), + mode: "insensitive", + }; + } + + if (filters.adopter_id !== undefined) { + assertValidId(Number(filters.adopter_id)); + + where.adopterId = Number(filters.adopter_id); + } + + if (filters.adopter !== undefined) { + if (!filters.adopter.trim()) { + throw new AppError(400, "Invalid adopter filter", ERROR_CODES.VAL_002); + } + + where.adopter = { + name: { + contains: filters.adopter.trim(), + mode: "insensitive", + }, + }; + } + + if (filters.year !== undefined) { + const year = Number(filters.year); + + assertValidYear(year); + + where.adoptedAt = { + gte: new Date(`${year}-01-01T00:00:00.000Z`), + lte: new Date(`${year}-12-31T23:59:59.999Z`), + }; + } + const [data, total] = await Promise.all([ prisma.adoption.findMany({ + where, skip, take: limit, orderBy: { id: "desc" }, + include: { + adopter: true, + }, }), - prisma.adoption.count(), + + prisma.adoption.count({ where }), ]); return { @@ -129,7 +227,7 @@ export class AdoptionsService { data: { adopterId: Number(data.adopter_id), fobId: data.fob_id.trim(), - adoptedAt: assertValidDate(data.adopted_at), + adoptedAt: parseStrictDate(data.adopted_at), }, }); } @@ -139,6 +237,9 @@ export class AdoptionsService { const adoption = await prisma.adoption.findUnique({ where: { id }, + include: { + adopter: true, + }, }); if (!adoption) { @@ -150,6 +251,7 @@ export class AdoptionsService { async updateAdoption(id: number, data: UpdateAdoptionInput) { assertValidId(id); + assertUpdatePayload(data); await this.getAdoptionById(id); @@ -166,13 +268,16 @@ export class AdoptionsService { return prisma.adoption.update({ where: { id }, + data: { ...(data.adopter_id !== undefined ? { adopterId: Number(data.adopter_id) } : {}), + ...(data.fob_id !== undefined ? { fobId: data.fob_id.trim() } : {}), + ...(data.adopted_at !== undefined - ? { adoptedAt: assertValidDate(data.adopted_at) } + ? { adoptedAt: parseStrictDate(data.adopted_at) } : {}), }, }); @@ -195,4 +300,4 @@ export class AdoptionsService { export const adoptionsService = new AdoptionsService(); -export type { CreateAdoptionInput, UpdateAdoptionInput }; +export type { CreateAdoptionInput, UpdateAdoptionInput, ListAdoptionsFilters }; diff --git a/tests/integration/adoptions.test.ts b/tests/integration/adoptions.test.ts index b87c13d..c3bbf21 100644 --- a/tests/integration/adoptions.test.ts +++ b/tests/integration/adoptions.test.ts @@ -1,7 +1,7 @@ process.env.NODE_ENV = "development"; process.env.AUTH_DEV_MODE = "true"; -import { describe, expect, it, beforeAll } from "@jest/globals"; +import { describe, expect, it, beforeAll, afterAll } from "@jest/globals"; import request from "supertest"; import app from "../../src/app"; @@ -15,6 +15,7 @@ const TOKENS = { describe("Adoptions API Integration Tests", () => { let adopterId: number; let adoptionId: number; + const cleanupAdoptionIds: number[] = []; beforeAll(async () => { const adopter = await request(app) @@ -23,12 +24,29 @@ describe("Adoptions API Integration Tests", () => { .set("Content-Type", "application/json") .send({ name: "Integration Adopter", - email: "integration@gmail.com", + email: `integration-${Date.now()}@gmail.com`, }); + expect(adopter.status).toBe(201); + expect(adopter.body).toHaveProperty("data.id"); + adopterId = adopter.body.data.id; }); + afterAll(async () => { + for (const id of cleanupAdoptionIds) { + await request(app) + .delete(`/adoptions/${id}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + } + + if (adopterId) { + await request(app) + .delete(`/adopters/${adopterId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + } + }); + it("POST /adoptions - should create adoption", async () => { const res = await request(app) .post("/adoptions") @@ -42,14 +60,17 @@ describe("Adoptions API Integration Tests", () => { expect(res.status).toBe(201); expect(res.body).toHaveProperty("data.id"); + expect(res.body.data.fobId).toBe("NFC-001"); adoptionId = res.body.data.id; + cleanupAdoptionIds.push(adoptionId); }); it("POST /adoptions - should return 400 when fob_id missing", async () => { const res = await request(app) .post("/adoptions") .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") .send({ adopter_id: adopterId, adopted_at: "2026-05-14", @@ -58,12 +79,82 @@ describe("Adoptions API Integration Tests", () => { expect(res.status).toBe(400); }); + it("POST /adoptions - should return 400 for invalid adopted_at format", async () => { + const res = await request(app) + .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") + .send({ + adopter_id: adopterId, + fob_id: "NFC-BAD-DATE", + adopted_at: "14-05-2026", + }); + + expect(res.status).toBe(400); + }); + it("GET /adoptions - should return list", async () => { const res = await request(app) .get("/adoptions?page=1&limit=10") .set("Authorization", `Bearer ${TOKENS.ADMIN}`); expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toHaveProperty("data"); + expect(res.body.data).toHaveProperty("meta"); + }); + + it("GET /adoptions - should filter by fob_id", async () => { + const res = await request(app) + .get("/adoptions?fob_id=NFC-001") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.data.length).toBeGreaterThanOrEqual(1); + }); + + it("GET /adoptions - should filter by adopter_id", async () => { + const res = await request(app) + .get(`/adoptions?adopter_id=${adopterId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.data.length).toBeGreaterThanOrEqual(1); + }); + + it("GET /adoptions - should filter by adopter name", async () => { + const res = await request(app) + .get("/adoptions?adopter=Integration") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.data.length).toBeGreaterThanOrEqual(1); + }); + + it("GET /adoptions - should filter by year", async () => { + const res = await request(app) + .get("/adoptions?year=2026") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.data.length).toBeGreaterThanOrEqual(1); + }); + + it("GET /adoptions - should return 400 for invalid page query", async () => { + const res = await request(app) + .get("/adoptions?page=abc") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(400); + }); + + it("GET /adoptions/:id - should return adoption by id", async () => { + const res = await request(app) + .get(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(res.status).toBe(200); + expect(res.body.data.id).toBe(adoptionId); }); it("GET /adoptions/:id - should return 404", async () => { @@ -78,69 +169,156 @@ describe("Adoptions API Integration Tests", () => { const res = await request(app) .put(`/adoptions/${adoptionId}`) .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") .send({ fob_id: "NFC-UPDATED", }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); + expect(res.body.data.fobId).toBe("NFC-UPDATED"); }); it("DELETE /adoptions/:id - should delete adoption", async () => { const created = await request(app) .post("/adoptions") .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .set("Content-Type", "application/json") .send({ adopter_id: adopterId, fob_id: "NFC-DELETE", adopted_at: "2026-05-14", }); + expect(created.status).toBe(201); + expect(created.body).toHaveProperty("data.id"); + const res = await request(app) .delete(`/adoptions/${created.body.data.id}`) .set("Authorization", `Bearer ${TOKENS.ADMIN}`); expect(res.status).toBe(200); + + const checkDeleted = await request(app) + .get(`/adoptions/${created.body.data.id}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(checkDeleted.status).toBe(404); }); it("POST /adoptions - should return 401 when no token", async () => { + const res = await request(app).post("/adoptions").send({ + adopter_id: adopterId, + fob_id: "NFC-NO-TOKEN", + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(401); + }); + + it("GET /adoptions - should return 401 when no token", async () => { + const res = await request(app).get("/adoptions"); + + expect(res.status).toBe(401); + }); + + it("GET /adoptions/:id - should return 401 when no token", async () => { + const res = await request(app).get(`/adoptions/${adoptionId}`); + + expect(res.status).toBe(401); + }); + + it("PUT /adoptions/:id - should return 401 when no token", async () => { + const res = await request(app).put(`/adoptions/${adoptionId}`).send({ + fob_id: "NFC-NO-TOKEN-UPDATE", + }); + + expect(res.status).toBe(401); + }); + + it("DELETE /adoptions/:id - should return 401 when no token", async () => { + const res = await request(app).delete(`/adoptions/${adoptionId}`); + + expect(res.status).toBe(401); + }); + + it("GET /adoptions - MANAGER should access list", async () => { + const res = await request(app) + .get("/adoptions") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(200); + }); + + it("GET /adoptions/:id - MANAGER should access details", async () => { + const res = await request(app) + .get(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(200); + }); + + it("POST /adoptions - MANAGER should return 403", async () => { const res = await request(app) .post("/adoptions") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) .send({ adopter_id: adopterId, - fob_id: "NFC-001", + fob_id: "NFC-MANAGER", adopted_at: "2026-05-14", }); - expect(res.status).toBe(401); + expect(res.status).toBe(403); }); - it("POST /adoptions - should return 403 for FARMER", async () => { + it("PUT /adoptions/:id - MANAGER should return 403", async () => { const res = await request(app) - .post("/adoptions") - .set("Authorization", `Bearer ${TOKENS.FARMER}`) + .put(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) .send({ - adopter_id: adopterId, - fob_id: "NFC-001", - adopted_at: "2026-05-14", + fob_id: "NFC-MANAGER-UPDATE", }); expect(res.status).toBe(403); }); - it("GET /adoptions - MANAGER should access list", async () => { + it("DELETE /adoptions/:id - MANAGER should return 403", async () => { const res = await request(app) - .get("/adoptions") + .delete(`/adoptions/${adoptionId}`) .set("Authorization", `Bearer ${TOKENS.MANAGER}`); - expect(res.status).toBe(200); + expect(res.status).toBe(403); }); - it("DELETE cleanup created adoption", async () => { + it("GET /adoptions - INSPECTOR should return 403", async () => { const res = await request(app) - .delete(`/adoptions/${adoptionId}`) - .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + .get("/adoptions") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); - expect(res.status).toBe(200); + expect(res.status).toBe(403); + }); + + it("GET /adoptions/:id - INSPECTOR should return 403", async () => { + const res = await request(app) + .get(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(res.status).toBe(403); + }); + + it("GET /adoptions - FARMER should return 403", async () => { + const res = await request(app) + .get("/adoptions") + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(res.status).toBe(403); + }); + + it("GET /adoptions/:id - FARMER should return 403", async () => { + const res = await request(app) + .get(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(res.status).toBe(403); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/adoptions.test.ts b/tests/unit/adoptions.test.ts index d564cc0..72b6f03 100644 --- a/tests/unit/adoptions.test.ts +++ b/tests/unit/adoptions.test.ts @@ -121,18 +121,87 @@ describe("AdoptionsService - Unit Tests", () => { mockedPrismaAdoption.count.mockResolvedValue(1); - const result = await adoptionsService.listAdoptions(1, 10); + const result = await adoptionsService.listAdoptions({ + page: 1, + limit: 10, + }); expect(result.data.length).toBe(1); expect(result.meta.total).toBe(1); expect(mockedPrismaAdoption.findMany).toHaveBeenCalled(); }); - it("should throw 400 for invalid pagination", async () => { - await expect(adoptionsService.listAdoptions(0, 10)).rejects.toThrow( - AppError, + it("should apply fob_id filter", async () => { + mockedPrismaAdoption.findMany.mockResolvedValue([]); + mockedPrismaAdoption.count.mockResolvedValue(0); + + await adoptionsService.listAdoptions({ + page: 1, + limit: 10, + fob_id: "NFC-001", + }); + + expect(mockedPrismaAdoption.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + fobId: { + contains: "NFC-001", + mode: "insensitive", + }, + }), + }), ); }); + + it("should apply adopter_id filter", async () => { + mockedPrismaAdoption.findMany.mockResolvedValue([]); + mockedPrismaAdoption.count.mockResolvedValue(0); + + await adoptionsService.listAdoptions({ + page: 1, + limit: 10, + adopter_id: 1, + }); + + expect(mockedPrismaAdoption.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + adopterId: 1, + }), + }), + ); + }); + + it("should apply year filter", async () => { + mockedPrismaAdoption.findMany.mockResolvedValue([]); + mockedPrismaAdoption.count.mockResolvedValue(0); + + await adoptionsService.listAdoptions({ + page: 1, + limit: 10, + year: 2026, + }); + + expect(mockedPrismaAdoption.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + adoptedAt: expect.objectContaining({ + gte: expect.any(Date), + lte: expect.any(Date), + }), + }), + }), + ); + }); + + it("should throw 400 for invalid pagination", async () => { + await expect( + adoptionsService.listAdoptions({ + page: 0, + limit: 10, + }), + ).rejects.toThrow(AppError); + }); }); describe("getAdoptionById", () => { @@ -153,9 +222,9 @@ describe("AdoptionsService - Unit Tests", () => { it("should throw 404 when adoption not found", async () => { mockedPrismaAdoption.findUnique.mockResolvedValue(null); - await expect( - adoptionsService.getAdoptionById(999), - ).rejects.toThrow(AppError); + await expect(adoptionsService.getAdoptionById(999)).rejects.toThrow( + AppError, + ); }); it("should throw 400 for invalid id", async () => { @@ -247,9 +316,9 @@ describe("AdoptionsService - Unit Tests", () => { it("should throw 404 when deleting non-existing adoption", async () => { mockedPrismaAdoption.findUnique.mockResolvedValue(null); - await expect( - adoptionsService.deleteAdoption(999), - ).rejects.toThrow(AppError); + await expect(adoptionsService.deleteAdoption(999)).rejects.toThrow( + AppError, + ); }); }); -}); \ No newline at end of file +});