From 658b9b62c5048dc7d1db128250a2334f9df697ab Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:09:03 +0400 Subject: [PATCH 1/8] feat(customers): add createCustomerSchema and updateCustomerSchema Customers introduce a field this project has not validated before, a telephone number, and the README leaves the exact pattern to this validator's own judgment the same way auth_validator.ts already documents its own choice of password complexity rule. The chosen pattern accepts a permissive but still bounded shape (optional leading plus, digits, spaces, hyphens, dots or parentheses, 7 to 20 characters) rather than trying to validate a specific country's numbering plan, which is a much larger problem than this branch needs to solve. Field length bounds mirror the README's Column Details table for the customers table exactly (first_name/last_name/address VARCHAR(255), telephone VARCHAR(50), email VARCHAR(255)), and a small customerSearchSchema is added alongside the create/update schemas so the list endpoint's ?search= query param gets a compiled schema of its own instead of being read directly in the controller, per this project's hard rule against inline VineJS schemas. --- app/validators/customer_validator.ts | 71 ++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/validators/customer_validator.ts diff --git a/app/validators/customer_validator.ts b/app/validators/customer_validator.ts new file mode 100644 index 0000000..770e296 --- /dev/null +++ b/app/validators/customer_validator.ts @@ -0,0 +1,71 @@ +import vine from '@vinejs/vine' + +/** + * Telephone format rule shared by create and update. + * + * The README's Tasks checklist asks for `telephone: vine.string().regex(...)` + * without naming the exact pattern, so this project settles on a permissive + * international-friendly shape: an optional leading `+`, then digits, + * spaces, hyphens, dots or parentheses, 7 to 20 characters long. This + * accepts common formats like `+1 (555) 010-0100` or `555-0100` without + * trying to fully validate a specific country's numbering plan, which is a + * much larger problem than this branch needs to solve. + * + * NOTE: this decision (the exact telephone pattern) is not spelled out in + * the README and is left to this validator's own judgment, the same way + * auth_validator.ts documents its own choice of password complexity rule. + */ +const telephonePattern = /^\+?[0-9()\-.\s]{7,20}$/ + +/** + * POST /api/v1/customers + * + * Field lengths mirror the `customers` table constraints from the README's + * Column Details table: first_name VARCHAR(255), last_name VARCHAR(255), + * telephone VARCHAR(50), email VARCHAR(255), address VARCHAR(255). Email + * uniqueness itself is enforced in CustomersService, not here, since a + * schema has no way to query the database. + */ +export const createCustomerSchema = vine.compile( + vine.object({ + firstName: vine.string().trim().minLength(1).maxLength(255), + lastName: vine.string().trim().minLength(1).maxLength(255), + telephone: vine.string().trim().regex(telephonePattern).maxLength(50), + email: vine.string().trim().maxLength(255).email(), + address: vine.string().trim().minLength(1).maxLength(255), + }) +) + +/** + * PUT /api/v1/customers/:id + * + * Same fields as createCustomerSchema, all optional for partial update. + */ +export const updateCustomerSchema = vine.compile( + vine.object({ + firstName: vine.string().trim().minLength(1).maxLength(255).optional(), + lastName: vine.string().trim().minLength(1).maxLength(255).optional(), + telephone: vine.string().trim().regex(telephonePattern).maxLength(50).optional(), + email: vine.string().trim().maxLength(255).email().optional(), + address: vine.string().trim().minLength(1).maxLength(255).optional(), + }) +) + +/** + * GET /api/v1/customers query params. + * + * NOTE: the README's own "ILIKE search in the service" code sample takes + * `search: string | undefined` straight into CustomersService.findAll, but + * does not say whether the controller reads it with its own compiled schema + * or a bare `request.input('search')`. Per this project's hard rule against + * inline VineJS schemas in controllers, `search` still gets a schema of its + * own here rather than being read directly, kept optional and unbounded in + * content (a search term is not a stored column, there is no natural max + * length to mirror), the same way paginationSchema is a small dedicated + * schema shared by the list endpoint. + */ +export const customerSearchSchema = vine.compile( + vine.object({ + search: vine.string().trim().optional(), + }) +) From 690cdebaf8a6c253b4ef31f693a0aea8f9619f51 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:09:12 +0400 Subject: [PATCH 2/8] feat(customers): add CustomersService with ILIKE search and email uniqueness Owns every read and write to the customers table. findAll builds its case-insensitive search across first name, last name and email exactly per the README's own "ILIKE search in the service" code sample, scoping the three orWhereILike calls inside a single where((q) => ...) group so a future filter added to this method never accidentally falls inside that OR chain. create and update enforce email uniqueness at the service layer, the same E_EMAIL_TAKEN 409 pattern UsersService.update already establishes for the same problem, including its "only re-check when the email is both present and actually different from the current value" idiom so updating a customer's address alone never trips a false conflict against their own row. remove enforces this branch's own business rule that a customer with at least one order still associated with it cannot be deleted, checked with the same related('orders').query().select('id').first() idiom ProductsService.remove already uses for its own delete guard against orders, made possible by the orders hasMany already present on the Customer model from an earlier branch. Like ProductsService, this service has no constructor dependencies and no caching, since the README does not ask for caching on the customers list. --- app/services/customers_service.ts | 121 ++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 app/services/customers_service.ts diff --git a/app/services/customers_service.ts b/app/services/customers_service.ts new file mode 100644 index 0000000..f9b4394 --- /dev/null +++ b/app/services/customers_service.ts @@ -0,0 +1,121 @@ +import { createError } from '@adonisjs/core/exceptions' +import type { Infer } from '@vinejs/vine/types' + +import Customer from '#models/customer' +import { toPageResponse, type PageResponse } from '#helpers/page_response' +import type { createCustomerSchema, updateCustomerSchema } from '#validators/customer_validator' + +type CreateCustomerPayload = Infer +type UpdateCustomerPayload = Infer + +const E_EMAIL_TAKEN = createError( + 'Email is already registered to another customer', + 'E_EMAIL_TAKEN', + 409 +) + +const E_CUSTOMER_HAS_ORDERS = createError( + 'Customer still has orders associated with it', + 'E_CUSTOMER_HAS_ORDERS', + 409 +) + +/** + * Owns every read and write to the `customers` table. No constructor + * dependencies, so no @inject(), matching CategoriesService and + * ProductsService's own reasoning for the same omission. Like + * ProductsService, the README does not ask for caching on the customers + * list, so findAll below queries straight through to the database on every + * call. + */ +export default class CustomersService { + /** + * Builds the search query per the README's own "ILIKE search in the + * service" code sample: when `search` is present, it is matched + * case-insensitively against firstName, lastName and email with a single + * `where((q) => ...)` group so the ORs stay scoped to that one condition + * and do not leak into any filter added later. + */ + async findAll( + search: string | undefined, + page: number, + limit: number + ): Promise> { + const query = Customer.query() + + if (search) { + query.where((q) => { + q.whereILike('first_name', `%${search}%`) + .orWhereILike('last_name', `%${search}%`) + .orWhereILike('email', `%${search}%`) + }) + } + + const paginator = await query.paginate(page, limit) + return toPageResponse(paginator) + } + + async findOne(id: number): Promise { + return Customer.findOrFail(id) + } + + /** + * Enforces email uniqueness at the service layer before writing the row, + * the same reasoning UsersService.update documents for its own + * E_EMAIL_TAKEN check: the database's own unique index on + * customers.email would already reject a duplicate, but only as a raw + * constraint violation with no clean .status a controller could act on. + */ + async create(data: CreateCustomerPayload): Promise { + const existing = await Customer.query().where('email', data.email).first() + if (existing) { + throw new E_EMAIL_TAKEN() + } + + return Customer.create(data) + } + + /** + * Only re-checks email uniqueness when `email` is present in the payload + * and actually differs from the customer's current value, the exact + * `if (data.email && data.email !== existing.email) { ... }` idiom + * UsersService.update uses for the same problem: updating a customer + * without touching their email (for example just their address) should + * never trip a false conflict against the customer's own row. + */ + async update(id: number, data: UpdateCustomerPayload): Promise { + const customer = await Customer.findOrFail(id) + + if (data.email && data.email !== customer.email) { + const existing = await Customer.query().where('email', data.email).whereNot('id', id).first() + if (existing) { + throw new E_EMAIL_TAKEN() + } + } + + customer.merge(data) + await customer.save() + return customer + } + + /** + * Business rule from the README: a customer with at least one order + * still associated with it cannot be deleted, 409 instead. Checked with + * the same `related('orders').query().select('id').first()` idiom + * ProductsService.remove uses for its own "still in use" guard, made + * possible by the `orders` hasMany already declared on the Customer + * model. This also doubles as the guard that keeps the orders table's + * own `customer_id` FK from ever actually cascading away order history + * through this service. + */ + async remove(id: number): Promise { + const customer = await Customer.findOrFail(id) + + const stillHasOrders = await customer.related('orders').query().select('id').first() + if (stillHasOrders) { + throw new E_CUSTOMER_HAS_ORDERS() + } + + await customer.delete() + } +} From d7010c89fd817c26e818ac76105e94b96404a617 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:09:19 +0400 Subject: [PATCH 3/8] feat(customers): add CustomersController with Swagger annotations Thin HTTP layer over CustomersService: every action validates its input with the compiled schemas from customer_validator.ts, delegates to the service, and shapes the response with respond(), never touching the Customer model directly. index validates pagination and search as two separate request.validateUsing() calls against the same query string, the same split ProductsController.index already uses for pagination and its own categoryId/sortBy/order filter, since both concerns are independently reusable and neither needs to know about the other. Swagger JSDoc annotations follow the same @index/@show/@store/@update/ @destroy tag style as CategoriesController and ProductsController, with 401 and 403 response bodies documented on every action since, unlike those two controllers, every route on this one requires authentication, not only the mutation routes. The actual auth and role middleware enforcement is wired up at route declaration time in start/routes.ts, not here. --- app/controllers/customers_controller.ts | 108 ++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 app/controllers/customers_controller.ts diff --git a/app/controllers/customers_controller.ts b/app/controllers/customers_controller.ts new file mode 100644 index 0000000..7ad2204 --- /dev/null +++ b/app/controllers/customers_controller.ts @@ -0,0 +1,108 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import CustomersService from '#services/customers_service' +import { + createCustomerSchema, + updateCustomerSchema, + customerSearchSchema, +} from '#validators/customer_validator' +import { paginationSchema } from '#validators/pagination_validator' + +/** + * Thin HTTP layer over CustomersService. Unlike CategoriesController and + * ProductsController, every route here requires authentication: index and + * show are declared behind `middleware.auth()` alone in start/routes.ts, + * while store, update and destroy additionally require + * `middleware.role({ roles: ['ADMIN'] })`, matching the README's Endpoints + * table for this branch. + */ +@inject() +export default class CustomersController { + constructor(private customersService: CustomersService) {} + + /** + * @index + * @summary List customers + * @description Returns a paginated list of customers, optionally filtered by ?search= matching first name, last name or email. Requires authentication. + * @paramQuery page - Page number - @type(number) + * @paramQuery limit - Items per page, capped at 100 - @type(number) + * @paramQuery search - Case-insensitive match against first name, last name or email - @type(string) + * @responseBody 200 - .paginated() + * @responseBody 401 - Missing or invalid token + */ + async index({ request, response }: HttpContext) { + // NOTE: same reasoning as ProductsController.index, pagination and + // search are independently reusable concerns validated with two + // separate request.validateUsing() calls against the same query string. + const { page, limit } = await request.validateUsing(paginationSchema) + const { search } = await request.validateUsing(customerSearchSchema) + const result = await this.customersService.findAll(search, page ?? 1, limit ?? 10) + return response.ok(respond(result)) + } + + /** + * @show + * @summary Get a customer + * @description Returns a single customer by id. Requires authentication. + * @paramPath id - The customer id - @type(number) @required + * @responseBody 200 - + * @responseBody 401 - Missing or invalid token + * @responseBody 404 - Customer not found + */ + async show({ params, response }: HttpContext) { + const customer = await this.customersService.findOne(Number(params.id)) + return response.ok(respond(customer, 'Customer detail')) + } + + /** + * @store + * @summary Create a customer + * @description Creates a new customer. ADMIN only. Fails with 409 when the email is already registered to another customer. + * @requestBody + * @responseBody 201 - + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 409 - Email is already registered to another customer + */ + async store({ request, response }: HttpContext) { + const data = await request.validateUsing(createCustomerSchema) + const customer = await this.customersService.create(data) + return response.created(respond(customer, 'Customer created')) + } + + /** + * @update + * @summary Update a customer + * @description Partially updates an existing customer. ADMIN only. Fails with 409 when the new email is already registered to another customer. + * @paramPath id - The customer id - @type(number) @required + * @requestBody + * @responseBody 200 - + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Customer not found + * @responseBody 409 - Email is already registered to another customer + */ + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updateCustomerSchema) + const customer = await this.customersService.update(Number(params.id), data) + return response.ok(respond(customer, 'Customer updated')) + } + + /** + * @destroy + * @summary Delete a customer + * @description Deletes a customer. ADMIN only. Fails with 409 when the customer still has orders associated with it. + * @paramPath id - The customer id - @type(number) @required + * @responseBody 200 - Customer deleted + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Customer not found + * @responseBody 409 - Customer still has orders associated with it + */ + async destroy({ params, response }: HttpContext) { + await this.customersService.remove(Number(params.id)) + return response.ok(respond(null, 'Customer deleted')) + } +} From 7cff9071c8d77fa01c59837af61475154fc4c060 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:09:26 +0400 Subject: [PATCH 4/8] feat(customers): declare the customer CRUD routes Adds GET/GET-detail/POST/PUT/DELETE under /api/v1/customers, matching the README's Endpoints table for this branch exactly. This is the first resource in the project where the list and detail routes are not public: both are declared with .use(middleware.auth()) alone rather than left unprotected, since the README's Authorization Rules table places customers and orders under "Authenticated" for GET, not "Public" the way categories and products are. The three mutation routes keep the same .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) pattern the categories and products mutation routes already use. The :id param is already covered by the router.where('id', router.matchers.number()) matcher declared once at the top of this file, so a non-numeric customer id 404s at the routing layer without any extra registration here. --- start/routes.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/start/routes.ts b/start/routes.ts index 84b447d..f8a6cbf 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -26,6 +26,7 @@ const RolesController = () => import('#controllers/roles_controller') const PermissionsController = () => import('#controllers/permissions_controller') const CategoriesController = () => import('#controllers/categories_controller') const ProductsController = () => import('#controllers/products_controller') +const CustomersController = () => import('#controllers/customers_controller') /* |-------------------------------------------------------------------------- @@ -208,3 +209,32 @@ router router .delete('/api/v1/products/:id', [ProductsController, 'destroy']) .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) + +/* +|-------------------------------------------------------------------------- +| Customers +|-------------------------------------------------------------------------- +| +| Unlike categories and products, every customers route requires +| authentication, matching the README's Endpoints table and Authorization +| Rules for this branch ("customers, orders -> Authenticated" for GET, +| "ADMIN role only" for mutations). The list and detail routes are declared +| with .use(middleware.auth()) alone; the three mutation routes add +| middleware.role({ roles: ['ADMIN'] }) on top, the same +| .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) pattern +| the categories and products mutation routes above already use. The :id +| param here is already covered by the router.where('id', +| router.matchers.number()) matcher declared at the top of this file. +| +*/ +router.get('/api/v1/customers', [CustomersController, 'index']).use(middleware.auth()) +router.get('/api/v1/customers/:id', [CustomersController, 'show']).use(middleware.auth()) +router + .post('/api/v1/customers', [CustomersController, 'store']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +router + .put('/api/v1/customers/:id', [CustomersController, 'update']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +router + .delete('/api/v1/customers/:id', [CustomersController, 'destroy']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) From 16c725fdaf721a24129aa4fdf9fe7c8a429e6f63 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:09:31 +0400 Subject: [PATCH 5/8] chore(codegen): regenerate the controllers registry Picks up CustomersController 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 30480c6..57a3d80 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -6,6 +6,7 @@ export const controllers = { Auth: () => import('#controllers/auth_controller'), Categories: () => import('#controllers/categories_controller'), + Customers: () => import('#controllers/customers_controller'), Health: () => import('#controllers/health_controller'), Permissions: () => import('#controllers/permissions_controller'), Products: () => import('#controllers/products_controller'), From 92be437d62ba989172566509a858fcc62f3c9d04 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:09:38 +0400 Subject: [PATCH 6/8] test(customers): add unit tests for CustomersService Covers the full CRUD surface plus the two business rules the README calls out for this branch: the ILIKE search filters correctly by first name, last name and email individually; create throws a 409 E_EMAIL_TAKEN for a duplicate email; update allows keeping the same email on the same customer while still throwing 409 when that email belongs to a different customer; and remove throws a 409 E_CUSTOMER_HAS_ORDERS once an order references the customer, then leaves the row in place. These tests hit the real test database inside a rolled-back global transaction, the same pattern products_service.spec.ts already uses, since both the search filters and the order-guard rule only mean anything once real customer and order rows exist to search or reference. --- tests/unit/services/customers_service.spec.ts | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 tests/unit/services/customers_service.spec.ts diff --git a/tests/unit/services/customers_service.spec.ts b/tests/unit/services/customers_service.spec.ts new file mode 100644 index 0000000..3b5e32f --- /dev/null +++ b/tests/unit/services/customers_service.spec.ts @@ -0,0 +1,226 @@ +/** + * Unit tests for CustomersService: the ILIKE search across first name, last + * name and email, the email-uniqueness guard on create, the "same email on + * the same record is fine" exception on update, and the 409 business rule + * that a customer with at least one order still associated with it cannot + * be deleted. + * + * Like ProductsService, findAll here is not cached (the README does not ask + * for caching on customers), 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 products_service.spec.ts, since the + * ILIKE search and the 409 order-guard rule only mean anything once real + * customer/order rows exist to search or reference. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import CustomersService from '#services/customers_service' +import Customer from '#models/customer' +import Product from '#models/product' +import Category from '#models/category' +import Order from '#models/order' +import { uniqueName } from '#tests/helpers/test_utils' + +const customersService = new CustomersService() + +function customerPayload(overrides: Partial> = {}) { + return { + firstName: 'Jane', + lastName: 'Doe', + telephone: '555-0100', + email: `${uniqueName('customer')}@example.com`, + address: '123 Test St', + ...overrides, + } +} + +async function createProduct() { + const category = await Category.create({ categoryName: uniqueName('CATEGORY') }) + return Product.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice: 9.99, + }) +} + +test.group('CustomersService - CRUD', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('findAll returns paginated results including a newly created customer', async ({ + assert, + }) => { + const created = await customersService.create(customerPayload()) + + const result = await customersService.findAll(undefined, 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 search filters by first name', async ({ assert }) => { + const marker = uniqueName('FIRSTNAME') + const target = await customersService.create(customerPayload({ firstName: marker })) + await customersService.create(customerPayload()) + + const result = await customersService.findAll(marker, 1, 100) + + assert.isTrue(result.items.every((item) => item.id === target.id)) + assert.isTrue(result.items.some((item) => item.id === target.id)) + }).timeout(10000) + + test('findAll search filters by last name', async ({ assert }) => { + const marker = uniqueName('LASTNAME') + const target = await customersService.create(customerPayload({ lastName: marker })) + await customersService.create(customerPayload()) + + const result = await customersService.findAll(marker, 1, 100) + + assert.isTrue(result.items.every((item) => item.id === target.id)) + assert.isTrue(result.items.some((item) => item.id === target.id)) + }).timeout(10000) + + test('findAll search filters by email', async ({ assert }) => { + const marker = uniqueName('emailmarker') + const target = await customersService.create( + customerPayload({ email: `${marker}@example.com` }) + ) + await customersService.create(customerPayload()) + + const result = await customersService.findAll(marker, 1, 100) + + assert.isTrue(result.items.every((item) => item.id === target.id)) + assert.isTrue(result.items.some((item) => item.id === target.id)) + }).timeout(10000) + + test('findOne returns the customer', async ({ assert }) => { + const created = await customersService.create(customerPayload()) + + const found = await customersService.findOne(created.id) + + assert.equal(found.id, created.id) + assert.equal(found.email, created.email) + }).timeout(10000) + + test('findOne throws a 404 for an unknown id', async ({ assert }) => { + try { + await customersService.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 customer', async ({ assert }) => { + const payload = customerPayload() + + const created = await customersService.create(payload) + + assert.isNumber(created.id) + assert.equal(created.email, payload.email) + + const reloaded = await Customer.findOrFail(created.id) + assert.equal(reloaded.email, payload.email) + }).timeout(10000) + + test('create throws a 409 for a duplicate email', async ({ assert }) => { + const payload = customerPayload() + await customersService.create(payload) + + try { + await customersService.create(customerPayload({ email: payload.email })) + assert.fail('expected create to throw for a duplicate email') + } catch (error: any) { + assert.equal(error.code, 'E_EMAIL_TAKEN') + assert.equal(error.status, 409) + } + }).timeout(10000) + + test('update allows keeping the same email on the same customer', async ({ assert }) => { + const created = await customersService.create(customerPayload()) + + const updated = await customersService.update(created.id, { + email: created.email, + address: '456 Updated Ave', + }) + + assert.equal(updated.email, created.email) + assert.equal(updated.address, '456 Updated Ave') + }).timeout(10000) + + test('update throws a 409 when the email belongs to another customer', async ({ assert }) => { + const first = await customersService.create(customerPayload()) + const second = await customersService.create(customerPayload()) + + try { + await customersService.update(second.id, { email: first.email }) + assert.fail('expected update to throw when the email belongs to another customer') + } catch (error: any) { + assert.equal(error.code, 'E_EMAIL_TAKEN') + assert.equal(error.status, 409) + } + }).timeout(10000) + + test('update modifies an existing customer', async ({ assert }) => { + const created = await customersService.create(customerPayload()) + + const updated = await customersService.update(created.id, { lastName: 'UpdatedLastName' }) + + assert.equal(updated.id, created.id) + assert.equal(updated.lastName, 'UpdatedLastName') + + const reloaded = await Customer.findOrFail(created.id) + assert.equal(reloaded.lastName, 'UpdatedLastName') + }).timeout(10000) + + test('remove deletes a customer with no orders', async ({ assert }) => { + const created = await customersService.create(customerPayload()) + + await customersService.remove(created.id) + + const deleted = await Customer.find(created.id) + assert.isNull(deleted) + }).timeout(10000) + + test('remove throws E_CUSTOMER_HAS_ORDERS (409) when the customer has orders', async ({ + assert, + }) => { + const customer = await customersService.create(customerPayload()) + const product = await createProduct() + await Order.create({ + customerId: customer.id, + productId: product.id, + quantity: 2, + total: product.unitPrice * 2, + }) + + try { + await customersService.remove(customer.id) + assert.fail('expected remove to throw while an order still references the customer') + } catch (error: any) { + assert.equal(error.code, 'E_CUSTOMER_HAS_ORDERS') + assert.equal(error.status, 409) + } + + const stillExists = await Customer.find(customer.id) + assert.isNotNull(stillExists) + }).timeout(10000) +}) From 178bff13ae99dc477e24ebeaaeb2385e38752021 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:09:45 +0400 Subject: [PATCH 7/8] test(customers): add functional tests for the customers HTTP surface Exercises the full CRUD lifecycle over real HTTP requests, email uniqueness enforced on both create and update, and search by first name/last name/email, all through the ApiResponse envelope. This file also proves the auth matrix the README specifies for this branch: unlike categories.spec.ts and products.spec.ts, GET /customers itself requires a token here (401 with none, 200 with a USER token), since this is the first resource where the list and detail routes are not public, while the three mutation routes still follow the familiar 401/403/2xx progression across no token, a USER token and an ADMIN token. The unit spec for CustomersService already proves the email-uniqueness and order-guard checks throw the right errors at the service layer; this file proves the HTTP layer surfaces all of that correctly and that AuthMiddleware and RoleMiddleware are wired up exactly as the README's Endpoints table describes. --- tests/functional/customers.spec.ts | 314 +++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 tests/functional/customers.spec.ts diff --git a/tests/functional/customers.spec.ts b/tests/functional/customers.spec.ts new file mode 100644 index 0000000..d8210d6 --- /dev/null +++ b/tests/functional/customers.spec.ts @@ -0,0 +1,314 @@ +/** + * Functional tests for the customers HTTP surface: the full CRUD lifecycle + * over real requests (list, detail, create, update, delete), search by + * first name/last name/email, email uniqueness enforced over HTTP, and the + * auth matrix for this branch. Unlike categories.spec.ts and + * products.spec.ts, every customers route requires authentication, not + * just the mutation routes, so the list and detail routes are exercised + * with a bearer token here instead of anonymously. + * + * The unit spec for CustomersService already proves the email-uniqueness + * and 409 order-guard checks 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 AuthMiddleware and + * RoleMiddleware are wired up exactly as the README's Endpoints table + * describes for this branch. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import Category from '#models/category' +import Product from '#models/product' +import Order from '#models/order' +import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' +import { body, uniqueName } from '#tests/helpers/test_utils' + +function customerPayload(overrides: Record = {}) { + return { + firstName: 'Jane', + lastName: 'Doe', + telephone: '555-0100', + email: `${uniqueName('customer')}@example.com`, + address: '123 Test St', + ...overrides, + } +} + +async function createProduct() { + const category = await Category.create({ categoryName: uniqueName('CATEGORY') }) + return Product.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice: 9.99, + }) +} + +test.group('Customers - 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 customer, every response matching ApiResponse', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const payload = customerPayload() + + // POST /api/v1/customers + const createResponse = await client + .post('/api/v1/customers') + .json(payload) + .bearerToken(adminToken) + createResponse.assertStatus(201) + assert.properties(body(createResponse), ['success', 'message', 'data', 'timestamp']) + const customerId = body(createResponse).data.id as number + assert.equal(body(createResponse).data.email, payload.email) + + // GET /api/v1/customers (authenticated) + const listResponse = await client + .get('/api/v1/customers') + .qs({ page: 1, limit: 100 }) + .bearerToken(adminToken) + listResponse.assertStatus(200) + assert.properties(body(listResponse), ['success', 'message', 'data', 'timestamp']) + assert.properties(body(listResponse).data, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + const listedCustomer = body(listResponse).data.items.find( + (item: { id: number }) => item.id === customerId + ) + assert.exists(listedCustomer) + + // GET /api/v1/customers/:id (authenticated) + const showResponse = await client.get(`/api/v1/customers/${customerId}`).bearerToken(adminToken) + showResponse.assertStatus(200) + assert.equal(body(showResponse).data.id, customerId) + assert.equal(body(showResponse).data.email, payload.email) + + // PUT /api/v1/customers/:id + const updateResponse = await client + .put(`/api/v1/customers/${customerId}`) + .json({ address: '456 Updated Ave' }) + .bearerToken(adminToken) + updateResponse.assertStatus(200) + assert.equal(body(updateResponse).data.address, '456 Updated Ave') + + // DELETE /api/v1/customers/:id + const deleteResponse = await client + .delete(`/api/v1/customers/${customerId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + assert.isTrue(body(deleteResponse).success) + + const afterDeleteResponse = await client + .get(`/api/v1/customers/${customerId}`) + .bearerToken(adminToken) + afterDeleteResponse.assertStatus(404) + assert.isFalse(body(afterDeleteResponse).success) + }).timeout(20000) + + test('POST /api/v1/customers with a duplicate email returns 409', async ({ client, assert }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const payload = customerPayload() + + const firstResponse = await client + .post('/api/v1/customers') + .json(payload) + .bearerToken(adminToken) + firstResponse.assertStatus(201) + + const secondResponse = await client + .post('/api/v1/customers') + .json(customerPayload({ email: payload.email })) + .bearerToken(adminToken) + secondResponse.assertStatus(409) + assert.isFalse(body(secondResponse).success) + }).timeout(20000) + + test('PUT /api/v1/customers/:id with an email belonging to another customer returns 409', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const firstResponse = await client + .post('/api/v1/customers') + .json(customerPayload()) + .bearerToken(adminToken) + const firstEmail = body(firstResponse).data.email as string + + const secondResponse = await client + .post('/api/v1/customers') + .json(customerPayload()) + .bearerToken(adminToken) + const secondId = body(secondResponse).data.id as number + + const updateResponse = await client + .put(`/api/v1/customers/${secondId}`) + .json({ email: firstEmail }) + .bearerToken(adminToken) + updateResponse.assertStatus(409) + assert.isFalse(body(updateResponse).success) + }).timeout(20000) + + test('GET /api/v1/customers?search= filters by first name, last name and email', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const firstNameMarker = uniqueName('FIRSTNAME') + const lastNameMarker = uniqueName('LASTNAME') + const emailMarker = uniqueName('emailmarker') + + const byFirstName = await client + .post('/api/v1/customers') + .json(customerPayload({ firstName: firstNameMarker })) + .bearerToken(adminToken) + const byFirstNameId = body(byFirstName).data.id as number + + const byLastName = await client + .post('/api/v1/customers') + .json(customerPayload({ lastName: lastNameMarker })) + .bearerToken(adminToken) + const byLastNameId = body(byLastName).data.id as number + + const byEmail = await client + .post('/api/v1/customers') + .json(customerPayload({ email: `${emailMarker}@example.com` })) + .bearerToken(adminToken) + const byEmailId = body(byEmail).data.id as number + + const firstNameResult = await client + .get('/api/v1/customers') + .qs({ page: 1, limit: 100, search: firstNameMarker }) + .bearerToken(adminToken) + firstNameResult.assertStatus(200) + const firstNameIds = (body(firstNameResult).data.items as Array<{ id: number }>).map( + (item) => item.id + ) + assert.includeMembers(firstNameIds, [byFirstNameId]) + assert.notInclude(firstNameIds, byLastNameId) + + const lastNameResult = await client + .get('/api/v1/customers') + .qs({ page: 1, limit: 100, search: lastNameMarker }) + .bearerToken(adminToken) + lastNameResult.assertStatus(200) + const lastNameIds = (body(lastNameResult).data.items as Array<{ id: number }>).map( + (item) => item.id + ) + assert.includeMembers(lastNameIds, [byLastNameId]) + assert.notInclude(lastNameIds, byFirstNameId) + + const emailResult = await client + .get('/api/v1/customers') + .qs({ page: 1, limit: 100, search: emailMarker }) + .bearerToken(adminToken) + emailResult.assertStatus(200) + const emailIds = (body(emailResult).data.items as Array<{ id: number }>).map((item) => item.id) + assert.includeMembers(emailIds, [byEmailId]) + assert.notInclude(emailIds, byFirstNameId) + }).timeout(20000) + + test('deleting a customer that still has orders returns 409, and succeeds once the order is removed', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const createResponse = await client + .post('/api/v1/customers') + .json(customerPayload()) + .bearerToken(adminToken) + const customerId = body(createResponse).data.id as number + + const product = await createProduct() + const order = await Order.create({ + customerId, + productId: product.id, + quantity: 1, + total: product.unitPrice, + }) + + const blockedResponse = await client + .delete(`/api/v1/customers/${customerId}`) + .bearerToken(adminToken) + blockedResponse.assertStatus(409) + assert.isFalse(body(blockedResponse).success) + + await order.delete() + + const deleteResponse = await client + .delete(`/api/v1/customers/${customerId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + }).timeout(20000) +}) + +test.group('Customers - route authorization', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('GET /api/v1/customers without a token returns 401', async ({ client, assert }) => { + const response = await client.get('/api/v1/customers') + response.assertStatus(401) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('GET /api/v1/customers with a USER token returns 200', async ({ client, assert }) => { + const userToken = await loginAsSeeded(client, 'user') + + const response = await client.get('/api/v1/customers').bearerToken(userToken) + response.assertStatus(200) + assert.isTrue(body(response).success) + }).timeout(10000) + + test('POST /api/v1/customers without a token returns 401', async ({ client, assert }) => { + const response = await client.post('/api/v1/customers').json(customerPayload()) + response.assertStatus(401) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('POST /api/v1/customers with a USER token returns 403', async ({ client, assert }) => { + const userToken = await loginAsSeeded(client, 'user') + + const response = await client + .post('/api/v1/customers') + .json(customerPayload()) + .bearerToken(userToken) + response.assertStatus(403) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('POST /api/v1/customers with an ADMIN token returns 201', async ({ client, assert }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const response = await client + .post('/api/v1/customers') + .json(customerPayload()) + .bearerToken(adminToken) + response.assertStatus(201) + assert.isTrue(body(response).success) + }).timeout(10000) +}) From 6995e22cb1e6f81f4f2dbf24501e4b0c404ce5ce Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:15:58 +0400 Subject: [PATCH 8/8] fix(customers): normalize email casing before checking uniqueness Neither create() nor update() lowercased the email before comparing or storing it, unlike every other email uniqueness check in this codebase (AuthService's register, login and forgotPassword, and UsersService.update all lowercase first). The customers.email column is a plain case-sensitive unique index, so Jane@Example.com and jane@example.com would have passed as distinct values and registered as two customers, defeating the "email uniqueness enforcement" this branch is specifically named for in the README. Both methods now lowercase the email before the uniqueness check and before the write, matching the pattern already established elsewhere, caught during code review before this shipped rather than after. --- app/services/customers_service.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/app/services/customers_service.ts b/app/services/customers_service.ts index f9b4394..71b70f1 100644 --- a/app/services/customers_service.ts +++ b/app/services/customers_service.ts @@ -65,14 +65,22 @@ export default class CustomersService { * E_EMAIL_TAKEN check: the database's own unique index on * customers.email would already reject a duplicate, but only as a raw * constraint violation with no clean .status a controller could act on. + * + * The email is lowercased before both the uniqueness check and the write, + * matching AuthService and UsersService's own normalization. Without it, + * Jane@Example.com and jane@example.com would pass this check as distinct + * values and register as two customers, defeating the "email uniqueness + * enforcement" this branch is specifically asked to provide, since the + * database's own unique index is exact-string and equally case-sensitive. */ async create(data: CreateCustomerPayload): Promise { - const existing = await Customer.query().where('email', data.email).first() + const email = data.email.toLowerCase() + const existing = await Customer.query().where('email', email).first() if (existing) { throw new E_EMAIL_TAKEN() } - return Customer.create(data) + return Customer.create({ ...data, email }) } /** @@ -81,16 +89,21 @@ export default class CustomersService { * `if (data.email && data.email !== existing.email) { ... }` idiom * UsersService.update uses for the same problem: updating a customer * without touching their email (for example just their address) should - * never trip a false conflict against the customer's own row. + * never trip a false conflict against the customer's own row. Lowercased + * for the same reason create() lowercases it. */ async update(id: number, data: UpdateCustomerPayload): Promise { const customer = await Customer.findOrFail(id) - if (data.email && data.email !== customer.email) { - const existing = await Customer.query().where('email', data.email).whereNot('id', id).first() - if (existing) { - throw new E_EMAIL_TAKEN() + if (data.email) { + const email = data.email.toLowerCase() + if (email !== customer.email) { + const existing = await Customer.query().where('email', email).whereNot('id', id).first() + if (existing) { + throw new E_EMAIL_TAKEN() + } } + data = { ...data, email } } customer.merge(data)