Add user auth with JWT, switch to bun test

- User table with company_id FK, unique email, role enum
- Register/login routes with bcrypt + JWT token generation
- Auth plugin with authenticate decorator and role guards
- Login uses globally unique email (no company header needed)
- Dev-auth plugin kept as fallback when JWT_SECRET not set
- Switched from vitest to bun:test (vitest had ESM resolution
  issues with zod in Bun's module structure)
- Upgraded to zod 4
- Added Dockerfile.dev and API service to docker-compose
- 8 tests passing (health + auth)
This commit is contained in:
Ryan Moon
2026-03-27 17:33:05 -05:00
parent c1cddd6b74
commit 979a9a2c00
28 changed files with 1181 additions and 39 deletions

View File

@@ -0,0 +1,3 @@
[test]
preload = ["./src/test/setup.ts"]
timeout = 15000

View File

@@ -6,8 +6,8 @@
"scripts": {
"dev": "bun --watch run src/main.ts",
"start": "bun run src/main.ts",
"test": "vitest run",
"test:watch": "vitest",
"test": "bun test",
"test:watch": "bun test --watch",
"lint": "eslint src/",
"db:generate": "bunx drizzle-kit generate",
"db:migrate": "bunx drizzle-kit migrate",
@@ -21,13 +21,15 @@
"drizzle-orm": "^0.38",
"postgres": "^3",
"ioredis": "^5",
"zod": "^3"
"zod": "^4",
"@fastify/jwt": "^9",
"bcrypt": "^6"
},
"devDependencies": {
"typescript": "^5",
"drizzle-kit": "^0.30",
"pino-pretty": "^13",
"vitest": "^3",
"@types/node": "^22"
"@types/node": "^22",
"@types/bcrypt": "^5"
}
}

View File

@@ -1 +1,2 @@
export * from './schema/stores.js'
export * from './schema/users.js'

View File

@@ -0,0 +1,14 @@
CREATE TYPE "public"."user_role" AS ENUM('admin', 'manager', 'staff', 'technician', 'instructor');--> statement-breakpoint
CREATE TABLE "user" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"email" varchar(255) NOT NULL,
"password_hash" varchar(255) NOT NULL,
"first_name" varchar(100) NOT NULL,
"last_name" varchar(100) NOT NULL,
"role" "user_role" DEFAULT 'staff' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user" ADD CONSTRAINT "user_company_id_company_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."company"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");

View File

