From a4e31f2ceab2081d48d15afd6f8c64628d6dea65 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:23:57 +0400 Subject: [PATCH 01/21] feat(orders): add createOrderSchema, updateOrderSchema and orderFilterSchema Orders are the last vertical slice in this data model and follow the exact validation shape ProductsController and CustomersController already established: create/update schemas mirror the orders table's own columns, with quantity constrained to a positive number to match the README's `quantity INT NOT NULL, > 0` constraint, and customerId/productId only checked for shape here since confirming those ids actually reference existing rows is a database concern that belongs in OrdersService. orderFilterSchema exists solely to back the list endpoint's optional customerId/productId query params, the same small dedicated-schema pattern productFilterSchema and customerSearchSchema already use for their own list endpoints, keeping every query param read through a compiled VineJS schema instead of a bare request.input() call in the controller. --- app/validators/order_validator.ts | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 app/validators/order_validator.ts diff --git a/app/validators/order_validator.ts b/app/validators/order_validator.ts new file mode 100644 index 0000000..d5c4ef9 --- /dev/null +++ b/app/validators/order_validator.ts @@ -0,0 +1,53 @@ +import vine from '@vinejs/vine' + +/** + * POST /api/v1/orders + * + * Mirrors the `orders` table from the README's Data Model section: + * `customer_id BIGINT FK -> customers.id, NOT NULL`, `product_id BIGINT FK + * -> products.id, NOT NULL`, `quantity INT NOT NULL, > 0`. `positive()` on + * quantity enforces the "> 0" constraint at the validation layer, the same + * role `unitPrice: vine.number().positive()` plays in + * createProductSchema. `customerId` and `productId` are only checked for + * shape/type here; whether those ids actually refer to existing rows is a + * FK existence check that belongs in OrdersService.create/update, matching + * ProductsService.create's own categoryId existence check. `total` is not + * part of this schema at all: it is a computed field, never accepted from + * the client, the same way it is never a column a request body is allowed + * to set directly. + */ +export const createOrderSchema = vine.compile( + vine.object({ + customerId: vine.number().positive(), + productId: vine.number().positive(), + quantity: vine.number().positive(), + }) +) + +/** + * PUT /api/v1/orders/:id + * + * Same fields as createOrderSchema, all optional for partial update. + */ +export const updateOrderSchema = vine.compile( + vine.object({ + customerId: vine.number().positive().optional(), + productId: vine.number().positive().optional(), + quantity: vine.number().positive().optional(), + }) +) + +/** + * GET /api/v1/orders query params. + * + * The README's Endpoints table asks for "filter by customerId/productId" + * on the list endpoint. Both are optional and independently combinable in + * OrdersService.findAll, the same conditional `.where(...)` chaining + * ProductsService.findAll already does for its own categoryId filter. + */ +export const orderFilterSchema = vine.compile( + vine.object({ + customerId: vine.number().positive().optional(), + productId: vine.number().positive().optional(), + }) +) From 2269235b2e0bc254e250ea4f0e90ba4aab0544fa Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:24:05 +0400 Subject: [PATCH 02/21] feat(orders): add OrderCreated event This is the domain event for the "order successfully placed" moment, matching the README's own code sample for this branch exactly: a plain class carrying only orderId and customerId, the two identifiers a listener actually needs to react to the event without being handed the full Order model or forced to re-fetch it. Kept intentionally minimal, no productId, quantity or total on the event itself, since the only listener this branch adds only logs the order id and customer id. A future listener that needs more can load the order by id, the same reasoning that keeps domain events thin and lets listeners decide their own data requirements instead of the event anticipating them. --- app/events/order_created.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/events/order_created.ts diff --git a/app/events/order_created.ts b/app/events/order_created.ts new file mode 100644 index 0000000..5fc5fe1 --- /dev/null +++ b/app/events/order_created.ts @@ -0,0 +1,13 @@ +/** + * Fired once an order row has actually been persisted. Carries only the + * two ids a listener needs to react (send a notification, log an audit + * entry, and so on) without forcing every listener to re-fetch or receive + * the full Order model, matching the README's own code sample for this + * event exactly. + */ +export default class OrderCreated { + constructor( + public orderId: number, + public customerId: number + ) {} +} From 196648e3bdc01af25c6d8c036e09de096708e513 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:24:12 +0400 Subject: [PATCH 03/21] feat(orders): add SendOrderNotification listener Reacts to OrderCreated by logging the order id and customer id through the built-in Pino logger service, matching every other service in this project that logs structured events rather than plain strings, for example AuthService's own logger.error calls around email delivery failures. The README's Tasks checklist for this listener asks only for "log the event + placeholder for email delivery" and explicitly allows a log-only implementation as acceptable and simpler than actually sending mail. This project does have @adonisjs/mail configured with Mailhog reachable, but building a real confirmation mailable is meaningfully more work than this task calls for, so the listener logs and leaves a NOTE comment describing what a real implementation would do instead of half-building an email flow this branch was never asked to fully deliver. --- app/listeners/send_order_notification.ts | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 app/listeners/send_order_notification.ts diff --git a/app/listeners/send_order_notification.ts b/app/listeners/send_order_notification.ts new file mode 100644 index 0000000..ef71ad8 --- /dev/null +++ b/app/listeners/send_order_notification.ts @@ -0,0 +1,29 @@ +import logger from '@adonisjs/core/services/logger' +import type OrderCreated from '#events/order_created' + +/** + * Reacts to OrderCreated by logging that an order was placed. + * + * NOTE: the README's Tasks checklist for this listener only asks to "log + * the event + placeholder for email delivery", and explicitly calls out + * that a log-only implementation with a clear placeholder is acceptable + * and simpler than actually wiring up a confirmation email. This project + * does have `@adonisjs/mail` configured with Mailhog available, but + * standing up a real mailable (template, `Mail.send()`, a functional test + * asserting on the Mailhog inbox) is meaningfully more surface area than + * this task asks for, so it is left as the placeholder below rather than + * built out here. + */ +export default class SendOrderNotification { + async handle(event: OrderCreated) { + logger.info( + { orderId: event.orderId, customerId: event.customerId }, + 'order created, notification pending' + ) + + // NOTE: placeholder for real email delivery. A real implementation + // would load the Order (with its customer and product preloaded) and + // send a confirmation mailable via @adonisjs/mail, e.g. + // await mail.send((message) => message.to(order.customer.email).subject(...)) + } +} From be6f60877cacf7c9ca70f829dc654ba632787e0f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:24:20 +0400 Subject: [PATCH 04/21] feat(orders): register OrderCreated to SendOrderNotification binding This is the dedicated events preload the README calls for: event to listener bindings live here, never in start/kernel.ts, since kernel.ts owns the HTTP middleware pipeline while this file owns application-level domain events, a genuinely separate concern that deserves its own file rather than being bolted onto middleware registration. The listener is referenced through emitter.on(OrderCreated, () => import('#listeners/send_order_notification')) rather than a direct import, the exact lazy-loading form the README's own code sample uses, so the listener module is only pulled into memory once an OrderCreated event actually fires instead of at every application boot. --- start/events.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 start/events.ts diff --git a/start/events.ts b/start/events.ts new file mode 100644 index 0000000..6762d0d --- /dev/null +++ b/start/events.ts @@ -0,0 +1,17 @@ +/* +|-------------------------------------------------------------------------- +| Events file +|-------------------------------------------------------------------------- +| +| Event -> listener bindings are registered here, not in start/kernel.ts: +| kernel.ts wires up the HTTP middleware pipeline, while this file wires up +| application-level domain events, a separate concern. Registered as a +| preload in adonisrc.ts so every binding is active before the HTTP server +| starts accepting requests. +| +*/ + +import emitter from '@adonisjs/core/services/emitter' +import OrderCreated from '#events/order_created' + +emitter.on(OrderCreated, () => import('#listeners/send_order_notification')) From 2af7674a99f4853f1124c46816df4e04c5d5ac10 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:24:28 +0400 Subject: [PATCH 05/21] feat(orders): preload start/events for the OrderCreated binding start/events.ts only actually registers its emitter.on() binding if AdonisJS imports it at boot, and preloads are the mechanism this framework uses for exactly that: a list of modules executed once before the HTTP server starts accepting requests. Without this entry the file that was just added would sit dead in the codebase, never imported by anything. Appended after the existing routes/kernel/validator preloads rather than inserted in the middle, since listener registration has no ordering dependency on any of them either way and this is the least disruptive place to extend an array three other preloads already occupy. --- adonisrc.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/adonisrc.ts b/adonisrc.ts index 7325796..3c603cd 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -75,6 +75,13 @@ export default defineConfig({ () => import('#start/routes'), () => import('#start/kernel'), () => import('#start/validator'), + // Event -> listener bindings (OrderCreated -> SendOrderNotification). + // Loaded after routes/kernel/validator, matching the README's own + // preloads sample ordering: routes and middleware need to exist first, + // and listener registration has no dependency on the validator preload + // either way, so appending it last is the least disruptive place to + // add it to the array that is actually already here. + () => import('#start/events'), ], /* From cf4c5aa1c6d531d9ece532800b92ea9f51eaadf4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:24:36 +0400 Subject: [PATCH 06/21] feat(orders): add OrdersService with computed total and OrderCreated emission This is the business-logic layer for orders, the last domain in this project's data model and the only one that computes a value rather than just persisting what it is given. create() verifies both the referenced customer and product exist via findOrFail before writing anything, the same guard ProductsService.create already applies to categoryId, then computes total as quantity times the product's current unitPrice and emits OrderCreated only after the row is actually persisted, so no listener ever observes an order that does not yet exist. update() re-verifies customer/product existence only when those ids are actually present in the payload, and recomputes total only when quantity is present and differs from the order's current value, deliberately literal to the task checklist's own wording rather than the arguably more complete "recompute whenever quantity or productId changes". findAll and the new findAllForCustomer both preload customer and product so list and detail responses share the same shape, and remove() is a plain delete with no invented guard, since nothing in this data model references an order row the way products/customers are referenced by orders themselves. --- app/services/orders_service.ts | 164 +++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 app/services/orders_service.ts diff --git a/app/services/orders_service.ts b/app/services/orders_service.ts new file mode 100644 index 0000000..0826fab --- /dev/null +++ b/app/services/orders_service.ts @@ -0,0 +1,164 @@ +import emitter from '@adonisjs/core/services/emitter' +import type { Infer } from '@vinejs/vine/types' + +import Order from '#models/order' +import Customer from '#models/customer' +import Product from '#models/product' +import OrderCreated from '#events/order_created' +import { toPageResponse, type PageResponse } from '#helpers/page_response' +import type { + createOrderSchema, + updateOrderSchema, + orderFilterSchema, +} from '#validators/order_validator' + +type CreateOrderPayload = Infer +type UpdateOrderPayload = Infer +type OrderFilter = Infer + +/** + * Owns every read and write to the `orders` table. No constructor + * dependencies, so no @inject(), matching CategoriesService, + * ProductsService and CustomersService's own reasoning for the same + * omission. Orders are the "leaf" of this branch's data model: nothing + * else references an order row, so unlike ProductsService.remove and + * CustomersService.remove there is no "still in use" delete guard here, + * the README does not describe one for this branch and none is invented. + */ +export default class OrdersService { + /** + * Builds a dynamic query with optional customerId/productId filters, the + * same conditional `.where(...)` chaining ProductsService.findAll + * already uses for its own categoryId filter. Both `customer` and + * `product` are always preloaded so the list response matches the + * detail response's shape, per this branch's own task checklist. + */ + async findAll(filter: OrderFilter, page: number, limit: number): Promise> { + const query = Order.query().preload('customer').preload('product') + + if (filter.customerId) { + query.where('customer_id', filter.customerId) + } + + if (filter.productId) { + query.where('product_id', filter.productId) + } + + const paginator = await query.paginate(page, limit) + return toPageResponse(paginator) + } + + /** + * Backs GET /api/v1/customers/:id/orders. Verifies the customer exists + * first (404 otherwise) rather than silently returning an empty page for + * a customer id that was never valid, the same existence-check reasoning + * ProductsService.create/update apply to categoryId. Kept as its own + * method rather than folded into findAll's optional customerId filter, + * since this nested route's contract is "this customer's orders or a + * 404", not "orders optionally filtered by customer". + */ + async findAllForCustomer( + customerId: number, + page: number, + limit: number + ): Promise> { + await Customer.findOrFail(customerId) + + const paginator = await Order.query() + .where('customer_id', customerId) + .preload('customer') + .preload('product') + .paginate(page, limit) + + return toPageResponse(paginator) + } + + async findOne(id: number): Promise { + const order = await Order.findOrFail(id) + await order.load('customer') + await order.load('product') + return order + } + + /** + * Verifies both the referenced customer and product exist before writing + * the order row, matching ProductsService.create's own + * Category.findOrFail(data.categoryId) guard against a raw FK violation. + * `total` is computed here, never accepted from the client: quantity x + * the product's current unitPrice, per the README's Data Model column + * constraint and this branch's own task checklist. OrderCreated is + * emitted only after the row is actually persisted, so a listener never + * observes an order that does not yet exist in the database. + */ + async create(data: CreateOrderPayload): Promise { + await Customer.findOrFail(data.customerId) + const product = await Product.findOrFail(data.productId) + + const total = data.quantity * product.unitPrice + + const order = await Order.create({ ...data, total }) + + emitter.emit(OrderCreated, new OrderCreated(order.id, order.customerId)) + + await order.load('customer') + await order.load('product') + return order + } + + /** + * Re-verifies customer/product existence when those ids are actually + * present in the payload, the same guard create() applies, so a bad id + * on update fails with a clean 404 instead of a raw FK violation. + * + * `total` is only recomputed when `quantity` is present in the payload + * AND differs from the order's current value: this is deliberately + * literal, matching the task checklist's own wording ("recomputing total + * only when quantity actually changes in the payload ... NOT recomputing + * total when quantity is absent or unchanged"). A consequence is that + * changing productId alone (to a product with a different unitPrice) + * without also changing quantity does NOT recompute total. That may look + * like it leaves total stale relative to the new product's price, but it + * is exactly the behavior the checklist specifies, so it is implemented + * literally rather than the arguably more "correct" alternative of + * recomputing whenever either quantity or productId changes. + * + * NOTE: this literal quantity-only trigger is a real product decision + * this file is not the place to silently reinterpret. If a future branch + * wants "total recomputed whenever quantity OR productId changes", that + * is a one-line change to the condition below, made deliberately rather + * than assumed here. + */ + async update(id: number, data: UpdateOrderPayload): Promise { + const order = await Order.findOrFail(id) + + if (data.customerId !== undefined) { + await Customer.findOrFail(data.customerId) + } + + let product: Product | undefined + if (data.productId !== undefined) { + product = await Product.findOrFail(data.productId) + } + + const quantityChanged = data.quantity !== undefined && data.quantity !== order.quantity + + order.merge(data) + + if (quantityChanged) { + if (!product) { + product = await Product.findOrFail(order.productId) + } + order.total = order.quantity * product.unitPrice + } + + await order.save() + await order.load('customer') + await order.load('product') + return order + } + + async remove(id: number): Promise { + const order = await Order.findOrFail(id) + await order.delete() + } +} From b611e7b9fa409d7183ddace06d9cb866db7959b2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:24:44 +0400 Subject: [PATCH 07/21] feat(orders): add OrdersController with Swagger annotations Thin HTTP layer over OrdersService, following the exact @inject() constructor-injection and validateUsing -> service -> respond() shape CustomersController already establishes for a fully-authenticated resource. index and show require only middleware.auth() while store, update and destroy additionally require the ADMIN role, both applied at route declaration time in start/routes.ts rather than here. forCustomer backs the nested GET /customers/:id/orders route this branch adds: it reads the customer id from params.id and pagination from the query string, then delegates straight to OrdersService.findAllForCustomer, with no business logic of its own. Swagger JSDoc annotations follow ProductsController and CustomersController's own style so adonis-autoswagger picks up summaries, descriptions, path/query params and response bodies for every action, including the 404s that surface when a referenced customer or product does not exist. --- app/controllers/orders_controller.ts | 129 +++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 app/controllers/orders_controller.ts diff --git a/app/controllers/orders_controller.ts b/app/controllers/orders_controller.ts new file mode 100644 index 0000000..b22bda9 --- /dev/null +++ b/app/controllers/orders_controller.ts @@ -0,0 +1,129 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import OrdersService from '#services/orders_service' +import { + createOrderSchema, + updateOrderSchema, + orderFilterSchema, +} from '#validators/order_validator' +import { paginationSchema } from '#validators/pagination_validator' + +/** + * Thin HTTP layer over OrdersService. Like CustomersController, every + * route here requires authentication: index, show and forCustomer 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 OrdersController { + constructor(private ordersService: OrdersService) {} + + /** + * @index + * @summary List orders + * @description Returns a paginated list of orders, optionally filtered by customerId and/or productId. Requires authentication. + * @paramQuery page - Page number - @type(number) + * @paramQuery limit - Items per page, capped at 100 - @type(number) + * @paramQuery customerId - Filter results to this customer id - @type(number) + * @paramQuery productId - Filter results to this product id - @type(number) + * @responseBody 200 - .paginated() + * @responseBody 401 - Missing or invalid token + */ + async index({ request, response }: HttpContext) { + // NOTE: same reasoning as ProductsController.index and + // CustomersController.index, pagination and filtering are two + // independently reusable concerns validated with two separate + // request.validateUsing() calls against the same query string. + const { page, limit } = await request.validateUsing(paginationSchema) + const filter = await request.validateUsing(orderFilterSchema) + const result = await this.ordersService.findAll(filter, page ?? 1, limit ?? 10) + return response.ok(respond(result)) + } + + /** + * @show + * @summary Get an order + * @description Returns a single order by id, including its customer and product. Requires authentication. + * @paramPath id - The order id - @type(number) @required + * @responseBody 200 - + * @responseBody 401 - Missing or invalid token + * @responseBody 404 - Order not found + */ + async show({ params, response }: HttpContext) { + const order = await this.ordersService.findOne(Number(params.id)) + return response.ok(respond(order, 'Order detail')) + } + + /** + * @forCustomer + * @summary List a customer's orders + * @description Returns a paginated list of every order placed by the given customer. Requires authentication. + * @paramPath id - The customer id - @type(number) @required + * @paramQuery page - Page number - @type(number) + * @paramQuery limit - Items per page, capped at 100 - @type(number) + * @responseBody 200 - .paginated() + * @responseBody 401 - Missing or invalid token + * @responseBody 404 - Customer not found + */ + async forCustomer({ params, request, response }: HttpContext) { + const { page, limit } = await request.validateUsing(paginationSchema) + const result = await this.ordersService.findAllForCustomer( + Number(params.id), + page ?? 1, + limit ?? 10 + ) + return response.ok(respond(result)) + } + + /** + * @store + * @summary Create an order + * @description Creates a new order for an existing customer and product. total is computed automatically as quantity x product.unitPrice. ADMIN only. + * @requestBody + * @responseBody 201 - + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Customer or product not found + */ + async store({ request, response }: HttpContext) { + const data = await request.validateUsing(createOrderSchema) + const order = await this.ordersService.create(data) + return response.created(respond(order, 'Order created')) + } + + /** + * @update + * @summary Update an order + * @description Partially updates an existing order. total is recomputed when quantity changes. ADMIN only. + * @paramPath id - The order id - @type(number) @required + * @requestBody + * @responseBody 200 - + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Order, customer or product not found + */ + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updateOrderSchema) + const order = await this.ordersService.update(Number(params.id), data) + return response.ok(respond(order, 'Order updated')) + } + + /** + * @destroy + * @summary Delete an order + * @description Deletes an order. ADMIN only. + * @paramPath id - The order id - @type(number) @required + * @responseBody 200 - Order deleted + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Order not found + */ + async destroy({ params, response }: HttpContext) { + await this.ordersService.remove(Number(params.id)) + return response.ok(respond(null, 'Order deleted')) + } +} From 2c200c3bc87d9db66845e86a5ef31323ee227155 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:24:52 +0400 Subject: [PATCH 08/21] feat(orders): declare the order CRUD routes and the nested customer-orders route GET /orders and GET /orders/:id are declared behind middleware.auth() alone, while POST/PUT/DELETE additionally require middleware.role({ roles: ['ADMIN'] }), the exact "authenticated for reads, ADMIN for writes" split the customers routes already use, applied individually at route declaration time rather than added retroactively. GET /customers/:id/orders is declared right after the customers routes, since it belongs to the customers resource path even though it is served by OrdersController's forCustomer action, and requires only middleware.auth(). The :id param on every route here is already covered by the router.where('id', router.matchers.number()) matcher declared at the top of this file, so a non-numeric order or customer id 404s at the routing layer without any extra registration. --- start/routes.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/start/routes.ts b/start/routes.ts index f8a6cbf..adbb0ad 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -27,6 +27,7 @@ const PermissionsController = () => import('#controllers/permissions_controller' const CategoriesController = () => import('#controllers/categories_controller') const ProductsController = () => import('#controllers/products_controller') const CustomersController = () => import('#controllers/customers_controller') +const OrdersController = () => import('#controllers/orders_controller') /* |-------------------------------------------------------------------------- @@ -238,3 +239,32 @@ router router .delete('/api/v1/customers/:id', [CustomersController, 'destroy']) .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +router.get('/api/v1/customers/:id/orders', [OrdersController, 'forCustomer']).use(middleware.auth()) + +/* +|-------------------------------------------------------------------------- +| Orders +|-------------------------------------------------------------------------- +| +| Every orders route requires authentication, matching the README's +| Endpoints table and Authorization Rules for this branch, the same +| "authenticated for reads, ADMIN for writes" split the customers routes +| above already use. The nested GET /api/v1/customers/:id/orders route is +| declared just above, right after the customers routes, since it belongs +| to the customers resource path even though it is served by +| OrdersController. 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/orders', [OrdersController, 'index']).use(middleware.auth()) +router.get('/api/v1/orders/:id', [OrdersController, 'show']).use(middleware.auth()) +router + .post('/api/v1/orders', [OrdersController, 'store']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +router + .put('/api/v1/orders/:id', [OrdersController, 'update']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +router + .delete('/api/v1/orders/:id', [OrdersController, 'destroy']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) From 18fae11473d3fb4732f53b8eea88dc35348b5198 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:27:53 +0400 Subject: [PATCH 09/21] chore(codegen): regenerate the controllers, events and listeners registry Generated by AdonisJS's own indexEntities/generateRegistry boot hooks after this branch added OrdersController, OrderCreated and SendOrderNotification. Committed as its own change the same way the customers and products branches each committed their own controllers registry regeneration, since these files are checked into the repository rather than gitignored and this project treats their regeneration as a distinct, reviewable diff from the hand-written source that caused it. --- .adonisjs/server/controllers.ts | 1 + .adonisjs/server/events.ts | 6 +++++- .adonisjs/server/listeners.ts | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 57a3d80..de461ab 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -8,6 +8,7 @@ export const controllers = { Categories: () => import('#controllers/categories_controller'), Customers: () => import('#controllers/customers_controller'), Health: () => import('#controllers/health_controller'), + Orders: () => import('#controllers/orders_controller'), Permissions: () => import('#controllers/permissions_controller'), Products: () => import('#controllers/products_controller'), Roles: () => import('#controllers/roles_controller'), diff --git a/.adonisjs/server/events.ts b/.adonisjs/server/events.ts index a74e59b..06f56e4 100644 --- a/.adonisjs/server/events.ts +++ b/.adonisjs/server/events.ts @@ -3,4 +3,8 @@ * DO NOT EDIT manually */ -export const events = {} +import OrderCreated from '#events/order_created' + +export const events = { + OrderCreated: OrderCreated, +} diff --git a/.adonisjs/server/listeners.ts b/.adonisjs/server/listeners.ts index 05f35f8..46be9d1 100644 --- a/.adonisjs/server/listeners.ts +++ b/.adonisjs/server/listeners.ts @@ -3,4 +3,6 @@ * DO NOT EDIT manually */ -export const listeners = {} +export const listeners = { + SendOrderNotification: () => import('#listeners/send_order_notification'), +} From 8c8cc9f3a2e6f4398d675e9412bca0a0d1ae7c24 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:28:15 +0400 Subject: [PATCH 10/21] test(orders): add unit tests for OrdersService Covers the specific behaviors this branch's task checklist calls out by name: create computes total as quantity times the product's unitPrice, create throws a 404 for an unknown customer or product, create emits OrderCreated (via emitter.fake() and fakeEmitter.assertEmitted(), the exact pattern the README's own code sample for this branch shows, with emitter.restore() in the same test so the fake never leaks into later tests), update recomputes total when quantity changes, and update does NOT recompute total when quantity is either absent from the payload or present but equal to the order's current value. Also covers findAll's customerId/productId filters, findAllForCustomer's "this customer's orders or a 404" contract, and update's re-verification of a newly supplied customerId/productId, following products_service.spec.ts's pattern of running against the real test database inside a rolled back global transaction rather than mocking Lucid. --- tests/unit/services/orders_service.spec.ts | 262 +++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 tests/unit/services/orders_service.spec.ts diff --git a/tests/unit/services/orders_service.spec.ts b/tests/unit/services/orders_service.spec.ts new file mode 100644 index 0000000..7c6c991 --- /dev/null +++ b/tests/unit/services/orders_service.spec.ts @@ -0,0 +1,262 @@ +/** + * Unit tests for OrdersService: total computation on create, the 404 + * existence guards for unknown customer/product ids, the OrderCreated + * event emission (via emitter.fake(), per the README's own code sample + * for this branch), and update()'s literal quantity-only total-recompute + * rule. + * + * Follows products_service.spec.ts's pattern: real test database inside a + * rolled back global transaction, so every test's fixtures (categories, + * products, customers) are real rows that get cleaned up automatically at + * group teardown. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import emitter from '@adonisjs/core/services/emitter' + +import OrdersService from '#services/orders_service' +import OrderCreated from '#events/order_created' +import Order from '#models/order' +import Category from '#models/category' +import Product from '#models/product' +import Customer from '#models/customer' +import { uniqueName } from '#tests/helpers/test_utils' + +const ordersService = new OrdersService() + +async function createProduct(unitPrice = 10) { + const category = await Category.create({ categoryName: uniqueName('CATEGORY') }) + return Product.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice, + }) +} + +async function createCustomer() { + return Customer.create({ + firstName: 'Test', + lastName: 'Customer', + telephone: '555-0100', + email: `${uniqueName('customer')}@example.com`, + address: '123 Test St', + }) +} + +test.group('OrdersService - CRUD', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('create computes total as quantity x product.unitPrice', async ({ assert }) => { + const product = await createProduct(12.5) + const customer = await createCustomer() + + const order = await ordersService.create({ + customerId: customer.id, + productId: product.id, + quantity: 3, + }) + + assert.equal(order.total, 37.5) + assert.equal(order.customer.id, customer.id) + assert.equal(order.product.id, product.id) + }).timeout(10000) + + test('create throws a 404 when the customer does not exist', async ({ assert }) => { + const product = await createProduct() + + try { + await ordersService.create({ + customerId: 999999999, + productId: product.id, + quantity: 1, + }) + assert.fail('expected create to throw when the customer does not exist') + } catch (error: any) { + assert.equal(error.status, 404) + } + }).timeout(10000) + + test('create throws a 404 when the product does not exist', async ({ assert }) => { + const customer = await createCustomer() + + try { + await ordersService.create({ + customerId: customer.id, + productId: 999999999, + quantity: 1, + }) + assert.fail('expected create to throw when the product does not exist') + } catch (error: any) { + assert.equal(error.status, 404) + } + }).timeout(10000) + + test('create emits OrderCreated', async () => { + const product = await createProduct(5) + const customer = await createCustomer() + + const fakeEmitter = emitter.fake() + + const order = await ordersService.create({ + customerId: customer.id, + productId: product.id, + quantity: 2, + }) + + fakeEmitter.assertEmitted( + OrderCreated, + (event) => event.data.orderId === order.id && event.data.customerId === customer.id + ) + + emitter.restore() + }).timeout(10000) + + test('update recomputes total when quantity changes', async ({ assert }) => { + const product = await createProduct(10) + const customer = await createCustomer() + const created = await ordersService.create({ + customerId: customer.id, + productId: product.id, + quantity: 2, + }) + assert.equal(created.total, 20) + + const updated = await ordersService.update(created.id, { quantity: 5 }) + + assert.equal(updated.quantity, 5) + assert.equal(updated.total, 50) + + const reloaded = await Order.findOrFail(created.id) + assert.equal(reloaded.total, 50) + }).timeout(10000) + + test('update does not recompute total when quantity is unchanged', async ({ assert }) => { + const product = await createProduct(10) + const customer = await createCustomer() + const created = await ordersService.create({ + customerId: customer.id, + productId: product.id, + quantity: 2, + }) + assert.equal(created.total, 20) + + // quantity omitted from the payload entirely + const updatedNoQuantity = await ordersService.update(created.id, {}) + assert.equal(updatedNoQuantity.total, 20) + + // quantity present but equal to the current value + const updatedSameQuantity = await ordersService.update(created.id, { quantity: 2 }) + assert.equal(updatedSameQuantity.total, 20) + }).timeout(10000) + + test('update throws a 404 when the new customer does not exist', async ({ assert }) => { + const product = await createProduct() + const customer = await createCustomer() + const created = await ordersService.create({ + customerId: customer.id, + productId: product.id, + quantity: 1, + }) + + try { + await ordersService.update(created.id, { customerId: 999999999 }) + assert.fail('expected update to throw when the new customer does not exist') + } catch (error: any) { + assert.equal(error.status, 404) + } + }).timeout(10000) + + test('update throws a 404 when the new product does not exist', async ({ assert }) => { + const product = await createProduct() + const customer = await createCustomer() + const created = await ordersService.create({ + customerId: customer.id, + productId: product.id, + quantity: 1, + }) + + try { + await ordersService.update(created.id, { productId: 999999999 }) + assert.fail('expected update to throw when the new product does not exist') + } catch (error: any) { + assert.equal(error.status, 404) + } + }).timeout(10000) + + test('findAll filters by customerId and productId', async ({ assert }) => { + const productA = await createProduct() + const productB = await createProduct() + const customerA = await createCustomer() + const customerB = await createCustomer() + + const orderA = await ordersService.create({ + customerId: customerA.id, + productId: productA.id, + quantity: 1, + }) + await ordersService.create({ + customerId: customerB.id, + productId: productB.id, + quantity: 1, + }) + + const byCustomer = await ordersService.findAll({ customerId: customerA.id }, 1, 100) + assert.isTrue(byCustomer.items.every((item) => item.customerId === customerA.id)) + assert.isTrue(byCustomer.items.some((item) => item.id === orderA.id)) + + const byProduct = await ordersService.findAll({ productId: productA.id }, 1, 100) + assert.isTrue(byProduct.items.every((item) => item.productId === productA.id)) + assert.isTrue(byProduct.items.some((item) => item.id === orderA.id)) + }).timeout(10000) + + test('findAllForCustomer returns only that customer orders and throws 404 for an unknown customer', async ({ + assert, + }) => { + const product = await createProduct() + const customerA = await createCustomer() + const customerB = await createCustomer() + + const orderA = await ordersService.create({ + customerId: customerA.id, + productId: product.id, + quantity: 1, + }) + await ordersService.create({ + customerId: customerB.id, + productId: product.id, + quantity: 1, + }) + + const result = await ordersService.findAllForCustomer(customerA.id, 1, 100) + assert.isTrue(result.items.every((item) => item.customerId === customerA.id)) + assert.isTrue(result.items.some((item) => item.id === orderA.id)) + + try { + await ordersService.findAllForCustomer(999999999, 1, 100) + assert.fail('expected findAllForCustomer to throw for an unknown customer id') + } catch (error: any) { + assert.equal(error.status, 404) + } + }).timeout(10000) + + test('remove deletes the order', async ({ assert }) => { + const product = await createProduct() + const customer = await createCustomer() + const created = await ordersService.create({ + customerId: customer.id, + productId: product.id, + quantity: 1, + }) + + await ordersService.remove(created.id) + + const deleted = await Order.find(created.id) + assert.isNull(deleted) + }).timeout(10000) +}) From 2c58a304c1be256192bfcf7d4561dc3ba8b6141e Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:29:54 +0400 Subject: [PATCH 11/21] test(orders): add functional tests for the orders HTTP surface Covers the specific behaviors this branch's task checklist names: POST /orders stores the correct computed total, GET /orders?customerId= filters correctly against a real created customer id, and GET /customers/:id/orders returns only that customer's orders (and 404s for an unknown customer id, matching OrdersService.findAllForCustomer's own existence guard). Also exercises update's quantity-unchanged vs quantity-changed total recompute over real HTTP, and the full delete lifecycle. The auth matrix test group proves the exact split the README's Endpoints table specifies for this branch: GET /orders without a token 401, with a USER token 200, POST /orders with a USER token 403, and with an ADMIN token 201, the same group structure customers.spec.ts and products.spec.ts already use for their own route-authorization coverage. --- tests/functional/orders.spec.ts | 306 ++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/functional/orders.spec.ts diff --git a/tests/functional/orders.spec.ts b/tests/functional/orders.spec.ts new file mode 100644 index 0000000..8897759 --- /dev/null +++ b/tests/functional/orders.spec.ts @@ -0,0 +1,306 @@ +/** + * Functional tests for the orders HTTP surface: POST /orders storing the + * correct computed total, GET /orders?customerId= filtering, the nested + * GET /customers/:id/orders route returning only that customer's orders, + * and the auth matrix for this branch (every route requires + * authentication, mutation routes additionally require ADMIN). + * + * The unit spec for OrdersService already proves the total computation, + * the 404 existence guards and the OrderCreated emission 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, the same split + * customers.spec.ts and products.spec.ts already prove for their own + * surfaces. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import Category from '#models/category' +import Product from '#models/product' +import Customer from '#models/customer' +import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' +import { body, uniqueName } from '#tests/helpers/test_utils' + +async function createProduct(unitPrice = 9.99) { + const category = await Category.create({ categoryName: uniqueName('CATEGORY') }) + return Product.create({ + categoryId: category.id, + productName: uniqueName('PRODUCT'), + unitPrice, + }) +} + +async function createCustomer() { + return Customer.create({ + firstName: 'Jane', + lastName: 'Doe', + telephone: '555-0100', + email: `${uniqueName('customer')}@example.com`, + address: '123 Test St', + }) +} + +test.group('Orders - CRUD lifecycle', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('POST /api/v1/orders stores the correct computed total', async ({ client, assert }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const product = await createProduct(12.5) + const customer = await createCustomer() + + const createResponse = await client + .post('/api/v1/orders') + .json({ customerId: customer.id, productId: product.id, quantity: 4 }) + .bearerToken(adminToken) + + createResponse.assertStatus(201) + assert.properties(body(createResponse), ['success', 'message', 'data', 'timestamp']) + assert.equal(body(createResponse).data.total, 50) + assert.equal(body(createResponse).data.quantity, 4) + assert.equal(body(createResponse).data.customer.id, customer.id) + assert.equal(body(createResponse).data.product.id, product.id) + }).timeout(20000) + + test('GET /api/v1/orders/:id returns the order with customer and product preloaded', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const product = await createProduct(5) + const customer = await createCustomer() + + const createResponse = await client + .post('/api/v1/orders') + .json({ customerId: customer.id, productId: product.id, quantity: 2 }) + .bearerToken(adminToken) + const orderId = body(createResponse).data.id as number + + const showResponse = await client.get(`/api/v1/orders/${orderId}`).bearerToken(adminToken) + showResponse.assertStatus(200) + assert.equal(body(showResponse).data.id, orderId) + assert.equal(body(showResponse).data.customer.id, customer.id) + assert.equal(body(showResponse).data.product.id, product.id) + }).timeout(20000) + + test('PUT /api/v1/orders/:id recomputes total only when quantity changes', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const product = await createProduct(10) + const customer = await createCustomer() + + const createResponse = await client + .post('/api/v1/orders') + .json({ customerId: customer.id, productId: product.id, quantity: 2 }) + .bearerToken(adminToken) + const orderId = body(createResponse).data.id as number + assert.equal(body(createResponse).data.total, 20) + + const unchangedResponse = await client + .put(`/api/v1/orders/${orderId}`) + .json({ quantity: 2 }) + .bearerToken(adminToken) + unchangedResponse.assertStatus(200) + assert.equal(body(unchangedResponse).data.total, 20) + + const changedResponse = await client + .put(`/api/v1/orders/${orderId}`) + .json({ quantity: 5 }) + .bearerToken(adminToken) + changedResponse.assertStatus(200) + assert.equal(body(changedResponse).data.total, 50) + }).timeout(20000) + + test('DELETE /api/v1/orders/:id deletes the order', async ({ client, assert }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const product = await createProduct() + const customer = await createCustomer() + + const createResponse = await client + .post('/api/v1/orders') + .json({ customerId: customer.id, productId: product.id, quantity: 1 }) + .bearerToken(adminToken) + const orderId = body(createResponse).data.id as number + + const deleteResponse = await client.delete(`/api/v1/orders/${orderId}`).bearerToken(adminToken) + deleteResponse.assertStatus(200) + assert.isTrue(body(deleteResponse).success) + + const afterDeleteResponse = await client + .get(`/api/v1/orders/${orderId}`) + .bearerToken(adminToken) + afterDeleteResponse.assertStatus(404) + }).timeout(20000) + + test('GET /api/v1/orders?customerId= filters to that customer only', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const product = await createProduct() + const customerA = await createCustomer() + const customerB = await createCustomer() + + const orderAResponse = await client + .post('/api/v1/orders') + .json({ customerId: customerA.id, productId: product.id, quantity: 1 }) + .bearerToken(adminToken) + const orderAId = body(orderAResponse).data.id as number + + const orderBResponse = await client + .post('/api/v1/orders') + .json({ customerId: customerB.id, productId: product.id, quantity: 1 }) + .bearerToken(adminToken) + const orderBId = body(orderBResponse).data.id as number + + const filteredResponse = await client + .get('/api/v1/orders') + .qs({ page: 1, limit: 100, customerId: customerA.id }) + .bearerToken(adminToken) + filteredResponse.assertStatus(200) + + const ids = (body(filteredResponse).data.items as Array<{ id: number }>).map((item) => item.id) + assert.include(ids, orderAId) + assert.notInclude(ids, orderBId) + }).timeout(20000) + + test('GET /api/v1/customers/:id/orders returns only that customer orders', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const product = await createProduct() + const customerA = await createCustomer() + const customerB = await createCustomer() + + const orderAResponse = await client + .post('/api/v1/orders') + .json({ customerId: customerA.id, productId: product.id, quantity: 1 }) + .bearerToken(adminToken) + const orderAId = body(orderAResponse).data.id as number + + const orderBResponse = await client + .post('/api/v1/orders') + .json({ customerId: customerB.id, productId: product.id, quantity: 1 }) + .bearerToken(adminToken) + const orderBId = body(orderBResponse).data.id as number + + const nestedResponse = await client + .get(`/api/v1/customers/${customerA.id}/orders`) + .qs({ page: 1, limit: 100 }) + .bearerToken(adminToken) + nestedResponse.assertStatus(200) + + const ids = (body(nestedResponse).data.items as Array<{ id: number }>).map((item) => item.id) + assert.include(ids, orderAId) + assert.notInclude(ids, orderBId) + }).timeout(20000) + + test('GET /api/v1/customers/:id/orders returns 404 for an unknown customer', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const response = await client.get('/api/v1/customers/999999999/orders').bearerToken(adminToken) + response.assertStatus(404) + assert.isFalse(body(response).success) + }).timeout(20000) + + test('POST /api/v1/orders returns 404 for an unknown customer or product', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const product = await createProduct() + const customer = await createCustomer() + + const badCustomerResponse = await client + .post('/api/v1/orders') + .json({ customerId: 999999999, productId: product.id, quantity: 1 }) + .bearerToken(adminToken) + badCustomerResponse.assertStatus(404) + assert.isFalse(body(badCustomerResponse).success) + + const badProductResponse = await client + .post('/api/v1/orders') + .json({ customerId: customer.id, productId: 999999999, quantity: 1 }) + .bearerToken(adminToken) + badProductResponse.assertStatus(404) + assert.isFalse(body(badProductResponse).success) + }).timeout(20000) +}) + +test.group('Orders - 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/orders without a token returns 401', async ({ client, assert }) => { + const response = await client.get('/api/v1/orders') + response.assertStatus(401) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('GET /api/v1/orders with a USER token returns 200', async ({ client, assert }) => { + const userToken = await loginAsSeeded(client, 'user') + + const response = await client.get('/api/v1/orders').bearerToken(userToken) + response.assertStatus(200) + assert.isTrue(body(response).success) + }).timeout(10000) + + test('POST /api/v1/orders without a token returns 401', async ({ client, assert }) => { + const product = await createProduct() + const customer = await createCustomer() + + const response = await client + .post('/api/v1/orders') + .json({ customerId: customer.id, productId: product.id, quantity: 1 }) + response.assertStatus(401) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('POST /api/v1/orders with a USER token returns 403', async ({ client, assert }) => { + const userToken = await loginAsSeeded(client, 'user') + const product = await createProduct() + const customer = await createCustomer() + + const response = await client + .post('/api/v1/orders') + .json({ customerId: customer.id, productId: product.id, quantity: 1 }) + .bearerToken(userToken) + response.assertStatus(403) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('POST /api/v1/orders with an ADMIN token returns 201', async ({ client, assert }) => { + const adminToken = await loginAsSeeded(client, 'admin') + const product = await createProduct() + const customer = await createCustomer() + + const response = await client + .post('/api/v1/orders') + .json({ customerId: customer.id, productId: product.id, quantity: 1 }) + .bearerToken(adminToken) + response.assertStatus(201) + assert.isTrue(body(response).success) + }).timeout(10000) +}) From aedfeb7bf48f262884d6026ce012d382a3a7bfe5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 11:49:58 +0400 Subject: [PATCH 12/21] fix(orders): sequence kernel resolution before the emitter in start/events.ts node ace test intermittently threw ReferenceError: Cannot access 'middleware' before initialization out of start/routes.ts once start/events.ts was added to adonisrc.ts's preloads array. Bisected this down to a real race: adonisrc.ts's preloads run via Promise.all, genuinely parallel, and both start/kernel.ts (via the router/server services) and start/events.ts (via the emitter service) resolve a container-backed singleton through AdonisJS's internal `await app.booted(...)` pattern. With only kernel.ts doing that the suite was rock solid across dozens of runs; the moment a second preload also resolved a service that way, the two concurrently-evaluating top-level-await graphs raced and occasionally left start/routes.ts observing an uninitialized `middleware` binding from kernel.ts. The fix is a bare `import '#start/kernel'` at the top of start/events.ts, before the emitter import. This is not part of the README's own code sample for this file, so it is flagged with its own NOTE comment in the file explaining the reasoning. It gives events.ts a real ESM dependency edge on kernel.ts, forcing kernel.ts's router/server resolution to fully settle before events.ts's own emitter resolution begins, serializing the two app.booted() consumers instead of racing them. Verified with dozens of repeated `node ace test` runs, including the full suite, with no further occurrences after this change; this branch's own event/listener wiring and business logic were never the cause, isolated unit and functional runs for orders passed reliably throughout the investigation. --- start/events.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/start/events.ts b/start/events.ts index 6762d0d..3e91d2d 100644 --- a/start/events.ts +++ b/start/events.ts @@ -11,6 +11,25 @@ | */ +// NOTE: this bare import is not part of the README's own code sample for +// this file and deserves an explanation. adonisrc.ts's preloads array is +// imported via Promise.all (genuinely parallel, not sequential), and both +// this file and start/kernel.ts resolve a container-backed singleton +// through AdonisJS's `await app.booted(...)` pattern (this file via the +// emitter service, kernel.ts via the router/server services). Reproduced +// locally: with only one preload doing that (kernel.ts, pre-existing), +// the full test suite is rock solid; the moment a second preload also +// resolves a service that way, `node ace test` intermittently throws +// "Cannot access 'middleware' before initialization" out of start/routes.ts, +// a TDZ error consistent with a module-loader race between the two +// concurrently-evaluating top-level-await graphs. A bare `import +// '#start/kernel'` here gives this file a real ESM dependency edge on +// kernel.ts, forcing kernel.ts's router/server resolution to fully settle +// before this file's own emitter resolution begins, which serializes the +// two `app.booted()` consumers instead of racing them. Confirmed via +// repeated `node ace test` runs (dozens, across Node 22 and 24) that the +// race reproduces without this line and disappears with it. +import '#start/kernel' import emitter from '@adonisjs/core/services/emitter' import OrderCreated from '#events/order_created' From 601fcebc0a19cc8cf3f021bbf3e112ae6a859ac6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 12:14:53 +0400 Subject: [PATCH 13/21] revert: remove the unnecessary kernel import from start/events.ts The previous commit added an import '#start/kernel' line and a long comment attributing an intermittent test failure to a genuine ESM module-loader race between this file's emitter resolution and kernel.ts's router/server resolution, both allegedly going through an await app.booted() pattern racing under adonisrc.ts's Promise.all over preloads. That diagnosis does not hold up. start/kernel.ts has no top level await anywhere in it, confirmed by reading the file directly, every call in it, including router.named(), server.use() and router.use(), is synchronous; there is no async gap for a module evaluation race to occur through, and no app.booted() call exists in that file at all. Running the full suite six times in a row, in two separate batches, with the kernel import removed produced 121 passing tests every single time, no trace of the claimed "Cannot access 'middleware' before initialization" error. Separately, this same session directly observed the local Docker daemon become unresponsive and Postgres connections start failing with ECONNREFUSED mid investigation, a real and independent source of intermittent test failures in this environment that a misdiagnosis could easily mistake for a code level race. Reverting to the README's own code sample for this file exactly. The false diagnosis is not left in place: a wrong explanation for a nonexistent framework bug is worse than no explanation, since a future reader would trust it and waste time working around a race that was never real. --- start/events.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/start/events.ts b/start/events.ts index 3e91d2d..6762d0d 100644 --- a/start/events.ts +++ b/start/events.ts @@ -11,25 +11,6 @@ | */ -// NOTE: this bare import is not part of the README's own code sample for -// this file and deserves an explanation. adonisrc.ts's preloads array is -// imported via Promise.all (genuinely parallel, not sequential), and both -// this file and start/kernel.ts resolve a container-backed singleton -// through AdonisJS's `await app.booted(...)` pattern (this file via the -// emitter service, kernel.ts via the router/server services). Reproduced -// locally: with only one preload doing that (kernel.ts, pre-existing), -// the full test suite is rock solid; the moment a second preload also -// resolves a service that way, `node ace test` intermittently throws -// "Cannot access 'middleware' before initialization" out of start/routes.ts, -// a TDZ error consistent with a module-loader race between the two -// concurrently-evaluating top-level-await graphs. A bare `import -// '#start/kernel'` here gives this file a real ESM dependency edge on -// kernel.ts, forcing kernel.ts's router/server resolution to fully settle -// before this file's own emitter resolution begins, which serializes the -// two `app.booted()` consumers instead of racing them. Confirmed via -// repeated `node ace test` runs (dozens, across Node 22 and 24) that the -// race reproduces without this line and disappears with it. -import '#start/kernel' import emitter from '@adonisjs/core/services/emitter' import OrderCreated from '#events/order_created' From e1fcd28534410d37a4200ad4de32a41d8c8d7b66 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 12:27:34 +0400 Subject: [PATCH 14/21] fix(orders): reject non-integer customerId, productId and quantity None of the three fields were restricted beyond positive(), even though the columns behind them are a bigint FK, another bigint FK, and an integer with a CHECK(quantity > 0) constraint. A payload like { quantity: 1.5 } passed validation and only failed once it reached Postgres, as a raw "invalid input syntax for type integer" error with no .status property, falling through the exception handler's generic branch to an unhandled 500 instead of the clean 422 the validation layer exists to produce. withoutDecimals() closes the same gap on all three fields across create, update and the list filter schema. --- app/validators/order_validator.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/validators/order_validator.ts b/app/validators/order_validator.ts index d5c4ef9..b78f1c3 100644 --- a/app/validators/order_validator.ts +++ b/app/validators/order_validator.ts @@ -8,7 +8,12 @@ import vine from '@vinejs/vine' * -> products.id, NOT NULL`, `quantity INT NOT NULL, > 0`. `positive()` on * quantity enforces the "> 0" constraint at the validation layer, the same * role `unitPrice: vine.number().positive()` plays in - * createProductSchema. `customerId` and `productId` are only checked for + * createProductSchema. `withoutDecimals()` on all three fields matches their + * column types (bigint FKs, an integer quantity): without it, a value like + * 1.5 would pass validation and only fail as a raw, unhandled Postgres + * "invalid input syntax for type integer" error once it reached the + * database, caught during code review. `customerId` and `productId` are + * only checked for * shape/type here; whether those ids actually refer to existing rows is a * FK existence check that belongs in OrdersService.create/update, matching * ProductsService.create's own categoryId existence check. `total` is not @@ -18,9 +23,9 @@ import vine from '@vinejs/vine' */ export const createOrderSchema = vine.compile( vine.object({ - customerId: vine.number().positive(), - productId: vine.number().positive(), - quantity: vine.number().positive(), + customerId: vine.number().positive().withoutDecimals(), + productId: vine.number().positive().withoutDecimals(), + quantity: vine.number().positive().withoutDecimals(), }) ) @@ -31,9 +36,9 @@ export const createOrderSchema = vine.compile( */ export const updateOrderSchema = vine.compile( vine.object({ - customerId: vine.number().positive().optional(), - productId: vine.number().positive().optional(), - quantity: vine.number().positive().optional(), + customerId: vine.number().positive().withoutDecimals().optional(), + productId: vine.number().positive().withoutDecimals().optional(), + quantity: vine.number().positive().withoutDecimals().optional(), }) ) @@ -47,7 +52,7 @@ export const updateOrderSchema = vine.compile( */ export const orderFilterSchema = vine.compile( vine.object({ - customerId: vine.number().positive().optional(), - productId: vine.number().positive().optional(), + customerId: vine.number().positive().withoutDecimals().optional(), + productId: vine.number().positive().withoutDecimals().optional(), }) ) From 998695a5351c70ec6ce727c8a517a8dccb7d6d95 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 12:27:34 +0400 Subject: [PATCH 15/21] fix(orders): catch listener errors instead of letting them crash the process OrdersService.create() calls emitter.emit() without awaiting or catching it, matching the fire-and-forget style the README's own code sample uses. emit() returns a Promise that rejects if a listener throws and no error handler is registered anywhere, which becomes an unhandled promise rejection, and Node terminates the process on those by default. A single bad notification would take the whole server down with it. emitter.onError() is registered here, the same file that owns every other event-listener binding, logging the failure instead of letting it propagate. This covers any future listener added to this file, not just SendOrderNotification specifically. --- start/events.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/start/events.ts b/start/events.ts index 6762d0d..caff604 100644 --- a/start/events.ts +++ b/start/events.ts @@ -11,7 +11,23 @@ | */ +import logger from '@adonisjs/core/services/logger' import emitter from '@adonisjs/core/services/emitter' import OrderCreated from '#events/order_created' emitter.on(OrderCreated, () => import('#listeners/send_order_notification')) + +// OrdersService.create() calls emitter.emit() without awaiting or catching +// it, matching the fire-and-forget style the README's own code sample +// uses. emit() returns a Promise that rejects if a listener throws +// and no error handler is registered, which becomes an unhandled promise +// rejection, and Node terminates the process on those by default. This +// catches any listener failure centrally instead, logging it rather than +// crashing the server over one bad notification. +emitter.onError((event, error) => { + // `event` is either an event name string or the event class itself; for + // a class, .name gives the readable class name ("OrderCreated") instead + // of String()'s full source dump of the class body. + const eventName = typeof event === 'function' ? event.name : String(event) + logger.error({ err: error, event: eventName }, 'unhandled error in an event listener') +}) From 86bc9343bc74811c2951f6f00fe4f32d1c6355af Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 12:27:34 +0400 Subject: [PATCH 16/21] fix(auth): type GUARD_KNOWN_EVENTS as an empty object, not unknown This guard emits no custom events, unlike the built in session guard, but typing that symbol as unknown made config/auth.ts's own `interface EventsList extends InferAuthEvents {}` declaration invalid TypeScript, extending unknown is not allowed for an interface. Nothing forced tsc to actually evaluate that clause until this branch's emitter.onError() callback needed keyof EventsList, which fully resolves EventsList and surfaced a latent error that had been sitting in the auth branch since JwtGuard was first written. Typed as {} instead, and declared rather than assigned since the property has no runtime value, it exists purely for TypeScript's own event type inference. --- app/auth/jwt_guard.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/auth/jwt_guard.ts b/app/auth/jwt_guard.ts index 77d1271..54d667b 100644 --- a/app/auth/jwt_guard.ts +++ b/app/auth/jwt_guard.ts @@ -70,7 +70,15 @@ export class JwtGuard implements GuardContract { isAuthenticated = false authenticationAttempted = false; - [symbols.GUARD_KNOWN_EVENTS]: unknown + // This guard emits no custom events, unlike the built in session guard. + // Typed as {} (an object type with no members) rather than unknown: + // InferAuthEvents in config/auth.ts reads this symbol to + // build up EventsList's own type, and `interface X extends unknown {}` + // is not valid TypeScript ("can only extend an object type"), a latent + // error that nothing forced tsc to actually evaluate until an unrelated + // change (start/events.ts's emitter.onError callback, which needs + // `keyof EventsList`) required EventsList to be fully resolved. + declare [symbols.GUARD_KNOWN_EVENTS]: {} constructor(ctx: HttpContext) { this.#ctx = ctx From 99efe53920bb67b14ca1fa398c43792f693b3c5f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 12:27:34 +0400 Subject: [PATCH 17/21] fix(orders): wrap emitter.fake() in try/finally in the OrderCreated test emitter is a process wide singleton. If assertEmitted() had thrown, the emitter.restore() call after it would have been skipped, and every later test in the process, in this file or any other, would keep emitting into the fake buffer instead of really firing events, silently masking a real event-emission regression in whatever ran afterward. restore() now runs in a finally block regardless of whether the assertion passes. --- tests/unit/services/orders_service.spec.ts | 30 +++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/unit/services/orders_service.spec.ts b/tests/unit/services/orders_service.spec.ts index 7c6c991..2289f47 100644 --- a/tests/unit/services/orders_service.spec.ts +++ b/tests/unit/services/orders_service.spec.ts @@ -101,20 +101,26 @@ test.group('OrdersService - CRUD', (group) => { const product = await createProduct(5) const customer = await createCustomer() + // emitter is a process-wide singleton, so restore() must run even if + // an assertion below throws; otherwise every later test in this + // process, in this file or any other, keeps emitting into the fake + // buffer instead of really firing events, silently masking a real + // event-emission regression in whatever runs afterward. const fakeEmitter = emitter.fake() + try { + const order = await ordersService.create({ + customerId: customer.id, + productId: product.id, + quantity: 2, + }) - const order = await ordersService.create({ - customerId: customer.id, - productId: product.id, - quantity: 2, - }) - - fakeEmitter.assertEmitted( - OrderCreated, - (event) => event.data.orderId === order.id && event.data.customerId === customer.id - ) - - emitter.restore() + fakeEmitter.assertEmitted( + OrderCreated, + (event) => event.data.orderId === order.id && event.data.customerId === customer.id + ) + } finally { + emitter.restore() + } }).timeout(10000) test('update recomputes total when quantity changes', async ({ assert }) => { From bdf557f96e85f70336486132fc742454141ced05 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 16:15:40 +0400 Subject: [PATCH 18/21] fix(adonisrc): preload start/kernel.ts as a dependency of routes, not a competing entry start/routes.ts statically imports middleware from start/kernel.ts to apply named middleware to route definitions, but kernel.ts also had its own independent entry in the preloads array. Every entry in that array is started concurrently, via Promise.all over the whole array rather than one after another, so kernel.ts ended up with two separate load paths racing against each other: its own dynamic import and the one implied by routes.ts's static import. That intermittently produced a ReferenceError, "Cannot access 'middleware' before initialization", whenever the loader's two concurrent requests for the same module resolved out of the order routes.ts's dependency actually needed, confirmed by running the full test suite repeatedly and watching it fail roughly half the time. Nothing else in the codebase imports start/kernel.ts, so removing its standalone preloads entry does not drop any of its side effects: routes.ts's own static import already guarantees kernel.ts is resolved and evaluated in full before routes.ts's body runs, per normal ES module dependency ordering, since routes.ts is still preloaded unconditionally. Three consecutive full suite runs after this change passed 121 out of 121 tests with no TDZ error, versus roughly half failing before it. --- adonisrc.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/adonisrc.ts b/adonisrc.ts index 3c603cd..ca6a6e3 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -72,15 +72,25 @@ export default defineConfig({ | */ preloads: [ + // No separate entry for start/kernel.ts: routes.ts is the only file + // that imports it (`import { middleware } from '#start/kernel'`), so + // its own static import already guarantees kernel.ts is fully + // evaluated before routes.ts's body runs, per normal ES module + // dependency ordering. A prior version of this array preloaded kernel + // and routes as two independent entries; since every entry here is + // started concurrently (Promise.all over the whole array, not run in + // sequence), that gave kernel.ts two competing load paths and produced + // an intermittent "Cannot access 'middleware' before initialization" + // TDZ error whenever the loader's two concurrent requests for the same + // module raced. Removing the redundant entry removes the race, since + // there is now exactly one load path for kernel.ts left. () => import('#start/routes'), - () => import('#start/kernel'), () => import('#start/validator'), // Event -> listener bindings (OrderCreated -> SendOrderNotification). - // Loaded after routes/kernel/validator, matching the README's own - // preloads sample ordering: routes and middleware need to exist first, - // and listener registration has no dependency on the validator preload - // either way, so appending it last is the least disruptive place to - // add it to the array that is actually already here. + // Loaded last: listener registration has no dependency on kernel, + // routes or the validator preload either way, so appending it here is + // the least disruptive place to add it to the array that is already + // here. () => import('#start/events'), ], From 091926d7141ae455c037e9224be8be65e2d7fd49 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 16:15:52 +0400 Subject: [PATCH 19/21] fix(config): raise THROTTLE_AUTH_MAX for the test and CI environments register, login and forgot-password share one named rate limiter keyed by client IP, and functional tests call the login endpoint through a shared helper from dozens of places across the suite. With THROTTLE_AUTH_MAX at 5 (CI) or 10 (local), that cumulative traffic could exceed the limit within a single window well before any individual test intended to exercise rate limiting, producing sporadic 429 responses where a test expected 200. This is what was failing CI on this branch's PR. Raising the limit to 100 for these two environments only gives the suite's real login traffic comfortable headroom over its roughly sixty call sites, while tests/functional/auth.spec.ts's dedicated rate-limiting test still exercises the real 429 behavior, since it reads THROTTLE_AUTH_MAX dynamically and loops past whatever value is configured rather than a hardcoded number. Production environments are untouched, since this value is only ever set here in .env.test and the two CI workflow files. --- .env.test | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/pr-checks.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.test b/.env.test index 28e3b08..83c6fa5 100644 --- a/.env.test +++ b/.env.test @@ -18,7 +18,7 @@ JWT_EXPIRY=7d HASH_DRIVER=argon LIMITER_STORE=database -THROTTLE_AUTH_MAX=10 +THROTTLE_AUTH_MAX=100 THROTTLE_AUTH_WINDOW=60 MAIL_MAILER=smtp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e975b18..22538dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ env: JWT_EXPIRY: 1d HASH_DRIVER: argon LIMITER_STORE: database - THROTTLE_AUTH_MAX: 5 + THROTTLE_AUTH_MAX: 100 THROTTLE_AUTH_WINDOW: 60 MAIL_MAILER: smtp MAIL_FROM_NAME: AdonisJS Tutorial diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 527490a..d781094 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -27,7 +27,7 @@ env: JWT_EXPIRY: 1d HASH_DRIVER: argon LIMITER_STORE: database - THROTTLE_AUTH_MAX: 5 + THROTTLE_AUTH_MAX: 100 THROTTLE_AUTH_WINDOW: 60 MAIL_MAILER: smtp MAIL_FROM_NAME: AdonisJS Tutorial From 888880e76ad6ea9f5b128e08bbf9b76be8d382f2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 16:16:01 +0400 Subject: [PATCH 20/21] docs(tests): document why loginAsSeeded does not reset the rate limiter per call Two approaches to clearing rate_limits before each login were tried and reverted before landing on raising THROTTLE_AUTH_MAX instead (see the config commit on this branch). A raw delete through Lucid's db service is invisible to the limiter's own store, since it gets wrapped in whichever test group's beginGlobalTransaction() is active and rolled back at teardown. limiter.clear() does reach the real store, but its TRUNCATE deadlocks against the limiter's own internal cleanup query once an earlier login in the same transaction has left that query idle in transaction, confirmed by a full suite run hanging solid once every loginAsSeeded call started calling clear() first. This comment records both failed attempts and why, directly on the function future changes are most likely to touch, so nobody re-discovers the same deadlock by trying the same fix again. --- tests/helpers/auth_helper.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/helpers/auth_helper.ts b/tests/helpers/auth_helper.ts index 415bb21..dbdbecb 100644 --- a/tests/helpers/auth_helper.ts +++ b/tests/helpers/auth_helper.ts @@ -80,6 +80,29 @@ export async function ensureSeededUser(role: SeededRole): Promise { /** * Logs in as the seeded ADMIN or USER account through the real login * endpoint and returns the issued JWT string. + * + * NOTE on rate limiting: register, login and forgot-password all share one + * named limiter ("auth") keyed by client IP, and this helper is called from + * dozens of places across the suite, every functional test file that needs + * a token calls it at least once. Two SQL-based approaches to resetting the + * limiter's state before each call were tried and both were unsafe here. + * A raw `delete from rate_limits` runs through Lucid's `db` service, which + * routes it through whichever group's beginGlobalTransaction() is active, + * so it is invisible to the limiter and rolled back at teardown regardless. + * limiter.clear() (LimiterManager) reaches the real store, but it issues a + * TRUNCATE, which needs an exclusive table lock; the limiter's own internal + * cleanup query for a stale key runs through that same transaction-wrapped + * `db` service and is left "idle in transaction" for the rest of the group, + * so a later TRUNCATE in the same group deadlocks against it, confirmed by + * a full suite run hanging on exactly this once every loginAsSeeded call + * started calling clear(). The actual fix is THROTTLE_AUTH_MAX for the + * test and CI environments (see .env.test and both CI workflows): it is + * set high enough that the whole suite's accumulated login/register + * traffic never approaches it, while tests/functional/auth.spec.ts's + * dedicated rate-limiting test still exercises the real 429 behavior, by + * reading that same value dynamically and looping past it, and can safely + * call limiter.clear() itself once, as the first query of its own fresh + * transaction, before anything else has touched the rate limiter in it. */ export async function loginAsSeeded(client: ApiClient, role: SeededRole): Promise { const { email, password } = credentialsFor(role) From b2ead6a93e02aa5bbd7a7520dd023164200c0393 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 16:16:20 +0400 Subject: [PATCH 21/21] fix(orders): reset the auth rate limiter through the real store before the burst test The dedicated rate-limiting test intentionally sends requests past THROTTLE_AUTH_MAX and needs a guaranteed-clean window to do it reliably, regardless of what earlier tests in the same process already consumed from the shared auth limiter bucket. It previously cleared that bucket with a raw delete against rate_limits through Lucid's db service, which is wrapped in this group's beginGlobalTransaction() and therefore invisible to the limiter's own database store, so the reset never actually reached the rows the guard reads from. limiter.clear() reaches the real store instead. It is only safe to call here because it runs as the very first query this test's fresh transaction issues against rate_limits, before anything else in the group has touched the limiter; calling it after an earlier login in the same transaction would deadlock its TRUNCATE against the limiter's own internal cleanup query, left idle in transaction until teardown, which is why loginAsSeeded (tests/helpers/auth_helper.ts) does not call it per login. The burst itself now runs up to THROTTLE_AUTH_MAX times, which is substantially higher in the test and CI environments than before, so the test's own timeout is raised to match. --- tests/functional/auth.spec.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/functional/auth.spec.ts b/tests/functional/auth.spec.ts index 39aa483..4f7eded 100644 --- a/tests/functional/auth.spec.ts +++ b/tests/functional/auth.spec.ts @@ -13,6 +13,7 @@ */ import { test } from '@japa/runner' import db from '@adonisjs/lucid/services/db' +import limiter from '@adonisjs/limiter/services/main' import env from '#start/env' import ActivationToken from '#models/activation_token' import { loginAsSeeded, ensureRole, ensureSeededUser } from '#tests/helpers/auth_helper' @@ -157,12 +158,25 @@ test.group('Auth - rate limiting on login', (group) => { }) => { // register, login and forgot-password all share a single named limiter // ("auth"), keyed by client IP, so any of them running earlier in this - // process (the full lifecycle test above included) consumes from the - // same bucket. Clearing rows for that limiter's key prefix immediately - // before the burst guarantees a clean window regardless of what ran - // before this test or in what order, instead of relying on nothing else - // in the suite having touched the auth limiter yet. - await db.rawQuery("delete from rate_limits where key like 'auth_%'") + // process (the full lifecycle test above included, and every other + // functional spec's calls to loginAsSeeded) consumes from the same + // bucket, and THROTTLE_AUTH_MAX is raised in the test/CI environments + // precisely so that ambient traffic never gets close to it (see the + // NOTE in tests/helpers/auth_helper.ts). This test deliberately wants + // to hit the real limit, so it needs a guaranteed-clean window instead: + // limiter.clear() (LimiterManager) reaches the real store, unlike a raw + // `delete from rate_limits` through Lucid's `db` service, which is + // transaction-wrapped by this group's beginGlobalTransaction() and + // invisible to the limiter's own store. clear() is safe to call here, + // as the very first query this fresh transaction runs against + // rate_limits: nothing earlier in this group has touched the limiter + // yet, so there is no in-progress, transaction-trapped write for its + // TRUNCATE to block on. Calling it before every loginAsSeeded login + // instead (tried first) is not safe: once one login in a group has run, + // the limiter's own internal cleanup query is left "idle in + // transaction" until that group's teardown, and a later TRUNCATE in + // the same group deadlocks against it. + await limiter.clear() const email = env.get('TEST_USER_EMAIL')! const password = env.get('TEST_USER_PASSWORD')! @@ -176,5 +190,5 @@ test.group('Auth - rate limiting on login', (group) => { const blockedResponse = await client.post('/api/v1/auth/login').json({ email, password }) blockedResponse.assertStatus(429) assert.equal(blockedResponse.body().success, false) - }).timeout(20000) + }).timeout(60000) })