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/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/.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' { 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')) + } +} diff --git a/app/services/categories_service.ts b/app/services/categories_service.ts new file mode 100644 index 0000000..26c307d --- /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_TTL = '5m' + +// 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 + * 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().select('id').first() + if (stillInUse) { + throw new E_CATEGORY_IN_USE() + } + + await category.delete() + await categoriesListCache.clear() + } +} 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(), + }) +) 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 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() } } diff --git a/start/routes.ts b/start/routes.ts index de533dc..fcfef1e 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 @@ -25,6 +24,26 @@ 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') + +/* +|-------------------------------------------------------------------------- +| 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()) /* |-------------------------------------------------------------------------- @@ -140,28 +159,24 @@ router /* |-------------------------------------------------------------------------- -| Temporary middleware smoke-test stubs (feature/auth only) +| Categories |-------------------------------------------------------------------------- | -| 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. +| 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 - .get('/api/v1/_stub/protected', ({ response }) => { - return response.ok(respond({ ok: true }, 'Protected stub reached')) - }) - .use(middleware.auth()) - + .post('/api/v1/categories', [CategoriesController, 'store']) + .use([middleware.auth(), middleware.role({ roles: ['ADMIN'] })]) router - .get('/api/v1/_stub/protected-admin', ({ response }) => { - return response.ok(respond({ ok: true }, 'Protected admin stub reached')) - }) + .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'] })]) 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) }) diff --git a/tests/functional/categories.spec.ts b/tests/functional/categories.spec.ts new file mode 100644 index 0000000..a7b9ac2 --- /dev/null +++ b/tests/functional/categories.spec.ts @@ -0,0 +1,169 @@ +/** + * 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' +import { body, uniqueName } from '#tests/helpers/test_utils' + +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) +}) 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 () => { 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 () => { 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()}` +} 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) => { 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) +}) diff --git a/tests/unit/services/categories_service.spec.ts b/tests/unit/services/categories_service.spec.ts new file mode 100644 index 0000000..9b42a3e --- /dev/null +++ b/tests/unit/services/categories_service.spec.ts @@ -0,0 +1,135 @@ +/** + * 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' +import { uniqueName } from '#tests/helpers/test_utils' + +const categoriesService = new CategoriesService() + +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) +})