diff --git a/docs/API.md b/docs/API.md index b7f83c1..9b9c4b2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3489,4 +3489,416 @@ The Adoptions API follows the TreeO2 backend engineering standard: - Prisma-backed data access - Swagger documentation - Automated tests -- Scalable structure for future enhancements \ No newline at end of file +- Scalable structure for future enhancements + +--- +## 17. Scan Batches API + +This module manages scan batch upload and retrieval operations across the TreeO2 platform. It provides scan batch creation, pagination, project-scoped access control, validation, deletion protection, Swagger documentation, and automated testing coverage. + +**Module Path:** `src/modules/scan-batches/` + +### Files +- `scanBatches.routes.ts` +- `scanBatches.controller.ts` +- `scanBatches.service.ts` +- `scan-batches.schema.ts` +- `scan-batches.constants.ts` +- `scan-batches.docs.ts` +- `index.ts` + +--- + +## 17.1 Purpose + +The Scan Batches API is responsible for managing grouped tree scan uploads in the system. + +Scan batches are operational upload containers used for: +- Grouping uploaded tree scans +- Managing inspector uploads +- Tracking project-based scan submissions +- Validating relationships between inspectors, projects, farmers, and species +- Enforcing project-scoped access control +- Preventing deletion of batches linked to existing tree scans + +--- + +## 17.2 Architecture Flow + +Every request follows the standard backend module structure: + +```text +Route → Validation Schema → Controller → Service → Prisma ORM → PostgreSQL → Response +``` + +### Responsibilities + +### Routes +- Define endpoints +- Apply authentication middleware +- Apply role-based authorization +- Register Swagger documentation + +### Controller +- Parse request params/query/body +- Validate authenticated user context +- Pass validated data to service layer +- Return structured HTTP responses + +### Service +- Perform business validation +- Validate project relationships +- Apply access control rules +- Execute Prisma database operations +- Handle transactional batch creation +- Prevent invalid delete operations +- Throw structured application errors + +### Schemas +- Validate request body +- Validate query parameters +- Validate path parameters +- Enforce numeric/date validation rules +- Validate coordinates and measurements + +--- + +## 17.3 Security + +All endpoints are protected using Bearer Token authentication. + +### Middleware Used +- `authMiddleware` +- `roleMiddleware` + +### Service-Level Access Control + +- `ADMIN` can access all scan batches +- `MANAGER` can access batches from assigned projects only +- `INSPECTOR` can upload and access only their own batches + +--- + +## 17.4 Access Control Matrix + +| Endpoint | ADMIN | MANAGER | INSPECTOR | FARMER | DEVELOPER | +|---|---|---|---|---|---| +| GET /scan-batches | Yes | Yes (assigned projects only) | Yes (own batches only) | No | No | +| GET /scan-batches/{id} | Yes | Yes (assigned projects only) | Yes (own batches only) | No | No | +| POST /scan-batches | No | No | Yes | No | No | +| DELETE /scan-batches/{id} | Yes | No | No | No | No | + +--- + +## 17.5 Endpoints + +### GET /scan-batches + +Retrieve paginated scan batches with optional filtering. + +#### Query Parameters + +| Name | Type | Required | +|---|---|---| +| page | integer | No | +| limit | integer | No | +| project_id | integer | No | +| inspector_id | integer | No | + +#### Response + +```json +{ + "success": true, + "message": "Scan batches fetched successfully", + "data": [ + { + "id": 1, + "inspectorId": 4, + "projectId": 1, + "uploadedAt": "2024-05-20T10:35:00.000Z" + } + ], + "pagination": { + "page": 1, + "limit": 20, + "total": 1, + "totalPages": 1 + } +} +``` + +#### Status Codes +- `200` Success +- `400` Invalid query parameters +- `401` Authentication required +- `403` Insufficient permissions + +--- + +### GET /scan-batches/{id} + +Retrieve a single scan batch by ID. + +#### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +#### Response + +```json +{ + "success": true, + "message": "Scan batch fetched successfully", + "data": { + "id": 1, + "inspectorId": 4, + "projectId": 1, + "uploadedAt": "2024-05-20T10:35:00.000Z" + } +} +``` + +#### Status Codes +- `200` Success +- `400` Invalid scan batch ID +- `401` Authentication required +- `403` Insufficient permissions +- `404` Scan batch not found + +--- + +### POST /scan-batches + +Create a new scan batch and associate uploaded tree scans. + +#### Request Body + +```json +{ + "project_id": 1, + "uploaded_at": "2024-05-20T10:35:00.000Z", + "scans": [ + { + "fob_id": "SWAGGER-001", + "farmer_id": 16, + "species_id": 1, + "estimated_planted_year": 2024, + "estimated_planted_month": 5, + "planted_date": "2024-05-20", + "height_m": 2.5, + "circumference_cm": 45.3, + "diameter_cm": 14.4, + "latitude": -8.5569, + "longitude": 125.5603, + "device_id": "MOB-001" + } + ] +} +``` + +#### Required Fields +- `project_id` +- `scans` +- `fob_id` +- `farmer_id` +- `species_id` +- `estimated_planted_year` +- `estimated_planted_month` + +#### Response + +```json +{ + "success": true, + "message": "Scan batch uploaded successfully", + "data": { + "id": 1, + "inspectorId": 4, + "projectId": 1 + } +} +``` + +#### Status Codes +- `201` Created +- `400` Validation failed +- `401` Authentication required +- `403` Insufficient permissions +- `404` Related entity not found +- `422` Invalid project or measurement values + +--- + +### DELETE /scan-batches/{id} + +Delete a scan batch if it contains no related tree scans. + +#### Path Parameters + +| Name | Type | Required | +|---|---|---| +| id | integer | Yes | + +#### Response + +```json +{ + "success": true, + "message": "Scan batch deleted successfully" +} +``` + +#### Status Codes +- `200` Success +- `401` Authentication required +- `403` Insufficient permissions +- `404` Scan batch not found +- `409` Scan batch contains related tree scans + +--- + +## 17.6 Validation Rules + +### Create Validation +- Project ID must be positive integer +- Inspector must exist +- Inspector must have INSPECTOR role +- Inspector account must be active +- Project must exist +- Project must be active +- Inspector must belong to project +- Farmer must exist +- Farmer must belong to project +- Species must exist +- Species must belong to project +- Height must not exceed allowed limit +- Diameter must not exceed allowed limit +- Circumference must not exceed allowed limit +- Coordinates must be valid +- Month must be between 1 and 12 + +### Access Control Validation +- Managers can access batches from assigned projects only +- Inspectors can access only their own batches +- Only inspectors can upload batches +- Only admins can delete batches + +### Delete Validation +- Batch must exist +- Batch cannot contain related tree scans + +--- + +## 17.7 Error Handling + +Uses centralized error middleware. + +### Standard Error Response + +```json +{ + "success": false, + "message": "Scan batch not found" +} +``` + +### Common Errors +- Authentication required +- Insufficient permissions +- Invalid scan batch ID +- Invalid coordinates +- Invalid measurement values +- Project inactive +- Inspector not assigned to project +- Farmer not assigned to project +- Species not assigned to project +- Scan batch not found +- Scan batch contains related tree scans + +--- + +## 17.8 Swagger Documentation + +All endpoints are documented in: + +```text +scan-batches.docs.ts +``` + +Available at: + +```text +http://localhost:3000/api-docs +``` + +### Swagger Supports +- Interactive endpoint testing +- Request examples +- Response schemas +- Security schemas +- Query parameter documentation + +--- + +## 17.9 Testing + +### Test Files +- `tests/unit/scan-batches.test.ts` +- `tests/integration/scan-batches.test.ts` + +### Covered Scenarios + +#### Authentication +- No token returns `401` + +#### Authorization +- Admin access validation +- Manager assigned-project restrictions +- Inspector own-batch restrictions +- Inspector-only upload validation +- Admin-only delete validation + +#### Read +- Get all scan batches +- Get scan batch by ID +- Pagination validation +- Filtering validation +- Missing batch returns `404` + +#### Create +- Valid scan batch upload +- Invalid role rejection +- Inactive inspector rejection +- Inactive project rejection +- Invalid coordinates rejection +- Invalid measurement rejection +- Unassigned farmer rejection +- Unassigned inspector rejection +- Unassigned species rejection +- Multi-scan validation + +#### Delete +- Valid delete succeeds +- Delete blocked when tree scans exist +- Missing scan batch rejected + +--- + +## 17.10 Summary + +The Scan Batches API follows the TreeO2 backend engineering standard: + +- Modular backend architecture +- Secure authentication +- Role-based access control +- Project-scoped authorization +- Strong validation rules +- Relationship integrity validation +- Protected delete operations +- Swagger documentation +- Automated unit testing +- Automated integration testing +- Scalable backend structure \ No newline at end of file diff --git a/src/modules/scan-batches/index.ts b/src/modules/scan-batches/index.ts index e69de29..c58607c 100644 --- a/src/modules/scan-batches/index.ts +++ b/src/modules/scan-batches/index.ts @@ -0,0 +1,9 @@ +// scan-batches/index.ts + +export * from "./scan-batches.constants"; +export * from "./scan-batches.schema"; + +export * from "./scanBatches.controller"; +export * from "./scanBatches.service"; + +export { default as scanBatchesRoutes } from "./scanBatches.routes"; diff --git a/src/modules/scan-batches/scan-batches.constants.ts b/src/modules/scan-batches/scan-batches.constants.ts new file mode 100644 index 0000000..5a17d77 --- /dev/null +++ b/src/modules/scan-batches/scan-batches.constants.ts @@ -0,0 +1,93 @@ +export const SCAN_BATCHES_MESSAGES = { + FETCHED: "Scan batches fetched successfully", + FETCHED_ONE: "Scan batch fetched successfully", + CREATED: "Scan batch uploaded successfully", + DELETED: "Scan batch deleted successfully", + + NOT_FOUND: "Scan batch not found", + INVALID_ID: "Invalid scan batch ID", + + CREATE_FAILED: "Failed to create scan batch", + DELETE_FAILED: "Failed to delete scan batch", + + INVALID_SCANS_ARRAY: "Scans must be provided as a non-empty array", + + INSPECTOR_REQUIRED: "Inspector ID is required", + PROJECT_REQUIRED: "Project ID is required", + + INSPECTOR_NOT_FOUND: "Inspector not found", + PROJECT_NOT_FOUND: "Project not found", + PROJECT_INACTIVE: "Project is not active and cannot accept scan uploads", + + INVALID_INSPECTOR_ROLE: "User must have Inspector role", + INVALID_FARMER_ROLE: "Selected farmer_id must belong to a Farmer user", + + FARMER_NOT_FOUND: "Farmer not found", + FARMER_NOT_ASSIGNED: "Farmer is not assigned to the selected project", + + SPECIES_NOT_FOUND: "Tree species not found", + SPECIES_NOT_IN_PROJECT: "Tree species is not assigned to this project", + + INSPECTOR_NOT_ASSIGNED: "Inspector is not assigned to the selected project", + + UNAUTHORIZED_ACCESS: "You do not have permission to access this scan batch", + ADMIN_DELETE_ONLY: "Only Admin users can delete scan batches", + + INVALID_PLANTED_DATE: "Planted date cannot be in the future", + INVALID_PLANTED_YEAR: + "Estimated planted year must be between 1950 and the current year", + INVALID_PLANTED_MONTH: "Estimated planted month must be between 1 and 12", + INVALID_MEASUREMENT: "Tree measurement value is outside the allowed range", + + DELETE_BLOCKED_HAS_SCANS: + "Scan batch cannot be deleted because it has related tree scans", +} as const; + +export const SCAN_BATCHES_ERRORS = { + VALIDATION_ERROR: "VALIDATION_ERROR", + NOT_FOUND: "SCAN_BATCH_NOT_FOUND", + FORBIDDEN: "SCAN_BATCH_FORBIDDEN", + CREATE_FAILED: "SCAN_BATCH_CREATE_FAILED", + DELETE_FAILED: "SCAN_BATCH_DELETE_FAILED", + DELETE_BLOCKED: "SCAN_BATCH_DELETE_BLOCKED", + + PROJECT_INACTIVE: "PROJECT_INACTIVE", + INVALID_ROLE: "INVALID_ROLE", + NOT_ASSIGNED: "NOT_ASSIGNED_TO_PROJECT", + SPECIES_NOT_IN_PROJECT: "SPECIES_NOT_IN_PROJECT", + INVALID_DATE: "INVALID_DATE", + INVALID_MEASUREMENT: "INVALID_MEASUREMENT", +} as const; + +export const SCAN_BATCHES_DEFAULTS = { + PAGE: 1, + LIMIT: 20, + MAX_LIMIT: 100, +} as const; + +export const SCAN_BATCHES_AUTH_ROLES = { + ADMIN: "ADMIN", + MANAGER: "MANAGER", + INSPECTOR: "INSPECTOR", + FARMER: "FARMER", +} as const; + +export const SCAN_BATCHES_DB_ROLES = { + ADMIN: "Admin", + MANAGER: "Manager", + INSPECTOR: "Inspector", + FARMER: "Farmer", +} as const; + +export const SCAN_BATCHES_LIMITS = { + MAX_SCANS_PER_BATCH: 500, + + MIN_PLANTED_YEAR: 1950, + + MAX_HEIGHT_M: 100, + MAX_DIAMETER_CM: 1000, + MAX_CIRCUMFERENCE_CM: 4000, + + FOB_ID_MAX_LENGTH: 80, + DEVICE_ID_MAX_LENGTH: 100, +} as const; diff --git a/src/modules/scan-batches/scan-batches.docs.ts b/src/modules/scan-batches/scan-batches.docs.ts new file mode 100644 index 0000000..f1b030d --- /dev/null +++ b/src/modules/scan-batches/scan-batches.docs.ts @@ -0,0 +1,241 @@ +/** + * @swagger + * tags: + * name: Scan Batches + * description: Scan batch upload and management endpoints + */ + +/** + * @swagger + * /scan-batches: + * get: + * summary: Retrieve scan batches + * description: Admin can view all batches. Managers can view batches for assigned projects. Inspectors can view only their own batches. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * example: 1 + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * example: 20 + * - in: query + * name: project_id + * schema: + * type: integer + * example: 1 + * - in: query + * name: inspector_id + * schema: + * type: integer + * example: 2 + * responses: + * 200: + * description: Scan batches fetched successfully + * 400: + * description: Invalid query parameters + * 401: + * description: Authentication required + * 403: + * description: Insufficient permissions + */ + +/** + * @swagger + * /scan-batches/{id}: + * get: + * summary: Retrieve a scan batch by ID + * description: Admin can view any batch. Managers can view batches from assigned projects. Inspectors can view only their own batches. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 1 + * responses: + * 200: + * description: Scan batch fetched successfully + * 400: + * description: Invalid scan batch ID + * 401: + * description: Authentication required + * 403: + * description: You do not have permission to access this scan batch + * 404: + * description: Scan batch not found + */ + +/** + * @swagger + * /scan-batches: + * post: + * summary: Upload a new scan batch + * description: Inspector-only endpoint. Creates one scan batch and associates all submitted tree scans with that batch. All scans must belong to the same inspector and project. Fob recycling is not automatically applied. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - project_id + * - scans + * properties: + * project_id: + * type: integer + * minimum: 1 + * example: 1 + * uploaded_at: + * type: string + * format: date-time + * example: 2024-05-20T10:35:00.000Z + * scans: + * type: array + * minItems: 1 + * maxItems: 500 + * items: + * type: object + * required: + * - fob_id + * - farmer_id + * - species_id + * - estimated_planted_year + * - estimated_planted_month + * properties: + * fob_id: + * type: string + * maxLength: 80 + * example: NFC-001 + * farmer_id: + * type: integer + * minimum: 1 + * example: 10 + * species_id: + * type: integer + * minimum: 1 + * example: 2 + * estimated_planted_year: + * type: integer + * minimum: 1950 + * example: 2024 + * estimated_planted_month: + * type: integer + * minimum: 1 + * maximum: 12 + * example: 5 + * planted_date: + * type: string + * format: date + * example: 2024-05-20 + * height_m: + * type: number + * minimum: 0 + * maximum: 100 + * example: 2.5 + * circumference_cm: + * type: number + * minimum: 0 + * maximum: 4000 + * example: 45.3 + * diameter_cm: + * type: number + * minimum: 0 + * maximum: 1000 + * example: 14.4 + * latitude: + * type: number + * minimum: -90 + * maximum: 90 + * example: -8.5569 + * longitude: + * type: number + * minimum: -180 + * maximum: 180 + * example: 125.5603 + * device_id: + * type: string + * maxLength: 100 + * example: MOB-001 + * photo_id: + * type: string + * format: uuid + * example: 550e8400-e29b-41d4-a716-446655440000 + * example: + * project_id: 1 + * uploaded_at: 2024-05-20T10:35:00.000Z + * scans: + * - fob_id: NFC-001 + * farmer_id: 10 + * species_id: 2 + * estimated_planted_year: 2024 + * estimated_planted_month: 5 + * planted_date: 2024-05-20 + * height_m: 2.5 + * circumference_cm: 45.3 + * diameter_cm: 14.4 + * latitude: -8.5569 + * longitude: 125.5603 + * device_id: MOB-001 + * responses: + * 201: + * description: Scan batch uploaded successfully + * 400: + * description: Validation failed + * 401: + * description: Authentication required + * 403: + * description: User is not allowed to upload this scan batch + * 404: + * description: Inspector, project, farmer, or species not found + * 422: + * description: Business rule validation failed, such as inactive project, farmer not assigned, species not assigned to project, or invalid measurement/date values + */ + +/** + * @swagger + * /scan-batches/{id}: + * delete: + * summary: Delete a scan batch + * description: Admin-only endpoint. A scan batch cannot be deleted if it has related tree scans. This protects historical scan data. + * tags: [Scan Batches] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * minimum: 1 + * example: 1 + * responses: + * 200: + * description: Scan batch deleted successfully + * 400: + * description: Invalid scan batch ID + * 401: + * description: Authentication required + * 403: + * description: Only Admin users can delete scan batches + * 404: + * description: Scan batch not found + * 409: + * description: Scan batch cannot be deleted because it has related tree scans + */ diff --git a/src/modules/scan-batches/scan-batches.schema.ts b/src/modules/scan-batches/scan-batches.schema.ts new file mode 100644 index 0000000..5b61601 --- /dev/null +++ b/src/modules/scan-batches/scan-batches.schema.ts @@ -0,0 +1,130 @@ +import { z } from "zod"; +import { + SCAN_BATCHES_DEFAULTS, + SCAN_BATCHES_LIMITS, + SCAN_BATCHES_MESSAGES, +} from "./scan-batches.constants"; + +const currentYear = new Date().getFullYear(); + +const futureDateValidator = (value: string | Date): boolean => { + const parsedDate = value instanceof Date ? value : new Date(value); + return ( + !Number.isNaN(parsedDate.getTime()) && parsedDate.getTime() <= Date.now() + ); +}; + +const optionalPositiveNumber = (max: number) => + z.coerce + .number() + .positive() + .max(max, SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT) + .optional() + .nullable(); + +const scanSchema = z.object({ + fob_id: z + .string() + .trim() + .min(1, "fob_id is required") + .max(SCAN_BATCHES_LIMITS.FOB_ID_MAX_LENGTH), + + farmer_id: z.coerce + .number() + .int() + .positive("farmer_id must be a positive integer"), + + species_id: z.coerce + .number() + .int() + .positive("species_id must be a positive integer"), + + estimated_planted_year: z.coerce + .number() + .int() + .min( + SCAN_BATCHES_LIMITS.MIN_PLANTED_YEAR, + SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR, + ) + .max(currentYear, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_YEAR), + + estimated_planted_month: z.coerce + .number() + .int() + .min(1, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_MONTH) + .max(12, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_MONTH), + + planted_date: z.coerce + .date() + .refine(futureDateValidator, SCAN_BATCHES_MESSAGES.INVALID_PLANTED_DATE) + .optional() + .nullable(), + + height_m: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_HEIGHT_M), + + diameter_cm: optionalPositiveNumber(SCAN_BATCHES_LIMITS.MAX_DIAMETER_CM), + + circumference_cm: optionalPositiveNumber( + SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM, + ), + + latitude: z.coerce.number().min(-90).max(90).optional().nullable(), + + longitude: z.coerce.number().min(-180).max(180).optional().nullable(), + + photo_id: z + .string() + .uuid("photo_id must be a valid UUID") + .optional() + .nullable(), + + device_id: z + .string() + .trim() + .max(SCAN_BATCHES_LIMITS.DEVICE_ID_MAX_LENGTH) + .optional() + .nullable(), +}); + +export const createScanBatchSchema = z.object({ + project_id: z.coerce + .number() + .int() + .positive(SCAN_BATCHES_MESSAGES.PROJECT_REQUIRED), + + uploaded_at: z.coerce + .date() + .refine(futureDateValidator, "Uploaded date cannot be in the future") + .optional() + .nullable(), + + scans: z + .array(scanSchema) + .min(1, SCAN_BATCHES_MESSAGES.INVALID_SCANS_ARRAY) + .max(SCAN_BATCHES_LIMITS.MAX_SCANS_PER_BATCH), +}); + +export const getScanBatchesQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(SCAN_BATCHES_DEFAULTS.PAGE), + + limit: z.coerce + .number() + .int() + .positive() + .max(SCAN_BATCHES_DEFAULTS.MAX_LIMIT) + .default(SCAN_BATCHES_DEFAULTS.LIMIT), + + project_id: z.coerce.number().int().positive().optional(), + + inspector_id: z.coerce.number().int().positive().optional(), +}); + +export const scanBatchIdParamSchema = z.object({ + id: z.coerce.number().int().positive(SCAN_BATCHES_MESSAGES.INVALID_ID), +}); + +export type CreateScanBatchInput = z.infer; +export type GetScanBatchesQueryInput = z.infer< + typeof getScanBatchesQuerySchema +>; +export type ScanBatchIdParamInput = z.infer; diff --git a/src/modules/scan-batches/scanBatches.controller.ts b/src/modules/scan-batches/scanBatches.controller.ts index e69de29..96fdd40 100644 --- a/src/modules/scan-batches/scanBatches.controller.ts +++ b/src/modules/scan-batches/scanBatches.controller.ts @@ -0,0 +1,103 @@ +import { Request, Response, NextFunction } from "express"; +import { + createScanBatch, + deleteScanBatch, + getScanBatchById, + getScanBatches, +} from "./scanBatches.service"; + +import { + createScanBatchSchema, + getScanBatchesQuerySchema, + scanBatchIdParamSchema, +} from "./scan-batches.schema"; + +import { SCAN_BATCHES_MESSAGES } from "./scan-batches.constants"; + +const getCurrentUser = (req: Request) => ({ + id: Number(req.user?.sub), + role: req.user?.role ?? "", +}); + +// Handle request to fetch paginated scan batches +export const getScanBatchesController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const query = getScanBatchesQuerySchema.parse(req.query); + + const result = await getScanBatches(query, getCurrentUser(req)); + + res.status(200).json({ + success: true, + message: SCAN_BATCHES_MESSAGES.FETCHED, + ...result, + }); + } catch (error) { + next(error); + } +}; + +// Handle request to fetch a single scan batch by ID +export const getScanBatchByIdController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { id } = scanBatchIdParamSchema.parse(req.params); + + const scanBatch = await getScanBatchById(id, getCurrentUser(req)); + + res.status(200).json({ + success: true, + message: SCAN_BATCHES_MESSAGES.FETCHED_ONE, + data: scanBatch, + }); + } catch (error) { + next(error); + } +}; + +// Handle request to create a new scan batch with tree scans +export const createScanBatchController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const validatedData = createScanBatchSchema.parse(req.body); + + const scanBatch = await createScanBatch({ + ...validatedData, + inspector_id: getCurrentUser(req).id, + }); + + res.status(201).json({ + success: true, + message: SCAN_BATCHES_MESSAGES.CREATED, + data: scanBatch, + }); + } catch (error) { + next(error); + } +}; + +// Handle request to delete a scan batch +export const deleteScanBatchController = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { id } = scanBatchIdParamSchema.parse(req.params); + + const result = await deleteScanBatch(id); + + res.status(200).json(result); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/scan-batches/scanBatches.routes.ts b/src/modules/scan-batches/scanBatches.routes.ts index e69de29..a385b24 100644 --- a/src/modules/scan-batches/scanBatches.routes.ts +++ b/src/modules/scan-batches/scanBatches.routes.ts @@ -0,0 +1,62 @@ +import { Router } from "express"; +import { authMiddleware } from "../../middleware/auth.middleware"; +import { roleMiddleware } from "../../middleware/role.middleware"; + +import { + getScanBatchesController, + getScanBatchByIdController, + createScanBatchController, + deleteScanBatchController, +} from "./scanBatches.controller"; + +import { SCAN_BATCHES_AUTH_ROLES } from "./scan-batches.constants"; + +import "./scan-batches.docs"; + +const router = Router(); + +router.get( + "/", + authMiddleware, + roleMiddleware([ + SCAN_BATCHES_AUTH_ROLES.ADMIN, + SCAN_BATCHES_AUTH_ROLES.MANAGER, + SCAN_BATCHES_AUTH_ROLES.INSPECTOR, + ]), + (req, res, next) => { + void getScanBatchesController(req, res, next); + }, +); + +router.get( + "/:id", + authMiddleware, + roleMiddleware([ + SCAN_BATCHES_AUTH_ROLES.ADMIN, + SCAN_BATCHES_AUTH_ROLES.MANAGER, + SCAN_BATCHES_AUTH_ROLES.INSPECTOR, + ]), + (req, res, next) => { + void getScanBatchByIdController(req, res, next); + }, +); + +router.post( + "/", + authMiddleware, + roleMiddleware([SCAN_BATCHES_AUTH_ROLES.INSPECTOR]), + (req, res, next) => { + void createScanBatchController(req, res, next); + }, +); + +router.delete( + "/:id", + authMiddleware, + roleMiddleware([SCAN_BATCHES_AUTH_ROLES.ADMIN]), + (req, res, next) => { + void deleteScanBatchController(req, res, next); + }, +); + +export default router; diff --git a/src/modules/scan-batches/scanBatches.service.ts b/src/modules/scan-batches/scanBatches.service.ts index e69de29..af48c32 100644 --- a/src/modules/scan-batches/scanBatches.service.ts +++ b/src/modules/scan-batches/scanBatches.service.ts @@ -0,0 +1,426 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "../../lib/prisma"; +import { AppError } from "../../middleware/errorHandler"; +import { + CreateScanBatchInput, + GetScanBatchesQueryInput, +} from "./scan-batches.schema"; +import { + SCAN_BATCHES_AUTH_ROLES, + SCAN_BATCHES_DB_ROLES, + SCAN_BATCHES_DEFAULTS, + SCAN_BATCHES_ERRORS, + SCAN_BATCHES_LIMITS, + SCAN_BATCHES_MESSAGES, +} from "./scan-batches.constants"; + +interface CurrentUser { + id: number; + role: string; +} + +type CreateScanBatchServiceInput = CreateScanBatchInput & { + inspector_id: number; +}; + +// Fetch paginated scan batches with role-based access filtering +export const getScanBatches = async ( + query: GetScanBatchesQueryInput, + currentUser: CurrentUser, +) => { + const page = query.page || SCAN_BATCHES_DEFAULTS.PAGE; + const limit = query.limit || SCAN_BATCHES_DEFAULTS.LIMIT; + const skip = (page - 1) * limit; + + const where: Prisma.ScanBatchWhereInput = {}; + + if (query.project_id) { + where.projectId = query.project_id; + } + + if (query.inspector_id) { + where.inspectorId = query.inspector_id; + } + + if (currentUser.role === SCAN_BATCHES_AUTH_ROLES.INSPECTOR) { + where.inspectorId = currentUser.id; + } + + if (currentUser.role === SCAN_BATCHES_AUTH_ROLES.MANAGER) { + where.project = { + userProjects: { + some: { + userId: currentUser.id, + }, + }, + }; + } + + const [scanBatches, total] = await Promise.all([ + prisma.scanBatch.findMany({ + where, + skip, + take: limit, + orderBy: { + uploadedAt: "desc", + }, + include: { + inspector: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + _count: { + select: { + treeScans: true, + }, + }, + }, + }), + prisma.scanBatch.count({ where }), + ]); + + return { + data: scanBatches, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; +}; + +// Retrieve a single scan batch with role-based access validation +export const getScanBatchById = async ( + id: number, + currentUser: CurrentUser, +) => { + const scanBatch = await prisma.scanBatch.findUnique({ + where: { id }, + include: { + inspector: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + treeScans: { + orderBy: { + createdAt: "desc", + }, + }, + }, + }); + + if (!scanBatch) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if ( + currentUser.role === SCAN_BATCHES_AUTH_ROLES.INSPECTOR && + scanBatch.inspectorId !== currentUser.id + ) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + SCAN_BATCHES_ERRORS.FORBIDDEN, + ); + } + + if (currentUser.role === SCAN_BATCHES_AUTH_ROLES.MANAGER) { + const hasAccess = await prisma.userProject.findFirst({ + where: { + userId: currentUser.id, + projectId: scanBatch.projectId, + }, + }); + + if (!hasAccess) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + SCAN_BATCHES_ERRORS.FORBIDDEN, + ); + } + } + + return scanBatch; +}; + +// Validate and create a scan batch with related tree scans +export const createScanBatch = async (data: CreateScanBatchServiceInput) => { + const inspector = await prisma.user.findUnique({ + where: { id: data.inspector_id }, + include: { + primaryRole: true, + }, + }); + + if (!inspector) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (inspector.primaryRole?.name !== SCAN_BATCHES_DB_ROLES.INSPECTOR) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.INVALID_INSPECTOR_ROLE, + SCAN_BATCHES_ERRORS.INVALID_ROLE, + ); + } + + if (!inspector.accountActive || !inspector.canSignIn) { + throw new AppError( + 403, + "Inspector account is inactive or cannot sign in", + SCAN_BATCHES_ERRORS.FORBIDDEN, + ); + } + + const project = await prisma.project.findUnique({ + where: { id: data.project_id }, + }); + + if (!project) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.PROJECT_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (!project.isActive) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.PROJECT_INACTIVE, + SCAN_BATCHES_ERRORS.PROJECT_INACTIVE, + ); + } + + const inspectorAssignment = await prisma.userProject.findFirst({ + where: { + userId: data.inspector_id, + projectId: data.project_id, + }, + }); + + if (!inspectorAssignment) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_ASSIGNED, + SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + ); + } + + for (const scan of data.scans) { + const farmer = await prisma.user.findUnique({ + where: { id: scan.farmer_id }, + include: { + primaryRole: true, + }, + }); + + if (!farmer) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.FARMER_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (farmer.primaryRole?.name !== SCAN_BATCHES_DB_ROLES.FARMER) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.INVALID_FARMER_ROLE, + SCAN_BATCHES_ERRORS.INVALID_ROLE, + ); + } + + const farmerAssignment = await prisma.userProject.findFirst({ + where: { + userId: scan.farmer_id, + projectId: data.project_id, + }, + }); + + if (!farmerAssignment) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.FARMER_NOT_ASSIGNED, + SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + ); + } + + const species = await prisma.treeType.findUnique({ + where: { id: scan.species_id }, + }); + + if (!species) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.SPECIES_NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + const projectSpecies = await prisma.projectTreeType.findFirst({ + where: { + projectId: data.project_id, + treeTypeId: scan.species_id, + }, + }); + + if (!projectSpecies) { + throw new AppError( + 403, + SCAN_BATCHES_MESSAGES.SPECIES_NOT_IN_PROJECT, + SCAN_BATCHES_ERRORS.SPECIES_NOT_IN_PROJECT, + ); + } + + if (scan.height_m && scan.height_m > SCAN_BATCHES_LIMITS.MAX_HEIGHT_M) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + ); + } + + if ( + scan.diameter_cm && + scan.diameter_cm > SCAN_BATCHES_LIMITS.MAX_DIAMETER_CM + ) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + ); + } + + if ( + scan.circumference_cm && + scan.circumference_cm > SCAN_BATCHES_LIMITS.MAX_CIRCUMFERENCE_CM + ) { + throw new AppError( + 422, + SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + ); + } + } + + return prisma.$transaction(async (tx) => { + const scanBatch = await tx.scanBatch.create({ + data: { + inspectorId: data.inspector_id, + projectId: data.project_id, + uploadedAt: data.uploaded_at ?? new Date(), + }, + }); + + await tx.treeScan.createMany({ + data: data.scans.map((scan) => ({ + fobId: scan.fob_id, + projectId: data.project_id, + farmerId: scan.farmer_id, + inspectorId: data.inspector_id, + speciesId: scan.species_id, + estimatedPlantedYear: scan.estimated_planted_year, + estimatedPlantedMonth: scan.estimated_planted_month, + plantedDate: scan.planted_date ?? null, + heightM: scan.height_m ?? null, + diameterCm: scan.diameter_cm ?? null, + circumferenceCm: scan.circumference_cm ?? null, + latitude: scan.latitude ?? null, + longitude: scan.longitude ?? null, + photoId: scan.photo_id ?? null, + deviceId: scan.device_id ?? null, + batchId: scanBatch.id, + })), + }); + + return tx.scanBatch.findUnique({ + where: { + id: scanBatch.id, + }, + include: { + inspector: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + }, + }, + treeScans: true, + }, + }); + }); +}; + +// Delete a scan batch only when it has no related tree scans +export const deleteScanBatch = async (id: number) => { + const scanBatch = await prisma.scanBatch.findUnique({ + where: { id }, + include: { + _count: { + select: { + treeScans: true, + }, + }, + }, + }); + + if (!scanBatch) { + throw new AppError( + 404, + SCAN_BATCHES_MESSAGES.NOT_FOUND, + SCAN_BATCHES_ERRORS.NOT_FOUND, + ); + } + + if (scanBatch._count.treeScans > 0) { + throw new AppError( + 409, + SCAN_BATCHES_MESSAGES.DELETE_BLOCKED_HAS_SCANS, + SCAN_BATCHES_ERRORS.DELETE_BLOCKED, + ); + } + + await prisma.scanBatch.delete({ + where: { id }, + }); + + return { + success: true, + message: SCAN_BATCHES_MESSAGES.DELETED, + }; +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 3453d34..d055767 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -10,6 +10,7 @@ import { adoptersRouter } from "../modules/adopters"; import { adoptionsRoutes } from "../modules/adoptions"; import { userProjectAssignmentRoutes } from "../modules/user-project-assignment"; import { partnersRoutes } from "../modules/partners"; +import { scanBatchesRoutes } from "../modules/scan-batches"; import treeScansRoutes from "../modules/tree-scans"; @@ -26,6 +27,7 @@ router.use("/localized-strings", localizationRoutes); router.use("/user-projects", userProjectAssignmentRoutes); router.use("/project-tree-types", projectTreeTypesRoutes); router.use("/partners", partnersRoutes); +router.use("/scan-batches", scanBatchesRoutes); router.use("/tree-scans", treeScansRoutes); diff --git a/tests/integration/scan-batches.test.ts b/tests/integration/scan-batches.test.ts index 73e2381..d4ce5c4 100644 --- a/tests/integration/scan-batches.test.ts +++ b/tests/integration/scan-batches.test.ts @@ -1,5 +1,870 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); +import "dotenv/config"; +import request from "supertest"; +import { PrismaClient } from "@prisma/client"; +import app from "../../src/app"; + +const prisma = new PrismaClient(); + +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!, + DEVELOPER: process.env.AUTH_DEV_DEVELOPER_TOKEN!, +}; + +const DEV_USER_IDS = { + ADMIN: 1, + FARMER: 2, + MANAGER: 3, + INSPECTOR: 4, + DEVELOPER: 5, +}; + +describe("Scan Batches Integration Tests", () => { + let countryId: number; + let adminLocationId: number; + let projectId: number; + let inactiveProjectId: number; + let farmerId: number; + let unassignedFarmerId: number; + let managerId: number; + let inspectorId: number; + let unassignedInspectorId: number; + let speciesId: number; + let unassignedSpeciesId: number; + let batchId: number; + + const validPayload = () => ({ + project_id: projectId, + uploaded_at: "2024-05-20T10:35:00.000Z", + scans: [ + { + fob_id: `SCAN-BATCH-${Date.now()}-${Math.random()}`, + farmer_id: farmerId, + species_id: speciesId, + estimated_planted_year: 2024, + estimated_planted_month: 5, + planted_date: "2024-05-20", + height_m: 2.5, + circumference_cm: 45.3, + diameter_cm: 14.4, + latitude: -8.5569, + longitude: 125.5603, + photo_id: "550e8400-e29b-41d4-a716-446655440000", + device_id: "MOB-001", + }, + ], }); -}); + + beforeAll(async () => { + await prisma.treeScanAudit.deleteMany(); + await prisma.treeScan.deleteMany(); + await prisma.scanBatch.deleteMany(); + await prisma.projectTreeType.deleteMany(); + await prisma.userProject.deleteMany(); + + await prisma.user.deleteMany({ + where: { + email: { + in: [ + "scan-batch-dev-admin@test.com", + "scan-batch-dev-farmer@test.com", + "scan-batch-dev-manager@test.com", + "scan-batch-dev-inspector@test.com", + "scan-batch-dev-developer@test.com", + "scan-batch-farmer@test.com", + "scan-batch-unassigned-farmer@test.com", + "scan-batch-unassigned-inspector@test.com", + ], + }, + }, + }); + + await prisma.user.deleteMany({ + where: { + id: { + in: [ + DEV_USER_IDS.ADMIN, + DEV_USER_IDS.FARMER, + DEV_USER_IDS.MANAGER, + DEV_USER_IDS.INSPECTOR, + DEV_USER_IDS.DEVELOPER, + ], + }, + }, + }); + + await prisma.project.deleteMany({ + where: { + name: { + startsWith: "Scan Batch Test", + }, + }, + }); + + await prisma.treeType.deleteMany({ + where: { + key: { + in: ["scan-batch-mahogany", "scan-batch-unassigned-species"], + }, + }, + }); + + await prisma.location.deleteMany({ + where: { + name: "Scan Batch Test Location", + }, + }); + + await prisma.country.deleteMany({ + where: { + iso2: "SB", + }, + }); + + const adminRole = await prisma.role.upsert({ + where: { name: "Admin" }, + update: {}, + create: { name: "Admin" }, + }); + + const managerRole = await prisma.role.upsert({ + where: { name: "Manager" }, + update: {}, + create: { name: "Manager" }, + }); + + const inspectorRole = await prisma.role.upsert({ + where: { name: "Inspector" }, + update: {}, + create: { name: "Inspector" }, + }); + + const farmerRole = await prisma.role.upsert({ + where: { name: "Farmer" }, + update: {}, + create: { name: "Farmer" }, + }); + + const developerRole = await prisma.role.upsert({ + where: { name: "Developer" }, + update: {}, + create: { name: "Developer" }, + }); + + const country = await prisma.country.create({ + data: { + name: "Scan Batch Test Country", + iso2: "SB", + iso3: "SBT", + }, + }); + + countryId = country.id; + + const location = await prisma.location.create({ + data: { + countryId, + level: 1, + name: "Scan Batch Test Location", + }, + }); + + adminLocationId = location.id; + + const project = await prisma.project.create({ + data: { + name: "Scan Batch Test Project", + description: "Project used for scan batch tests", + countryId, + adminLocationId, + isActive: true, + }, + }); + + projectId = project.id; + + const inactiveProject = await prisma.project.create({ + data: { + name: "Scan Batch Test Inactive Project", + description: "Inactive project used for scan batch tests", + countryId, + adminLocationId, + isActive: false, + }, + }); + + inactiveProjectId = inactiveProject.id; + + await prisma.user.upsert({ + where: { id: DEV_USER_IDS.ADMIN }, + update: { + name: "Scan Batch Dev Admin", + email: "scan-batch-dev-admin@test.com", + roleId: adminRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.ADMIN, + name: "Scan Batch Dev Admin", + email: "scan-batch-dev-admin@test.com", + roleId: adminRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + await prisma.user.upsert({ + where: { id: DEV_USER_IDS.FARMER }, + update: { + name: "Scan Batch Dev Farmer", + email: "scan-batch-dev-farmer@test.com", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.FARMER, + name: "Scan Batch Dev Farmer", + email: "scan-batch-dev-farmer@test.com", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + const manager = await prisma.user.upsert({ + where: { id: DEV_USER_IDS.MANAGER }, + update: { + name: "Scan Batch Dev Manager", + email: "scan-batch-dev-manager@test.com", + roleId: managerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.MANAGER, + name: "Scan Batch Dev Manager", + email: "scan-batch-dev-manager@test.com", + roleId: managerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + managerId = manager.id; + + const inspector = await prisma.user.upsert({ + where: { id: DEV_USER_IDS.INSPECTOR }, + update: { + name: "Scan Batch Dev Inspector", + email: "scan-batch-dev-inspector@test.com", + roleId: inspectorRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.INSPECTOR, + name: "Scan Batch Dev Inspector", + email: "scan-batch-dev-inspector@test.com", + roleId: inspectorRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + inspectorId = inspector.id; + + await prisma.user.upsert({ + where: { id: DEV_USER_IDS.DEVELOPER }, + update: { + name: "Scan Batch Dev Developer", + email: "scan-batch-dev-developer@test.com", + roleId: developerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + id: DEV_USER_IDS.DEVELOPER, + name: "Scan Batch Dev Developer", + email: "scan-batch-dev-developer@test.com", + roleId: developerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + await prisma.$executeRaw` + SELECT setval( + pg_get_serial_sequence('users', 'id'), + COALESCE((SELECT MAX(id) FROM "users"), 1), + true + ); + `; + + const farmer = await prisma.user.upsert({ + where: { email: "scan-batch-farmer@test.com" }, + update: { + name: "Scan Batch Farmer", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + name: "Scan Batch Farmer", + email: "scan-batch-farmer@test.com", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + farmerId = farmer.id; + + const unassignedFarmer = await prisma.user.upsert({ + where: { email: "scan-batch-unassigned-farmer@test.com" }, + update: { + name: "Scan Batch Unassigned Farmer", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + name: "Scan Batch Unassigned Farmer", + email: "scan-batch-unassigned-farmer@test.com", + roleId: farmerRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + unassignedFarmerId = unassignedFarmer.id; + + const unassignedInspector = await prisma.user.upsert({ + where: { email: "scan-batch-unassigned-inspector@test.com" }, + update: { + name: "Scan Batch Unassigned Inspector", + roleId: inspectorRole.id, + accountActive: true, + canSignIn: true, + }, + create: { + name: "Scan Batch Unassigned Inspector", + email: "scan-batch-unassigned-inspector@test.com", + roleId: inspectorRole.id, + accountActive: true, + canSignIn: true, + }, + }); + + unassignedInspectorId = unassignedInspector.id; + + const species = await prisma.treeType.create({ + data: { + name: "Scan Batch Mahogany", + key: "scan-batch-mahogany", + scientificName: "Swietenia macrophylla", + dryWeightDensity: 550, + }, + }); + + speciesId = species.id; + + const unassignedSpecies = await prisma.treeType.create({ + data: { + name: "Scan Batch Unassigned Species", + key: "scan-batch-unassigned-species", + scientificName: "Unassigned species", + dryWeightDensity: 500, + }, + }); + + unassignedSpeciesId = unassignedSpecies.id; + + await prisma.userProject.createMany({ + data: [ + { + userId: farmerId, + projectId, + }, + { + userId: inspectorId, + projectId, + }, + { + userId: managerId, + projectId, + }, + ], + skipDuplicates: true, + }); + + await prisma.projectTreeType.create({ + data: { + projectId, + treeTypeId: speciesId, + }, + }); + }); + + beforeEach(async () => { + await prisma.treeScanAudit.deleteMany(); + await prisma.treeScan.deleteMany(); + await prisma.scanBatch.deleteMany(); + + const batch = await prisma.scanBatch.create({ + data: { + inspectorId, + projectId, + uploadedAt: new Date("2024-05-20T10:35:00.000Z"), + }, + }); + + batchId = batch.id; + + await prisma.treeScan.create({ + data: { + fobId: "SCAN-BATCH-BASE", + projectId, + farmerId, + inspectorId, + speciesId, + estimatedPlantedYear: 2024, + estimatedPlantedMonth: 5, + plantedDate: new Date("2024-05-20"), + heightM: 2.5, + circumferenceCm: 45.3, + diameterCm: 14.4, + latitude: -8.5569, + longitude: 125.5603, + photoId: "550e8400-e29b-41d4-a716-446655440000", + deviceId: "MOB-001", + batchId, + }, + }); + }); + + afterAll(async () => { + await prisma.treeScanAudit.deleteMany(); + await prisma.treeScan.deleteMany(); + await prisma.scanBatch.deleteMany(); + + await prisma.projectTreeType.deleteMany({ + where: { + projectId, + }, + }); + + await prisma.userProject.deleteMany({ + where: { + projectId, + }, + }); + + await prisma.project.deleteMany({ + where: { + id: { + in: [projectId, inactiveProjectId].filter( + (id): id is number => id !== undefined, + ), + }, + }, + }); + + await prisma.user.deleteMany({ + where: { + id: { + in: [ + DEV_USER_IDS.ADMIN, + DEV_USER_IDS.FARMER, + DEV_USER_IDS.MANAGER, + DEV_USER_IDS.INSPECTOR, + DEV_USER_IDS.DEVELOPER, + farmerId, + unassignedFarmerId, + unassignedInspectorId, + ].filter((id): id is number => id !== undefined), + }, + }, + }); + + await prisma.treeType.deleteMany({ + where: { + id: { + in: [speciesId, unassignedSpeciesId].filter( + (id): id is number => id !== undefined, + ), + }, + }, + }); + + await prisma.location.deleteMany({ + where: { + id: adminLocationId, + }, + }); + + await prisma.country.deleteMany({ + where: { + id: countryId, + }, + }); + + await prisma.$disconnect(); + }); + + // Tests for GET /scan-batches endpoint authorization, filtering, and pagination behaviour. + describe("GET /scan-batches", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app).get("/scan-batches"); + + expect(response.status).toBe(401); + }); + + it("should return 200 for ADMIN token", async () => { + const response = await request(app) + .get("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + }); + + it("should return 200 for MANAGER token and only return assigned project batches", async () => { + const response = await request(app) + .get("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + expect( + response.body.data.every( + (batch: { projectId: number }) => batch.projectId === projectId, + ), + ).toBe(true); + }); + + it("should return 200 for INSPECTOR token and only return own batches", async () => { + const response = await request(app) + .get("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + expect( + response.body.data.every( + (batch: { inspectorId: number }) => + batch.inspectorId === inspectorId, + ), + ).toBe(true); + }); + + it("should return 403 for FARMER token", async () => { + const response = await request(app) + .get("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(response.status).toBe(403); + }); + + it("should filter by project_id", async () => { + const response = await request(app) + .get(`/scan-batches?project_id=${projectId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBeGreaterThanOrEqual(1); + expect( + response.body.data.every( + (batch: { projectId: number }) => batch.projectId === projectId, + ), + ).toBe(true); + }); + + it("should return 400 for invalid pagination", async () => { + const response = await request(app) + .get("/scan-batches?page=0&limit=0") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(400); + }); + }); + + // Tests for GET /scan-batches/:id endpoint authorization, validation, and retrieval behaviour. + describe("GET /scan-batches/:id", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app).get(`/scan-batches/${batchId}`); + + expect(response.status).toBe(401); + }); + + it("should return 200 for ADMIN token when batch exists", async () => { + const response = await request(app) + .get(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.id).toBe(batchId); + }); + + it("should return 200 for MANAGER token when batch belongs to assigned project", async () => { + const response = await request(app) + .get(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.projectId).toBe(projectId); + }); + + it("should return 200 for INSPECTOR token when batch belongs to inspector", async () => { + const response = await request(app) + .get(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.inspectorId).toBe(inspectorId); + }); + + it("should return 403 for FARMER token", async () => { + const response = await request(app) + .get(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid scan batch id", async () => { + const response = await request(app) + .get("/scan-batches/0") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(400); + }); + + it("should return 404 when scan batch does not exist", async () => { + const response = await request(app) + .get("/scan-batches/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(404); + }); + }); + + // Tests for POST /scan-batches endpoint validation, authorization, and batch creation behaviour. + describe("POST /scan-batches", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app) + .post("/scan-batches") + .send(validPayload()); + + expect(response.status).toBe(401); + }); + + it("should return 201 for INSPECTOR token and create scan batch with tree scans", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send(validPayload()); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.data.projectId).toBe(projectId); + expect(response.body.data.inspectorId).toBe(inspectorId); + expect(response.body.data.treeScans.length).toBe(1); + }); + + it("should return 403 for ADMIN token", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send(validPayload()); + + expect(response.status).toBe(403); + }); + + it("should return 403 for MANAGER token", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) + .send(validPayload()); + + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid payload", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [], + }); + + expect(response.status).toBe(400); + }); + + it("should return 422 for inactive project", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + project_id: inactiveProjectId, + }); + + expect(response.status).toBe(422); + }); + + it("should return 403 when farmer is not assigned to project", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + farmer_id: unassignedFarmerId, + }, + ], + }); + + expect(response.status).toBe(403); + }); + + it("should return 403 when species is not assigned to project", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + species_id: unassignedSpeciesId, + }, + ], + }); + + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid coordinates", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + latitude: 100, + }, + ], + }); + + expect(response.status).toBe(400); + }); + + it("should return 400 for future planted date", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + planted_date: "2035-01-01", + }, + ], + }); + + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid estimated planted month", async () => { + const response = await request(app) + .post("/scan-batches") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ + ...validPayload(), + scans: [ + { + ...validPayload().scans[0], + estimated_planted_month: 15, + }, + ], + }); + + expect(response.status).toBe(400); + }); + }); + + // Tests for DELETE /scan-batches/:id endpoint authorization and dependency protection behaviour. + describe("DELETE /scan-batches/:id", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app).delete(`/scan-batches/${batchId}`); + + expect(response.status).toBe(401); + }); + + it("should return 403 for INSPECTOR token", async () => { + const response = await request(app) + .delete(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(response.status).toBe(403); + }); + + it("should return 403 for MANAGER token", async () => { + const response = await request(app) + .delete(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(response.status).toBe(403); + }); + + it("should return 409 for ADMIN token when batch has related tree scans", async () => { + const response = await request(app) + .delete(`/scan-batches/${batchId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(409); + expect(response.body.success).toBe(false); + }); + + it("should return 200 for ADMIN token when batch has no related tree scans", async () => { + const emptyBatch = await prisma.scanBatch.create({ + data: { + inspectorId, + projectId, + uploadedAt: new Date(), + }, + }); + + const response = await request(app) + .delete(`/scan-batches/${emptyBatch.id}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it("should return 404 when scan batch does not exist", async () => { + const response = await request(app) + .delete("/scan-batches/999999") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(404); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/scan-batches.test.ts b/tests/unit/scan-batches.test.ts index 73e2381..2d7fa7d 100644 --- a/tests/unit/scan-batches.test.ts +++ b/tests/unit/scan-batches.test.ts @@ -1,5 +1,744 @@ -describe.skip("placeholder", () => { - it("to be implemented", () => { - expect(true).toBe(true); - }); +import { + createScanBatch, + deleteScanBatch, + getScanBatchById, + getScanBatches, +} from "../../src/modules/scan-batches/scanBatches.service"; + +import { + SCAN_BATCHES_AUTH_ROLES, + SCAN_BATCHES_DB_ROLES, + SCAN_BATCHES_ERRORS, + SCAN_BATCHES_MESSAGES, +} from "../../src/modules/scan-batches/scan-batches.constants"; + +jest.mock("../../src/lib/prisma", () => { + const mockPrisma: any = { + scanBatch: { + findMany: jest.fn(), + count: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + treeScan: { + createMany: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + project: { + findUnique: jest.fn(), + }, + userProject: { + findFirst: jest.fn(), + }, + treeType: { + findUnique: jest.fn(), + }, + projectTreeType: { + findFirst: jest.fn(), + }, + }; + + mockPrisma.$transaction = jest.fn((callback: any) => callback(mockPrisma)); + + return { + prisma: mockPrisma, + }; }); + +const { prisma: mockPrisma } = jest.requireMock("../../src/lib/prisma"); + +describe("ScanBatchesService", () => { + const adminUser = { + id: 1, + role: SCAN_BATCHES_AUTH_ROLES.ADMIN, + }; + + const managerUser = { + id: 3, + role: SCAN_BATCHES_AUTH_ROLES.MANAGER, + }; + + const inspectorUser = { + id: 4, + role: SCAN_BATCHES_AUTH_ROLES.INSPECTOR, + }; + + const validCreateInput = { + inspector_id: inspectorUser.id, + project_id: 1, + uploaded_at: new Date("2024-05-20T10:35:00.000Z"), + scans: [ + { + fob_id: "SWAGGER-001", + farmer_id: 16, + species_id: 1, + estimated_planted_year: 2024, + estimated_planted_month: 5, + planted_date: new Date("2024-05-20"), + height_m: 2.5, + circumference_cm: 45.3, + diameter_cm: 14.4, + latitude: -8.5569, + longitude: 125.5603, + photo_id: "550e8400-e29b-41d4-a716-446655440000", + device_id: "MOB-001", + }, + ], + }; + + const inspectorRecord = { + id: inspectorUser.id, + accountActive: true, + canSignIn: true, + primaryRole: { + id: 3, + name: SCAN_BATCHES_DB_ROLES.INSPECTOR, + }, + }; + + const farmerRecord = { + id: 16, + accountActive: true, + canSignIn: true, + primaryRole: { + id: 4, + name: SCAN_BATCHES_DB_ROLES.FARMER, + }, + }; + + const scanBatchRecord = { + id: 1, + inspectorId: inspectorUser.id, + projectId: 1, + uploadedAt: new Date("2024-05-20T10:35:00.000Z"), + inspector: { + id: inspectorUser.id, + name: "Dev Inspector", + email: "dev-inspector@treeo2.local", + }, + project: { + id: 1, + name: "Hera Reforestation 2025", + }, + treeScans: [ + { + id: 1, + fobId: "SWAGGER-001", + projectId: 1, + farmerId: 16, + inspectorId: inspectorUser.id, + speciesId: 1, + estimatedPlantedYear: 2024, + estimatedPlantedMonth: 5, + batchId: 1, + }, + ], + }; + + const mockSuccessfulCreateDependencies = () => { + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce(farmerRecord); + + mockPrisma.project.findUnique.mockResolvedValue({ + id: 1, + isActive: true, + }); + + mockPrisma.userProject.findFirst.mockResolvedValue({ + userId: inspectorUser.id, + projectId: 1, + }); + + mockPrisma.treeType.findUnique.mockResolvedValue({ + id: 1, + name: "Mahogany", + }); + + mockPrisma.projectTreeType.findFirst.mockResolvedValue({ + projectId: 1, + treeTypeId: 1, + }); + + mockPrisma.scanBatch.create.mockResolvedValue({ + id: 1, + inspectorId: inspectorUser.id, + projectId: 1, + uploadedAt: validCreateInput.uploaded_at, + }); + + mockPrisma.treeScan.createMany.mockResolvedValue({ + count: 1, + }); + + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("getScanBatches", () => { + // Tests admin retrieval of paginated scan batches + it("should return paginated scan batches for admin", async () => { + mockPrisma.scanBatch.findMany.mockResolvedValue([scanBatchRecord]); + mockPrisma.scanBatch.count.mockResolvedValue(1); + + const result = await getScanBatches( + { + page: 1, + limit: 20, + project_id: 1, + inspector_id: 4, + }, + adminUser, + ); + + expect(mockPrisma.scanBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + projectId: 1, + inspectorId: 4, + }, + skip: 0, + take: 20, + orderBy: { + uploadedAt: "desc", + }, + }), + ); + + expect(result).toEqual({ + data: [scanBatchRecord], + pagination: { + page: 1, + limit: 20, + total: 1, + totalPages: 1, + }, + }); + }); + + // Tests inspector-only filtering for scan batch listing + it("should scope inspector results to own batches", async () => { + mockPrisma.scanBatch.findMany.mockResolvedValue([scanBatchRecord]); + mockPrisma.scanBatch.count.mockResolvedValue(1); + + await getScanBatches( + { + page: 1, + limit: 20, + }, + inspectorUser, + ); + + expect(mockPrisma.scanBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + inspectorId: inspectorUser.id, + }, + }), + ); + }); + + // Tests manager filtering by assigned project batches + it("should scope manager results to assigned projects", async () => { + mockPrisma.scanBatch.findMany.mockResolvedValue([scanBatchRecord]); + mockPrisma.scanBatch.count.mockResolvedValue(1); + + await getScanBatches( + { + page: 1, + limit: 20, + }, + managerUser, + ); + + expect(mockPrisma.scanBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + project: { + userProjects: { + some: { + userId: managerUser.id, + }, + }, + }, + }, + }), + ); + }); + + // Tests pagination calculation for scan batch listing + it("should calculate pagination correctly", async () => { + mockPrisma.scanBatch.findMany.mockResolvedValue([scanBatchRecord]); + mockPrisma.scanBatch.count.mockResolvedValue(45); + + const result = await getScanBatches( + { + page: 2, + limit: 20, + }, + adminUser, + ); + + expect(mockPrisma.scanBatch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 20, + take: 20, + }), + ); + + expect(result.pagination).toEqual({ + page: 2, + limit: 20, + total: 45, + totalPages: 3, + }); + }); + }); + + describe("getScanBatchById", () => { + // Tests admin retrieval of a single scan batch + it("should return scan batch when admin requests valid batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + + const result = await getScanBatchById(1, adminUser); + + expect(mockPrisma.scanBatch.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 1 }, + }), + ); + + expect(result).toEqual(scanBatchRecord); + }); + + // Tests inspector access to their own scan batch + it("should allow inspector to access own scan batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + + const result = await getScanBatchById(1, inspectorUser); + + expect(result).toEqual(scanBatchRecord); + }); + + // Tests inspector forbidden access to another inspector batch + it("should throw forbidden when inspector accesses another inspector batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue({ + ...scanBatchRecord, + inspectorId: 999, + }); + + await expect(getScanBatchById(1, inspectorUser)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + code: SCAN_BATCHES_ERRORS.FORBIDDEN, + }); + }); + + // Tests manager access to a batch from assigned project + it("should allow manager to access assigned project batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + mockPrisma.userProject.findFirst.mockResolvedValue({ + userId: managerUser.id, + projectId: scanBatchRecord.projectId, + }); + + const result = await getScanBatchById(1, managerUser); + + expect(mockPrisma.userProject.findFirst).toHaveBeenCalledWith({ + where: { + userId: managerUser.id, + projectId: scanBatchRecord.projectId, + }, + }); + + expect(result).toEqual(scanBatchRecord); + }); + + // Tests manager forbidden access to an unassigned project batch + it("should throw forbidden when manager accesses unassigned project batch", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(scanBatchRecord); + mockPrisma.userProject.findFirst.mockResolvedValue(null); + + await expect(getScanBatchById(1, managerUser)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.UNAUTHORIZED_ACCESS, + code: SCAN_BATCHES_ERRORS.FORBIDDEN, + }); + }); + + // Tests not-found behavior for missing scan batch + it("should throw not found when scan batch does not exist", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(null); + + await expect(getScanBatchById(999, adminUser)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + }); + + describe("createScanBatch", () => { + beforeEach(() => { + mockSuccessfulCreateDependencies(); + }); + + // Tests successful creation of scan batch and child tree scans + it("should create a scan batch successfully with valid input", async () => { + const result = await createScanBatch(validCreateInput); + + expect(mockPrisma.scanBatch.create).toHaveBeenCalledWith({ + data: { + inspectorId: inspectorUser.id, + projectId: 1, + uploadedAt: validCreateInput.uploaded_at, + }, + }); + + expect(mockPrisma.treeScan.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + fobId: "SWAGGER-001", + projectId: 1, + farmerId: 16, + inspectorId: inspectorUser.id, + speciesId: 1, + estimatedPlantedYear: 2024, + estimatedPlantedMonth: 5, + batchId: 1, + }), + ], + }); + + expect(result).toEqual(scanBatchRecord); + }); + + // Tests default upload timestamp when uploaded_at is omitted + it("should use current date when uploaded_at is not provided", async () => { + const inputWithoutUploadedAt = { + ...validCreateInput, + uploaded_at: null, + }; + + await createScanBatch(inputWithoutUploadedAt); + + expect(mockPrisma.scanBatch.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + uploadedAt: expect.any(Date), + }), + }); + }); + + // Tests missing inspector validation + it("should throw not found when inspector does not exist", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique.mockResolvedValueOnce(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests inspector role validation + it("should throw invalid role when user is not inspector", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique.mockResolvedValueOnce({ + ...inspectorRecord, + primaryRole: { + id: 2, + name: SCAN_BATCHES_DB_ROLES.MANAGER, + }, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.INVALID_INSPECTOR_ROLE, + code: SCAN_BATCHES_ERRORS.INVALID_ROLE, + }); + }); + + // Tests inactive inspector account validation + it("should throw forbidden when inspector account is inactive", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique.mockResolvedValueOnce({ + ...inspectorRecord, + accountActive: false, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: "Inspector account is inactive or cannot sign in", + code: SCAN_BATCHES_ERRORS.FORBIDDEN, + }); + }); + + // Tests inspector canSignIn validation + it("should throw forbidden when inspector cannot sign in", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique.mockResolvedValueOnce({ + ...inspectorRecord, + canSignIn: false, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: "Inspector account is inactive or cannot sign in", + code: SCAN_BATCHES_ERRORS.FORBIDDEN, + }); + }); + + // Tests missing project validation + it("should throw not found when project does not exist", async () => { + mockPrisma.project.findUnique.mockResolvedValue(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.PROJECT_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests inspector project assignment validation + it("should throw forbidden when inspector is not assigned to project", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce(farmerRecord); + + mockPrisma.userProject.findFirst.mockReset(); + mockPrisma.userProject.findFirst.mockResolvedValueOnce(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.INSPECTOR_NOT_ASSIGNED, + code: SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + }); + }); + + // Tests missing farmer validation + it("should throw not found when farmer does not exist", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce(null); + + mockPrisma.userProject.findFirst.mockReset(); + mockPrisma.userProject.findFirst.mockResolvedValueOnce({ + userId: inspectorUser.id, + projectId: 1, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.FARMER_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests farmer role validation + it("should throw invalid role when farmer_id does not belong to Farmer role", async () => { + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce({ + ...farmerRecord, + primaryRole: { + id: 2, + name: SCAN_BATCHES_DB_ROLES.MANAGER, + }, + }); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.INVALID_FARMER_ROLE, + code: SCAN_BATCHES_ERRORS.INVALID_ROLE, + }); + }); + + // Tests farmer project assignment validation + it("should throw forbidden when farmer is not assigned to project", async () => { + mockPrisma.userProject.findFirst + .mockResolvedValueOnce({ + userId: inspectorUser.id, + projectId: 1, + }) + .mockResolvedValueOnce(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.FARMER_NOT_ASSIGNED, + code: SCAN_BATCHES_ERRORS.NOT_ASSIGNED, + }); + }); + + // Tests missing species validation + it("should throw not found when species does not exist", async () => { + mockPrisma.treeType.findUnique.mockResolvedValue(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.SPECIES_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests species-project assignment validation + it("should throw forbidden when species is not assigned to project", async () => { + mockPrisma.projectTreeType.findFirst.mockResolvedValue(null); + + await expect(createScanBatch(validCreateInput)).rejects.toMatchObject({ + statusCode: 403, + message: SCAN_BATCHES_MESSAGES.SPECIES_NOT_IN_PROJECT, + code: SCAN_BATCHES_ERRORS.SPECIES_NOT_IN_PROJECT, + }); + }); + + // Tests height measurement upper-limit validation + it("should throw invalid measurement when height exceeds limit", async () => { + await expect( + createScanBatch({ + ...validCreateInput, + scans: [ + { + ...validCreateInput.scans[0], + height_m: 101, + }, + ], + }), + ).rejects.toMatchObject({ + statusCode: 422, + message: SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + code: SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + }); + }); + + // Tests diameter measurement upper-limit validation + it("should throw invalid measurement when diameter exceeds limit", async () => { + await expect( + createScanBatch({ + ...validCreateInput, + scans: [ + { + ...validCreateInput.scans[0], + diameter_cm: 1001, + }, + ], + }), + ).rejects.toMatchObject({ + statusCode: 422, + message: SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + code: SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + }); + }); + + // Tests circumference measurement upper-limit validation + it("should throw invalid measurement when circumference exceeds limit", async () => { + await expect( + createScanBatch({ + ...validCreateInput, + scans: [ + { + ...validCreateInput.scans[0], + circumference_cm: 4001, + }, + ], + }), + ).rejects.toMatchObject({ + statusCode: 422, + message: SCAN_BATCHES_MESSAGES.INVALID_MEASUREMENT, + code: SCAN_BATCHES_ERRORS.INVALID_MEASUREMENT, + }); + }); + + // Tests that each scan in a multi-scan batch is validated + it("should validate every scan in a multi-scan batch", async () => { + const multiScanInput = { + ...validCreateInput, + scans: [ + validCreateInput.scans[0], + { + ...validCreateInput.scans[0], + fob_id: "SWAGGER-002", + farmer_id: 999, + }, + ], + }; + + mockPrisma.user.findUnique.mockReset(); + mockPrisma.user.findUnique + .mockResolvedValueOnce(inspectorRecord) + .mockResolvedValueOnce(farmerRecord) + .mockResolvedValueOnce(null); + + await expect(createScanBatch(multiScanInput)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.FARMER_NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + }); + + describe("deleteScanBatch", () => { + // Tests successful deletion of an empty scan batch + it("should delete scan batch when it exists and has no related scans", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue({ + id: 1, + _count: { + treeScans: 0, + }, + }); + + mockPrisma.scanBatch.delete.mockResolvedValue({ + id: 1, + }); + + const result = await deleteScanBatch(1); + + expect(mockPrisma.scanBatch.delete).toHaveBeenCalledWith({ + where: { id: 1 }, + }); + + expect(result).toEqual({ + success: true, + message: SCAN_BATCHES_MESSAGES.DELETED, + }); + }); + + // Tests not-found behavior when deleting missing scan batch + it("should throw not found when scan batch does not exist", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue(null); + + await expect(deleteScanBatch(999)).rejects.toMatchObject({ + statusCode: 404, + message: SCAN_BATCHES_MESSAGES.NOT_FOUND, + code: SCAN_BATCHES_ERRORS.NOT_FOUND, + }); + }); + + // Tests deletion protection when scan batch has related tree scans + it("should block delete when scan batch has related tree scans", async () => { + mockPrisma.scanBatch.findUnique.mockResolvedValue({ + id: 1, + _count: { + treeScans: 1, + }, + }); + + await expect(deleteScanBatch(1)).rejects.toMatchObject({ + statusCode: 409, + message: SCAN_BATCHES_MESSAGES.DELETE_BLOCKED_HAS_SCANS, + code: SCAN_BATCHES_ERRORS.DELETE_BLOCKED, + }); + + expect(mockPrisma.scanBatch.delete).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file