diff --git a/.adonisjs/client/data.d.ts b/.adonisjs/client/data.d.ts index ee014a4..1513a7a 100644 --- a/.adonisjs/client/data.d.ts +++ b/.adonisjs/client/data.d.ts @@ -5,11 +5,6 @@ /// import type { InferData, InferVariants } from '@adonisjs/core/types/transformers' -import type UserTransformer from '#transformers/user_transformer' export namespace Data { - export type User = InferData - export namespace User { - export type Variants = InferVariants - } } diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 202eb41..91cd9f0 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -4,7 +4,5 @@ */ export const controllers = { - AccessTokens: () => import('#controllers/access_tokens_controller'), - NewAccount: () => import('#controllers/new_account_controller'), - Profile: () => import('#controllers/profile_controller'), + Health: () => import('#controllers/health_controller'), } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef1e0f4..9a3fc45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,5 +65,8 @@ jobs: - name: Lint run: npm run lint + - name: Run migrations + run: node ace migration:run --force + - name: Run tests run: npm test diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1beaa51..21ae17e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -72,6 +72,12 @@ jobs: - name: Lint run: npm run lint + # Migrations must run against the fresh service container before any + # test touches the database, unit specs under tests/unit/database and + # tests/unit/models assume the schema already exists. + - name: Run migrations + run: node ace migration:run --force + # Functional tests start the AdonisJS HTTP server and issue real requests. # Future browser / E2E suites will use @japa/browser-client with Playwright # in headless-chromium mode; install steps will be added when that suite diff --git a/app/controllers/access_tokens_controller.ts b/app/controllers/access_tokens_controller.ts deleted file mode 100644 index 153267c..0000000 --- a/app/controllers/access_tokens_controller.ts +++ /dev/null @@ -1,29 +0,0 @@ -import User from '#models/user' -import { loginValidator } from '#validators/user' -import type { HttpContext } from '@adonisjs/core/http' -import UserTransformer from '#transformers/user_transformer' - -export default class AccessTokensController { - async store({ request, serialize }: HttpContext) { - const { email, password } = await request.validateUsing(loginValidator) - - const user = await User.verifyCredentials(email, password) - const token = await User.accessTokens.create(user) - - return serialize({ - user: UserTransformer.transform(user), - token: token.value!.release(), - }) - } - - async destroy({ auth }: HttpContext) { - const user = auth.getUserOrFail() - if (user.currentAccessToken) { - await User.accessTokens.delete(user, user.currentAccessToken.identifier) - } - - return { - message: 'Logged out successfully', - } - } -} diff --git a/app/controllers/new_account_controller.ts b/app/controllers/new_account_controller.ts deleted file mode 100644 index 1251fb7..0000000 --- a/app/controllers/new_account_controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import User from '#models/user' -import { signupValidator } from '#validators/user' -import type { HttpContext } from '@adonisjs/core/http' -import UserTransformer from '#transformers/user_transformer' - -export default class NewAccountController { - async store({ request, serialize }: HttpContext) { - const { fullName, email, password } = await request.validateUsing(signupValidator) - - const user = await User.create({ fullName, email, password }) - const token = await User.accessTokens.create(user) - - return serialize({ - user: UserTransformer.transform(user), - token: token.value!.release(), - }) - } -} diff --git a/app/controllers/profile_controller.ts b/app/controllers/profile_controller.ts deleted file mode 100644 index 1fe82f2..0000000 --- a/app/controllers/profile_controller.ts +++ /dev/null @@ -1,8 +0,0 @@ -import UserTransformer from '#transformers/user_transformer' -import type { HttpContext } from '@adonisjs/core/http' - -export default class ProfileController { - async show({ auth, serialize }: HttpContext) { - return serialize(UserTransformer.transform(auth.getUserOrFail())) - } -} diff --git a/app/models/activation_token.ts b/app/models/activation_token.ts new file mode 100644 index 0000000..6997828 --- /dev/null +++ b/app/models/activation_token.ts @@ -0,0 +1,30 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import User from '#models/user' + +export default class ActivationToken extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare userId: number + + @column() + declare token: string + + // NOT NULL per the README's Auth Model table. Uses Lucid's autoCreate so + // every row gets a creation timestamp without the issuing service (owned + // by feature/auth) having to set it manually. + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime() + declare expiresAt: DateTime | null + + @column.dateTime() + declare validatedAt: DateTime | null + + @belongsTo(() => User) + declare user: BelongsTo +} diff --git a/app/models/blacklisted_token.ts b/app/models/blacklisted_token.ts new file mode 100644 index 0000000..c861705 --- /dev/null +++ b/app/models/blacklisted_token.ts @@ -0,0 +1,40 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import User from '#models/user' + +/** + * Backs the JWT revocation list. `jti` is the JWT ID claim of the token + * being blacklisted and is unique so the same token cannot be blacklisted + * twice; feature/auth's AuthMiddleware queries this table by `jti` on + * every authenticated request to reject revoked tokens before the guard + * even looks at the signature's expiry. + */ +export default class BlacklistedToken extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare userId: number + + @column() + declare token: string + + @column() + declare jti: string + + @column.dateTime() + declare blacklistedAt: DateTime | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime() + declare expiresAt: DateTime | null + + @column.dateTime() + declare validatedAt: DateTime | null + + @belongsTo(() => User) + declare user: BelongsTo +} diff --git a/app/models/category.ts b/app/models/category.ts new file mode 100644 index 0000000..e00d9cb --- /dev/null +++ b/app/models/category.ts @@ -0,0 +1,20 @@ +import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm' +import type { HasMany } from '@adonisjs/lucid/types/relations' +import Product from '#models/product' + +// NOTE: the README's Data Model "Column Details" table for `categories` +// lists only `id` and `category_name`, with no `created_at` / `updated_at` +// columns, unlike the generic Lucid model example in the patterns skill. +// This model and its migration follow the README's explicit column list +// rather than the generic pattern, since the README is the authoritative +// spec for this branch. +export default class Category extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare categoryName: string + + @hasMany(() => Product) + declare products: HasMany +} diff --git a/app/models/customer.ts b/app/models/customer.ts new file mode 100644 index 0000000..306cb41 --- /dev/null +++ b/app/models/customer.ts @@ -0,0 +1,32 @@ +import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm' +import type { HasMany } from '@adonisjs/lucid/types/relations' +import Order from '#models/order' + +export default class Customer extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare firstName: string + + @column() + declare lastName: string + + @column() + declare telephone: string + + @column() + declare email: string + + @column() + declare address: string + + // NOTE: the README's Data Model diagram shows customers 1 -- N orders, + // and the Column Details table only defines orders.customer_id as the FK + // side. The inverse hasMany here is not explicitly requested by the + // Tasks checklist (which only lists scalar columns for Customer) but is + // added because the diagram documents the relation and orders.customer_id + // would otherwise be unreachable from the Customer side. + @hasMany(() => Order) + declare orders: HasMany +} diff --git a/app/models/order.ts b/app/models/order.ts new file mode 100644 index 0000000..44c9304 --- /dev/null +++ b/app/models/order.ts @@ -0,0 +1,27 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import Customer from '#models/customer' +import Product from '#models/product' + +export default class Order extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare customerId: number + + @column() + declare productId: number + + @column() + declare quantity: number + + @column() + declare total: number + + @belongsTo(() => Customer) + declare customer: BelongsTo + + @belongsTo(() => Product) + declare product: BelongsTo +} diff --git a/app/models/password_reset_token.ts b/app/models/password_reset_token.ts new file mode 100644 index 0000000..b996110 --- /dev/null +++ b/app/models/password_reset_token.ts @@ -0,0 +1,28 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import User from '#models/user' + +export default class PasswordResetToken extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare userId: number + + @column() + declare token: string + + // NOTE: the README does not enumerate the values for `type`. feature/auth + // owns the actual reset flow and will decide the concrete values (for + // example 'password_reset' vs 'account_activation'); this model keeps + // the column as a plain string to stay unopinionated about that choice. + @column() + declare type: string + + @column.dateTime() + declare expiryDate: DateTime + + @belongsTo(() => User) + declare user: BelongsTo +} diff --git a/app/models/permission.ts b/app/models/permission.ts new file mode 100644 index 0000000..cfaedf8 --- /dev/null +++ b/app/models/permission.ts @@ -0,0 +1,22 @@ +import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm' +import type { ManyToMany } from '@adonisjs/lucid/types/relations' +import Role from '#models/role' + +export default class Permission extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare resource: string + + @column() + declare action: string + + // NOTE: the README only specifies the Role -> Permission direction + // (`@manyToMany(() => Permission, { pivotTable: 'role_permission' })` on + // Role). The inverse is declared here so a Permission can be traced back + // to the roles that grant it, which the `role_permission` FK pair + // supports without any extra schema. + @manyToMany(() => Role, { pivotTable: 'role_permission' }) + declare roles: ManyToMany +} diff --git a/app/models/product.ts b/app/models/product.ts new file mode 100644 index 0000000..1649378 --- /dev/null +++ b/app/models/product.ts @@ -0,0 +1,20 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import Category from '#models/category' + +export default class Product extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare categoryId: number + + @column() + declare productName: string + + @column() + declare unitPrice: number + + @belongsTo(() => Category) + declare category: BelongsTo +} diff --git a/app/models/role.ts b/app/models/role.ts new file mode 100644 index 0000000..7c2b154 --- /dev/null +++ b/app/models/role.ts @@ -0,0 +1,18 @@ +import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm' +import type { ManyToMany } from '@adonisjs/lucid/types/relations' +import User from '#models/user' +import Permission from '#models/permission' + +export default class Role extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare roleName: string + + @manyToMany(() => User, { pivotTable: 'role_user' }) + declare users: ManyToMany + + @manyToMany(() => Permission, { pivotTable: 'role_permission' }) + declare permissions: ManyToMany +} diff --git a/app/models/user.ts b/app/models/user.ts index 7bc5a08..e96ac67 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -1,18 +1,51 @@ -import { UserSchema } from '#database/schema' +import { BaseModel, beforeSave, column, manyToMany } from '@adonisjs/lucid/orm' +import type { ManyToMany } from '@adonisjs/lucid/types/relations' import hash from '@adonisjs/core/services/hash' -import { compose } from '@adonisjs/core/helpers' -import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' -import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens' - -export default class User extends compose(UserSchema, withAuthFinder(hash)) { - static accessTokens = DbAccessTokensProvider.forModel(User) - declare currentAccessToken?: AccessToken - - get initials() { - const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@') - if (first && last) { - return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase() +import Role from '#models/role' + +/** + * The EER_AUTH `users` table. This model deliberately does not compose + * `withAuthFinder` from `@adonisjs/auth/mixins/lucid` the way the default + * `--kit=api` scaffold did: that mixin pairs with the access-tokens guard, + * which config/auth.ts no longer configures on this branch (the real JWT + * guard belongs to feature/auth). Keeping User a plain BaseModel here + * avoids coupling the schema branch to an auth strategy it does not own. + * + * Password hashing happens once, in the @beforeSave hook below, and nowhere + * else in the codebase. The hook only re-hashes when `password` is dirty, + * so re-saving a user for an unrelated field change (for example toggling + * `accountLocked`) never re-hashes an already-hashed value. + */ +export default class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare firstName: string + + @column() + declare lastName: string + + @column() + declare email: string + + // Never serialized back to the client, in responses or JSON.stringify. + @column({ serializeAs: null }) + declare password: string + + @column() + declare enabled: boolean + + @column() + declare accountLocked: boolean + + @manyToMany(() => Role, { pivotTable: 'role_user' }) + declare roles: ManyToMany + + @beforeSave() + static async hashPassword(user: User) { + if (user.$dirty.password) { + user.password = await hash.make(user.password) } - return `${first.slice(0, 2)}`.toUpperCase() } } diff --git a/app/transformers/user_transformer.ts b/app/transformers/user_transformer.ts deleted file mode 100644 index c772ff7..0000000 --- a/app/transformers/user_transformer.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type User from '#models/user' -import { BaseTransformer } from '@adonisjs/core/transformers' - -export default class UserTransformer extends BaseTransformer { - toObject() { - return this.pick(this.resource, [ - 'id', - 'fullName', - 'email', - 'createdAt', - 'updatedAt', - 'initials', - ]) - } -} diff --git a/app/validators/user.ts b/app/validators/user.ts deleted file mode 100644 index 5c4ff9a..0000000 --- a/app/validators/user.ts +++ /dev/null @@ -1,26 +0,0 @@ -import vine from '@vinejs/vine' - -/** - * Shared rules for email and password. - */ -const email = () => vine.string().email().maxLength(254) -const password = () => vine.string().minLength(8).maxLength(32) - -/** - * Validator to use when performing self-signup - */ -export const signupValidator = vine.create({ - fullName: vine.string().nullable(), - email: email().unique({ table: 'users', column: 'email' }), - password: password(), - passwordConfirmation: password().sameAs('password'), -}) - -/** - * Validator to use before validating user credentials - * during login - */ -export const loginValidator = vine.create({ - email: email(), - password: vine.string(), -}) diff --git a/config/auth.ts b/config/auth.ts index 686e0ae..c3a4707 100644 --- a/config/auth.ts +++ b/config/auth.ts @@ -1,25 +1,21 @@ import { defineConfig } from '@adonisjs/auth' import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session' -import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens' import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types' +// NOTE: the stateless JWT guard described in the README belongs to +// feature/auth. This branch only needs the User model to resolve through +// an auth guard well enough for the app to boot and typecheck; the +// tokens guard from the default --kit=api scaffold required an +// accessTokens provider on User that the EER_AUTH design does not use, +// so it is dropped here rather than kept alongside a model that no +// longer supports it. feature/auth replaces this guard with the JWT one. const authConfig = defineConfig({ /** * Default guard used when no guard is explicitly specified. */ - default: 'api', + default: 'web', guards: { - /** - * Token-based guard for stateless API authentication. - */ - api: tokensGuard({ - provider: tokensUserProvider({ - tokens: 'accessTokens', - model: () => import('#models/user'), - }), - }), - /** * Session-based guard for browser authentication. */ diff --git a/config/database.ts b/config/database.ts index e36cef1..e23fe99 100644 --- a/config/database.ts +++ b/config/database.ts @@ -1,71 +1,58 @@ import app from '@adonisjs/core/services/app' +import env from '#start/env' +import pg from 'pg' import { defineConfig } from '@adonisjs/lucid' +// NOTE: node-postgres returns BIGINT (OID 20) columns as strings by +// default, since a bigint can exceed Number.MAX_SAFE_INTEGER. Every model +// in this project types its bigIncrements primary keys and bigInteger +// foreign keys as `number`, matching the README's own examples, so this +// parser is registered here, before any pg connection is opened, to keep +// ids and foreign keys as actual numbers rather than strings at runtime. +pg.types.setTypeParser(20, (value: string) => Number.parseInt(value, 10)) + const dbConfig = defineConfig({ /** * Default connection used for all queries. */ - connection: 'sqlite', + connection: 'pg', connections: { /** - * SQLite connection (default). + * PostgreSQL connection (default). Credentials are read from the typed + * env service so the app refuses to boot with an incomplete .env file. */ - sqlite: { - client: 'better-sqlite3', - + pg: { + client: 'pg', connection: { - filename: app.tmpPath('db.sqlite3'), + host: env.get('DB_HOST'), + port: env.get('DB_PORT'), + user: env.get('DB_USER'), + password: env.get('DB_PASSWORD'), + database: env.get('DB_DATABASE'), }, - - /** - * Required by Knex for SQLite defaults. - */ - useNullAsDefault: true, - migrations: { - /** - * Sort migration files naturally by filename. - */ naturalSort: true, - - /** - * Paths containing migration files. - */ paths: ['database/migrations'], }, - - schemaGeneration: { - /** - * Enable schema generation from Lucid models. - */ - enabled: true, - - /** - * Custom schema rules file paths. - */ - rulesPaths: ['./database/schema_rules.js'], - }, + debug: app.inDev, }, /** - * PostgreSQL connection. - * Install package to switch: npm install pg + * SQLite connection. + * Kept as a documented alternative, no schema generation configured + * since the project standardizes on PostgreSQL migrations. */ - // pg: { - // client: 'pg', + // sqlite: { + // client: 'better-sqlite3', // connection: { - // host: env.get('DB_HOST'), - // port: env.get('DB_PORT'), - // user: env.get('DB_USER'), - // password: env.get('DB_PASSWORD'), - // database: env.get('DB_DATABASE'), + // filename: app.tmpPath('db.sqlite3'), // }, + // useNullAsDefault: true, // migrations: { // naturalSort: true, // paths: ['database/migrations'], // }, - // debug: app.inDev, // }, /** diff --git a/database/migrations/1700000001_create_core_schema.ts b/database/migrations/1700000001_create_core_schema.ts new file mode 100644 index 0000000..ee74e99 --- /dev/null +++ b/database/migrations/1700000001_create_core_schema.ts @@ -0,0 +1,78 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Creates the catalog and sales tables: categories, products, customers, + * orders. Table creation order follows the FK dependency chain from the + * README's Data Model diagram (categories -> products, customers and + * products -> orders), so every `references().inTable()` call points at a + * table that already exists by the time it runs. + * + * `down()` drops the same tables in the reverse order, child tables first, + * so PostgreSQL never rejects a drop because a foreign key still points at + * the table being removed. + */ +export default class extends BaseSchema { + async up() { + this.schema.createTable('categories', (table) => { + table.bigIncrements('id') + table.string('category_name', 255).notNullable() + }) + + this.schema.createTable('products', (table) => { + table.bigIncrements('id') + table + .bigInteger('category_id') + .unsigned() + .notNullable() + .references('id') + .inTable('categories') + .onDelete('CASCADE') + table.string('product_name', 255).notNullable() + table.float('unit_price').notNullable() + table.check('unit_price > 0', [], 'products_unit_price_positive') + }) + + this.schema.createTable('customers', (table) => { + table.bigIncrements('id') + table.string('first_name', 255).notNullable() + table.string('last_name', 255).notNullable() + table.string('telephone', 50).notNullable() + table.string('email', 255).notNullable().unique() + table.string('address', 255).notNullable() + }) + + this.schema.createTable('orders', (table) => { + table.bigIncrements('id') + table + .bigInteger('customer_id') + .unsigned() + .notNullable() + .references('id') + .inTable('customers') + .onDelete('CASCADE') + table + .bigInteger('product_id') + .unsigned() + .notNullable() + .references('id') + .inTable('products') + .onDelete('CASCADE') + table.integer('quantity').notNullable() + table.check('quantity > 0', [], 'orders_quantity_positive') + // NOTE: the README documents `total` as "computed = quantity x + // unit_price". That computation happens in the orders service + // (feature/orders), not as a generated/stored column here, because + // the price used at order time must be the product's unit_price at + // that moment, not a value PostgreSQL would recompute if unit_price + // changes later. The column simply stores whatever the service wrote. + table.float('total').notNullable() + }) + } + + async down() { + this.schema.dropTable('orders') + this.schema.dropTable('customers') + this.schema.dropTable('products') + this.schema.dropTable('categories') + } +} diff --git a/database/migrations/1700000002_create_auth_schema.ts b/database/migrations/1700000002_create_auth_schema.ts new file mode 100644 index 0000000..a85d234 --- /dev/null +++ b/database/migrations/1700000002_create_auth_schema.ts @@ -0,0 +1,147 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Creates the EER_AUTH schema: users, roles, permissions, the two pivot + * tables that back the many-to-many relations declared on the User and + * Role models, and the three token tables (activation, blacklist, + * password reset) that feature/auth writes to. + * + * Table order mirrors the FK dependency graph: users/roles/permissions + * have no dependencies and are created first, the pivot tables depend on + * two of those three, and the token tables depend only on users. `down()` + * reverses that order so no drop ever runs while a table still references + * it. + */ +export default class extends BaseSchema { + async up() { + this.schema.createTable('users', (table) => { + table.bigIncrements('id') + table.string('first_name', 50).notNullable() + table.string('last_name', 100).notNullable() + table.string('email', 100).notNullable().unique() + // NOTE: the README's column table does not mark `password` NOT NULL, + // unlike every other column here. Left nullable so an account created + // ahead of activation (invited but not yet set a password) is + // representable; the @beforeSave hook on User only hashes when a + // password is actually set. + table.string('password', 255).nullable() + table.boolean('enabled').notNullable() + table.boolean('account_locked').notNullable() + }) + + this.schema.createTable('roles', (table) => { + table.bigIncrements('id') + table.string('role_name', 50).notNullable().unique() + }) + + this.schema.createTable('permissions', (table) => { + table.bigIncrements('id') + table.string('resource', 50).notNullable() + table.string('action', 50).notNullable() + }) + + this.schema.createTable('role_user', (table) => { + table + .bigInteger('user_id') + .unsigned() + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table + .bigInteger('role_id') + .unsigned() + .notNullable() + .references('id') + .inTable('roles') + .onDelete('CASCADE') + // NOTE: the README lists only user_id and role_id for this pivot, + // no surrogate id. A composite primary key is added here (Lucid's + // manyToMany does not require a dedicated id column on the pivot) + // so the same user cannot be assigned the same role twice. + table.primary(['user_id', 'role_id']) + }) + + this.schema.createTable('role_permission', (table) => { + table + .bigInteger('role_id') + .unsigned() + .notNullable() + .references('id') + .inTable('roles') + .onDelete('CASCADE') + table + .bigInteger('permission_id') + .unsigned() + .notNullable() + .references('id') + .inTable('permissions') + .onDelete('CASCADE') + table.primary(['role_id', 'permission_id']) + }) + + this.schema.createTable('activation_tokens', (table) => { + table.bigIncrements('id') + // NOTE: the README does not mark activation_tokens.user_id NOT NULL, + // so the FK is nullable here. CASCADE still applies to rows that do + // have a user_id: an activation token has no purpose once its owning + // account is gone. + table + .bigInteger('user_id') + .unsigned() + .nullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.string('token', 255).nullable() + table.timestamp('created_at').notNullable() + table.timestamp('expires_at').nullable() + table.timestamp('validated_at').nullable() + }) + + this.schema.createTable('blacklisted_tokens', (table) => { + table.bigIncrements('id') + table + .bigInteger('user_id') + .unsigned() + .nullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.string('token', 768).notNullable() + // jti is unique so the same JWT cannot be blacklisted twice; the + // AuthMiddleware built in feature/auth looks up incoming tokens by + // jti on every authenticated request. + table.string('jti', 255).unique() + table.timestamp('blacklisted_at').nullable() + table.timestamp('created_at').notNullable() + table.timestamp('expires_at').nullable() + table.timestamp('validated_at').nullable() + }) + + this.schema.createTable('password_reset_tokens', (table) => { + table.bigIncrements('id') + table + .bigInteger('user_id') + .unsigned() + .nullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.string('token', 255).notNullable() + table.string('type', 255).notNullable() + table.timestamp('expiry_date').notNullable() + }) + } + + async down() { + this.schema.dropTable('password_reset_tokens') + this.schema.dropTable('blacklisted_tokens') + this.schema.dropTable('activation_tokens') + this.schema.dropTable('role_permission') + this.schema.dropTable('role_user') + this.schema.dropTable('permissions') + this.schema.dropTable('roles') + this.schema.dropTable('users') + } +} diff --git a/database/migrations/1761885935168_create_users_table.ts b/database/migrations/1761885935168_create_users_table.ts deleted file mode 100644 index dbca083..0000000 --- a/database/migrations/1761885935168_create_users_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BaseSchema } from '@adonisjs/lucid/schema' - -export default class extends BaseSchema { - protected tableName = 'users' - - async up() { - this.schema.createTable(this.tableName, (table) => { - table.increments('id').notNullable() - table.string('full_name').nullable() - table.string('email', 254).notNullable().unique() - table.string('password').notNullable() - - table.timestamp('created_at').notNullable() - table.timestamp('updated_at').nullable() - }) - } - - async down() { - this.schema.dropTable(this.tableName) - } -} diff --git a/database/migrations/1768620764696_create_access_tokens_table.ts b/database/migrations/1768620764696_create_access_tokens_table.ts deleted file mode 100644 index a3ce197..0000000 --- a/database/migrations/1768620764696_create_access_tokens_table.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { BaseSchema } from '@adonisjs/lucid/schema' - -export default class extends BaseSchema { - protected tableName = 'auth_access_tokens' - - async up() { - this.schema.createTable(this.tableName, (table) => { - table.increments('id') - table - .integer('tokenable_id') - .notNullable() - .unsigned() - .references('id') - .inTable('users') - .onDelete('CASCADE') - - table.string('type').notNullable() - table.string('name').nullable() - table.string('hash').notNullable() - table.text('abilities').notNullable() - table.timestamp('created_at') - table.timestamp('updated_at') - table.timestamp('last_used_at').nullable() - table.timestamp('expires_at').nullable() - }) - } - - async down() { - this.schema.dropTable(this.tableName) - } -} diff --git a/database/schema.ts b/database/schema.ts index 521fa3a..76665a1 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -7,55 +7,166 @@ import { BaseModel, column } from '@adonisjs/lucid/orm' import { DateTime } from 'luxon' -export class AuthAccessTokenSchema extends BaseModel { - static $columns = [ - 'abilities', - 'createdAt', - 'expiresAt', - 'hash', - 'id', - 'lastUsedAt', - 'name', - 'tokenableId', - 'type', - 'updatedAt', - ] as const - $columns = AuthAccessTokenSchema.$columns - @column() - declare abilities: string +export class ActivationTokenSchema extends BaseModel { + static $columns = ['createdAt', 'expiresAt', 'id', 'token', 'userId', 'validatedAt'] as const + $columns = ActivationTokenSchema.$columns @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime | null + declare createdAt: DateTime @column.dateTime() declare expiresAt: DateTime | null + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare token: string | null @column() - declare hash: string + declare userId: bigint | number | null + @column.dateTime() + declare validatedAt: DateTime | null +} + +export class BlacklistedTokenSchema extends BaseModel { + static $columns = ['blacklistedAt', 'createdAt', 'expiresAt', 'id', 'jti', 'token', 'userId', 'validatedAt'] as const + $columns = BlacklistedTokenSchema.$columns + @column.dateTime() + declare blacklistedAt: DateTime | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column.dateTime() + declare expiresAt: DateTime | null @column({ isPrimary: true }) - declare id: number + declare id: bigint | number + @column() + declare jti: string | null + @column() + declare token: string + @column() + declare userId: bigint | number | null @column.dateTime() - declare lastUsedAt: DateTime | null + declare validatedAt: DateTime | null +} + +export class CategorySchema extends BaseModel { + static $columns = ['categoryName', 'id'] as const + $columns = CategorySchema.$columns + @column() + declare categoryName: string + @column({ isPrimary: true }) + declare id: bigint | number +} + +export class CustomerSchema extends BaseModel { + static $columns = ['address', 'email', 'firstName', 'id', 'lastName', 'telephone'] as const + $columns = CustomerSchema.$columns + @column() + declare address: string + @column() + declare email: string + @column() + declare firstName: string + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare lastName: string + @column() + declare telephone: string +} + +export class OrderSchema extends BaseModel { + static $columns = ['customerId', 'id', 'productId', 'quantity', 'total'] as const + $columns = OrderSchema.$columns + @column() + declare customerId: bigint | number + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare productId: bigint | number + @column() + declare quantity: number @column() - declare name: string | null + declare total: number +} + +export class PasswordResetTokenSchema extends BaseModel { + static $columns = ['expiryDate', 'id', 'token', 'type', 'userId'] as const + $columns = PasswordResetTokenSchema.$columns + @column.dateTime() + declare expiryDate: DateTime + @column({ isPrimary: true }) + declare id: bigint | number @column() - declare tokenableId: number + declare token: string @column() declare type: string - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime | null + @column() + declare userId: bigint | number | null +} + +export class PermissionSchema extends BaseModel { + static $columns = ['action', 'id', 'resource'] as const + $columns = PermissionSchema.$columns + @column() + declare action: string + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare resource: string +} + +export class ProductSchema extends BaseModel { + static $columns = ['categoryId', 'id', 'productName', 'unitPrice'] as const + $columns = ProductSchema.$columns + @column() + declare categoryId: bigint | number + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare productName: string + @column() + declare unitPrice: number +} + +export class RolePermissionSchema extends BaseModel { + static $columns = ['permissionId', 'roleId'] as const + $columns = RolePermissionSchema.$columns + @column() + declare permissionId: bigint | number + @column({ isPrimary: true }) + declare roleId: bigint | number +} + +export class RoleUserSchema extends BaseModel { + static $columns = ['roleId', 'userId'] as const + $columns = RoleUserSchema.$columns + @column() + declare roleId: bigint | number + @column({ isPrimary: true }) + declare userId: bigint | number +} + +export class RoleSchema extends BaseModel { + static $columns = ['id', 'roleName'] as const + $columns = RoleSchema.$columns + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare roleName: string } export class UserSchema extends BaseModel { - static $columns = ['createdAt', 'email', 'fullName', 'id', 'password', 'updatedAt'] as const + static $columns = ['accountLocked', 'email', 'enabled', 'firstName', 'id', 'lastName', 'password'] as const $columns = UserSchema.$columns - @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime + @column() + declare accountLocked: boolean @column() declare email: string @column() - declare fullName: string | null + declare enabled: boolean + @column() + declare firstName: string @column({ isPrimary: true }) - declare id: number + declare id: bigint | number + @column() + declare lastName: string @column({ serializeAs: null }) - declare password: string - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime | null + declare password: string | null } diff --git a/database/seeders/category_seeder.ts b/database/seeders/category_seeder.ts new file mode 100644 index 0000000..f807c48 --- /dev/null +++ b/database/seeders/category_seeder.ts @@ -0,0 +1,23 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import Category from '#models/category' + +/** + * Seeds a small, realistic catalog of categories. + * + * `updateOrCreateMany` keys on `categoryName` so this seeder is idempotent: + * running `node ace db:seed` re-runs every file in database/seeders on + * 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. + */ +export default class CategorySeeder extends BaseSeeder { + async run() { + await Category.updateOrCreateMany('categoryName', [ + { categoryName: 'Electronics' }, + { categoryName: 'Books' }, + { categoryName: 'Clothing' }, + { categoryName: 'Home & Kitchen' }, + { categoryName: 'Sports & Outdoors' }, + ]) + } +} diff --git a/database/seeders/customer_seeder.ts b/database/seeders/customer_seeder.ts new file mode 100644 index 0000000..4577473 --- /dev/null +++ b/database/seeders/customer_seeder.ts @@ -0,0 +1,42 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import Customer from '#models/customer' + +/** + * Seeds sample customers. Keyed on `email` via `updateOrCreateMany` for the + * same idempotency reason as CategorySeeder: `db:seed` re-runs every + * seeder file on each invocation. + */ +export default class CustomerSeeder extends BaseSeeder { + async run() { + await Customer.updateOrCreateMany('email', [ + { + firstName: 'Alice', + lastName: 'Johnson', + telephone: '+1-202-555-0143', + email: 'alice.johnson@example.com', + address: '742 Evergreen Terrace, Springfield, IL', + }, + { + firstName: 'Marcus', + lastName: 'Lee', + telephone: '+1-415-555-0198', + email: 'marcus.lee@example.com', + address: '1600 Amphitheatre Pkwy, Mountain View, CA', + }, + { + firstName: 'Sofia', + lastName: 'Garcia', + telephone: '+34-91-555-0176', + email: 'sofia.garcia@example.com', + address: 'Calle Gran Via 28, Madrid, Spain', + }, + { + firstName: 'Noah', + lastName: 'Williams', + telephone: '+44-20-7946-0958', + email: 'noah.williams@example.com', + address: '221B Baker Street, London, UK', + }, + ]) + } +} diff --git a/database/seeders/main_seeder.ts b/database/seeders/main_seeder.ts new file mode 100644 index 0000000..f4eff70 --- /dev/null +++ b/database/seeders/main_seeder.ts @@ -0,0 +1,30 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import CategorySeeder from '#database/seeders/category_seeder' +import ProductSeeder from '#database/seeders/product_seeder' +import CustomerSeeder from '#database/seeders/customer_seeder' +import UserSeeder from '#database/seeders/user_seeder' + +/** + * Orchestrates every domain seeder in dependency order: categories before + * products (products reference a category), then customers, then users + * (which also creates the ADMIN/USER roles it needs before assigning them). + * + * NOTE: `node ace db:seed`, the command this README's setup instructions + * use, runs every seeder file under database/seeders on its own, not just + * this one, so CategorySeeder/ProductSeeder/CustomerSeeder/UserSeeder also + * execute independently of this orchestration. Each of them is written to + * be idempotent (updateOrCreate / updateOrCreateMany keyed on a natural + * unique column) specifically so that running them a second time here, or + * out of this explicit order, never produces duplicate rows or a unique + * constraint violation. This class still exists because it documents the + * intended dependency order and because `node ace db:seed --files` can + * target it alone to seed everything in one deterministic pass. + */ +export default class MainSeeder extends BaseSeeder { + async run() { + await new CategorySeeder(this.client).run() + await new ProductSeeder(this.client).run() + await new CustomerSeeder(this.client).run() + await new UserSeeder(this.client).run() + } +} diff --git a/database/seeders/product_seeder.ts b/database/seeders/product_seeder.ts new file mode 100644 index 0000000..fa71d90 --- /dev/null +++ b/database/seeders/product_seeder.ts @@ -0,0 +1,72 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import Category from '#models/category' +import Product from '#models/product' + +/** + * Seeds sample products linked to the categories CategorySeeder creates. + * + * Products are looked up by category name rather than assuming fixed IDs, + * because seeder execution order is not guaranteed to assign the same + * primary keys across environments (a fresh database vs. one that already + * has rows from a previous seed run). `updateOrCreateMany` on `productName` + * makes this idempotent for the same reason CategorySeeder is: `db:seed` + * runs every seeder file in the directory on each invocation. + */ +export default class ProductSeeder extends BaseSeeder { + async run() { + const categories = await Category.query().exec() + const categoryIdByName = new Map( + categories.map((category) => [category.categoryName, category.id]) + ) + + const requireCategory = (name: string) => { + const id = categoryIdByName.get(name) + if (!id) { + // NOTE: this seeder depends on CategorySeeder having run first. In + // the documented `node ace db:seed` workflow the alphabetical file + // order (category_seeder before product_seeder) and MainSeeder's + // explicit ordering both guarantee that, so this is a defensive + // guard rather than the primary ordering mechanism. + throw new Error( + `ProductSeeder requires the "${name}" category to exist. Run CategorySeeder first.` + ) + } + return id + } + + await Product.updateOrCreateMany('productName', [ + { + productName: 'Wireless Mouse', + unitPrice: 19.99, + categoryId: requireCategory('Electronics'), + }, + { + productName: 'Mechanical Keyboard', + unitPrice: 79.99, + categoryId: requireCategory('Electronics'), + }, + { + productName: 'The Pragmatic Programmer', + unitPrice: 34.99, + categoryId: requireCategory('Books'), + }, + { productName: 'Clean Code', unitPrice: 29.99, categoryId: requireCategory('Books') }, + { + productName: "Men's Denim Jacket", + unitPrice: 59.99, + categoryId: requireCategory('Clothing'), + }, + { productName: 'Cotton T-Shirt', unitPrice: 14.99, categoryId: requireCategory('Clothing') }, + { + productName: 'Stainless Steel Cookware Set', + unitPrice: 129.99, + categoryId: requireCategory('Home & Kitchen'), + }, + { + productName: 'Yoga Mat', + unitPrice: 24.99, + categoryId: requireCategory('Sports & Outdoors'), + }, + ]) + } +} diff --git a/database/seeders/user_seeder.ts b/database/seeders/user_seeder.ts new file mode 100644 index 0000000..a28d8ad --- /dev/null +++ b/database/seeders/user_seeder.ts @@ -0,0 +1,69 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import env from '#start/env' +import User from '#models/user' +import Role from '#models/role' + +/** + * Seeds the two accounts every subsequent CRUD branch's functional tests + * authenticate as: an ADMIN and a plain USER. Credentials come from the + * typed env service (TEST_ADMIN_EMAIL / TEST_ADMIN_PASSWORD / TEST_USER_EMAIL + * / TEST_USER_PASSWORD) rather than being hardcoded here, so tests/functional + * specs and this seeder share a single source of truth in .env.test. + * + * Those four variables are declared optional in start/env.ts because + * .env.development does not define them. When they are absent (i.e. this + * seeder is run against the development database) the literal fallbacks + * below are used, and they are intentionally identical to the values + * already committed in .env.test, so behaviour does not change across + * environments. + * + * NOTE: the README's seeder file list (main_seeder, category_seeder, + * product_seeder, customer_seeder, user_seeder) has no dedicated + * RoleSeeder file. Since role_user assignment requires the ADMIN and USER + * roles to exist first, this seeder creates them inline with + * `updateOrCreateMany` before creating the two accounts, rather than adding + * a sixth seeder file the README does not list. + */ +export default class UserSeeder extends BaseSeeder { + async run() { + const [adminRole, userRole] = await Role.updateOrCreateMany('roleName', [ + { roleName: 'ADMIN' }, + { roleName: 'USER' }, + ]) + + const adminEmail = env.get('TEST_ADMIN_EMAIL') ?? 'admin@example.com' + const adminPassword = env.get('TEST_ADMIN_PASSWORD') ?? 'Admin1234!' + const userEmail = env.get('TEST_USER_EMAIL') ?? 'user@example.com' + const userPassword = env.get('TEST_USER_PASSWORD') ?? 'User1234!' + + const admin = await User.updateOrCreate( + { email: adminEmail }, + { + firstName: 'Admin', + lastName: 'User', + email: adminEmail, + password: adminPassword, + enabled: true, + accountLocked: false, + } + ) + + const user = await User.updateOrCreate( + { email: userEmail }, + { + firstName: 'Regular', + lastName: 'User', + email: userEmail, + password: userPassword, + enabled: true, + accountLocked: false, + } + ) + + // `sync(ids, false)` attaches the role without detaching any existing + // pivot rows, which keeps this call idempotent across repeated + // `node ace db:seed` runs. + await admin.related('roles').sync([adminRole.id], false) + await user.related('roles').sync([userRole.id], false) + } +} diff --git a/package-lock.json b/package-lock.json index 638a59e..9a92682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@vinejs/vine": "^4.4.0", "better-sqlite3": "^13.0.3", "luxon": "^3.7.2", + "pg": "^8.23.0", "reflect-metadata": "^0.2.2" }, "devDependencies": { @@ -33,6 +34,7 @@ "@poppinss/ts-exec": "^1.4.4", "@types/luxon": "^3.7.4", "@types/node": "~26.2.0", + "@types/pg": "^8.23.1", "eslint": "^10.8.1", "hot-hook": "^1.0.0", "pino-pretty": "^13.1.3", @@ -788,9 +790,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -807,9 +806,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -826,9 +822,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -845,9 +838,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1761,9 +1751,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1781,9 +1768,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1801,9 +1785,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1821,9 +1802,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1841,9 +1819,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1861,9 +1836,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2081,6 +2053,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/pluralize": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.33.tgz", @@ -2561,6 +2545,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" @@ -5511,12 +5496,101 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, "node_modules/pg-connection-string": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", "license": "MIT" }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5623,6 +5697,45 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/powershell-utils": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", @@ -7061,6 +7174,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yargs-parser": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", diff --git a/package.json b/package.json index a6a2d31..f04437f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@poppinss/ts-exec": "^1.4.4", "@types/luxon": "^3.7.4", "@types/node": "~26.2.0", + "@types/pg": "^8.23.1", "eslint": "^10.8.1", "hot-hook": "^1.0.0", "pino-pretty": "^13.1.3", @@ -68,6 +69,7 @@ "@vinejs/vine": "^4.4.0", "better-sqlite3": "^13.0.3", "luxon": "^3.7.2", + "pg": "^8.23.0", "reflect-metadata": "^0.2.2" }, "hotHook": { @@ -84,7 +86,12 @@ }, "prettier": "@adonisjs/prettier-config", "lint-staged": { - "*.{ts,js}": ["eslint --fix", "prettier --write"], - "*.{json,md}": ["prettier --write"] + "*.{ts,js}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,md}": [ + "prettier --write" + ] } -} \ No newline at end of file +} diff --git a/start/env.ts b/start/env.ts index 7d4b8e3..3463eb7 100644 --- a/start/env.ts +++ b/start/env.ts @@ -43,4 +43,14 @@ export default await Env.create(new URL('../', import.meta.url), { LIMITER_STORE: Env.schema.enum(['database', 'redis'] as const), THROTTLE_AUTH_MAX: Env.schema.number(), THROTTLE_AUTH_WINDOW: Env.schema.number(), + + // Test seed credentials + // NOTE: declared optional (not required) so the app still boots against + // .env.development, which does not define them. UserSeeder falls back to + // the same values .env.test hardcodes when these are absent, so seeding + // works the same way in development as it does in the test database. + TEST_ADMIN_EMAIL: Env.schema.string.optional(), + TEST_ADMIN_PASSWORD: Env.schema.string.optional(), + TEST_USER_EMAIL: Env.schema.string.optional(), + TEST_USER_PASSWORD: Env.schema.string.optional(), }) diff --git a/start/routes.ts b/start/routes.ts index ee8f1b3..5615cc8 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -12,9 +12,7 @@ | */ -import { middleware } from '#start/kernel' import router from '@adonisjs/core/services/router' -import { controllers } from '#generated/controllers' // Lazy import — AdonisJS resolves the module on the first matching request const HealthController = () => import('#controllers/health_controller') @@ -32,26 +30,10 @@ router.get('/api/v1/health', [HealthController, 'index']) /* |-------------------------------------------------------------------------- -| API v1 — scaffold routes (replaced in feature/auth) +| API v1 — resource routes |-------------------------------------------------------------------------- +| +| Auth, categories, products, customers and orders routes are added by +| their respective feature branches, starting with feature/auth. +| */ -router - .group(() => { - router - .group(() => { - router.post('signup', [controllers.NewAccount, 'store']) - router.post('login', [controllers.AccessTokens, 'store']) - }) - .prefix('auth') - .as('auth') - - router - .group(() => { - router.get('profile', [controllers.Profile, 'show']) - router.post('logout', [controllers.AccessTokens, 'destroy']) - }) - .prefix('account') - .as('profile') - .use(middleware.auth()) - }) - .prefix('/api/v1') diff --git a/tests/unit/database/migrations.spec.ts b/tests/unit/database/migrations.spec.ts new file mode 100644 index 0000000..488a4e9 --- /dev/null +++ b/tests/unit/database/migrations.spec.ts @@ -0,0 +1,109 @@ +/** + * Verifies that the branch's migrations run and roll back without error + * against the real test database. + * + * This deliberately does not use `db.beginGlobalTransaction()` / + * `rollbackGlobalTransaction()` the way the model specs in this branch do. + * Schema changes (`CREATE TABLE`, `DROP TABLE`) are DDL, and migrations run + * their own transaction handling internally through the Lucid migrator, so + * wrapping them in an outer global transaction would either hide real + * failures or fight with the migrator's own commits. Instead this test + * drives the `MigrationRunner` class directly, the same class the + * `migration:run` / `migration:rollback` ace commands use internally, and + * asserts against actual PostgreSQL catalog state (`to_regclass`) before and + * after each direction so a broken `up()` or `down()` in either migration + * file fails the test instead of silently leaving the schema half-applied. + * + * Because rollback drops every table from both core and auth migrations, + * the test always re-runs migrations afterward, including from inside a + * catch block, so the database is left in the same migrated, runnable state + * it was found in even if an assertion in the middle of the test fails. + * + * Note this test deliberately never calls `MigrationRunner#close()`. That + * method calls `db.manager.closeAll(true)`, which releases every connection + * from the shared `db` service, not a private one scoped to the migrator. + * Since this file runs inside the same process as every other unit test + * file, closing the manager here would tear down the connection pool the + * rest of the suite depends on. + */ +import { test } from '@japa/runner' +import app from '@adonisjs/core/services/app' +import db from '@adonisjs/lucid/services/db' +import { MigrationRunner } from '@adonisjs/lucid/migration' + +async function tableExists(tableName: string): Promise { + const result = await db.rawQuery(`select to_regclass('public.${tableName}') as reg`) + return result.rows[0].reg !== null +} + +test.group('Database migrations - run and rollback', () => { + test('rolling back and re-running the migrations leaves the schema intact', async ({ + assert, + }) => { + const coreTables = ['categories', 'products', 'customers', 'orders'] + const authTables = [ + 'users', + 'roles', + 'permissions', + 'role_user', + 'role_permission', + 'activation_tokens', + 'blacklisted_tokens', + 'password_reset_tokens', + ] + const allTables = [...coreTables, ...authTables] + + // Sanity check: the tables should exist before this test does anything, + // since the migrations were already applied ahead of the test run. + for (const tableName of allTables) { + assert.isTrue( + await tableExists(tableName), + `expected "${tableName}" to exist before rollback` + ) + } + + try { + const rollback = new MigrationRunner(db, app, { + direction: 'down', + connectionName: 'pg', + }) + await rollback.run() + + assert.isNull(rollback.error, rollback.error?.message) + assert.equal(rollback.status, 'completed') + + for (const tableName of allTables) { + assert.isFalse( + await tableExists(tableName), + `expected "${tableName}" to be dropped by rollback` + ) + } + + const rerun = new MigrationRunner(db, app, { + direction: 'up', + connectionName: 'pg', + }) + await rerun.run() + + assert.isNull(rerun.error, rerun.error?.message) + assert.equal(rerun.status, 'completed') + + for (const tableName of allTables) { + assert.isTrue( + await tableExists(tableName), + `expected "${tableName}" to exist again after re-running` + ) + } + } catch (error) { + // Best-effort recovery: make sure the schema is back in place even if + // an assertion above threw mid-way, so later tests and any local + // developer database are not left without tables. + const recovery = new MigrationRunner(db, app, { + direction: 'up', + connectionName: 'pg', + }) + await recovery.run().catch(() => {}) + throw error + } + }).timeout(30000) +}) diff --git a/tests/unit/models/category.spec.ts b/tests/unit/models/category.spec.ts new file mode 100644 index 0000000..869b23b --- /dev/null +++ b/tests/unit/models/category.spec.ts @@ -0,0 +1,68 @@ +/** + * Unit tests for the Category model's `@hasMany(() => Product)` relation. + * + * The Tasks checklist for feature/data-modeling asks specifically for proof + * that `hasMany` returns the associated products, not just that the decorator + * compiles. These tests exercise `category.load('products')` against the real + * test database so a mismatched `category_id` foreign key or a misconfigured + * `localKey` / `foreignKey` on the relation would fail loudly instead of only + * showing up once the categories service starts using `preload()` in a later + * branch. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import Category from '#models/category' +import Product from '#models/product' + +test.group('Category model - hasMany products', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('loading products returns every product scoped to that category', async ({ assert }) => { + const category = await Category.create({ categoryName: 'Electronics' }) + const otherCategory = await Category.create({ categoryName: 'Groceries' }) + + const laptop = await Product.create({ + categoryId: category.id, + productName: 'Laptop', + unitPrice: 999.99, + }) + const mouse = await Product.create({ + categoryId: category.id, + productName: 'Mouse', + unitPrice: 19.99, + }) + await Product.create({ + categoryId: otherCategory.id, + productName: 'Bread', + unitPrice: 2.5, + }) + + await category.load('products') + + assert.lengthOf(category.products, 2) + assert.sameMembers( + category.products.map((product) => product.productName), + [laptop.productName, mouse.productName] + ) + category.products.forEach((product) => { + assert.equal(product.categoryId, category.id) + }) + }).timeout(10000) + + test('a category with no products loads an empty relation instead of throwing', async ({ + assert, + }) => { + const category = await Category.create({ categoryName: 'Empty Category' }) + + await category.load('products') + + assert.isArray(category.products) + assert.lengthOf(category.products, 0) + }).timeout(10000) +}) diff --git a/tests/unit/models/order.spec.ts b/tests/unit/models/order.spec.ts new file mode 100644 index 0000000..4993f77 --- /dev/null +++ b/tests/unit/models/order.spec.ts @@ -0,0 +1,93 @@ +/** + * Unit tests for the Order model's `total` column. + * + * The migration stores `total` as a plain `float` written by the orders + * service (not a generated column), specifically so it can freeze the + * product's unit_price at the moment of purchase. That design choice only + * holds up if the value that gets written is the exact value that comes back + * out, with no silent coercion or precision loss between the `@column()` + * mapping and PostgreSQL's numeric type. These tests write an Order with a + * decimal total and read it back both from the in-memory model instance and + * from a fresh query, so a regression in the column type or the model's + * property mapping would be caught here rather than surfacing as a + * mispriced order in feature/orders. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import Category from '#models/category' +import Product from '#models/product' +import Customer from '#models/customer' +import Order from '#models/order' + +test.group('Order model - total column', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('total is stored and retrieved as the same numeric value', async ({ assert }) => { + const category = await Category.create({ categoryName: 'Books' }) + const product = await Product.create({ + categoryId: category.id, + productName: 'Novel', + unitPrice: 15.5, + }) + const customer = await Customer.create({ + firstName: 'Ada', + lastName: 'Lovelace', + telephone: '555-0100', + email: 'ada@example.com', + address: '1 Analytical Engine Way', + }) + + const quantity = 3 + const total = quantity * product.unitPrice + + const order = await Order.create({ + customerId: customer.id, + productId: product.id, + quantity, + total, + }) + + assert.isNumber(order.total) + assert.equal(order.total, total) + }).timeout(10000) + + test('total survives a round trip through a fresh query, independent of the created instance', async ({ + assert, + }) => { + const category = await Category.create({ categoryName: 'Furniture' }) + const product = await Product.create({ + categoryId: category.id, + productName: 'Desk', + unitPrice: 149.75, + }) + const customer = await Customer.create({ + firstName: 'Grace', + lastName: 'Hopper', + telephone: '555-0101', + email: 'grace@example.com', + address: '2 Compiler Street', + }) + + const total = 2 * product.unitPrice + + const created = await Order.create({ + customerId: customer.id, + productId: product.id, + quantity: 2, + total, + }) + + const found = await Order.query().where('id', created.id).firstOrFail() + + assert.equal(found.total, total) + assert.equal(found.quantity, 2) + assert.equal(found.customerId, customer.id) + assert.equal(found.productId, product.id) + }).timeout(10000) +}) diff --git a/tests/unit/models/user.spec.ts b/tests/unit/models/user.spec.ts new file mode 100644 index 0000000..54aa59a --- /dev/null +++ b/tests/unit/models/user.spec.ts @@ -0,0 +1,89 @@ +/** + * Unit tests for the `@beforeSave` password hashing hook on the User model. + * + * The hook's whole reason for existing is the "no manual password hashing" + * hard rule: every write path (registration, password reset, an admin + * editing a profile) must end up with a hashed password without the caller + * remembering to call `hash.make()` itself. That only works safely if the + * hook hashes on insert, hashes again when an existing user's password is + * reassigned, and, just as importantly, leaves an already-hashed password + * alone when some unrelated column changes. Without that last guarantee, a + * save triggered by toggling `accountLocked` would hash an already-hashed + * value, corrupting the password for the next login. + * + * These tests check `user.$dirty.password` behaviour indirectly, by + * asserting on the actual hash produced against the real hash service and + * database, since the hook is only meaningful once the full round trip + * insert -> read -> save works end to end. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import hash from '@adonisjs/core/services/hash' +import User from '#models/user' + +test.group('User model - beforeSave password hashing hook', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('hashes the plain text password before insert', async ({ assert }) => { + const plainPassword = 'Sup3rSecret!' + + const user = await User.create({ + firstName: 'Katherine', + lastName: 'Johnson', + email: 'katherine@example.com', + password: plainPassword, + enabled: true, + accountLocked: false, + }) + + assert.notEqual(user.password, plainPassword) + assert.isTrue(await hash.verify(user.password, plainPassword)) + }).timeout(10000) + + test('re-hashes the password when it is reassigned on an existing user', async ({ assert }) => { + const user = await User.create({ + firstName: 'Alan', + lastName: 'Turing', + email: 'alan@example.com', + password: 'FirstPassword1!', + enabled: true, + accountLocked: false, + }) + + const firstHash = user.password + + user.password = 'SecondPassword2!' + await user.save() + + assert.notEqual(user.password, firstHash) + assert.isTrue(await hash.verify(user.password, 'SecondPassword2!')) + }).timeout(10000) + + test('does not re-hash the password when an unrelated column changes', async ({ assert }) => { + const user = await User.create({ + firstName: 'Grace', + lastName: 'Hopper', + email: 'grace.hopper@example.com', + password: 'OriginalPass1!', + enabled: true, + accountLocked: false, + }) + + const originalHash = user.password + + user.accountLocked = true + await user.save() + + assert.equal(user.password, originalHash) + + const reloaded = await User.findOrFail(user.id) + assert.equal(reloaded.password, originalHash) + assert.isTrue(reloaded.accountLocked) + }).timeout(10000) +})