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'), +} 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 diff --git a/adonisrc.ts b/adonisrc.ts index 7325796..ca6a6e3 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -72,9 +72,26 @@ 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 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'), ], /* 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 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')) + } +} 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 + ) {} +} 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(...)) + } +} 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() + } +} diff --git a/app/validators/order_validator.ts b/app/validators/order_validator.ts new file mode 100644 index 0000000..b78f1c3 --- /dev/null +++ b/app/validators/order_validator.ts @@ -0,0 +1,58 @@ +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. `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 + * 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().withoutDecimals(), + productId: vine.number().positive().withoutDecimals(), + quantity: vine.number().positive().withoutDecimals(), + }) +) + +/** + * 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().withoutDecimals().optional(), + productId: vine.number().positive().withoutDecimals().optional(), + quantity: vine.number().positive().withoutDecimals().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().withoutDecimals().optional(), + productId: vine.number().positive().withoutDecimals().optional(), + }) +) diff --git a/start/events.ts b/start/events.ts new file mode 100644 index 0000000..caff604 --- /dev/null +++ b/start/events.ts @@ -0,0 +1,33 @@ +/* +|-------------------------------------------------------------------------- +| 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 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') +}) 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'] })]) 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) }) 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) +}) 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) diff --git a/tests/unit/services/orders_service.spec.ts b/tests/unit/services/orders_service.spec.ts new file mode 100644 index 0000000..2289f47 --- /dev/null +++ b/tests/unit/services/orders_service.spec.ts @@ -0,0 +1,268 @@ +/** + * 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() + + // 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, + }) + + 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 }) => { + 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) +})