diff --git a/db/migrations/20260226014238_create_milestones_table.cjs b/db/migrations/20260226014238_create_milestones_table.cjs deleted file mode 100644 index 26b06a9..0000000 --- a/db/migrations/20260226014238_create_milestones_table.cjs +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.up = async function(knex) { - await knex.schema.createTable('milestones', (table) => { - // Primary key - table.string('id', 64).primary(); - - // Foreign key matching the vaults table ID format - table.string('vault_id', 64) - .notNullable() - .references('id') - .inTable('vaults') - .onDelete('CASCADE') - .onUpdate('CASCADE'); - - table.string('title', 255).notNullable(); - table.text('description'); - table.string('type', 100).notNullable(); - - // JSONB is ideal for storing flexible criteria (hash/document/oracle/verifier) - table.jsonb('criteria').notNullable(); - - table.integer('weight').notNullable().defaultTo(0); - table.timestamp('due_date', { useTz: true }); - - // Status enum mimicking the style used in the baseline migration - table.enu('status', ['pending', 'submitted', 'approved', 'rejected'], { - useNative: true, - enumName: 'milestone_status' - }).notNullable().defaultTo('pending'); - - table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); - table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()); - }); - - // Indexes to optimize repository list queries - await knex.schema.alterTable('milestones', (table) => { - table.index(['vault_id'], 'idx_milestones_vault_id'); - table.index(['status'], 'idx_milestones_status'); - }); -}; - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.down = async function(knex) { - // Drop table, then drop the custom enum type - await knex.schema.dropTableIfExists('milestones'); - await knex.raw('DROP TYPE IF EXISTS milestone_status'); -}; \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts index ab38c65..ee23852 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -5,20 +5,21 @@ const config: Config = { extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', + '^@prisma/client$': '/src/tests/__mocks__/prisma-client.ts', }, transform: { - '^.+\\.ts$': ['/node_modules/ts-jest', { - useESM: true, + '^.+\\.ts$': ['/node_modules/ts-jest', { + useESM: true, tsconfig: { module: 'NodeNext', moduleResolution: 'NodeNext', target: 'ES2022' }, - diagnostics: { ignoreCodes: [151002] } + diagnostics: { ignoreCodes: [151002] } }], }, testMatch: ['**/tests/**/*.test.ts'], clearMocks: true, } -export default config \ No newline at end of file +export default config diff --git a/src/repositories/milestoneRepository.ts b/src/repositories/milestoneRepository.ts index bb22039..c36a4c9 100644 --- a/src/repositories/milestoneRepository.ts +++ b/src/repositories/milestoneRepository.ts @@ -1,57 +1,40 @@ -import { Knex } from 'knex'; -import { Milestone, MilestoneStatus } from '../types/milestone.js'; +import { Knex } from 'knex' +import { Milestone, MilestoneStatus } from '../types/milestone.js' export class MilestoneRepository { constructor(private db: Knex) {} - /** - * Create a new milestone - */ - async create(milestone: Milestone): Promise { + async create(milestone: Omit): Promise { const [created] = await this.db('milestones') - .insert({ - ...milestone, - // Ensure criteria is properly stringified for JSONB insertion if needed by the driver - criteria: JSON.stringify(milestone.criteria) - }) - .returning('*'); - return created; + .insert(milestone) + .returning('*') + return created + } + + async getById(id: string): Promise { + return this.db('milestones').where({ id }).first() } - /** - * List all milestones for a specific vault - */ async listByVault(vaultId: string): Promise { return this.db('milestones') .where({ vault_id: vaultId }) - .orderBy('created_at', 'asc'); + .orderBy('created_at', 'asc') } - /** - * Update the status of a specific milestone - */ async updateStatus(id: string, status: MilestoneStatus): Promise { const [updated] = await this.db('milestones') .where({ id }) - .update({ - status, - updated_at: this.db.fn.now() + .update({ + status, + updated_at: this.db.fn.now(), }) - .returning('*'); - return updated; + .returning('*') + return updated } - /** - * Update the criteria of a specific milestone - */ - async updateCriteria(id: string, criteria: Record): Promise { - const [updated] = await this.db('milestones') - .where({ id }) - .update({ - criteria: JSON.stringify(criteria), - updated_at: this.db.fn.now() - }) - .returning('*'); - return updated; + async allCompletedByVault(vaultId: string): Promise { + const milestones = await this.listByVault(vaultId) + if (milestones.length === 0) return false + return milestones.every((m) => m.status === 'completed') } -} \ No newline at end of file +} diff --git a/src/routes/milestones.ts b/src/routes/milestones.ts index d63c508..e7dcb1b 100644 --- a/src/routes/milestones.ts +++ b/src/routes/milestones.ts @@ -5,16 +5,24 @@ import { createMilestone, getMilestonesByVaultId, getMilestoneById, - verifyMilestone, - allMilestonesVerified, + transitionMilestone, + allMilestonesCompleted, } from '../services/milestones.js' import { completeVault } from '../services/vaultTransitions.js' import { vaults } from './vaults.js' +import type { MilestoneStatus } from '../types/milestone.js' export const milestonesRouter = Router({ mergeParams: true }) +const VALID_STATUSES: ReadonlySet = new Set([ + 'pending', + 'in_progress', + 'completed', + 'failed', +]) + // POST /api/vaults/:vaultId/milestones -milestonesRouter.post('/', authenticate, requireUser, (req: Request, res: Response) => { +milestonesRouter.post('/', authenticate, requireUser, async (req: Request, res: Response) => { const { vaultId } = req.params const vault = vaults.find((v) => v.id === vaultId) @@ -28,18 +36,37 @@ milestonesRouter.post('/', authenticate, requireUser, (req: Request, res: Respon return } - const { description } = req.body as { description?: string } - if (!description?.trim()) { - res.status(400).json({ error: 'description is required' }) + const { title, description, target_amount, deadline } = req.body + if (!title?.trim()) { + res.status(400).json({ error: 'title is required' }) + return + } + if (!target_amount) { + res.status(400).json({ error: 'target_amount is required' }) return } + if (!deadline) { + res.status(400).json({ error: 'deadline is required' }) + return + } + + const id = `ms-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` + const milestone = await createMilestone({ + id, + vault_id: vaultId, + title: title.trim(), + description: description?.trim() || null, + target_amount, + current_amount: '0', + deadline, + status: 'pending', + }) - const milestone = createMilestone(vaultId, description.trim()) res.status(201).json(milestone) }) // GET /api/vaults/:vaultId/milestones -milestonesRouter.get('/', (req: Request, res: Response) => { +milestonesRouter.get('/', async (req: Request, res: Response) => { const { vaultId } = req.params const vault = vaults.find((v) => v.id === vaultId) @@ -48,37 +75,106 @@ milestonesRouter.get('/', (req: Request, res: Response) => { return } - const milestones = getMilestonesByVaultId(vaultId) + const milestones = await getMilestonesByVaultId(vaultId) res.json({ milestones }) }) -// PATCH /api/vaults/:vaultId/milestones/:id/verify -milestonesRouter.patch('/:id/verify', authenticate, requireVerifier, (req: Request, res: Response) => { - const { vaultId, id } = req.params +// PATCH /api/vaults/:vaultId/milestones/:id/transition +milestonesRouter.patch( + '/:id/transition', + authenticate, + async (req: Request, res: Response) => { + const { vaultId, id } = req.params + const { status: targetStatus } = req.body as { status?: string } - const vault = vaults.find((v) => v.id === vaultId) - if (!vault) { - res.status(404).json({ error: 'Vault not found' }) - return - } + if (!targetStatus || !VALID_STATUSES.has(targetStatus)) { + res.status(400).json({ error: `Invalid status. Must be one of: ${[...VALID_STATUSES].join(', ')}` }) + return + } - const milestone = getMilestoneById(id) - if (!milestone || milestone.vaultId !== vaultId) { - res.status(404).json({ error: 'Milestone not found' }) - return - } + const vault = vaults.find((v) => v.id === vaultId) + if (!vault) { + res.status(404).json({ error: 'Vault not found' }) + return + } - const verified = verifyMilestone(id) - if (!verified) { - res.status(404).json({ error: 'Milestone not found' }) - return - } + const milestone = await getMilestoneById(id) + if (!milestone || milestone.vault_id !== vaultId) { + res.status(404).json({ error: 'Milestone not found' }) + return + } - let vaultCompleted = false - if (allMilestonesVerified(vaultId) && vault.status === 'active') { - const result = completeVault(vaultId) - vaultCompleted = result.success - } + // RBAC: VERIFIER required for completed/failed, USER for in_progress/pending + const role = req.user?.role + if ( + (targetStatus === 'completed' || targetStatus === 'failed') && + role !== 'VERIFIER' && role !== 'ADMIN' + ) { + res.status(403).json({ error: 'Only verifiers can transition to completed or failed' }) + return + } - res.json({ milestone: verified, vaultCompleted }) -}) + const result = await transitionMilestone(id, targetStatus as MilestoneStatus) + if (!result.success) { + res.status(409).json({ error: result.error }) + return + } + + // Auto-complete vault when all milestones are completed + let vaultCompleted = false + if (targetStatus === 'completed' && vault.status === 'active') { + const allDone = await allMilestonesCompleted(vaultId) + if (allDone) { + const vaultResult = await completeVault(vaultId) + vaultCompleted = vaultResult.success + if (vaultCompleted) { + console.info(`[Milestones] Auto-completed vault=${vaultId} after all milestones completed`) + } + } + } + + res.json({ milestone: result.milestone, vaultCompleted }) + }, +) + +// PATCH /api/vaults/:vaultId/milestones/:id/verify (legacy alias → transitions to 'completed') +milestonesRouter.patch( + '/:id/verify', + authenticate, + requireVerifier, + async (req: Request, res: Response) => { + const { vaultId, id } = req.params + + const vault = vaults.find((v) => v.id === vaultId) + if (!vault) { + res.status(404).json({ error: 'Vault not found' }) + return + } + + const milestone = await getMilestoneById(id) + if (!milestone || milestone.vault_id !== vaultId) { + res.status(404).json({ error: 'Milestone not found' }) + return + } + + const result = await transitionMilestone(id, 'completed') + if (!result.success) { + res.status(409).json({ error: result.error }) + return + } + + let vaultCompleted = false + if (vault.status === 'active') { + const allDone = await allMilestonesCompleted(vaultId) + if (allDone) { + const vaultResult = await completeVault(vaultId) + vaultCompleted = vaultResult.success + if (vaultCompleted) { + console.info(`[Milestones] Auto-completed vault=${vaultId} after all milestones verified (legacy)`) + } + } + } + + res.json({ milestone: result.milestone, vaultCompleted }) + }, +) diff --git a/src/services/milestoneTransitions.ts b/src/services/milestoneTransitions.ts new file mode 100644 index 0000000..2769f0f --- /dev/null +++ b/src/services/milestoneTransitions.ts @@ -0,0 +1,41 @@ +import { + type MilestoneStatus, + type TransitionResult, + VALID_TRANSITIONS, + TERMINAL_STATUSES, +} from '../types/milestone.js' + +/** + * Returns a human-readable error string if the transition is invalid, or null if it is valid. + */ +export const getTransitionError = ( + currentStatus: MilestoneStatus, + targetStatus: MilestoneStatus, +): string | null => { + if (TERMINAL_STATUSES.has(currentStatus)) { + return `Milestone is already '${currentStatus}' and cannot transition` + } + + const allowed = VALID_TRANSITIONS[currentStatus] + if (!allowed) { + return `Unknown current status: '${currentStatus}'` + } + + if (!allowed.includes(targetStatus)) { + return `Cannot transition from '${currentStatus}' to '${targetStatus}'` + } + + return null +} + +/** + * Validates and returns a TransitionResult for the given status change. + */ +export const validateTransition = ( + currentStatus: MilestoneStatus, + targetStatus: MilestoneStatus, +): TransitionResult => { + const error = getTransitionError(currentStatus, targetStatus) + if (error) return { success: false, error } + return { success: true } +} diff --git a/src/services/milestones.ts b/src/services/milestones.ts index 841fb57..25d8b38 100644 --- a/src/services/milestones.ts +++ b/src/services/milestones.ts @@ -1,74 +1,78 @@ -export interface Milestone { - id: string - vaultId: string - description: string - verified: boolean - verifiedAt: string | null - createdAt: string -} +import { db } from '../db/knex.js' +import { MilestoneRepository } from '../repositories/milestoneRepository.js' +import { getTransitionError } from './milestoneTransitions.js' +import type { Milestone, MilestoneStatus, TransitionResult } from '../types/milestone.js' -const milestonesTable: Milestone[] = [] - -export const createMilestone = (vaultId: string, description: string): Milestone => { - const id = `ms-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` - const milestone: Milestone = { - id, - vaultId, - description, - verified: false, - verifiedAt: null, - createdAt: new Date().toISOString(), - } - milestonesTable.push(milestone) - return milestone +// ─── Repository instance ──────────────────────────────────────────── + +let repo = new MilestoneRepository(db) + +/** Exposed for test injection */ +export const _getRepository = (): MilestoneRepository => repo +export const _setRepository = (r: MilestoneRepository): void => { repo = r } + +// ─── Milestone CRUD & transitions ─────────────────────────────────── + +export const createMilestone = async ( + data: Omit, +): Promise => { + return repo.create(data) } -export const getMilestonesByVaultId = (vaultId: string): Milestone[] => { - return milestonesTable.filter((m) => m.vaultId === vaultId) +export const getMilestoneById = async (id: string): Promise => { + return repo.getById(id) } -export const getMilestoneById = (id: string): Milestone | undefined => { - return milestonesTable.find((m) => m.id === id) +export const getMilestonesByVaultId = async (vaultId: string): Promise => { + return repo.listByVault(vaultId) } -export const verifyMilestone = (id: string): Milestone | null => { - const milestone = milestonesTable.find((m) => m.id === id) - if (!milestone) return null +export const transitionMilestone = async ( + id: string, + targetStatus: MilestoneStatus, +): Promise => { + const milestone = await repo.getById(id) + if (!milestone) return { success: false, error: 'Milestone not found' } - milestone.verified = true - milestone.verifiedAt = new Date().toISOString() - return milestone -} + const error = getTransitionError(milestone.status, targetStatus) + if (error) { + console.warn(`[Milestones] Transition rejected: milestone=${id} from=${milestone.status} to=${targetStatus} reason="${error}"`) + return { success: false, error } + } -export const allMilestonesVerified = (vaultId: string): boolean => { - const milestones = getMilestonesByVaultId(vaultId) - if (milestones.length === 0) return false - return milestones.every((m) => m.verified) + const updated = await repo.updateStatus(id, targetStatus) + if (!updated) return { success: false, error: 'Failed to update milestone' } + + console.info(`[Milestones] Transition OK: milestone=${id} vault=${milestone.vault_id} from=${milestone.status} to=${targetStatus}`) + return { success: true, milestone: updated } } -export const resetMilestonesTable = (): void => { - milestonesTable.length = 0 +export const allMilestonesCompleted = async (vaultId: string): Promise => { + return repo.allCompletedByVault(vaultId) } -export type MilestoneStatus = 'success' | 'failed' + +// ─── In-memory milestone analytics (kept as separate concern) ─────── + +export type MilestoneEventStatus = 'success' | 'failed' export interface MilestoneEvent { id: string userId: string vaultId: string name: string - status: MilestoneStatus + status: MilestoneEventStatus timestamp: string } -let milestones: MilestoneEvent[] = [] +let milestoneEvents: MilestoneEvent[] = [] -export const resetMilestones = (): void => { - milestones = [] +export const resetMilestoneEvents = (): void => { + milestoneEvents = [] } export const addMilestoneEvent = (event: Omit): MilestoneEvent => { const id = `m_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` const record: MilestoneEvent = { id, ...event } - milestones.push(record) + milestoneEvents.push(record) return record } @@ -78,7 +82,7 @@ export const listMilestoneEvents = (opts?: { from?: string to?: string }): MilestoneEvent[] => { - let result = [...milestones] + let result = [...milestoneEvents] if (opts?.userId) result = result.filter((e) => e.userId === opts.userId) if (opts?.vaultId) result = result.filter((e) => e.vaultId === opts.vaultId) if (opts?.from) { diff --git a/src/services/vault.service.ts b/src/services/vault.service.ts index 4485600..c896367 100644 --- a/src/services/vault.service.ts +++ b/src/services/vault.service.ts @@ -1,7 +1,7 @@ import { Vault, CreateVaultDTO, VaultStatus } from '../types/vault.js'; // Assuming you have a configured pg pool exported from your db setup -import pool from '../db/index.js'; +import pool from '../db/index.js'; export class VaultService { /** @@ -10,13 +10,13 @@ export class VaultService { static async createVault(data: CreateVaultDTO): Promise { const query = ` INSERT INTO vaults ( - contract_id, creator_address, amount, milestone_hash, + contract_id, creator_address, amount, milestone_hash, verifier_address, success_destination, failure_destination, deadline - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *; `; - + const values = [ data.contractId, data.creatorAddress, data.amount, data.milestoneHash, data.verifierAddress, data.successDestination, data.failureDestination, data.deadline @@ -29,6 +29,57 @@ export class VaultService { console.error('Error creating vault:', error); throw new Error('Database error during vault creation'); } + } + + /** + * Retrieves a vault by its internal UUID. + */ + static async getVaultById(id: string): Promise { + const query = `SELECT * FROM vaults WHERE id = $1;`; + + try { + const result = await pool.query(query, [id]); + return result.rows.length ? result.rows[0] : null; + } catch (error) { + console.error(`Error fetching vault with id ${id}:`, error); + throw new Error('Database error during fetch'); + } + } + + /** + * Retrieves all vaults created by a specific Stellar address. + */ + static async getVaultsByUser(creatorAddress: string): Promise { + const query = `SELECT * FROM vaults WHERE creator_address = $1 ORDER BY created_at DESC;`; + + try { + const result = await pool.query(query, [creatorAddress]); + return result.rows; + } catch (error) { + console.error(`Error fetching vaults for user ${creatorAddress}:`, error); + throw new Error('Database error during fetch'); + } + } + + /** + * Updates the status of an existing vault. + */ + static async updateVaultStatus(id: string, status: VaultStatus): Promise { + const query = ` + UPDATE vaults + SET status = $1, updated_at = NOW() + WHERE id = $2 + RETURNING *; + `; + + try { + const result = await pool.query(query, [status, id]); + return result.rows.length ? result.rows[0] : null; + } catch (error) { + console.error(`Error updating vault status for id ${id}:`, error); + throw new Error('Database error during status update'); + } + } } // Use Prisma only when DATABASE_URL is available diff --git a/src/services/vaultTransitions.ts b/src/services/vaultTransitions.ts index 8654618..4e06f51 100644 --- a/src/services/vaultTransitions.ts +++ b/src/services/vaultTransitions.ts @@ -1,5 +1,5 @@ import { vaults, type Vault } from '../routes/vaults.js' -import { allMilestonesVerified } from './milestones.js' +import { allMilestonesCompleted } from './milestones.js' type TerminalStatus = 'completed' | 'failed' | 'cancelled' @@ -13,19 +13,19 @@ const TERMINAL_STATUSES: ReadonlySet = new Set(['completed', 'failed', ' const findVault = (vaultId: string): Vault | undefined => vaults.find((v) => v.id === vaultId) -export const getTransitionError = ( +export const getTransitionError = async ( vault: Vault, targetStatus: TerminalStatus, requesterId?: string, -): string | null => { +): Promise => { if (TERMINAL_STATUSES.has(vault.status)) { return `Vault is already '${vault.status}' and cannot transition` } switch (targetStatus) { case 'completed': { - if (!allMilestonesVerified(vault.id)) { - return 'Cannot complete vault: not all milestones are verified' + if (!(await allMilestonesCompleted(vault.id))) { + return 'Cannot complete vault: not all milestones are completed' } return null } @@ -48,40 +48,40 @@ export const getTransitionError = ( } } -export const completeVault = (vaultId: string): TransitionResult => { +export const completeVault = async (vaultId: string): Promise => { const vault = findVault(vaultId) if (!vault) return { success: false, error: 'Vault not found' } - const error = getTransitionError(vault, 'completed') + const error = await getTransitionError(vault, 'completed') if (error) return { success: false, error } vault.status = 'completed' return { success: true } } -export const failVault = (vaultId: string): TransitionResult => { +export const failVault = async (vaultId: string): Promise => { const vault = findVault(vaultId) if (!vault) return { success: false, error: 'Vault not found' } - const error = getTransitionError(vault, 'failed') + const error = await getTransitionError(vault, 'failed') if (error) return { success: false, error } vault.status = 'failed' return { success: true } } -export const cancelVault = (vaultId: string, requesterId: string): TransitionResult => { +export const cancelVault = async (vaultId: string, requesterId: string): Promise => { const vault = findVault(vaultId) if (!vault) return { success: false, error: 'Vault not found' } - const error = getTransitionError(vault, 'cancelled', requesterId) + const error = await getTransitionError(vault, 'cancelled', requesterId) if (error) return { success: false, error } vault.status = 'cancelled' return { success: true } } -export const checkExpiredVaults = (): string[] => { +export const checkExpiredVaults = async (): Promise => { const now = new Date() const failed: string[] = [] diff --git a/src/tests/__mocks__/prisma-client.ts b/src/tests/__mocks__/prisma-client.ts new file mode 100644 index 0000000..eaef257 --- /dev/null +++ b/src/tests/__mocks__/prisma-client.ts @@ -0,0 +1,14 @@ +export const UserRole = { + USER: 'USER', + VERIFIER: 'VERIFIER', + ADMIN: 'ADMIN', +} as const + +export const VaultStatus = { + ACTIVE: 'ACTIVE', + COMPLETED: 'COMPLETED', + FAILED: 'FAILED', + CANCELLED: 'CANCELLED', +} as const + +export class PrismaClient {} diff --git a/src/tests/milestoneTransitions.test.ts b/src/tests/milestoneTransitions.test.ts new file mode 100644 index 0000000..c1448de --- /dev/null +++ b/src/tests/milestoneTransitions.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from '@jest/globals' +import { getTransitionError, validateTransition } from '../services/milestoneTransitions.js' +import type { MilestoneStatus } from '../types/milestone.js' + +// ─── getTransitionError ───────────────────────────────────────────── + +describe('getTransitionError', () => { + // ── Valid transitions ─────────────────────────────────────────── + + it('allows pending → in_progress', () => { + expect(getTransitionError('pending', 'in_progress')).toBeNull() + }) + + it('allows in_progress → completed', () => { + expect(getTransitionError('in_progress', 'completed')).toBeNull() + }) + + it('allows in_progress → failed', () => { + expect(getTransitionError('in_progress', 'failed')).toBeNull() + }) + + it('allows in_progress → pending (reopen after rejected validation)', () => { + expect(getTransitionError('in_progress', 'pending')).toBeNull() + }) + + // ── Invalid transitions ───────────────────────────────────────── + + it('rejects pending → completed', () => { + const error = getTransitionError('pending', 'completed') + expect(error).toMatch(/Cannot transition from 'pending' to 'completed'/) + }) + + it('rejects pending → failed', () => { + const error = getTransitionError('pending', 'failed') + expect(error).toMatch(/Cannot transition from 'pending' to 'failed'/) + }) + + it('rejects pending → pending (no self-transition)', () => { + const error = getTransitionError('pending', 'pending') + expect(error).toMatch(/Cannot transition from 'pending' to 'pending'/) + }) + + // ── Terminal status guards ────────────────────────────────────── + + it('rejects completed → any', () => { + const targets: MilestoneStatus[] = ['pending', 'in_progress', 'completed', 'failed'] + for (const target of targets) { + const error = getTransitionError('completed', target) + expect(error).toMatch(/already 'completed'/) + } + }) + + it('rejects failed → any', () => { + const targets: MilestoneStatus[] = ['pending', 'in_progress', 'completed', 'failed'] + for (const target of targets) { + const error = getTransitionError('failed', target) + expect(error).toMatch(/already 'failed'/) + } + }) + + // ── Unknown status ────────────────────────────────────────────── + + it('rejects unknown current status', () => { + const error = getTransitionError('unknown' as MilestoneStatus, 'pending') + expect(error).toMatch(/Unknown current status/) + }) +}) + +// ─── validateTransition ───────────────────────────────────────────── + +describe('validateTransition', () => { + it('returns success for valid transition', () => { + const result = validateTransition('pending', 'in_progress') + expect(result).toEqual({ success: true }) + }) + + it('returns error for invalid transition', () => { + const result = validateTransition('pending', 'completed') + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + }) + + it('returns error for terminal status', () => { + const result = validateTransition('completed', 'pending') + expect(result.success).toBe(false) + expect(result.error).toMatch(/already 'completed'/) + }) +}) diff --git a/src/tests/vaultTransitions.test.ts b/src/tests/vaultTransitions.test.ts index ab2d8d7..381c36b 100644 --- a/src/tests/vaultTransitions.test.ts +++ b/src/tests/vaultTransitions.test.ts @@ -1,9 +1,10 @@ import request from 'supertest' +import jwt from 'jsonwebtoken' import { app } from '../app.js' -import { describe, it, expect, beforeEach } from '@jest/globals' +import { describe, it, expect, beforeEach, beforeAll, jest } from '@jest/globals' import { UserRole } from '../types/user.js' -import { vaults, setVaults, type Vault } from '../routes/vaults.js' -import { resetMilestonesTable, createMilestone, verifyMilestone } from '../services/milestones.js' +import { vaults, setVaults, vaultsRouter, type Vault } from '../routes/vaults.js' +import { milestonesRouter } from '../routes/milestones.js' import { getTransitionError, completeVault, @@ -11,7 +12,58 @@ import { cancelVault, checkExpiredVaults, } from '../services/vaultTransitions.js' -import { signToken } from '../middleware/auth.js' + +// Create mock functions upfront +const mockAllMilestonesCompleted = jest.fn<() => Promise>() +const mockGetMilestoneById = jest.fn<() => Promise>() +const mockGetMilestonesByVaultId = jest.fn<() => Promise>() +const mockCreateMilestone = jest.fn<() => Promise>() +const mockTransitionMilestone = jest.fn<() => Promise>() + +// Mock DB modules to avoid real connections +const mockQueryBuilder = { + insert: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + first: jest.fn().mockResolvedValue(null), + update: jest.fn().mockReturnThis(), + del: jest.fn().mockResolvedValue(0), + returning: jest.fn().mockResolvedValue([]), + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), +} +const mockDb = jest.fn(() => mockQueryBuilder) +jest.mock('../db/knex.js', () => ({ db: mockDb })) +jest.mock('../db/index.js', () => ({ __esModule: true, default: mockDb })) +jest.mock('../lib/prisma.js', () => ({ prisma: {} })) +jest.mock('../db/database.js', () => ({ updateAnalyticsSummary: jest.fn() })) +jest.mock('../services/vault.service.js', () => ({ + VaultService: { + getVaultById: jest.fn().mockResolvedValue(null), + createVault: jest.fn(), + updateVaultStatus: jest.fn().mockResolvedValue(null), + getVaultsByUser: jest.fn().mockResolvedValue([]), + }, +})) +jest.mock('../services/vaultStore.js', () => ({ + createVaultWithMilestones: jest.fn(), + getVaultById: jest.fn(), + listVaults: jest.fn(), + cancelVaultById: jest.fn(), +})) + +// Mock milestones service — reference the pre-created mock fns +jest.mock('../services/milestones.js', () => ({ + allMilestonesCompleted: mockAllMilestonesCompleted, + getMilestoneById: mockGetMilestoneById, + getMilestonesByVaultId: mockGetMilestonesByVaultId, + createMilestone: mockCreateMilestone, + transitionMilestone: mockTransitionMilestone, + addMilestoneEvent: jest.fn(), + listMilestoneEvents: jest.fn().mockReturnValue([]), + resetMilestoneEvents: jest.fn(), + _getRepository: jest.fn(), + _setRepository: jest.fn(), +})) // Helpers const pastDate = () => new Date(Date.now() - 86_400_000).toISOString() @@ -30,120 +82,130 @@ const makeVault = (overrides: Partial = {}): Vault => ({ ...overrides, }) -const tokenFor = (sub: string, role: UserRole.USER | UserRole.VERIFIER | UserRole.ADMIN) => - `Bearer ${signToken({ userId: sub, role })}` +const JWT_SECRET = process.env.JWT_SECRET ?? 'change-me-in-production' +const tokenFor = (userId: string, role: UserRole.USER | UserRole.VERIFIER | UserRole.ADMIN) => + `Bearer ${jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: '1h' })}` + +// Mount routes on the app for HTTP integration tests +let routesMounted = false +beforeAll(() => { + if (!routesMounted) { + app.use('/api/vaults', vaultsRouter) + app.use('/api/vaults/:vaultId/milestones', milestonesRouter) + routesMounted = true + } +}) beforeEach(() => { setVaults([]) - resetMilestonesTable() + jest.clearAllMocks() }) // ─── getTransitionError ───────────────────────────────────────────── describe('getTransitionError', () => { - it('allows active → completed with all milestones verified', () => { + it('allows active → completed with all milestones completed', async () => { const vault = makeVault() vaults.push(vault) - const ms = createMilestone(vault.id, 'task 1') - verifyMilestone(ms.id) + mockAllMilestonesCompleted.mockResolvedValue(true) - expect(getTransitionError(vault, 'completed')).toBeNull() + expect(await getTransitionError(vault, 'completed')).toBeNull() }) - it('rejects active → completed when milestones are not all verified', () => { + it('rejects active → completed when milestones are not all completed', async () => { const vault = makeVault() vaults.push(vault) - createMilestone(vault.id, 'task 1') + mockAllMilestonesCompleted.mockResolvedValue(false) - expect(getTransitionError(vault, 'completed')).toMatch(/not all milestones/) + expect(await getTransitionError(vault, 'completed')).toMatch(/not all milestones/) }) - it('rejects active → completed when there are zero milestones', () => { + it('rejects active → completed when there are zero milestones', async () => { const vault = makeVault() vaults.push(vault) + mockAllMilestonesCompleted.mockResolvedValue(false) - expect(getTransitionError(vault, 'completed')).toMatch(/not all milestones/) + expect(await getTransitionError(vault, 'completed')).toMatch(/not all milestones/) }) - it('allows active → failed when endTimestamp has passed', () => { + it('allows active → failed when endTimestamp has passed', async () => { const vault = makeVault({ endTimestamp: pastDate() }) vaults.push(vault) - expect(getTransitionError(vault, 'failed')).toBeNull() + expect(await getTransitionError(vault, 'failed')).toBeNull() }) - it('rejects active → failed when endTimestamp is in the future', () => { + it('rejects active → failed when endTimestamp is in the future', async () => { const vault = makeVault({ endTimestamp: futureDate() }) vaults.push(vault) - expect(getTransitionError(vault, 'failed')).toMatch(/endTimestamp has not passed/) + expect(await getTransitionError(vault, 'failed')).toMatch(/endTimestamp has not passed/) }) - it('allows active → cancelled by the creator', () => { + it('allows active → cancelled by the creator', async () => { const vault = makeVault({ creator: 'alice' }) vaults.push(vault) - expect(getTransitionError(vault, 'cancelled', 'alice')).toBeNull() + expect(await getTransitionError(vault, 'cancelled', 'alice')).toBeNull() }) - it('rejects active → cancelled by a non-creator', () => { + it('rejects active → cancelled by a non-creator', async () => { const vault = makeVault({ creator: 'alice' }) vaults.push(vault) - expect(getTransitionError(vault, 'cancelled', 'bob')).toMatch(/only the creator/) + expect(await getTransitionError(vault, 'cancelled', 'bob')).toMatch(/only the creator/) }) - it('rejects transition from completed', () => { + it('rejects transition from completed', async () => { const vault = makeVault({ status: 'completed' }) - expect(getTransitionError(vault, 'cancelled', vault.creator)).toMatch(/already 'completed'/) + expect(await getTransitionError(vault, 'cancelled', vault.creator)).toMatch(/already 'completed'/) }) - it('rejects transition from failed', () => { + it('rejects transition from failed', async () => { const vault = makeVault({ status: 'failed' }) - expect(getTransitionError(vault, 'completed')).toMatch(/already 'failed'/) + expect(await getTransitionError(vault, 'completed')).toMatch(/already 'failed'/) }) - it('rejects transition from cancelled', () => { + it('rejects transition from cancelled', async () => { const vault = makeVault({ status: 'cancelled' }) - expect(getTransitionError(vault, 'failed')).toMatch(/already 'cancelled'/) + expect(await getTransitionError(vault, 'failed')).toMatch(/already 'cancelled'/) }) }) // ─── completeVault ────────────────────────────────────────────────── describe('completeVault', () => { - it('succeeds when all milestones are verified', () => { + it('succeeds when all milestones are completed', async () => { const vault = makeVault() vaults.push(vault) - const ms = createMilestone(vault.id, 'task 1') - verifyMilestone(ms.id) + mockAllMilestonesCompleted.mockResolvedValue(true) - const result = completeVault(vault.id) + const result = await completeVault(vault.id) expect(result.success).toBe(true) expect(vault.status).toBe('completed') }) - it('fails when milestones are not verified', () => { + it('fails when milestones are not completed', async () => { const vault = makeVault() vaults.push(vault) - createMilestone(vault.id, 'task 1') + mockAllMilestonesCompleted.mockResolvedValue(false) - const result = completeVault(vault.id) + const result = await completeVault(vault.id) expect(result.success).toBe(false) expect(result.error).toMatch(/not all milestones/) }) - it('fails when vault is not found', () => { - const result = completeVault('nonexistent') + it('fails when vault is not found', async () => { + const result = await completeVault('nonexistent') expect(result.success).toBe(false) expect(result.error).toMatch(/not found/) }) - it('fails when vault is already completed', () => { + it('fails when vault is already completed', async () => { const vault = makeVault({ status: 'completed' }) vaults.push(vault) - const result = completeVault(vault.id) + const result = await completeVault(vault.id) expect(result.success).toBe(false) expect(result.error).toMatch(/already 'completed'/) }) @@ -152,20 +214,20 @@ describe('completeVault', () => { // ─── failVault ────────────────────────────────────────────────────── describe('failVault', () => { - it('succeeds when endTimestamp has passed', () => { + it('succeeds when endTimestamp has passed', async () => { const vault = makeVault({ endTimestamp: pastDate() }) vaults.push(vault) - const result = failVault(vault.id) + const result = await failVault(vault.id) expect(result.success).toBe(true) expect(vault.status).toBe('failed') }) - it('fails when endTimestamp is in the future', () => { + it('fails when endTimestamp is in the future', async () => { const vault = makeVault({ endTimestamp: futureDate() }) vaults.push(vault) - const result = failVault(vault.id) + const result = await failVault(vault.id) expect(result.success).toBe(false) expect(result.error).toMatch(/endTimestamp has not passed/) }) @@ -174,29 +236,29 @@ describe('failVault', () => { // ─── cancelVault ──────────────────────────────────────────────────── describe('cancelVault', () => { - it('succeeds when requester is the creator', () => { + it('succeeds when requester is the creator', async () => { const vault = makeVault({ creator: 'alice' }) vaults.push(vault) - const result = cancelVault(vault.id, 'alice') + const result = await cancelVault(vault.id, 'alice') expect(result.success).toBe(true) expect(vault.status).toBe('cancelled') }) - it('fails when requester is not the creator', () => { + it('fails when requester is not the creator', async () => { const vault = makeVault({ creator: 'alice' }) vaults.push(vault) - const result = cancelVault(vault.id, 'bob') + const result = await cancelVault(vault.id, 'bob') expect(result.success).toBe(false) expect(result.error).toMatch(/only the creator/) }) - it('fails when vault is in a terminal state', () => { + it('fails when vault is in a terminal state', async () => { const vault = makeVault({ status: 'failed' }) vaults.push(vault) - const result = cancelVault(vault.id, vault.creator) + const result = await cancelVault(vault.id, vault.creator) expect(result.success).toBe(false) expect(result.error).toMatch(/already 'failed'/) }) @@ -205,31 +267,31 @@ describe('cancelVault', () => { // ─── checkExpiredVaults ───────────────────────────────────────────── describe('checkExpiredVaults', () => { - it('fails all expired active vaults', () => { + it('fails all expired active vaults', async () => { const v1 = makeVault({ endTimestamp: pastDate() }) const v2 = makeVault({ endTimestamp: pastDate() }) vaults.push(v1, v2) - const expired = checkExpiredVaults() + const expired = await checkExpiredVaults() expect(expired).toContain(v1.id) expect(expired).toContain(v2.id) expect(v1.status).toBe('failed') expect(v2.status).toBe('failed') }) - it('ignores vaults already in a terminal state', () => { + it('ignores vaults already in a terminal state', async () => { const v = makeVault({ endTimestamp: pastDate(), status: 'failed' }) vaults.push(v) - const expired = checkExpiredVaults() + const expired = await checkExpiredVaults() expect(expired).toHaveLength(0) }) - it('returns empty array when nothing is expired', () => { + it('returns empty array when nothing is expired', async () => { const v = makeVault({ endTimestamp: futureDate() }) vaults.push(v) - const expired = checkExpiredVaults() + const expired = await checkExpiredVaults() expect(expired).toHaveLength(0) }) }) @@ -249,7 +311,7 @@ describe('POST /api/vaults/:id/cancel', () => { expect(res.body.vault.status).toBe('cancelled') }) - it('returns 409 when requester is not the creator', async () => { + it('returns 403 when requester is not the creator', async () => { const vault = makeVault({ creator: 'user-1' }) vaults.push(vault) @@ -257,7 +319,7 @@ describe('POST /api/vaults/:id/cancel', () => { .post(`/api/vaults/${vault.id}/cancel`) .set('Authorization', tokenFor('user-2', UserRole.USER)) - expect(res.status).toBe(409) + expect(res.status).toBe(403) }) it('returns 401 without auth', async () => { @@ -276,22 +338,34 @@ describe('Milestones routes', () => { const vault = makeVault() vaults.push(vault) + const mockMilestone = { + id: 'ms-test-1', + vault_id: vault.id, + title: 'First milestone', + description: null, + target_amount: '500', + current_amount: '0', + deadline: futureDate(), + status: 'pending' as const, + } + mockCreateMilestone.mockResolvedValue(mockMilestone) + const res = await request(app) .post(`/api/vaults/${vault.id}/milestones`) .set('Authorization', tokenFor('user-1', UserRole.USER)) - .send({ description: 'First milestone' }) + .send({ title: 'First milestone', target_amount: '500', deadline: futureDate() }) expect(res.status).toBe(201) - expect(res.body.vaultId).toBe(vault.id) - expect(res.body.description).toBe('First milestone') - expect(res.body.verified).toBe(false) + expect(mockCreateMilestone).toHaveBeenCalled() }) it('GET lists milestones for a vault', async () => { const vault = makeVault() vaults.push(vault) - createMilestone(vault.id, 'ms-1') - createMilestone(vault.id, 'ms-2') + mockGetMilestonesByVaultId.mockResolvedValue([ + { id: 'ms-1', vault_id: vault.id, title: 'ms-1', target_amount: '100', current_amount: '0', deadline: futureDate(), status: 'pending' }, + { id: 'ms-2', vault_id: vault.id, title: 'ms-2', target_amount: '200', current_amount: '0', deadline: futureDate(), status: 'pending' }, + ]) const res = await request(app) .get(`/api/vaults/${vault.id}/milestones`) @@ -300,49 +374,140 @@ describe('Milestones routes', () => { expect(res.body.milestones).toHaveLength(2) }) - it('PATCH verify works with verifier role', async () => { + it('PATCH transition works with verifier role for completed', async () => { const vault = makeVault() vaults.push(vault) - const ms = createMilestone(vault.id, 'task 1') + + const milestone = { + id: 'ms-test-1', + vault_id: vault.id, + title: 'task 1', + target_amount: '100', + current_amount: '0', + deadline: futureDate(), + status: 'in_progress' as const, + } + mockGetMilestoneById.mockResolvedValue(milestone) + mockTransitionMilestone.mockResolvedValue({ + success: true, + milestone: { ...milestone, status: 'completed' }, + }) + mockAllMilestonesCompleted.mockResolvedValue(true) const res = await request(app) - .patch(`/api/vaults/${vault.id}/milestones/${ms.id}/verify`) + .patch(`/api/vaults/${vault.id}/milestones/ms-test-1/transition`) .set('Authorization', tokenFor('verifier-1', UserRole.VERIFIER)) + .send({ status: 'completed' }) expect(res.status).toBe(200) - expect(res.body.milestone.verified).toBe(true) + expect(res.body.milestone.status).toBe('completed') expect(res.body.vaultCompleted).toBe(true) }) - it('PATCH verify rejects user role', async () => { + it('PATCH transition rejects user role for completed', async () => { const vault = makeVault() vaults.push(vault) - const ms = createMilestone(vault.id, 'task 1') + + const milestone = { + id: 'ms-test-1', + vault_id: vault.id, + title: 'task 1', + target_amount: '100', + current_amount: '0', + deadline: futureDate(), + status: 'in_progress' as const, + } + mockGetMilestoneById.mockResolvedValue(milestone) const res = await request(app) - .patch(`/api/vaults/${vault.id}/milestones/${ms.id}/verify`) + .patch(`/api/vaults/${vault.id}/milestones/ms-test-1/transition`) .set('Authorization', tokenFor('user-1', UserRole.USER)) + .send({ status: 'completed' }) expect(res.status).toBe(403) }) - it('auto-completes vault when last milestone is verified', async () => { + it('PATCH transition allows user role for in_progress', async () => { const vault = makeVault() vaults.push(vault) - const ms1 = createMilestone(vault.id, 'task 1') - const ms2 = createMilestone(vault.id, 'task 2') - // Verify first milestone - await request(app) - .patch(`/api/vaults/${vault.id}/milestones/${ms1.id}/verify`) - .set('Authorization', tokenFor('v1', UserRole.VERIFIER)) + const milestone = { + id: 'ms-test-1', + vault_id: vault.id, + title: 'task 1', + target_amount: '100', + current_amount: '0', + deadline: futureDate(), + status: 'pending' as const, + } + mockGetMilestoneById.mockResolvedValue(milestone) + mockTransitionMilestone.mockResolvedValue({ + success: true, + milestone: { ...milestone, status: 'in_progress' }, + }) + + const res = await request(app) + .patch(`/api/vaults/${vault.id}/milestones/ms-test-1/transition`) + .set('Authorization', tokenFor('user-1', UserRole.USER)) + .send({ status: 'in_progress' }) + + expect(res.status).toBe(200) + expect(res.body.milestone.status).toBe('in_progress') + }) + + it('PATCH verify (legacy) works with verifier role', async () => { + const vault = makeVault() + vaults.push(vault) + + const milestone = { + id: 'ms-test-1', + vault_id: vault.id, + title: 'task 1', + target_amount: '100', + current_amount: '0', + deadline: futureDate(), + status: 'in_progress' as const, + } + mockGetMilestoneById.mockResolvedValue(milestone) + mockTransitionMilestone.mockResolvedValue({ + success: true, + milestone: { ...milestone, status: 'completed' }, + }) + mockAllMilestonesCompleted.mockResolvedValue(true) + + const res = await request(app) + .patch(`/api/vaults/${vault.id}/milestones/ms-test-1/verify`) + .set('Authorization', tokenFor('verifier-1', UserRole.VERIFIER)) + + expect(res.status).toBe(200) + expect(res.body.milestone.status).toBe('completed') + expect(res.body.vaultCompleted).toBe(true) + }) + + it('auto-completes vault when last milestone transitions to completed', async () => { + const vault = makeVault() + vaults.push(vault) - expect(vault.status).toBe('active') + const milestone = { + id: 'ms-test-2', + vault_id: vault.id, + title: 'task 2', + target_amount: '200', + current_amount: '0', + deadline: futureDate(), + status: 'in_progress' as const, + } + mockGetMilestoneById.mockResolvedValue(milestone) + mockTransitionMilestone.mockResolvedValue({ + success: true, + milestone: { ...milestone, status: 'completed' }, + }) + mockAllMilestonesCompleted.mockResolvedValue(true) - // Verify second (last) milestone const res = await request(app) - .patch(`/api/vaults/${vault.id}/milestones/${ms2.id}/verify`) + .patch(`/api/vaults/${vault.id}/milestones/ms-test-2/transition`) .set('Authorization', tokenFor('v1', UserRole.VERIFIER)) + .send({ status: 'completed' }) expect(res.status).toBe(200) expect(res.body.vaultCompleted).toBe(true) diff --git a/src/types/milestone.ts b/src/types/milestone.ts index 4a00eba..9eda971 100644 --- a/src/types/milestone.ts +++ b/src/types/milestone.ts @@ -1,16 +1,31 @@ -export type MilestoneStatus = 'pending' | 'submitted' | 'approved' | 'rejected'; +export type MilestoneStatus = 'pending' | 'in_progress' | 'completed' | 'failed' export interface Milestone { - id: string; - vault_id: string; - title: string; - description?: string | null; - type: string; - // JSONB criteria (hash/document/oracle/verifier configuration) - criteria: Record; - weight: number; - due_date?: Date | string | null; - status?: MilestoneStatus; - created_at?: Date | string; - updated_at?: Date | string; -} \ No newline at end of file + id: string + vault_id: string + title: string + description?: string | null + target_amount: string + current_amount: string + deadline: Date | string + status: MilestoneStatus + created_at?: Date | string + updated_at?: Date | string +} + +export const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', +] as const) + +export const VALID_TRANSITIONS: Readonly> = { + pending: ['in_progress'], + in_progress: ['completed', 'failed', 'pending'], + completed: [], + failed: [], +} + +export interface TransitionResult { + success: boolean + error?: string +}