From 2af7fdc428f3cb1c03894af103318a41a40479bc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 09:54:58 +0400 Subject: [PATCH 01/20] feat(categories): add createCategorySchema and updateCategorySchema Adds the VineJS validators for the categories endpoints, compiled with vine.compile() per the project's validation rules. categoryName is trimmed and bounded at maxLength(255) to match the categories.category_name VARCHAR(255) column from the core schema migration, with minLength(1) added on top since the column's own NOT NULL constraint does not by itself rule out a blank string, the same reasoning role_validator.ts already applies to roleName. updateCategorySchema reuses the same bound but marks the field optional so PUT requests can send a partial payload, following the createCategorySchema/updateCategorySchema pair the README's own code sample for this branch shows. --- app/validators/category_validator.ts | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 app/validators/category_validator.ts diff --git a/app/validators/category_validator.ts b/app/validators/category_validator.ts new file mode 100644 index 0000000..8d33a3b --- /dev/null +++ b/app/validators/category_validator.ts @@ -0,0 +1,29 @@ +import vine from '@vinejs/vine' + +/** + * POST /api/v1/categories + * + * `categoryName` mirrors the `categories.category_name VARCHAR(255) NOT + * NULL` column from the README's Data Model section (see the migration in + * database/migrations/1700000001_create_core_schema.ts). `minLength(1)` + * matches the same reasonable-lower-bound reasoning already applied to + * `roleName` in role_validator.ts, since the column itself has no minimum + * beyond NOT NULL and a blank name is not a useful category. + */ +export const createCategorySchema = vine.compile( + vine.object({ + categoryName: vine.string().trim().minLength(1).maxLength(255), + }) +) + +/** + * PUT /api/v1/categories/:id + * + * Optional since this is a partial update, though `categoryName` is the + * only field a category has to update. + */ +export const updateCategorySchema = vine.compile( + vine.object({ + categoryName: vine.string().trim().minLength(1).maxLength(255).optional(), + }) +) From b29c3f93cf1b38bcd5aeb159b21c02909b89e2e9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 09:55:07 +0400 Subject: [PATCH 02/20] feat(categories): add CategoriesService with paginated, cached listing Adds findAll, findOne, create, update and remove for the categories table, following the same shape RolesService and PermissionsService already established: no constructor dependencies so no @inject(), findOrFail for detail lookups so a missing id surfaces as a clean 404, and toPageResponse() wrapping Lucid's paginate() output rather than a hand rolled envelope. findAll is cached through @adonisjs/cache's getOrSet(), scoped to a dedicated 'categories' namespace and keyed by page and limit so different pages never collide or serve each other's results. create, update and remove all clear that namespace before returning rather than trying to reason about which cached page a given write affects, since any insert or delete shifts the contents of every later page anyway; a short TTL is kept as a backstop. remove also enforces the README's business rule that a category still referenced by at least one product cannot be deleted, throwing a 409 via createError the same way RolesService's E_ROLE_IN_USE does, which doubles as the guard that keeps the products table's own ON DELETE CASCADE foreign key from ever actually firing through this service. --- app/services/categories_service.ts | 95 ++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 app/services/categories_service.ts diff --git a/app/services/categories_service.ts b/app/services/categories_service.ts new file mode 100644 index 0000000..6aeaaa0 --- /dev/null +++ b/app/services/categories_service.ts @@ -0,0 +1,95 @@ +import { createError } from '@adonisjs/core/exceptions' +import type { Infer } from '@vinejs/vine/types' + +import cache from '@adonisjs/cache/services/main' +import Category from '#models/category' +import { toPageResponse, type PageResponse } from '#helpers/page_response' +import type { createCategorySchema, updateCategorySchema } from '#validators/category_validator' + +type CreateCategoryPayload = Infer +type UpdateCategoryPayload = Infer + +const E_CATEGORY_IN_USE = createError( + 'Category still has products associated with it', + 'E_CATEGORY_IN_USE', + 409 +) + +// NOTE: cache key/invalidation strategy. The README asks specifically for +// the categories list to be cached but leaves the exact key shape and +// invalidation approach to this branch's own judgment. Every findAll(page, +// limit) call is cached under its own key inside a dedicated 'categories' +// namespace (via cache.namespace()), the pattern bentocache's own README +// documents for "invalidate everything at once later". Keying by page and +// limit keeps different pages/page-sizes from colliding with (or serving) +// each other's results, and create/update/remove all clear the whole +// namespace rather than trying to compute which page(s) a given write +// affects, since a create or delete shifts every later page's contents +// anyway. A short 5 minute TTL is kept as a backstop in case a write path +// is ever added that forgets to clear the namespace. +const CATEGORIES_LIST_NAMESPACE = 'categories' +const CATEGORIES_LIST_TTL = '5m' + +function categoriesListCache() { + return cache.namespace(CATEGORIES_LIST_NAMESPACE) +} + +/** + * Owns every read and write to the `categories` table. No constructor + * dependencies, so no @inject(), matching RolesService and + * PermissionsService's own reasoning for the same omission. + */ +export default class CategoriesService { + async findAll(page: number, limit: number): Promise> { + return categoriesListCache().getOrSet({ + key: `list:page:${page}:limit:${limit}`, + ttl: CATEGORIES_LIST_TTL, + factory: async () => { + const paginator = await Category.query().paginate(page, limit) + return toPageResponse(paginator) + }, + }) + } + + async findOne(id: number): Promise { + return Category.findOrFail(id) + } + + async create(data: CreateCategoryPayload): Promise { + const category = await Category.create(data) + await categoriesListCache().clear() + return category + } + + async update(id: number, data: UpdateCategoryPayload): Promise { + const category = await Category.findOrFail(id) + category.merge(data) + await category.save() + await categoriesListCache().clear() + return category + } + + /** + * Business rule from the README: a category with at least one product + * still associated with it cannot be deleted, 409 instead. Checked by + * querying the `products` relation for a single match rather than + * counting every row, the same `related(...).query().first()` idiom + * RolesService.remove uses for its own "still assigned" guard, since the + * only thing that matters here is "does at least one exist". This also + * doubles as the guard that keeps the products table's own + * `category_id` FK (declared ON DELETE CASCADE in the migration) from + * ever actually cascading through this service: the delete below is + * only reached once no product references the category anymore. + */ + async remove(id: number): Promise { + const category = await Category.findOrFail(id) + + const stillInUse = await category.related('products').query().first() + if (stillInUse) { + throw new E_CATEGORY_IN_USE() + } + + await category.delete() + await categoriesListCache().clear() + } +} From 21f779ff90b58dc2dbd70e4ebd9ca603dff33e08 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 09:55:16 +0400 Subject: [PATCH 03/20] feat(categories): add CategoriesController with Swagger annotations Adds the thin HTTP layer over CategoriesService: index and show are public, store, update and destroy validate the request with request.validateUsing() against the category_validator schemas and delegate everything else to the service, matching the project's rule that controllers never touch a model or contain business logic directly. paginationSchema handles page and limit on the list endpoint the same way it already does for roles and permissions. Each action carries adonis-autoswagger JSDoc annotations (@summary, @description, @paramQuery/@paramPath, @requestBody, @responseBody) per this branch's own task checklist item. @requestBody references the exported validator names directly (createCategorySchema, updateCategorySchema) since adonis-autoswagger resolves schemas from app/validators/* by their exported identifier, and @responseBody uses the Category model schema autoswagger derives from app/models automatically. The paginated list response is documented with the package's own .paginated() helper as the closest built in approximation of this project's PageResponse envelope, since autoswagger has no first class notion of a custom pagination shape. --- app/controllers/categories_controller.ts | 94 ++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 app/controllers/categories_controller.ts diff --git a/app/controllers/categories_controller.ts b/app/controllers/categories_controller.ts new file mode 100644 index 0000000..1930aea --- /dev/null +++ b/app/controllers/categories_controller.ts @@ -0,0 +1,94 @@ +import { inject } from '@adonisjs/core' +import type { HttpContext } from '@adonisjs/core/http' + +import { respond } from '#helpers/api_response' +import CategoriesService from '#services/categories_service' +import { createCategorySchema, updateCategorySchema } from '#validators/category_validator' +import { paginationSchema } from '#validators/pagination_validator' + +/** + * Thin HTTP layer over CategoriesService. GET routes are public; the + * mutation routes (store, update, destroy) are declared behind + * `middleware.auth()` + `middleware.role({ roles: ['ADMIN'] })` in + * start/routes.ts, the same pattern RolesController and + * PermissionsController already use for their own mutation routes. + */ +@inject() +export default class CategoriesController { + constructor(private categoriesService: CategoriesService) {} + + /** + * @index + * @summary List categories + * @description Returns a paginated list of categories. Public endpoint, cached by page and limit. + * @paramQuery page - Page number - @type(number) + * @paramQuery limit - Items per page, capped at 100 - @type(number) + * @responseBody 200 - .paginated() + */ + async index({ request, response }: HttpContext) { + const { page, limit } = await request.validateUsing(paginationSchema) + const result = await this.categoriesService.findAll(page ?? 1, limit ?? 10) + return response.ok(respond(result)) + } + + /** + * @show + * @summary Get a category + * @description Returns a single category by id. Public endpoint. + * @paramPath id - The category id - @type(number) @required + * @responseBody 200 - + * @responseBody 404 - Category not found + */ + async show({ params, response }: HttpContext) { + const category = await this.categoriesService.findOne(Number(params.id)) + return response.ok(respond(category, 'Category detail')) + } + + /** + * @store + * @summary Create a category + * @description Creates a new category. ADMIN only. + * @requestBody + * @responseBody 201 - + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + */ + async store({ request, response }: HttpContext) { + const data = await request.validateUsing(createCategorySchema) + const category = await this.categoriesService.create(data) + return response.created(respond(category, 'Category created')) + } + + /** + * @update + * @summary Update a category + * @description Partially updates an existing category. ADMIN only. + * @paramPath id - The category id - @type(number) @required + * @requestBody + * @responseBody 200 - + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Category not found + */ + async update({ params, request, response }: HttpContext) { + const data = await request.validateUsing(updateCategorySchema) + const category = await this.categoriesService.update(Number(params.id), data) + return response.ok(respond(category, 'Category updated')) + } + + /** + * @destroy + * @summary Delete a category + * @description Deletes a category. ADMIN only. Fails with 409 when the category still has products associated with it. + * @paramPath id - The category id - @type(number) @required + * @responseBody 200 - Category deleted + * @responseBody 401 - Missing or invalid token + * @responseBody 403 - Caller is not an ADMIN + * @responseBody 404 - Category not found + * @responseBody 409 - Category still has products associated with it + */ + async destroy({ params, response }: HttpContext) { + await this.categoriesService.remove(Number(params.id)) + return response.ok(respond(null, 'Category deleted')) + } +} From 87b10288fd7428abf0b26e12411563644249d4d7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 09:55:24 +0400 Subject: [PATCH 04/20] feat(categories): declare the category CRUD routes Registers GET /api/v1/categories, GET /api/v1/categories/:id, POST /api/v1/categories, PUT /api/v1/categories/:id and DELETE /api/v1/categories/:id, matching the README's Endpoints table for this branch exactly on method, path and access. The two GET routes stay public; the three mutation routes are each chained individually with .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]), the exact per-route pattern the README's own routes.ts code sample for this branch shows, rather than a grouped prefix the way the user/role/permission administration routes use, since categories mixes public and ADMIN-only routes under the same resource. CategoriesController is imported lazily, consistent with every other controller in this file and required for adonis-autoswagger to pick up its JSDoc annotations. --- start/routes.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/start/routes.ts b/start/routes.ts index de533dc..b0bd423 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -25,6 +25,7 @@ const AuthController = () => import('#controllers/auth_controller') const UsersController = () => import('#controllers/users_controller') const RolesController = () => import('#controllers/roles_controller') const PermissionsController = () => import('#controllers/permissions_controller') +const CategoriesController = () => import('#controllers/categories_controller') /* |-------------------------------------------------------------------------- @@ -138,6 +139,30 @@ router .prefix('/api/v1') .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +/* +|-------------------------------------------------------------------------- +| Categories +|-------------------------------------------------------------------------- +| +| The list and detail routes are public, matching the README's Endpoints +| table for this branch. The three mutation routes are ADMIN only, applied +| individually at route declaration time with +| .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]), the +| exact pattern the README's own code sample for this branch shows. +| +*/ +router.get('/api/v1/categories', [CategoriesController, 'index']) +router.get('/api/v1/categories/:id', [CategoriesController, 'show']) +router + .post('/api/v1/categories', [CategoriesController, 'store']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +router + .put('/api/v1/categories/:id', [CategoriesController, 'update']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) +router + .delete('/api/v1/categories/:id', [CategoriesController, 'destroy']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) + /* |-------------------------------------------------------------------------- | Temporary middleware smoke-test stubs (feature/auth only) From 9b9ea922e62a8145d8874c03141311449827634a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:29:42 +0400 Subject: [PATCH 05/20] test(categories): cover CategoriesService's CRUD and the 409 business rule The full CRUD surface plus the rule that a category with at least one product still associated with it cannot be deleted, mirroring the pattern already established for RolesService and PermissionsService. findAll's caching is exercised implicitly rather than through the cache service directly, every test reads the list immediately after a write that clears the categories cache namespace, proving the invalidation actually happens rather than only trusting the TTL. --- .../unit/services/categories_service.spec.ts | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/unit/services/categories_service.spec.ts diff --git a/tests/unit/services/categories_service.spec.ts b/tests/unit/services/categories_service.spec.ts new file mode 100644 index 0000000..1e21567 --- /dev/null +++ b/tests/unit/services/categories_service.spec.ts @@ -0,0 +1,138 @@ +/** + * Unit tests for CategoriesService: the full CRUD surface plus the 409 + * business rule that a category with at least one product still + * associated with it cannot be deleted. + * + * findAll is cached (see the NOTE in categories_service.ts for the exact + * namespace/invalidation strategy), so every test that reads the list + * through the service does so immediately after a call to create/update/ + * remove, all of which clear the 'categories' cache namespace before + * returning. That ordering is what guarantees findAll observes the write + * that just happened rather than a stale cached page from an earlier test + * or from CategorySeeder's own seed data, without this test file having to + * reach into the cache service itself. + * + * These tests hit the real test database inside a rolled-back global + * transaction, the same pattern as roles_service.spec.ts, since the 409 + * rule only means anything once a real product row references the + * category being deleted. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import CategoriesService from '#services/categories_service' +import Category from '#models/category' +import Product from '#models/product' + +const categoriesService = new CategoriesService() + +function uniqueName(prefix: string) { + return `${prefix}_${crypto.randomUUID()}` +} + +test.group('CategoriesService - CRUD', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('findAll returns paginated results including a newly created category', async ({ + assert, + }) => { + const categoryName = uniqueName('CATEGORY') + const created = await categoriesService.create({ categoryName }) + + const result = await categoriesService.findAll(1, 100) + + assert.properties(result, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + assert.equal(result.page, 1) + assert.equal(result.limit, 100) + assert.isTrue(result.items.some((item) => item.id === created.id)) + }).timeout(10000) + + test('findOne returns the category for a valid id', async ({ assert }) => { + const categoryName = uniqueName('CATEGORY') + const created = await categoriesService.create({ categoryName }) + + const found = await categoriesService.findOne(created.id) + + assert.equal(found.id, created.id) + assert.equal(found.categoryName, categoryName) + }).timeout(10000) + + test('findOne throws a 404 for an unknown id', async ({ assert }) => { + try { + await categoriesService.findOne(999999999) + assert.fail('expected findOne to throw for an unknown id') + } catch (error: any) { + assert.equal(error.status, 404) + } + }).timeout(10000) + + test('create saves and returns the new category', async ({ assert }) => { + const categoryName = uniqueName('CATEGORY') + + const created = await categoriesService.create({ categoryName }) + + assert.isNumber(created.id) + assert.equal(created.categoryName, categoryName) + + const reloaded = await Category.findOrFail(created.id) + assert.equal(reloaded.categoryName, categoryName) + }).timeout(10000) + + test('update modifies an existing category', async ({ assert }) => { + const created = await categoriesService.create({ categoryName: uniqueName('CATEGORY') }) + const newName = uniqueName('CATEGORY_UPDATED') + + const updated = await categoriesService.update(created.id, { categoryName: newName }) + + assert.equal(updated.id, created.id) + assert.equal(updated.categoryName, newName) + + const reloaded = await Category.findOrFail(created.id) + assert.equal(reloaded.categoryName, newName) + }).timeout(10000) + + test('remove deletes a category with no products', async ({ assert }) => { + const created = await categoriesService.create({ categoryName: uniqueName('CATEGORY') }) + + await categoriesService.remove(created.id) + + const deleted = await Category.find(created.id) + assert.isNull(deleted) + }).timeout(10000) + + test('remove throws E_CATEGORY_IN_USE (409) when the category has products', async ({ + assert, + }) => { + const created = await categoriesService.create({ categoryName: uniqueName('CATEGORY') }) + await Product.create({ + categoryId: created.id, + productName: uniqueName('PRODUCT'), + unitPrice: 9.99, + }) + + try { + await categoriesService.remove(created.id) + assert.fail('expected remove to throw while a product still references the category') + } catch (error: any) { + assert.equal(error.code, 'E_CATEGORY_IN_USE') + assert.equal(error.status, 409) + } + + const stillExists = await Category.find(created.id) + assert.isNotNull(stillExists) + }).timeout(10000) +}) From e11f7c71ce9a811da73ff452c2dafebe2ee052f9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:29:42 +0400 Subject: [PATCH 06/20] test(categories): cover the categories HTTP surface end to end Full CRUD lifecycle over real requests, the 409 business rule surfaced through the HTTP layer rather than only the service, and the auth matrix for the three ADMIN-only mutation routes. Mirrors roles.spec.ts and permissions.spec.ts's own reasoning: the unit spec already proves correctness at the service layer, this proves the controller and route wiring actually expose it the same way. --- tests/functional/categories.spec.ts | 183 ++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/functional/categories.spec.ts diff --git a/tests/functional/categories.spec.ts b/tests/functional/categories.spec.ts new file mode 100644 index 0000000..8b7233a --- /dev/null +++ b/tests/functional/categories.spec.ts @@ -0,0 +1,183 @@ +/** + * Functional tests for the categories HTTP surface: the full CRUD + * lifecycle over real requests (list, detail, create, update, delete), the + * 409 business rule that a category still associated with at least one + * product cannot be deleted, and the auth matrix for the three ADMIN-only + * mutation routes (no token -> 401, USER token -> 403, ADMIN token -> + * success). + * + * The unit spec for CategoriesService already proves the 409 guard throws + * the right error at the service layer and that the caching in findAll + * does not serve stale data across a write; what this file still proves is + * that the HTTP layer surfaces all of that correctly through the + * ApiResponse envelope, and that the list/detail routes really are public + * while the mutation routes really are gated by AuthMiddleware and + * RoleMiddleware, the same split roles.spec.ts and the admin auth matrix + * already prove for the user/role/permission administration surface. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import Product from '#models/product' +import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' + +/** + * `response.body()` on a literal-path match (POST and GET both resolve to + * /api/v1/categories) is typed against the union of every action on that + * route, so TypeScript cannot narrow `.data` to the specific shape a given + * call actually returns. Same workaround roles.spec.ts and + * permissions.spec.ts already use. + */ +function body(response: { body(): unknown }): any { + return response.body() +} + +function uniqueName(prefix: string) { + return `${prefix}_${crypto.randomUUID()}` +} + +test.group('Categories - CRUD lifecycle', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('create, list, read, update, then delete a category, every response matching ApiResponse', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + // POST /api/v1/categories + const categoryName = uniqueName('CATEGORY') + const createResponse = await client + .post('/api/v1/categories') + .json({ categoryName }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + assert.properties(body(createResponse), ['success', 'message', 'data', 'timestamp']) + const categoryId = body(createResponse).data.id as number + assert.equal(body(createResponse).data.categoryName, categoryName) + + // GET /api/v1/categories (public, no token) + const listResponse = await client.get('/api/v1/categories').qs({ page: 1, limit: 100 }) + listResponse.assertStatus(200) + assert.properties(body(listResponse), ['success', 'message', 'data', 'timestamp']) + assert.properties(body(listResponse).data, [ + 'items', + 'total', + 'page', + 'limit', + 'totalPages', + 'hasNext', + 'hasPrevious', + ]) + assert.isTrue(body(listResponse).data.items.some((c: { id: number }) => c.id === categoryId)) + + // GET /api/v1/categories/:id (public, no token) + const showResponse = await client.get(`/api/v1/categories/${categoryId}`) + showResponse.assertStatus(200) + assert.equal(body(showResponse).data.id, categoryId) + assert.equal(body(showResponse).data.categoryName, categoryName) + + // PUT /api/v1/categories/:id + const newName = `${categoryName}_UPDATED` + const updateResponse = await client + .put(`/api/v1/categories/${categoryId}`) + .json({ categoryName: newName }) + .bearerToken(adminToken) + updateResponse.assertStatus(200) + assert.equal(body(updateResponse).data.categoryName, newName) + + // DELETE /api/v1/categories/:id + const deleteResponse = await client + .delete(`/api/v1/categories/${categoryId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + assert.isTrue(body(deleteResponse).success) + + const afterDeleteResponse = await client.get(`/api/v1/categories/${categoryId}`) + afterDeleteResponse.assertStatus(404) + assert.isFalse(body(afterDeleteResponse).success) + }).timeout(20000) + + test('deleting a category that still has products returns 409, and succeeds once the product is removed', async ({ + client, + assert, + }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const createResponse = await client + .post('/api/v1/categories') + .json({ categoryName: uniqueName('CATEGORY') }) + .bearerToken(adminToken) + createResponse.assertStatus(201) + const categoryId = body(createResponse).data.id as number + + const product = await Product.create({ + categoryId, + productName: uniqueName('PRODUCT'), + unitPrice: 12.5, + }) + + const blockedResponse = await client + .delete(`/api/v1/categories/${categoryId}`) + .bearerToken(adminToken) + blockedResponse.assertStatus(409) + assert.isFalse(body(blockedResponse).success) + + await product.delete() + + const deleteResponse = await client + .delete(`/api/v1/categories/${categoryId}`) + .bearerToken(adminToken) + deleteResponse.assertStatus(200) + }).timeout(20000) +}) + +test.group('Categories - mutation route authorization', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + await ensureSeededUser('admin') + await ensureSeededUser('user') + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('POST /api/v1/categories without a token returns 401', async ({ client, assert }) => { + const response = await client + .post('/api/v1/categories') + .json({ categoryName: uniqueName('CATEGORY') }) + response.assertStatus(401) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('POST /api/v1/categories with a USER token returns 403', async ({ client, assert }) => { + const userToken = await loginAsSeeded(client, 'user') + + const response = await client + .post('/api/v1/categories') + .json({ categoryName: uniqueName('CATEGORY') }) + .bearerToken(userToken) + response.assertStatus(403) + assert.isFalse(body(response).success) + }).timeout(10000) + + test('POST /api/v1/categories with an ADMIN token returns 201', async ({ client, assert }) => { + const adminToken = await loginAsSeeded(client, 'admin') + + const response = await client + .post('/api/v1/categories') + .json({ categoryName: uniqueName('CATEGORY') }) + .bearerToken(adminToken) + response.assertStatus(201) + assert.isTrue(body(response).success) + }).timeout(10000) +}) From 3815c7aa98c1260a2369b4e047fa1b9bbaacd2ed Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:29:55 +0400 Subject: [PATCH 07/20] refactor(categories): remove the temporary auth/role middleware smoke-test stubs feature/categories just added the first real ADMIN-protected route, categories, so the two /api/v1/_stub/* routes that existed solely to give feature/auth's middleware smoke test something to hit before any real protected resource existed are no longer needed, exactly as their own comment said they would be. The unused respond import that only those inline route handlers needed is removed too. --- start/routes.ts | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/start/routes.ts b/start/routes.ts index b0bd423..a255156 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -16,7 +16,6 @@ import router from '@adonisjs/core/services/router' import AutoSwagger from 'adonis-autoswagger' import { middleware } from '#start/kernel' import { authThrottle } from '#start/limiter' -import { respond } from '#helpers/api_response' import swagger from '#config/swagger' // Lazy import — AdonisJS resolves the module on the first matching request @@ -162,31 +161,3 @@ router router .delete('/api/v1/categories/:id', [CategoriesController, 'destroy']) .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) - -/* -|-------------------------------------------------------------------------- -| Temporary middleware smoke-test stubs (feature/auth only) -|-------------------------------------------------------------------------- -| -| feature/auth's task list requires a functional test proving AuthMiddleware -| and RoleMiddleware actually reject/allow requests (401 with no token, 200 -| with a valid USER token, 403 for USER / 200 for ADMIN behind role('ADMIN')). -| No real protected resource route exists yet - categories, products, -| customers and orders are all later branches - so these two routes exist -| solely to give that test something to hit. -| -| DELETE both routes once feature/categories adds its first ADMIN-protected -| route; they carry no business meaning of their own. -| -*/ -router - .get('/api/v1/_stub/protected', ({ response }) => { - return response.ok(respond({ ok: true }, 'Protected stub reached')) - }) - .use(middleware.auth()) - -router - .get('/api/v1/_stub/protected-admin', ({ response }) => { - return response.ok(respond({ ok: true }, 'Protected admin stub reached')) - }) - .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) From 4f0222e3b0ebf97f8b985568181abf8961d21a40 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:29:55 +0400 Subject: [PATCH 08/20] fix(auth): migrate the middleware smoke test off the stub routes The stub routes this test hit are removed now that a real ADMIN-protected route exists. GET /api/v1/auth/me replaces the auth-only stub, since it requires middleware.auth() with no role check, the same shape the stub had. POST /api/v1/categories replaces the admin-protected stub, since it requires middleware.auth() plus middleware.role({ roles: ['ADMIN'] }), the same shape that stub had. The test still proves exactly what it proved before, 401 without a token, 200/403/201 with the right or wrong role, just against routes with real business meaning instead of scaffolding. --- tests/functional/auth.spec.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/tests/functional/auth.spec.ts b/tests/functional/auth.spec.ts index d65c7b0..39aa483 100644 --- a/tests/functional/auth.spec.ts +++ b/tests/functional/auth.spec.ts @@ -103,30 +103,41 @@ test.group('Auth - middleware smoke test (AuthMiddleware + RoleMiddleware)', (gr await db.rollbackGlobalTransaction() }) - test('protected stub route returns 401 without a token', async ({ client }) => { - const response = await client.get('/api/v1/_stub/protected') + // feature/categories added the first real ADMIN-protected route, so this + // group now exercises GET /auth/me (auth only, no role) and + // POST /categories (auth + ADMIN) instead of the temporary + // /api/v1/_stub/* routes, which are removed from start/routes.ts. + + test('auth-only route returns 401 without a token', async ({ client }) => { + const response = await client.get('/api/v1/auth/me') response.assertStatus(401) }) - test('protected stub route returns 200 with a valid USER token', async ({ client }) => { + test('auth-only route returns 200 with a valid USER token', async ({ client }) => { const token = await loginAsSeeded(client, 'user') - const response = await client.get('/api/v1/_stub/protected').bearerToken(token) + const response = await client.get('/api/v1/auth/me').bearerToken(token) response.assertStatus(200) }).timeout(10000) - test('admin-protected stub route returns 403 for a USER token', async ({ client }) => { + test('admin-protected route returns 403 for a USER token', async ({ client }) => { const token = await loginAsSeeded(client, 'user') - const response = await client.get('/api/v1/_stub/protected-admin').bearerToken(token) + const response = await client + .post('/api/v1/categories') + .json({ categoryName: `Smoke ${crypto.randomUUID()}` }) + .bearerToken(token) response.assertStatus(403) }).timeout(10000) - test('admin-protected stub route returns 200 for an ADMIN token', async ({ client }) => { + test('admin-protected route returns 201 for an ADMIN token', async ({ client }) => { const token = await loginAsSeeded(client, 'admin') - const response = await client.get('/api/v1/_stub/protected-admin').bearerToken(token) - response.assertStatus(200) + const response = await client + .post('/api/v1/categories') + .json({ categoryName: `Smoke ${crypto.randomUUID()}` }) + .bearerToken(token) + response.assertStatus(201) }).timeout(10000) }) From 2fd8bbbbe2214a624f92cb1b0b96083472137786 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:29:55 +0400 Subject: [PATCH 09/20] style(auth): update a stale placeholder URL in the middleware unit test This fake HttpContext's request.url() never issues a real HTTP request, it only feeds the fail() envelope's path field, but it referenced a stub route that no longer exists in start/routes.ts. Pointed at a real route for accuracy, no behavior change. --- tests/unit/middleware/auth_middleware.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/middleware/auth_middleware.spec.ts b/tests/unit/middleware/auth_middleware.spec.ts index 508d45c..a4ea612 100644 --- a/tests/unit/middleware/auth_middleware.spec.ts +++ b/tests/unit/middleware/auth_middleware.spec.ts @@ -35,7 +35,9 @@ function fakeCtx(jti: string, user: User) { use: () => ({ payload: { jti } }), }, request: { - url: () => '/api/v1/_stub/protected', + // Arbitrary placeholder path for the fail() envelope's `path` field; + // this fake context never issues a real HTTP request. + url: () => '/api/v1/auth/me', }, response: { unauthorized: (body: unknown) => { From a1797ce5012222d2a7cf33b91ac4b23bad10ba8c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:30:02 +0400 Subject: [PATCH 10/20] chore(codegen): regenerate the controllers registry and schema snapshot Both auto-generated, refreshed to include CategoriesController and the categories table's current shape. Neither is hand edited. --- .adonisjs/server/controllers.ts | 1 + database/schema.ts | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index dfc2309..68e09da 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -5,6 +5,7 @@ export const controllers = { Auth: () => import('#controllers/auth_controller'), + Categories: () => import('#controllers/categories_controller'), Health: () => import('#controllers/health_controller'), Permissions: () => import('#controllers/permissions_controller'), Roles: () => import('#controllers/roles_controller'), diff --git a/database/schema.ts b/database/schema.ts index ee8acf2..f6c081f 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -45,6 +45,17 @@ export class BlacklistedTokenSchema extends BaseModel { declare validatedAt: DateTime | null } +export class CacheSchema extends BaseModel { + static $columns = ['expiresAt', 'key', 'value'] as const + $columns = CacheSchema.$columns + @column.dateTime() + declare expiresAt: DateTime | null + @column({ isPrimary: true }) + declare key: string + @column() + declare value: string | null +} + export class CategorySchema extends BaseModel { static $columns = ['categoryName', 'id'] as const $columns = CategorySchema.$columns From b28a01d3035968e4774ec12e3b452988167a3239 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:40:30 +0400 Subject: [PATCH 11/20] fix(categories): reject non-numeric route ids with a clean 404 GET/PUT/DELETE /api/v1/categories/:id, and the equivalent :roleId/ :permissionId params on the admin routes, coerced params.id with a bare Number(...) in their controllers. A non-numeric value produced NaN, which reached Postgres unguarded as "WHERE id = NaN" and raised a raw DatabaseError with no .status property, falling through the exception handler's generic branch to an unhandled 500 instead of a clean 404. Verified this exact failure against a running dev server before fixing it. router.matchers.number() restricts these params to digit strings before a route ever matches, and casts the matched value to a real number, so a non-numeric id 404s at the routing layer instead of reaching the service at all. Applied once as a global matcher via router.where() rather than per-route, so it fixes the same latent issue in RolesController and PermissionsController too, not just the categories controller this branch adds. --- start/routes.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/start/routes.ts b/start/routes.ts index a255156..fcfef1e 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -26,6 +26,25 @@ const RolesController = () => import('#controllers/roles_controller') const PermissionsController = () => import('#controllers/permissions_controller') const CategoriesController = () => import('#controllers/categories_controller') +/* +|-------------------------------------------------------------------------- +| Global route param matchers +|-------------------------------------------------------------------------- +| +| Without this, a non-numeric :id/:roleId/:permissionId (for example +| GET /api/v1/categories/abc) reaches the service layer as NaN, which +| Postgres rejects as a raw "invalid input syntax for type bigint" error +| with no .status property, falling through the exception handler's +| generic 500 branch instead of a clean 404. router.matchers.number() +| restricts these params to digit strings before a route ever matches, so +| a non-numeric value 404s at the routing layer, and casts the matched +| value to an actual number, not just a numeric string. +| +*/ +router.where('id', router.matchers.number()) +router.where('roleId', router.matchers.number()) +router.where('permissionId', router.matchers.number()) + /* |-------------------------------------------------------------------------- | Health check From a77145689df70892952abe6be8856172933780f9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:40:40 +0400 Subject: [PATCH 12/20] refactor(categories): hoist the cache namespace and trim an existence check cache.namespace('categories') is a pure lookup with no per-call state, so it was re-invoked identically at all four call sites in this service for no behavioral gain. Hoisted once as a module constant instead. The still-in-use check in remove() also now selects only the id column instead of every column on the matching product row, since the query only ever needs to know whether a match exists, not what it contains. --- app/services/categories_service.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/services/categories_service.ts b/app/services/categories_service.ts index 6aeaaa0..26c307d 100644 --- a/app/services/categories_service.ts +++ b/app/services/categories_service.ts @@ -27,12 +27,12 @@ const E_CATEGORY_IN_USE = createError( // affects, since a create or delete shifts every later page's contents // anyway. A short 5 minute TTL is kept as a backstop in case a write path // is ever added that forgets to clear the namespace. -const CATEGORIES_LIST_NAMESPACE = 'categories' const CATEGORIES_LIST_TTL = '5m' -function categoriesListCache() { - return cache.namespace(CATEGORIES_LIST_NAMESPACE) -} +// cache.namespace() is a pure, cheap lookup with no per-call state, so it is +// hoisted once here instead of being re-invoked at every findAll/create/ +// update/remove call site. +const categoriesListCache = cache.namespace('categories') /** * Owns every read and write to the `categories` table. No constructor @@ -41,7 +41,7 @@ function categoriesListCache() { */ export default class CategoriesService { async findAll(page: number, limit: number): Promise> { - return categoriesListCache().getOrSet({ + return categoriesListCache.getOrSet({ key: `list:page:${page}:limit:${limit}`, ttl: CATEGORIES_LIST_TTL, factory: async () => { @@ -57,7 +57,7 @@ export default class CategoriesService { async create(data: CreateCategoryPayload): Promise { const category = await Category.create(data) - await categoriesListCache().clear() + await categoriesListCache.clear() return category } @@ -65,7 +65,7 @@ export default class CategoriesService { const category = await Category.findOrFail(id) category.merge(data) await category.save() - await categoriesListCache().clear() + await categoriesListCache.clear() return category } @@ -84,12 +84,12 @@ export default class CategoriesService { async remove(id: number): Promise { const category = await Category.findOrFail(id) - const stillInUse = await category.related('products').query().first() + const stillInUse = await category.related('products').query().select('id').first() if (stillInUse) { throw new E_CATEGORY_IN_USE() } await category.delete() - await categoriesListCache().clear() + await categoriesListCache.clear() } } From a14435013affedaa40159e8ac6397cb5e0f1b1cc Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:40:40 +0400 Subject: [PATCH 13/20] fix(categories): clear the categories cache when reseeding CategorySeeder writes directly through the Category model rather than CategoriesService, so it never triggered the cache invalidation CategoriesService.create/update/remove all call after a write. Reseeding an environment where the categories list endpoint had already been hit once would serve a stale cached page for up to the service's five minute TTL, even though the database itself was already correct. The seeder now clears the same cache namespace directly after seeding. --- database/seeders/category_seeder.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/database/seeders/category_seeder.ts b/database/seeders/category_seeder.ts index f807c48..4520e16 100644 --- a/database/seeders/category_seeder.ts +++ b/database/seeders/category_seeder.ts @@ -1,4 +1,5 @@ import { BaseSeeder } from '@adonisjs/lucid/seeders' +import cache from '@adonisjs/cache/services/main' import Category from '#models/category' /** @@ -9,6 +10,14 @@ import Category from '#models/category' * every invocation (there is no --files flag in the README's documented * workflow), so CategorySeeder must tolerate being executed more than once * without producing duplicate rows or throwing on a second run. + * + * This writes directly through the Category model rather than + * CategoriesService, so it never goes through the cache invalidation + * CategoriesService.create/update/remove trigger on every write. Without + * the explicit clear below, reseeding an environment where the categories + * list endpoint has already been hit once would serve a stale cached page + * for up to CategoriesService's five minute TTL, even though the database + * itself is already correct. */ export default class CategorySeeder extends BaseSeeder { async run() { @@ -19,5 +28,7 @@ export default class CategorySeeder extends BaseSeeder { { categoryName: 'Home & Kitchen' }, { categoryName: 'Sports & Outdoors' }, ]) + + await cache.namespace('categories').clear() } } From f2000f765b2f7fdf4cb6ec65ca2e980bdaa42e41 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:40:51 +0400 Subject: [PATCH 14/20] test(auth): add an isolated unit test for RoleMiddleware's role check The middleware smoke test migrated off the removed /api/v1/_stub/* routes onto POST /api/v1/categories, which means its ADMIN-success case now implicitly depends on createCategorySchema and CategoriesService.create working correctly, not just the role check itself. A future change to that validator or service could fail this test for the wrong reason, misdiagnosed as an auth regression, since nothing pinned RoleMiddleware's own branching logic down in isolation the way auth_middleware.spec.ts already does for AuthMiddleware. This exercises RoleMiddleware.handle() directly against a fake HttpContext, no HTTP round trip and no dependency on any particular business route, so a regression in the role check itself fails here specifically regardless of which route happens to be ADMIN protected by the time this runs. --- tests/unit/middleware/role_middleware.spec.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 tests/unit/middleware/role_middleware.spec.ts diff --git a/tests/unit/middleware/role_middleware.spec.ts b/tests/unit/middleware/role_middleware.spec.ts new file mode 100644 index 0000000..5afc4ca --- /dev/null +++ b/tests/unit/middleware/role_middleware.spec.ts @@ -0,0 +1,141 @@ +/** + * Unit test for RoleMiddleware's role check in isolation from any real + * HTTP route. The migrated middleware smoke test in + * tests/functional/auth.spec.ts now proves the ADMIN-only gate works + * against POST /api/v1/categories specifically, which means that test's + * success case is also implicitly asserting createCategorySchema and + * CategoriesService.create behave correctly, not just the middleware. This + * file pins down RoleMiddleware's own branching logic directly, so a + * regression in the role check itself fails here regardless of which + * business route happens to be ADMIN protected at the time. + * + * The middleware is exercised against a minimal fake HttpContext exposing + * just the `auth` and `response` shape it actually reads, the same pattern + * auth_middleware.spec.ts already uses. The role pivot is real, written to + * the test database inside a rolled back global transaction, since + * RoleMiddleware loads `user.roles` through the real Lucid relation rather + * than anything mockable at the model layer. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' + +import RoleMiddleware from '#middleware/role_middleware' +import User from '#models/user' +import Role from '#models/role' + +function fakeCtx(user: User) { + const responses: { forbidden?: unknown } = {} + + const ctx = { + auth: { + authenticate: async () => user, + user, + }, + request: { + url: () => '/api/v1/categories', + }, + response: { + forbidden: (body: unknown) => { + responses.forbidden = body + return body + }, + }, + } as any + + return { ctx, responses } +} + +test.group('RoleMiddleware - role check', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('calls next() when the user has one of the required roles', async ({ assert }) => { + const roleName = `ADMIN_${crypto.randomUUID()}` + const role = await Role.create({ roleName }) + const user = await User.create({ + firstName: 'Has', + lastName: 'Role', + email: `hasrole-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) + await user.related('roles').attach([role.id]) + + const middleware = new RoleMiddleware() + let nextCalled = false + const { ctx, responses } = fakeCtx(user) + + await middleware.handle( + ctx, + async () => { + nextCalled = true + }, + { roles: [roleName] } + ) + + assert.isTrue(nextCalled) + assert.isUndefined(responses.forbidden) + }).timeout(10000) + + test('rejects with 403 when the user has none of the required roles', async ({ assert }) => { + const roleName = `USER_${crypto.randomUUID()}` + const role = await Role.create({ roleName }) + const user = await User.create({ + firstName: 'No', + lastName: 'Role', + email: `norole-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) + await user.related('roles').attach([role.id]) + + const middleware = new RoleMiddleware() + let nextCalled = false + const { ctx, responses } = fakeCtx(user) + + await middleware.handle( + ctx, + async () => { + nextCalled = true + }, + { roles: [`ADMIN_${crypto.randomUUID()}`] } + ) + + assert.isFalse(nextCalled) + assert.isDefined(responses.forbidden) + assert.deepInclude(responses.forbidden, { success: false }) + }).timeout(10000) + + test('rejects with 403 when the user has no roles at all', async ({ assert }) => { + const user = await User.create({ + firstName: 'Zero', + lastName: 'Roles', + email: `zeroroles-${crypto.randomUUID()}@example.com`, + password: 'Password1!', + enabled: true, + accountLocked: false, + }) + + const middleware = new RoleMiddleware() + let nextCalled = false + const { ctx, responses } = fakeCtx(user) + + await middleware.handle( + ctx, + async () => { + nextCalled = true + }, + { roles: ['ADMIN'] } + ) + + assert.isFalse(nextCalled) + assert.isDefined(responses.forbidden) + }).timeout(10000) +}) From 21764409fef98d834f573fcdb0677757ac8d92a9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:41:02 +0400 Subject: [PATCH 15/20] test: add shared body() and uniqueName() test utilities body() was independently copy-pasted into roles.spec.ts, permissions.spec.ts and categories.spec.ts, and uniqueName() into categories_service.spec.ts and categories.spec.ts, both the exact same implementation each time. Centralized here so a future spec file imports one implementation instead of writing a fourth or fifth copy, and so a change to either (for example the ApiResponse shape body() works around) only needs to happen once. --- tests/helpers/test_utils.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/helpers/test_utils.ts diff --git a/tests/helpers/test_utils.ts b/tests/helpers/test_utils.ts new file mode 100644 index 0000000..43d2fc1 --- /dev/null +++ b/tests/helpers/test_utils.ts @@ -0,0 +1,22 @@ +/** + * Small, generic test utilities shared across spec files, split out after + * `body()` was copy-pasted independently into roles.spec.ts, + * permissions.spec.ts and categories.spec.ts, and `uniqueName()` was + * copy-pasted into categories_service.spec.ts and categories.spec.ts. + */ + +/** + * `response.body()` on a literal-path match (POST and GET both resolving to + * the same collection route, e.g. /api/v1/categories) is typed against the + * union of every action registered on that route, so TypeScript cannot + * narrow `.data` to the specific shape a given call actually returns. This + * casts to `any` at the one place every such access goes through. + */ +export function body(response: { body(): unknown }): any { + return response.body() +} + +/** A unique, human-readable name for test fixtures that need to avoid colliding with seeded or other tests' data. */ +export function uniqueName(prefix: string): string { + return `${prefix}_${crypto.randomUUID()}` +} From 38ee5a9cbd8d660009a39e9c04de0feb878dddff Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:41:02 +0400 Subject: [PATCH 16/20] refactor(categories): use the shared body()/uniqueName() test utilities Replaces this file's own local copies with imports from tests/helpers/test_utils.ts, no behavior change. --- tests/functional/categories.spec.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/tests/functional/categories.spec.ts b/tests/functional/categories.spec.ts index 8b7233a..a7b9ac2 100644 --- a/tests/functional/categories.spec.ts +++ b/tests/functional/categories.spec.ts @@ -20,21 +20,7 @@ import db from '@adonisjs/lucid/services/db' import Product from '#models/product' import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' - -/** - * `response.body()` on a literal-path match (POST and GET both resolve to - * /api/v1/categories) is typed against the union of every action on that - * route, so TypeScript cannot narrow `.data` to the specific shape a given - * call actually returns. Same workaround roles.spec.ts and - * permissions.spec.ts already use. - */ -function body(response: { body(): unknown }): any { - return response.body() -} - -function uniqueName(prefix: string) { - return `${prefix}_${crypto.randomUUID()}` -} +import { body, uniqueName } from '#tests/helpers/test_utils' test.group('Categories - CRUD lifecycle', (group) => { group.setup(async () => { From 559dece4028429741c1fb2caba63d966db8ae20c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:41:02 +0400 Subject: [PATCH 17/20] refactor(categories): use the shared uniqueName() test utility Replaces this file's own local copy with an import from tests/helpers/test_utils.ts, no behavior change. --- tests/unit/services/categories_service.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/unit/services/categories_service.spec.ts b/tests/unit/services/categories_service.spec.ts index 1e21567..9b42a3e 100644 --- a/tests/unit/services/categories_service.spec.ts +++ b/tests/unit/services/categories_service.spec.ts @@ -23,13 +23,10 @@ import db from '@adonisjs/lucid/services/db' import CategoriesService from '#services/categories_service' import Category from '#models/category' import Product from '#models/product' +import { uniqueName } from '#tests/helpers/test_utils' const categoriesService = new CategoriesService() -function uniqueName(prefix: string) { - return `${prefix}_${crypto.randomUUID()}` -} - test.group('CategoriesService - CRUD', (group) => { group.setup(async () => { await db.beginGlobalTransaction() From 7c60d74505b3cfe1e9be8d36bf03cb4a74fa676c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:41:02 +0400 Subject: [PATCH 18/20] refactor(auth): use the shared body() test utility Replaces this file's own local copy with an import from tests/helpers/test_utils.ts, no behavior change. --- tests/functional/roles.spec.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tests/functional/roles.spec.ts b/tests/functional/roles.spec.ts index 6872ef0..9d2894c 100644 --- a/tests/functional/roles.spec.ts +++ b/tests/functional/roles.spec.ts @@ -17,18 +17,7 @@ import db from '@adonisjs/lucid/services/db' import Permission from '#models/permission' import { loginAsSeeded, ensureRole, ensureSeededUser } from '#tests/helpers/auth_helper' - -/** - * `response.body()` on a literal-path match (POST and GET both resolve to - * /api/v1/roles) is typed against the union of every action on that route, - * so TypeScript cannot narrow `.data` to the specific shape a given call - * actually returns. This cast is only needed at the two create-role call - * sites below; every other `.body()` access in this file hits a route with - * a dynamic `:id` segment, which Tuyau does not resolve to a literal type. - */ -function body(response: { body(): unknown }): any { - return response.body() -} +import { body } from '#tests/helpers/test_utils' test.group('Roles admin - CRUD lifecycle', (group) => { group.setup(async () => { From acc754369084f253b5acf5f7414088e574ace8a0 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:41:02 +0400 Subject: [PATCH 19/20] refactor(auth): use the shared body() test utility Replaces this file's own local copy with an import from tests/helpers/test_utils.ts, no behavior change. --- tests/functional/permissions.spec.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tests/functional/permissions.spec.ts b/tests/functional/permissions.spec.ts index 774856f..d15dd61 100644 --- a/tests/functional/permissions.spec.ts +++ b/tests/functional/permissions.spec.ts @@ -15,18 +15,7 @@ import { test } from '@japa/runner' import db from '@adonisjs/lucid/services/db' import { loginAsSeeded, ensureSeededUser } from '#tests/helpers/auth_helper' - -/** - * `response.body()` is typed against the union of every action registered - * on a matching literal route (for example both PermissionsController.index - * and .store resolve to /api/v1/permissions), so TypeScript cannot narrow - * `.data` to the specific shape a given call actually returns. This helper - * casts to `any` at the single point every access in this file goes - * through, rather than repeating the same cast at every property read. - */ -function body(response: { body(): unknown }): any { - return response.body() -} +import { body } from '#tests/helpers/test_utils' test.group('Permissions admin - CRUD lifecycle', (group) => { group.setup(async () => { From 1b8b6d4a5d38182e2dfef22f72858714bea0bce6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Wed, 26 Aug 2026 10:41:09 +0400 Subject: [PATCH 20/20] chore(codegen): regenerate the Tuyau client registry and route manifest Auto-generated, refreshed to reflect the new global route param matchers. Neither is hand edited. --- .adonisjs/client/registry/index.ts | 30 +++++++++++ .adonisjs/client/registry/schema.d.ts | 72 ++++++++++++++++++++++++--- .adonisjs/client/registry/tree.d.ts | 7 +++ .adonisjs/server/routes.d.ts | 12 +++++ 4 files changed, 115 insertions(+), 6 deletions(-) diff --git a/.adonisjs/client/registry/index.ts b/.adonisjs/client/registry/index.ts index c7d5de1..dcc79b9 100644 --- a/.adonisjs/client/registry/index.ts +++ b/.adonisjs/client/registry/index.ts @@ -162,6 +162,36 @@ const routes = { tokens: [{"old":"/api/v1/permissions/:id","type":0,"val":"api","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/permissions/:id","type":0,"val":"permissions","end":""},{"old":"/api/v1/permissions/:id","type":1,"val":"id","end":""}], types: placeholder as Registry['permissions.destroy']['types'], }, + 'categories.index': { + methods: ["GET","HEAD"], + pattern: '/api/v1/categories', + tokens: [{"old":"/api/v1/categories","type":0,"val":"api","end":""},{"old":"/api/v1/categories","type":0,"val":"v1","end":""},{"old":"/api/v1/categories","type":0,"val":"categories","end":""}], + types: placeholder as Registry['categories.index']['types'], + }, + 'categories.show': { + methods: ["GET","HEAD"], + pattern: '/api/v1/categories/:id', + tokens: [{"old":"/api/v1/categories/:id","type":0,"val":"api","end":""},{"old":"/api/v1/categories/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/categories/:id","type":0,"val":"categories","end":""},{"old":"/api/v1/categories/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['categories.show']['types'], + }, + 'categories.store': { + methods: ["POST"], + pattern: '/api/v1/categories', + tokens: [{"old":"/api/v1/categories","type":0,"val":"api","end":""},{"old":"/api/v1/categories","type":0,"val":"v1","end":""},{"old":"/api/v1/categories","type":0,"val":"categories","end":""}], + types: placeholder as Registry['categories.store']['types'], + }, + 'categories.update': { + methods: ["PUT"], + pattern: '/api/v1/categories/:id', + tokens: [{"old":"/api/v1/categories/:id","type":0,"val":"api","end":""},{"old":"/api/v1/categories/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/categories/:id","type":0,"val":"categories","end":""},{"old":"/api/v1/categories/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['categories.update']['types'], + }, + 'categories.destroy': { + methods: ["DELETE"], + pattern: '/api/v1/categories/:id', + tokens: [{"old":"/api/v1/categories/:id","type":0,"val":"api","end":""},{"old":"/api/v1/categories/:id","type":0,"val":"v1","end":""},{"old":"/api/v1/categories/:id","type":0,"val":"categories","end":""},{"old":"/api/v1/categories/:id","type":1,"val":"id","end":""}], + types: placeholder as Registry['categories.destroy']['types'], + }, } as const satisfies Record export { routes } diff --git a/.adonisjs/client/registry/schema.d.ts b/.adonisjs/client/registry/schema.d.ts index e068413..eaa51ed 100644 --- a/.adonisjs/client/registry/schema.d.ts +++ b/.adonisjs/client/registry/schema.d.ts @@ -122,9 +122,9 @@ export interface Registry { body: {} paramsTuple: [] params: {} - query: {} + query: ExtractQueryForGet> response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } } } 'users.show': { @@ -194,9 +194,9 @@ export interface Registry { body: {} paramsTuple: [] params: {} - query: {} + query: ExtractQueryForGet> response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } } } 'roles.show': { @@ -278,9 +278,9 @@ export interface Registry { body: {} paramsTuple: [] params: {} - query: {} + query: ExtractQueryForGet> response: ExtractResponse>> - errorResponse: ExtractErrorResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } } } 'permissions.store': { @@ -319,4 +319,64 @@ export interface Registry { errorResponse: ExtractErrorResponse>> } } + 'categories.index': { + methods: ["GET","HEAD"] + pattern: '/api/v1/categories' + types: { + body: {} + paramsTuple: [] + params: {} + query: ExtractQueryForGet> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'categories.show': { + methods: ["GET","HEAD"] + pattern: '/api/v1/categories/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } + 'categories.store': { + methods: ["POST"] + pattern: '/api/v1/categories' + types: { + body: ExtractBody> + paramsTuple: [] + params: {} + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'categories.update': { + methods: ["PUT"] + pattern: '/api/v1/categories/:id' + types: { + body: ExtractBody> + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: ExtractQuery> + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> | { status: 422; response: { errors: SimpleError[] } } + } + } + 'categories.destroy': { + methods: ["DELETE"] + pattern: '/api/v1/categories/:id' + types: { + body: {} + paramsTuple: [ParamValue] + params: { id: ParamValue } + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } } diff --git a/.adonisjs/client/registry/tree.d.ts b/.adonisjs/client/registry/tree.d.ts index 811db0a..69b348c 100644 --- a/.adonisjs/client/registry/tree.d.ts +++ b/.adonisjs/client/registry/tree.d.ts @@ -38,4 +38,11 @@ export interface ApiDefinition { update: typeof routes['permissions.update'] destroy: typeof routes['permissions.destroy'] } + categories: { + index: typeof routes['categories.index'] + show: typeof routes['categories.show'] + store: typeof routes['categories.store'] + update: typeof routes['categories.update'] + destroy: typeof routes['categories.destroy'] + } } diff --git a/.adonisjs/server/routes.d.ts b/.adonisjs/server/routes.d.ts index a3d18bf..ade02be 100644 --- a/.adonisjs/server/routes.d.ts +++ b/.adonisjs/server/routes.d.ts @@ -30,6 +30,11 @@ export type ScannedRoutes = { 'permissions.store': { paramsTuple?: []; params?: {} } 'permissions.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'permissions.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'categories.index': { paramsTuple?: []; params?: {} } + 'categories.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'categories.store': { paramsTuple?: []; params?: {} } + 'categories.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'categories.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } GET: { 'health.index': { paramsTuple?: []; params?: {} } @@ -39,6 +44,8 @@ export type ScannedRoutes = { 'roles.index': { paramsTuple?: []; params?: {} } 'roles.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'permissions.index': { paramsTuple?: []; params?: {} } + 'categories.index': { paramsTuple?: []; params?: {} } + 'categories.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } HEAD: { 'health.index': { paramsTuple?: []; params?: {} } @@ -48,6 +55,8 @@ export type ScannedRoutes = { 'roles.index': { paramsTuple?: []; params?: {} } 'roles.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'permissions.index': { paramsTuple?: []; params?: {} } + 'categories.index': { paramsTuple?: []; params?: {} } + 'categories.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } POST: { 'auth.register': { paramsTuple?: []; params?: {} } @@ -61,11 +70,13 @@ export type ScannedRoutes = { 'roles.store': { paramsTuple?: []; params?: {} } 'roles.assign_permission': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'permissions.store': { paramsTuple?: []; params?: {} } + 'categories.store': { paramsTuple?: []; params?: {} } } PUT: { 'users.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'roles.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'permissions.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'categories.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } DELETE: { 'users.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } @@ -73,6 +84,7 @@ export type ScannedRoutes = { 'roles.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'roles.revoke_permission': { paramsTuple: [ParamValue,ParamValue]; params: {'id': ParamValue,'permissionId': ParamValue} } 'permissions.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } + 'categories.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } } } declare module '@adonisjs/core/types/http' {