From 91db746f9759eed5228e2e2199786c7f1fab925f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:50:53 +0400 Subject: [PATCH 1/8] feat(products): add inverse orders relation to Product model ProductsService.remove needs to check whether at least one order still references a product before allowing the delete, the same guard CategoriesService.remove already applies against products through Category's own hasMany relation. Product previously declared only the belongsTo side toward Category with no way to reach its orders through the ORM, so the delete guard had no relation to query. The Customer model already added the equivalent inverse hasMany toward Order for the same reason, with its own NOTE explaining why a relation not explicitly listed in a Tasks checklist was still worth adding, so this follows that precedent rather than querying Order directly with a hand-written where clause. --- app/models/product.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/models/product.ts b/app/models/product.ts index 1649378..de4aaff 100644 --- a/app/models/product.ts +++ b/app/models/product.ts @@ -1,6 +1,7 @@ -import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' -import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { BaseModel, belongsTo, column, hasMany } from '@adonisjs/lucid/orm' +import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' import Category from '#models/category' +import Order from '#models/order' export default class Product extends BaseModel { @column({ isPrimary: true }) @@ -17,4 +18,17 @@ export default class Product extends BaseModel { @belongsTo(() => Category) declare category: BelongsTo + + // NOTE: the README's Data Model diagram shows products 1 -- N orders, and + // the Column Details table only defines orders.product_id as the FK + // side. The inverse hasMany here is not explicitly requested by the + // feature/products Tasks checklist, but is added the same way and for the + // same reason the Customer model already added its own `orders` hasMany: + // ProductsService.remove needs to check "does at least one order still + // reference this product" for its 409 delete guard, and `related('orders')` + // is the same query idiom CategoriesService.remove already uses for its + // own `products` relation, kept consistent rather than querying Order + // directly with a hand-written where('productId', ...). + @hasMany(() => Order) + declare orders: HasMany } From ed93e7ae381f4b2e52ab139b5632b9ed81284fd3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:51:01 +0400 Subject: [PATCH 2/8] feat(products): add createProductSchema, updateProductSchema and productFilterSchema createProductSchema and updateProductSchema mirror the products table from the README's Data Model section, with unitPrice.positive() enforcing the same "> 0" rule the migration already enforces at the database level via its check constraint, so a bad value is rejected with a clean 422 before it ever reaches Postgres. categoryId is validated for shape only here; whether it refers to an existing category is a FK existence check left to ProductsService. productFilterSchema backs the list endpoint's query params. sortBy is restricted with vine.enum() to the two documented sortable fields instead of a bare string, closing off the possibility of an arbitrary column name reaching the service's dynamic orderBy call. The allowlist deliberately uses the camelCase model attribute names (productName, unitPrice) rather than their snake_case column names, since Lucid's ModelQueryBuilder already resolves a known attribute name to its column automatically, the same way RolesService relies on for its own where('roleName', ...) call. --- app/validators/product_validator.ts | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 app/validators/product_validator.ts diff --git a/app/validators/product_validator.ts b/app/validators/product_validator.ts new file mode 100644 index 0000000..20b4d3a --- /dev/null +++ b/app/validators/product_validator.ts @@ -0,0 +1,65 @@ +import vine from '@vinejs/vine' + +/** + * POST /api/v1/products + * + * Mirrors the `products` table from the README's Data Model section: + * `category_id BIGINT FK -> categories.id, NOT NULL`, `product_name + * VARCHAR(255) NOT NULL`, `unit_price FLOAT NOT NULL, > 0`. `positive()` + * on unitPrice enforces the same "> 0" constraint the migration already + * enforces at the database level (`products_unit_price_positive` check), + * so a bad value is rejected with a clean 422 before ever reaching the DB. + * `categoryId` is only checked for shape/type here; whether that id + * actually refers to an existing category is a FK existence check that + * belongs in ProductsService.create/update, not in this schema. + */ +export const createProductSchema = vine.compile( + vine.object({ + categoryId: vine.number().positive(), + productName: vine.string().trim().minLength(1).maxLength(255), + unitPrice: vine.number().positive(), + }) +) + +/** + * PUT /api/v1/products/:id + * + * Same fields as createProductSchema, all optional for partial update. + */ +export const updateProductSchema = vine.compile( + vine.object({ + categoryId: vine.number().positive().optional(), + productName: vine.string().trim().minLength(1).maxLength(255).optional(), + unitPrice: vine.number().positive().optional(), + }) +) + +/** + * GET /api/v1/products query params. + * + * NOTE: sortBy allowlist. The README's Endpoints table says "sort by + * productName/unitPrice" and the Key AdonisJS patterns code sample passes + * `filter.sortBy` straight into `query.orderBy(filter.sortBy, ...)` + * without specifying how that value is restricted. A bare + * `vine.string().optional()` would let a client pass any string through + * to `.orderBy()`, including a column that does not exist (an unhandled + * DB error) or one that does but was never meant to be sortable. vine.enum + * closes that off at the validation layer, the same allowlist role + * router.matchers.number() plays for the :id param convention documented + * in start/routes.ts. 'productName' and 'unitPrice' (not 'product_name' / + * 'unit_price') are used deliberately: they are the camelCase model + * attribute names, and Lucid's ModelQueryBuilder resolves a known + * attribute name to its snake_case column automatically wherever a raw + * where()/orderBy() key is a directly a Lucid model attribute name, + * exactly the same way RolesService already relies on for + * `.where('roleName', ...)`. Keeping the query param camelCase also keeps + * the whole public API surface (request and response bodies alike) + * consistently camelCase. + */ +export const productFilterSchema = vine.compile( + vine.object({ + categoryId: vine.number().positive().optional(), + sortBy: vine.enum(['productName', 'unitPrice'] as const).optional(), + order: vine.enum(['asc', 'desc'] as const).optional(), + }) +) From 44066d617200e83efd32c61d413b2a1a4a96a943 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:51:10 +0400 Subject: [PATCH 3/8] feat(products): add ProductsService with dynamic filtering and delete guard Follows CategoriesService's own findAll/findOne/create/update/remove shape, minus the caching layer, since the README only asks for caching on the categories list and does not ask for it here. findAll builds the query the README's own "Dynamic QueryBuilder with optional filters" code sample shows: category is always preloaded, and the where(category_id) and orderBy(sortBy) calls are only chained on when the corresponding filter field is present, so an unfiltered, unsorted request still returns every product. create and update both check the referenced category exists via Category.findOrFail before writing the product row. Without that check a bad categoryId would only surface as a raw Postgres foreign key violation with no clean status code, the same class of bug a previous branch's code review caught for a different case, instead of the clean 404 a client can act on directly. remove enforces the README's business rule that a product still referenced by at least one order cannot be deleted, throwing a createError-based E_PRODUCT_IN_USE 409, the same pattern E_CATEGORY_IN_USE already establishes. It checks the orders relation added to the Product model in the previous commit rather than querying Order directly, keeping the same related(...).query().first() idiom CategoriesService.remove already uses for its own guard. --- app/services/products_service.ts | 111 +++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 app/services/products_service.ts diff --git a/app/services/products_service.ts b/app/services/products_service.ts new file mode 100644 index 0000000..32db2b7 --- /dev/null +++ b/app/services/products_service.ts @@ -0,0 +1,111 @@ +import { createError } from '@adonisjs/core/exceptions' +import type { Infer } from '@vinejs/vine/types' + +import Product from '#models/product' +import Category from '#models/category' +import { toPageResponse, type PageResponse } from '#helpers/page_response' +import type { + createProductSchema, + updateProductSchema, + productFilterSchema, +} from '#validators/product_validator' + +type CreateProductPayload = Infer +type UpdateProductPayload = Infer +type ProductFilter = Infer + +const E_PRODUCT_IN_USE = createError( + 'Product still has orders associated with it', + 'E_PRODUCT_IN_USE', + 409 +) + +/** + * Owns every read and write to the `products` table. No constructor + * dependencies, so no @inject(), matching CategoriesService's own + * reasoning for the same omission. Unlike CategoriesService.findAll, this + * branch's README does not ask for caching on the products list, so + * findAll below queries straight through to the database on every call. + */ +export default class ProductsService { + /** + * Builds a dynamic query per the README's own "Dynamic QueryBuilder with + * optional filters" code sample: `where('category_id', ...)` and + * `orderBy(filter.sortBy, ...)` are only chained on when the + * corresponding filter field is present, and `category` is always + * preloaded so the list response matches the detail response's shape. + */ + async findAll( + filter: ProductFilter, + page: number, + limit: number + ): Promise> { + const query = Product.query().preload('category') + + if (filter.categoryId) { + query.where('category_id', filter.categoryId) + } + + if (filter.sortBy) { + query.orderBy(filter.sortBy, filter.order ?? 'asc') + } + + const paginator = await query.paginate(page, limit) + return toPageResponse(paginator) + } + + async findOne(id: number): Promise { + const product = await Product.findOrFail(id) + await product.load('category') + return product + } + + /** + * Checks the referenced category exists before writing the product row. + * Without this, a bad categoryId would only surface as a raw Postgres FK + * violation with no clean .status, the same class of bug the code review + * on the previous branch caught for a different case, instead of the + * clean 404 a client can actually act on. + */ + async create(data: CreateProductPayload): Promise { + await Category.findOrFail(data.categoryId) + + const product = await Product.create(data) + await product.load('category') + return product + } + + async update(id: number, data: UpdateProductPayload): Promise { + const product = await Product.findOrFail(id) + + if (data.categoryId !== undefined) { + await Category.findOrFail(data.categoryId) + } + + product.merge(data) + await product.save() + await product.load('category') + return product + } + + /** + * Business rule from the README: a product linked to at least one order + * cannot be deleted, 409 instead. Checked by querying the `orders` + * relation for a single match, the same `related(...).query().first()` + * idiom CategoriesService.remove uses for its own "still in use" guard + * against `products`, made possible by the `orders` hasMany added to the + * Product model for exactly this check. This also doubles as the guard + * that keeps the orders table's own `product_id` FK (ON DELETE CASCADE) + * from ever actually cascading away order history through this service. + */ + async remove(id: number): Promise { + const product = await Product.findOrFail(id) + + const stillInUse = await product.related('orders').query().select('id').first() + if (stillInUse) { + throw new E_PRODUCT_IN_USE() + } + + await product.delete() + } +} From 399f5be4ac580678cd25d61c2302eceeec04f0d9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:51:20 +0400 Subject: [PATCH 4/8] feat(products): add ProductsController with Swagger JSDoc annotations Thin HTTP layer over ProductsService, matching CategoriesController's own shape and Swagger JSDoc style: every action does validateUsing -> service -> respond(), with no business logic and no direct model access, per the project's hard rules. GET routes are declared public in start/routes.ts and the three mutation routes are declared ADMIN only there, mirroring exactly how the categories routes are gated. index validates pagination and product filtering as two separate request.validateUsing() calls against paginationSchema and productFilterSchema rather than merging them into one combined schema. Both run against the same query string and VineJS does not mind the two calls overlapping in what they read, and keeping them separate lets paginationSchema stay the same shared schema every other list endpoint already uses instead of forking a products-specific variant of it. --- app/controllers/products_controller.ts | 110 +++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 app/controllers/products_controller.ts diff --git a/app/controllers/products_controller.ts b/app/controllers/products_controller.ts new file mode 100644 index 0000000..2d3a7f2 --- /dev/null +++ b/app/controllers/products_controller.ts @@ -0,0 +1,110 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import ProductsService from '#services/products_service' +import { + createProductSchema, + updateProductSchema, + productFilterSchema, +} from '#validators/product_validator' +import { paginationSchema } from '#validators/pagination_validator' + +/** + * Thin HTTP layer over ProductsService. GET routes are public; the + * mutation routes (store, update, destroy) are declared behind + * `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` in + * start/routes.ts, the same pattern CategoriesController already uses for + * its own mutation routes. + */ +@inject() +export default class ProductsController { + constructor(private productsService: ProductsService) {} + + /** + * @index + * @summary List products + * @description Returns a paginated list of products, optionally filtered by categoryId and sorted by productName or unitPrice. Public endpoint. + * @paramQuery page - Page number - @type(number) + * @paramQuery limit - Items per page, capped at 100 - @type(number) + * @paramQuery categoryId - Filter results to this category id - @type(number) + * @paramQuery sortBy - Field to sort by (productName or unitPrice) - @type(string) + * @paramQuery order - Sort order (asc or desc) - @type(string) + * @responseBody 200 - .paginated() + */ + async index({ request, response }: HttpContext) { + // NOTE: pagination and filtering are two independently reusable + // concerns (paginationSchema is already shared by every other list + // endpoint), so they are validated with two separate + // request.validateUsing() calls rather than one schema that merges + // page/limit/categoryId/sortBy/order together. Both run against the + // same request query string, VineJS does not care that they overlap, + // and this keeps paginationSchema reusable as-is instead of forking it. + const { page, limit } = await request.validateUsing(paginationSchema) + const filter = await request.validateUsing(productFilterSchema) + const result = await this.productsService.findAll(filter, page ?? 1, limit ?? 10) + return response.ok(respond(result)) + } + + /** + * @show + * @summary Get a product + * @description Returns a single product by id, including its category. Public endpoint. + * @paramPath id - The product id - @type(number) @required + * @responseBody 200 - + * @responseBody 404 - Product not found + */ + async show({ params, response }: HttpContext) { + const product = await this.productsService.findOne(Number(params.id)) + return response.ok(respond(product, 'Product detail')) + } + + /** + * @store + * @summary Create a product + * @description Creates a new product under an existing category. ADMIN only. + * @requestBody + * @responseBody 201 - + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Category not found + */ + async store({ request, response }: HttpContext) { + const data = await request.validateUsing(createProductSchema) + const product = await this.productsService.create(data) + return response.created(respond(product, 'Product created')) + } + + /** + * @update + * @summary Update a product + * @description Partially updates an existing product. ADMIN only. + * @paramPath id - The product id - @type(number) @required + * @requestBody + * @responseBody 200 - + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Product or category not found + */ + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updateProductSchema) + const product = await this.productsService.update(Number(params.id), data) + return response.ok(respond(product, 'Product updated')) + } + + /** + * @destroy + * @summary Delete a product + * @description Deletes a product. ADMIN only. Fails with 409 when the product still has orders associated with it. + * @paramPath id - The product id - @type(number) @required + * @responseBody 200 - Product deleted + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Product not found + * @responseBody 409 - Product still has orders associated with it + */ + async destroy({ params, response }: HttpContext) { + await this.productsService.remove(Number(params.id)) + return response.ok(respond(null, 'Product deleted')) + } +} From ae78b1c6b0c6ad57207a5bb209b9eebce5c889c0 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:51:26 +0400 Subject: [PATCH 5/8] feat(products): declare the product CRUD routes Adds the five routes from the README's feature/products Endpoints table: list and detail are public, and the three mutation routes are each individually declared behind .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]), the exact pattern the categories routes already establish just above them in this file. The :id param needs no new registration since router.where('id', router.matchers.number()) at the top of this file already covers every route using that param name, products included. --- start/routes.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/start/routes.ts b/start/routes.ts index fcfef1e..84b447d 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -25,6 +25,7 @@ const UsersController = () => import('#controllers/users_controller') const RolesController = () => import('#controllers/roles_controller') const PermissionsController = () => import('#controllers/permissions_controller') const CategoriesController = () => import('#controllers/categories_controller') +const ProductsController = () => import('#controllers/products_controller') /* |-------------------------------------------------------------------------- @@ -180,3 +181,30 @@ router router .delete('/api/v1/categories/:id', [CategoriesController, 'destroy']) .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) + +/* +|-------------------------------------------------------------------------- +| Products +|-------------------------------------------------------------------------- +| +| The list and detail routes are public, matching the README's Endpoints +| table for this branch. The three mutation routes are ADMIN only, applied +| individually at route declaration time with +| .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]), the +| same pattern the categories routes above use. The :id param here is +| already covered by the router.where('id', router.matchers.number()) +| matcher declared at the top of this file, so a non-numeric product id +| 404s at the routing layer without any extra registration. +| +*/ +router.get('/api/v1/products', [ProductsController, 'index']) +router.get('/api/v1/products/:id', [ProductsController, 'show']) +router + .post('/api/v1/products', [ProductsController, 'store']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +router + .put('/api/v1/products/:id', [ProductsController, 'update']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +router + .delete('/api/v1/products/:id', [ProductsController, 'destroy']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) From 46a3a261cdf473efad8573a7a9e3e062215377c8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:51:31 +0400 Subject: [PATCH 6/8] chore(codegen): regenerate the controllers registry Picks up ProductsController in the generated controllers map after the routes that reference it were added, following the same regeneration pattern the previous branches' codegen commits already establish. --- .adonisjs/server/controllers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 68e09da..30480c6 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -8,6 +8,7 @@ export const controllers = { Categories: () => import('#controllers/categories_controller'), Health: () => import('#controllers/health_controller'), Permissions: () => import('#controllers/permissions_controller'), + Products: () => import('#controllers/products_controller'), Roles: () => import('#controllers/roles_controller'), Users: () => import('#controllers/users_controller'), } From cce7e0ed90f5f3713569fff2bd75ed67d069a3ee Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:51:39 +0400 Subject: [PATCH 7/8] test(products): add unit tests for ProductsService Covers the full CRUD surface the same way categories_service.spec.ts does, plus the two behaviors specific to products: findAll filtering by categoryId returns only matching rows, and sorting by unitPrice actually orders the results. create is proven to throw a 404 when categoryId does not reference an existing category, update is proven to throw the same 404 when a categoryId in the payload does not exist, and remove is proven to throw the 409 E_PRODUCT_IN_USE error once an order references the product and to succeed once none do. Runs against the real test database inside a rolled-back global transaction, the same pattern categories_service.spec.ts uses, since the categoryId filter and the order-guard rule only mean anything with real category, product and order rows to check against. --- tests/unit/services/products_service.spec.ts | 246 +++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 tests/unit/services/products_service.spec.ts diff --git a/tests/unit/services/products_service.spec.ts b/tests/unit/services/products_service.spec.ts new file mode 100644 index 0000000..218801a --- /dev/null +++ b/tests/unit/services/products_service.spec.ts @@ -0,0 +1,246 @@ +/** + * Unit tests for ProductsService: the full CRUD surface, the dynamic + * categoryId filter, and the 409 business rule that a product with at + * least one order still associated with it cannot be deleted. + * + * Unlike CategoriesService, findAll here is not cached (the README does + * not ask for caching on products), so every test reads straight through + * to the database with no cache-invalidation ordering to worry about. + * + * These tests hit the real test database inside a rolled-back global + * transaction, the same pattern as categories_service.spec.ts, since both + * the categoryId filter and the 409 order-guard rule only mean anything + * once real category/order rows exist to filter against or reference. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import ProductsService from '#services/products_service' +import Product from '#models/product' +import Category from '#models/category' +import Customer from '#models/customer' +import Order from '#models/order' +import { uniqueName } from '#tests/helpers/test_utils' + +const productsService = new ProductsService() + +async function createCategory() { + return Category.create({ categoryName: uniqueName('CATEGORY') }) +} + +test.group('ProductsService - CRUD', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('findAll returns paginated results including a newly created product', async ({ + assert, + }) => { + const category = await createCategory() + const created = await productsService.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice: 9.99, + }) + + const result = await productsService.findAll({}, 1, 100) + + assert.properties(result, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + assert.equal(result.page, 1) + assert.equal(result.limit, 100) + assert.isTrue(result.items.some((item) => item.id === created.id)) + }).timeout(10000) + + test('findAll filters by categoryId, returning only matching products', async ({ assert }) => { + const categoryA = await createCategory() + const categoryB = await createCategory() + + const productA = await productsService.create({ + categoryId: categoryA.id, + productName: uniqueName('PRODUCT_A'), + unitPrice: 5, + }) + await productsService.create({ + categoryId: categoryB.id, + productName: uniqueName('PRODUCT_B'), + unitPrice: 7, + }) + + const result = await productsService.findAll({ categoryId: categoryA.id }, 1, 100) + + assert.isTrue(result.items.every((item) => item.categoryId === categoryA.id)) + assert.isTrue(result.items.some((item) => item.id === productA.id)) + }).timeout(10000) + + test('findAll sorts by unitPrice', async ({ assert }) => { + const category = await createCategory() + const cheap = await productsService.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT_CHEAP'), + unitPrice: 1, + }) + const expensive = await productsService.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT_EXPENSIVE'), + unitPrice: 1000, + }) + + const result = await productsService.findAll( + { categoryId: category.id, sortBy: 'unitPrice', order: 'asc' }, + 1, + 100 + ) + + const ids = result.items.map((item) => item.id) + assert.isBelow(ids.indexOf(cheap.id), ids.indexOf(expensive.id)) + }).timeout(10000) + + test('findOne returns the product with its category preloaded', async ({ assert }) => { + const category = await createCategory() + const created = await productsService.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice: 12.5, + }) + + const found = await productsService.findOne(created.id) + + assert.equal(found.id, created.id) + assert.equal(found.category.id, category.id) + }).timeout(10000) + + test('findOne throws a 404 for an unknown id', async ({ assert }) => { + try { + await productsService.findOne(999999999) + assert.fail('expected findOne to throw for an unknown id') + } catch (error: any) { + assert.equal(error.status, 404) + } + }).timeout(10000) + + test('create saves and returns the new product with its category preloaded', async ({ + assert, + }) => { + const category = await createCategory() + const productName = uniqueName('PRODUCT') + + const created = await productsService.create({ + categoryId: category.id, + productName, + unitPrice: 3.5, + }) + + assert.isNumber(created.id) + assert.equal(created.productName, productName) + assert.equal(created.category.id, category.id) + + const reloaded = await Product.findOrFail(created.id) + assert.equal(reloaded.productName, productName) + }).timeout(10000) + + test('create throws a 404 when the category does not exist', async ({ assert }) => { + try { + await productsService.create({ + categoryId: 999999999, + productName: uniqueName('PRODUCT'), + unitPrice: 3.5, + }) + assert.fail('expected create to throw when the category does not exist') + } catch (error: any) { + assert.equal(error.status, 404) + } + }).timeout(10000) + + test('update modifies an existing product', async ({ assert }) => { + const category = await createCategory() + const created = await productsService.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice: 3.5, + }) + const newName = uniqueName('PRODUCT_UPDATED') + + const updated = await productsService.update(created.id, { productName: newName }) + + assert.equal(updated.id, created.id) + assert.equal(updated.productName, newName) + + const reloaded = await Product.findOrFail(created.id) + assert.equal(reloaded.productName, newName) + }).timeout(10000) + + test('update throws a 404 when the new category does not exist', async ({ assert }) => { + const category = await createCategory() + const created = await productsService.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice: 3.5, + }) + + try { + await productsService.update(created.id, { categoryId: 999999999 }) + assert.fail('expected update to throw when the new category does not exist') + } catch (error: any) { + assert.equal(error.status, 404) + } + }).timeout(10000) + + test('remove deletes a product with no orders', async ({ assert }) => { + const category = await createCategory() + const created = await productsService.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice: 3.5, + }) + + await productsService.remove(created.id) + + const deleted = await Product.find(created.id) + assert.isNull(deleted) + }).timeout(10000) + + test('remove throws E_PRODUCT_IN_USE (409) when the product has orders', async ({ assert }) => { + const category = await createCategory() + const product = await productsService.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice: 3.5, + }) + const customer = await Customer.create({ + firstName: 'Test', + lastName: 'Customer', + telephone: '555-0100', + email: `${uniqueName('customer')}@example.com`, + address: '123 Test St', + }) + await Order.create({ + customerId: customer.id, + productId: product.id, + quantity: 2, + total: product.unitPrice * 2, + }) + + try { + await productsService.remove(product.id) + assert.fail('expected remove to throw while an order still references the product') + } catch (error: any) { + assert.equal(error.code, 'E_PRODUCT_IN_USE') + assert.equal(error.status, 409) + } + + const stillExists = await Product.find(product.id) + assert.isNotNull(stillExists) + }).timeout(10000) +}) From de814f5742817595d8c58fd602fa01dfd4e9914d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:51:46 +0400 Subject: [PATCH 8/8] test(products): add functional tests for the products HTTP surface Follows categories.spec.ts's pattern closely: a full create/list/read/ update/delete lifecycle over real HTTP requests asserting every response matches the ApiResponse envelope, plus products-specific coverage the categories spec has no equivalent of. That includes the nested category object appearing in both the list and detail responses, categoryId filtering and unitPrice sorting together on the list endpoint, a 404 when POSTing with a categoryId that does not exist, and the 409 order-guard on delete succeeding once the blocking order is removed. A separate mutation-route-authorization group proves the same auth matrix categories.spec.ts proves for its own mutation routes: POST without a token returns 401, with a USER token returns 403, and with an ADMIN token returns 201, confirming the routes are actually gated the way start/routes.ts declares them rather than just trusting the declaration. --- tests/functional/products.spec.ts | 246 ++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 tests/functional/products.spec.ts diff --git a/tests/functional/products.spec.ts b/tests/functional/products.spec.ts new file mode 100644 index 0000000..64d923d --- /dev/null +++ b/tests/functional/products.spec.ts @@ -0,0 +1,246 @@ +/** + * Functional tests for the products HTTP surface: the full CRUD lifecycle + * over real requests (list, detail, create, update, delete), filtering by + * categoryId, sorting by unitPrice, the nested category in list/detail + * responses, and the auth matrix for the three ADMIN-only mutation routes + * (no token -> 401, USER token -> 403, ADMIN token -> success). + * + * The unit spec for ProductsService already proves the category-existence + * checks and the 409 order-guard throw the right errors at the service + * layer; what this file still proves is that the HTTP layer surfaces all + * of that correctly through the ApiResponse envelope, and that the + * list/detail routes really are public while the mutation routes really + * are gated by AuthMiddleware and RoleMiddleware, the same split + * categories.spec.ts already proves for the categories surface. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import Category from '#models/category' +import Customer from '#models/customer' +import Order from '#models/order' +import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' +import { body, uniqueName } from '#tests/helpers/test_utils' + +async function createCategory() { + return Category.create({ categoryName: uniqueName('CATEGORY') }) +} + +test.group('Products - CRUD lifecycle', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('create, list, read, update, then delete a product, every response matching ApiResponse and including its category', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const category = await createCategory() + + // POST /api/v1/products + const productName = uniqueName('PRODUCT') + const createResponse = await client + .post('/api/v1/products') + .json({ categoryId: category.id, productName, unitPrice: 19.99 }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + assert.properties(body(createResponse), ['success', 'message', 'data', 'timestamp']) + const productId = body(createResponse).data.id as number + assert.equal(body(createResponse).data.productName, productName) + assert.equal(body(createResponse).data.category.id, category.id) + + // GET /api/v1/products (public, no token) + const listResponse = await client.get('/api/v1/products').qs({ page: 1, limit: 100 }) + listResponse.assertStatus(200) + assert.properties(body(listResponse), ['success', 'message', 'data', 'timestamp']) + assert.properties(body(listResponse).data, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + const listedProduct = body(listResponse).data.items.find( + (item: { id: number }) => item.id === productId + ) + assert.exists(listedProduct) + assert.equal(listedProduct.category.id, category.id) + + // GET /api/v1/products/:id (public, no token) + const showResponse = await client.get(`/api/v1/products/${productId}`) + showResponse.assertStatus(200) + assert.equal(body(showResponse).data.id, productId) + assert.equal(body(showResponse).data.productName, productName) + assert.equal(body(showResponse).data.category.id, category.id) + + // PUT /api/v1/products/:id + const newName = `${productName}_UPDATED` + const updateResponse = await client + .put(`/api/v1/products/${productId}`) + .json({ productName: newName }) + .bearerToken(adminToken) + updateResponse.assertStatus(200) + assert.equal(body(updateResponse).data.productName, newName) + + // DELETE /api/v1/products/:id + const deleteResponse = await client + .delete(`/api/v1/products/${productId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + assert.isTrue(body(deleteResponse).success) + + const afterDeleteResponse = await client.get(`/api/v1/products/${productId}`) + afterDeleteResponse.assertStatus(404) + assert.isFalse(body(afterDeleteResponse).success) + }).timeout(20000) + + test('POST /api/v1/products with an unknown categoryId returns 404', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const response = await client + .post('/api/v1/products') + .json({ categoryId: 999999999, productName: uniqueName('PRODUCT'), unitPrice: 5 }) + .bearerToken(adminToken) + response.assertStatus(404) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('GET /api/v1/products filters by categoryId and sorts by unitPrice', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const categoryA = await createCategory() + const categoryB = await createCategory() + + const cheapResponse = await client + .post('/api/v1/products') + .json({ categoryId: categoryA.id, productName: uniqueName('CHEAP'), unitPrice: 1 }) + .bearerToken(adminToken) + const cheapId = body(cheapResponse).data.id as number + + const expensiveResponse = await client + .post('/api/v1/products') + .json({ categoryId: categoryA.id, productName: uniqueName('EXPENSIVE'), unitPrice: 1000 }) + .bearerToken(adminToken) + const expensiveId = body(expensiveResponse).data.id as number + + await client + .post('/api/v1/products') + .json({ categoryId: categoryB.id, productName: uniqueName('OTHER_CATEGORY'), unitPrice: 50 }) + .bearerToken(adminToken) + + const filteredResponse = await client.get('/api/v1/products').qs({ + page: 1, + limit: 100, + categoryId: categoryA.id, + sortBy: 'unitPrice', + order: 'asc', + }) + filteredResponse.assertStatus(200) + + const items = body(filteredResponse).data.items as Array<{ id: number; categoryId: number }> + assert.isTrue(items.every((item) => item.categoryId === categoryA.id)) + const ids = items.map((item) => item.id) + assert.isBelow(ids.indexOf(cheapId), ids.indexOf(expensiveId)) + }).timeout(20000) + + test('deleting a product that still has orders returns 409, and succeeds once the order is removed', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const category = await createCategory() + + const createResponse = await client + .post('/api/v1/products') + .json({ categoryId: category.id, productName: uniqueName('PRODUCT'), unitPrice: 25 }) + .bearerToken(adminToken) + const productId = body(createResponse).data.id as number + + const customer = await Customer.create({ + firstName: 'Test', + lastName: 'Customer', + telephone: '555-0100', + email: `${uniqueName('customer')}@example.com`, + address: '123 Test St', + }) + const order = await Order.create({ + customerId: customer.id, + productId, + quantity: 1, + total: 25, + }) + + const blockedResponse = await client + .delete(`/api/v1/products/${productId}`) + .bearerToken(adminToken) + blockedResponse.assertStatus(409) + assert.isFalse(body(blockedResponse).success) + + await order.delete() + + const deleteResponse = await client + .delete(`/api/v1/products/${productId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + }).timeout(20000) +}) + +test.group('Products - mutation route authorization', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('POST /api/v1/products without a token returns 401', async ({ client, assert }) => { + const category = await createCategory() + + const response = await client + .post('/api/v1/products') + .json({ categoryId: category.id, productName: uniqueName('PRODUCT'), unitPrice: 5 }) + response.assertStatus(401) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('POST /api/v1/products with a USER token returns 403', async ({ client, assert }) => { + const userToken = await loginAsSeeded(client, 'user') + const category = await createCategory() + + const response = await client + .post('/api/v1/products') + .json({ categoryId: category.id, productName: uniqueName('PRODUCT'), unitPrice: 5 }) + .bearerToken(userToken) + response.assertStatus(403) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('POST /api/v1/products with an ADMIN token returns 201', async ({ client, assert }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const category = await createCategory() + + const response = await client + .post('/api/v1/products') + .json({ categoryId: category.id, productName: uniqueName('PRODUCT'), unitPrice: 5 }) + .bearerToken(adminToken) + response.assertStatus(201) + assert.isTrue(body(response).success) + }).timeout(10000) +})