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'), } 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')) + } +} 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 } 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() + } +} 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(), + }) +) 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'] })]) 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) +}) 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) +})