@@ -0,0 +1,273 @@
{
"id": "0f909014-3256-4320-9da2-a39a3b68671c",
"prevId": "fd79ece0-66f3-4238-a2ca-af44060363b5",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"last_name": {
"name": "last_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'staff'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"user_company_id_company_id_fk": {
"name": "user_company_id_company_id_fk",
"tableFrom": "user",
"tableTo": "company",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company": {
"name": "company",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"timezone": {
"name": "timezone",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"default": "'America/Chicago'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.location": {
"name": "location",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"address": {
"name": "address",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"timezone": {
"name": "timezone",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"location_company_id_company_id_fk": {
"name": "location_company_id_company_id_fk",
"tableFrom": "location",
"tableTo": "company",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": [
"admin",
"manager",
"staff",
"technician",
"instructor"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,281 @@
{
"id": "69ee3c88-af63-4f16-88e1-15e4ce355a87",
"prevId": "0f909014-3256-4320-9da2-a39a3b68671c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"last_name": {
"name": "last_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'staff'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"user_company_id_company_id_fk": {
"name": "user_company_id_company_id_fk",
"tableFrom": "user",
"tableTo": "company",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.company": {
"name": "company",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"timezone": {
"name": "timezone",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"default": "'America/Chicago'"
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.location": {
"name": "location",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"address": {
"name": "address",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"timezone": {
"name": "timezone",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"location_company_id_company_id_fk": {
"name": "location_company_id_company_id_fk",
"tableFrom": "location",
"tableTo": "company",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": [
"admin",
"manager",
"staff",
"technician",
"instructor"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,20 @@
"when": 1774635439354,
"tag": "0000_hot_purifiers",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1774646377107,
"tag": "0001_gray_lightspeed",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1774648659531,
"tag": "0002_bumpy_mandarin",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,27 @@
import { pgTable, uuid, varchar, timestamp, pgEnum, uniqueIndex } from 'drizzle-orm/pg-core'
import { companies } from './stores.js'
export const userRoleEnum = pgEnum('user_role', [
'admin',
'manager',
'staff',
'technician',
'instructor',
])
export const users = pgTable('user', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
email: varchar('email', { length: 255 }).notNull().unique(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
firstName: varchar('first_name', { length: 100 }).notNull(),
lastName: varchar('last_name', { length: 100 }).notNull(),
role: userRoleEnum('role').notNull().default('staff'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export type User = typeof users.$inferSelect
export type UserInsert = typeof users.$inferInsert

View File

@@ -3,8 +3,10 @@ import { databasePlugin } from './plugins/database.js'
import { redisPlugin } from './plugins/redis.js'
import { corsPlugin } from './plugins/cors.js'
import { errorHandlerPlugin } from './plugins/error-handler.js'
import { authPlugin } from './plugins/auth.js'
import { devAuthPlugin } from './plugins/dev-auth.js'
import { healthRoutes } from './routes/v1/health.js'
import { authRoutes } from './routes/v1/auth.js'
export async function buildApp() {
const app = Fastify({
@@ -20,10 +22,17 @@ export async function buildApp() {
await app.register(errorHandlerPlugin)
await app.register(databasePlugin)
await app.register(redisPlugin)
await app.register(devAuthPlugin)
// Auth — use JWT if secret is set, otherwise dev bypass
if (process.env.JWT_SECRET) {
await app.register(authPlugin)
} else {
await app.register(devAuthPlugin)
}
// Routes
await app.register(healthRoutes, { prefix: '/v1' })
await app.register(authRoutes, { prefix: '/v1' })
return app
}
@@ -42,4 +51,7 @@ async function start() {
}
}
start()
// Only auto-start when not imported by tests
if (process.env.NODE_ENV !== 'test') {
start()
}

View File

@@ -0,0 +1,62 @@
import fp from 'fastify-plugin'
import fjwt from '@fastify/jwt'
declare module 'fastify' {
interface FastifyRequest {
companyId: string
locationId: string
user: { id: string; companyId: string; role: string }
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { id: string; companyId: string; role: string }
user: { id: string; companyId: string; role: string }
}
}
export const authPlugin = fp(async (app) => {
const secret = process.env.JWT_SECRET
if (!secret) {
throw new Error('JWT_SECRET environment variable is required')
}
await app.register(fjwt, {
secret,
sign: { expiresIn: '24h' },
})
// Set companyId from header on all requests (for unauthenticated routes like register/login).
// Authenticated routes override this with the JWT payload via the authenticate decorator.
app.addHook('onRequest', async (request) => {
request.companyId = (request.headers['x-company-id'] as string) ?? ''
request.locationId = (request.headers['x-location-id'] as string) ?? ''
})
app.decorate('authenticate', async function (request: any, reply: any) {
try {
await request.jwtVerify()
request.companyId = request.user.companyId
} catch (_err) {
reply.status(401).send({ error: { message: 'Unauthorized', statusCode: 401 } })
}
})
app.decorate('requireRole', function (...roles: string[]) {
return async function (request: any, reply: any) {
if (!roles.includes(request.user.role)) {
reply
.status(403)
.send({ error: { message: 'Insufficient permissions', statusCode: 403 } })
}
}
})
})
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: any, reply: any) => Promise<void>
requireRole: (...roles: string[]) => (request: any, reply: any) => Promise<void>
}
}

View File

@@ -1,7 +1,10 @@
import fp from 'fastify-plugin'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from '../db/schema/stores.js'
import * as storeSchema from '../db/schema/stores.js'
import * as userSchema from '../db/schema/users.js'
const schema = { ...storeSchema, ...userSchema }
declare module 'fastify' {
interface FastifyInstance {

View File

@@ -4,27 +4,39 @@ declare module 'fastify' {
interface FastifyRequest {
companyId: string
locationId: string
user: { id: string; role: string }
user: { id: string; companyId: string; role: string }
}
}
/**
* Dev-only auth bypass. Reads headers to set request context
* without real JWT validation.
*
* Will be replaced by real JWT auth in Phase 2.
* Dev-only auth bypass. Used when JWT_SECRET is not set.
* Reads x-company-id and x-location-id headers to set request context.
*/
export const devAuthPlugin = fp(async (app) => {
app.addHook('onRequest', async (request) => {
const companyId = request.headers['x-dev-company'] as string | undefined
const locationId = request.headers['x-dev-location'] as string | undefined
const userId = request.headers['x-dev-user'] as string | undefined
const companyId = (request.headers['x-company-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
const locationId = (request.headers['x-location-id'] as string) ?? '00000000-0000-0000-0000-000000000010'
const userId = (request.headers['x-user-id'] as string) ?? '00000000-0000-0000-0000-000000000001'
request.companyId = companyId ?? '00000000-0000-0000-0000-000000000001'
request.locationId = locationId ?? '00000000-0000-0000-0000-000000000010'
request.companyId = companyId
request.locationId = locationId
request.user = {
id: userId ?? '00000000-0000-0000-0000-000000000001',
id: userId,
companyId,
role: 'admin',
}
})
// No-op decorators so routes that use authenticate/requireRole still work
app.decorate('authenticate', async function () {})
app.decorate('requireRole', function () {
return async function () {}
})
})
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: any, reply: any) => Promise<void>
requireRole: (...roles: string[]) => (request: any, reply: any) => Promise<void>
}
}

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test'
import type { FastifyInstance } from 'fastify'
import { createTestApp, cleanDb, seedTestCompany, TEST_COMPANY_ID } from '../../test/helpers.js'
describe('Auth routes', () => {
let app: FastifyInstance
beforeAll(async () => {
app = await createTestApp()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
})
afterAll(async () => {
await app.close()
})
describe('POST /v1/auth/register', () => {
it('creates a user and returns token', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/auth/register',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: 'staff@musicstore.com',
password: 'securepassword',
firstName: 'Jane',
lastName: 'Doe',
role: 'staff',
},
})
expect(response.statusCode).toBe(201)
const body = response.json()
expect(body.user.email).toBe('staff@musicstore.com')
expect(body.user.firstName).toBe('Jane')
expect(body.user.role).toBe('staff')
expect(body.token).toBeDefined()
expect(body.user.passwordHash).toBeUndefined()
})
it('rejects duplicate email within same company', async () => {
await app.inject({
method: 'POST',
url: '/v1/auth/register',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: 'dupe@test.com',
password: 'password123',
firstName: 'First',
lastName: 'User',
},
})
const response = await app.inject({
method: 'POST',
url: '/v1/auth/register',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: 'dupe@test.com',
password: 'password456',
firstName: 'Second',
lastName: 'User',
},
})
expect(response.statusCode).toBe(409)
})
it('rejects invalid email', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/auth/register',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: 'not-an-email',
password: 'password123',
firstName: 'Bad',
lastName: 'Email',
},
})
expect(response.statusCode).toBe(400)
})
it('rejects short password', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/auth/register',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: 'short@test.com',
password: '123',
firstName: 'Short',
lastName: 'Pass',
},
})
expect(response.statusCode).toBe(400)
})
})
describe('POST /v1/auth/login', () => {
beforeEach(async () => {
await app.inject({
method: 'POST',
url: '/v1/auth/register',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: 'login@test.com',
password: 'correctpassword',
firstName: 'Login',
lastName: 'User',
},
})
})
it('returns token with valid credentials', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/auth/login',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: 'login@test.com',
password: 'correctpassword',
},
})
expect(response.statusCode).toBe(200)
const body = response.json()
expect(body.token).toBeDefined()
expect(body.user.email).toBe('login@test.com')
})
it('rejects wrong password', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/auth/login',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: 'login@test.com',
password: 'wrongpassword',
},
})
expect(response.statusCode).toBe(401)
})
it('rejects nonexistent email', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/auth/login',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: 'nobody@test.com',
password: 'whatever',
},
})
expect(response.statusCode).toBe(401)
})
})
})

