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)
+})