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..5742de1 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, + 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 }); + } catch (error) { + return next(error); + } + } + + async listAdoptions(req: Request, res: Response, next: NextFunction) { + try { + 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); + } + } + + 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(); diff --git a/src/modules/adoptions/adoptions.routes.ts b/src/modules/adoptions/adoptions.routes.ts index e69de29..ca93637 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; diff --git a/src/modules/adoptions/adoptions.service.ts b/src/modules/adoptions/adoptions.service.ts index e69de29..1a38e9e 100644 --- a/src/modules/adoptions/adoptions.service.ts +++ b/src/modules/adoptions/adoptions.service.ts @@ -0,0 +1,303 @@ +import { Prisma } from "@prisma/client"; +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; +} + +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, "Invalid ID", 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 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); + } + + 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_002, + ); + } + + return parsedDate; +}; + +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, "fob_id is required", ERROR_CODES.VAL_003); + } + + parseStrictDate(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) { + parseStrictDate(data.adopted_at); + } +}; + +export class AdoptionsService { + 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({ where }), + ]); + + 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: parseStrictDate(data.adopted_at), + }, + }); + } + + async getAdoptionById(id: number) { + assertValidId(id); + + const adoption = await prisma.adoption.findUnique({ + where: { id }, + include: { + adopter: true, + }, + }); + + 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: parseStrictDate(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, ListAdoptionsFilters }; diff --git a/src/modules/adoptions/index.ts b/src/modules/adoptions/index.ts index e69de29..2a0de59 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"; 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..c3bbf21 100644 --- a/tests/integration/adoptions.test.ts +++ b/tests/integration/adoptions.test.ts @@ -1,5 +1,324 @@ -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, afterAll } 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; + const cleanupAdoptionIds: 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-${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") + .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"); + 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", + }); + + 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 () => { + 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}`) + .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-MANAGER", + adopted_at: "2026-05-14", + }); + + expect(res.status).toBe(403); + }); + + it("PUT /adoptions/:id - MANAGER should return 403", async () => { + const res = await request(app) + .put(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) + .send({ + fob_id: "NFC-MANAGER-UPDATE", + }); + + expect(res.status).toBe(403); + }); + + it("DELETE /adoptions/:id - MANAGER should return 403", async () => { + const res = await request(app) + .delete(`/adoptions/${adoptionId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(res.status).toBe(403); + }); + + it("GET /adoptions - INSPECTOR should return 403", async () => { + const res = await request(app) + .get("/adoptions") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + 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); }); }); diff --git a/tests/unit/adoptions.test.ts b/tests/unit/adoptions.test.ts index 73e2381..72b6f03 100644 --- a/tests/unit/adoptions.test.ts +++ b/tests/unit/adoptions.test.ts @@ -1,5 +1,324 @@ -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({ + page: 1, + limit: 10, + }); + + expect(result.data.length).toBe(1); + expect(result.meta.total).toBe(1); + expect(mockedPrismaAdoption.findMany).toHaveBeenCalled(); + }); + + 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", () => { + 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, + ); + }); }); });