View File

@@ -0,0 +1,111 @@
import type { FastifyPluginAsync } from 'fastify'
import { eq } from 'drizzle-orm'
import bcrypt from 'bcrypt'
import { RegisterSchema, LoginSchema } from '@forte/shared/schemas'
import { users } from '../../db/schema/users.js'
const SALT_ROUNDS = 10
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post('/auth/register', async (request, reply) => {
const parsed = RegisterSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 },
})
}
const { email, password, firstName, lastName, role } = parsed.data
const companyId = request.companyId
// Email is globally unique across all companies
const existing = await app.db
.select({ id: users.id })
.from(users)
.where(eq(users.email, email))
.limit(1)
if (existing.length > 0) {
return reply.status(409).send({
error: { message: 'User with this email already exists', statusCode: 409 },
})
}
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
const [user] = await app.db
.insert(users)
.values({
companyId,
email,
passwordHash,
firstName,
lastName,
role,
})
.returning({
id: users.id,
email: users.email,
firstName: users.firstName,
lastName: users.lastName,
role: users.role,
createdAt: users.createdAt,
})
const token = app.jwt.sign({
id: user.id,
companyId,
role: user.role,
})
return reply.status(201).send({ user, token })
})
app.post('/auth/login', async (request, reply) => {
const parsed = LoginSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 },
})
}
const { email, password } = parsed.data
// Email is globally unique — company is derived from the user record
const [user] = await app.db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1)
if (!user) {
return reply.status(401).send({
error: { message: 'Invalid email or password', statusCode: 401 },
})
}
const valid = await bcrypt.compare(password, user.passwordHash)
if (!valid) {
return reply.status(401).send({
error: { message: 'Invalid email or password', statusCode: 401 },
})
}
const token = app.jwt.sign({
id: user.id,
companyId: user.companyId,
role: user.role,
})
return reply.send({
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
token,
})
})
}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import type { FastifyInstance } from 'fastify'
import { createTestApp } from '../../test/helpers.js'

