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'), 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')) + } +} diff --git a/app/services/customers_service.ts b/app/services/customers_service.ts new file mode 100644 index 0000000..71b70f1 --- /dev/null +++ b/app/services/customers_service.ts @@ -0,0 +1,134 @@ +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. + * + * 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 email = data.email.toLowerCase() + const existing = await Customer.query().where('email', email).first() + if (existing) { + throw new E_EMAIL_TAKEN() + } + + return Customer.create({ ...data, email }) + } + + /** + * 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. Lowercased + * for the same reason create() lowercases it. + */ + async update(id: number, data: UpdateCustomerPayload): Promise { + const customer = await Customer.findOrFail(id) + + 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) + 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() + } +} 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(), + }) +) 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'] })]) 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) +}) 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) +})