View File

@@ -1,10 +1,13 @@
import type { FastifyInstance } from 'fastify'
import { buildApp } from '../main.js'
import { sql } from 'drizzle-orm'
import { companies, locations } from '../db/schema/stores.js'
export const TEST_COMPANY_ID = '00000000-0000-0000-0000-000000000099'
export const TEST_LOCATION_ID = '00000000-0000-0000-0000-000000000099'
/**
* Build a fresh Fastify app instance for testing.
* Each test gets its own app — no shared state.
*/
export async function createTestApp(): Promise<FastifyInstance> {
const app = await buildApp()
@@ -14,7 +17,6 @@ export async function createTestApp(): Promise<FastifyInstance> {
/**
* Truncate all tables in the test database.
* Call this in beforeEach to guarantee a clean slate per test.
*/
export async function cleanDb(app: FastifyInstance): Promise<void> {
await app.db.execute(sql`
@@ -27,3 +29,49 @@ export async function cleanDb(app: FastifyInstance): Promise<void> {
END $$
`)
}
/**
* Seed a test company and location. Call after cleanDb.
*/
export async function seedTestCompany(app: FastifyInstance): Promise<void> {
await app.db.insert(companies).values({
id: TEST_COMPANY_ID,
name: 'Test Music Co.',
timezone: 'America/Chicago',
})
await app.db.insert(locations).values({
id: TEST_LOCATION_ID,
companyId: TEST_COMPANY_ID,
name: 'Test Location',
})
}
/**
* Register a user and return the JWT token.
*/
export async function registerAndLogin(
app: FastifyInstance,
overrides: {
email?: string
password?: string
firstName?: string
lastName?: string
role?: string
} = {},
): Promise<{ token: string; user: Record<string, unknown> }> {
const response = await app.inject({
method: 'POST',
url: '/v1/auth/register',
headers: { 'x-company-id': TEST_COMPANY_ID },
payload: {
email: overrides.email ?? 'test@forte.dev',
password: overrides.password ?? 'testpassword123',
firstName: overrides.firstName ?? 'Test',
lastName: overrides.lastName ?? 'User',
role: overrides.role ?? 'admin',
},
})
const body = response.json()
return { token: body.token, user: body.user }
}

View File

@@ -8,6 +8,7 @@ const TEST_DB_URL =
process.env.DATABASE_URL = TEST_DB_URL
process.env.NODE_ENV = 'test'
process.env.LOG_LEVEL = 'silent'
process.env.JWT_SECRET = 'test-secret-for-jwt-signing'
/**
* Ensure the forte_test database exists before tests run.

View File

@@ -1,10 +0,0 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./src/test/setup.ts'],
testTimeout: 15000,
},
})