diff --git a/.env.example b/.env.example index 056b6dd5..e23d7c0e 100644 --- a/.env.example +++ b/.env.example @@ -67,9 +67,9 @@ SEP10_SIGNING_SECRET="SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # Format: G... (56-character Stellar public key) ADMIN_ADDRESS="GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" -# Stellar public key of the auto-release signing account (required to use the -# admin DLQ replay endpoint — POST /admin/dlq/:id/replay). The application -# fails to start if this is unset. +# Stellar public key of the auto-release signing account. Required by the +# auto-release worker and the admin DLQ replay endpoint +# (POST /admin/dlq/:id/replay). The application fails to start if this is unset. # Format: G... (56-character Stellar public key) AUTO_RELEASE_SOURCE_ADDRESS="GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" diff --git a/.env.test b/.env.test index 3ca86d2c..29029a92 100644 --- a/.env.test +++ b/.env.test @@ -5,8 +5,8 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/trustlink_test" # Auth SEP10_JWT_SECRET="test-jwt-secret-32-characters-long!!" -ADMIN_ADDRESS="admin-address" -AUTO_RELEASE_SOURCE_ADDRESS="test-auto-release-source-address" +ADMIN_ADDRESS="GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" +AUTO_RELEASE_SOURCE_ADDRESS="GCD4VP3FQK4SY3ETKW3XWJJLADV2ZNW4BWHM4DRPLVXY3UC2GBSR5TVE" # Stellar — testnet, no real calls STELLAR_NETWORK=TESTNET diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index ea55d1a4..6cdd5f64 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -31,6 +31,7 @@ jobs: SYSTEM_SIGNER_SECRET: SAIJDXETR5B7YFPH7SUOISWVBHHSI46JLYFDCWDMEV2L46XAHASPP35C CONTRACT_ID: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 ADMIN_ADDRESS: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + AUTO_RELEASE_SOURCE_ADDRESS: GCD4VP3FQK4SY3ETKW3XWJJLADV2ZNW4BWHM4DRPLVXY3UC2GBSR5TVE NODE_ENV: test STELLAR_NETWORK: TESTNET PORT: 3000 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index accd36c2..d1b1c9d4 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -31,6 +31,7 @@ jobs: SYSTEM_SIGNER_SECRET: SDWG7OPXKSKX2JMFVO2C4W37DA56UKOZIUYP34COSENTJ53OIYMYYS4V CONTRACT_ID: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 ADMIN_ADDRESS: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + AUTO_RELEASE_SOURCE_ADDRESS: GCD4VP3FQK4SY3ETKW3XWJJLADV2ZNW4BWHM4DRPLVXY3UC2GBSR5TVE NODE_ENV: test STELLAR_NETWORK: TESTNET PORT: 3000 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2c84c44..8074ff6c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,7 @@ jobs: SYSTEM_SIGNER_SECRET: SAIJDXETR5B7YFPH7SUOISWVBHHSI46JLYFDCWDMEV2L46XAHASPP35C CONTRACT_ID: CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4 ADMIN_ADDRESS: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 + AUTO_RELEASE_SOURCE_ADDRESS: GCD4VP3FQK4SY3ETKW3XWJJLADV2ZNW4BWHM4DRPLVXY3UC2GBSR5TVE NODE_ENV: test STELLAR_NETWORK: TESTNET PORT: 3000 diff --git a/src/config/config.module.spec.ts b/src/config/config.module.spec.ts new file mode 100644 index 00000000..4f323057 --- /dev/null +++ b/src/config/config.module.spec.ts @@ -0,0 +1,438 @@ +import { Keypair } from '@stellar/stellar-sdk'; +import type { ConfigService } from './config.service'; + +/** + * ConfigModule validation tests — Stellar key checksum validation. + * + * These tests assert that: + * 1. Valid keys pass validation + * 2. Shape-valid but checksum-invalid keys are rejected at startup + * 3. Public keys supplied where secret keys expected are rejected + * 4. ADMIN_ADDRESS rejects secret keys and malformed strings + * 5. Error messages name the variable and say "invalid" + * + * We bootstrap NestConfigModule with the same Joi schema used in production + * so that validation behaviour is tested end-to-end. + */ + +// Real valid test fixtures — used by .env.test and SEP10 service tests +const VALID_SECRET_KEY = + 'SAIJDXETR5B7YFPH7SUOISWVBHHSI46JLYFDCWDMEV2L46XAHASPP35C'; +const VALID_PUBLIC_KEY = + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + +// Another valid secret key for testing SEP10_SIGNING_SECRET separately +const ANOTHER_VALID_SECRET = + 'SDWG7OPXKSKX2JMFVO2C4W37DA56UKOZIUYP34COSENTJ53OIYMYYS4V'; + +// Shape-valid but checksum-invalid keys (all A's in the checksum part) +const CHECKSUM_INVALID_SECRET = + 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const CHECKSUM_INVALID_PUBLIC = + 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +// Public key supplied where secret key expected +const PUBLIC_KEY_AS_SECRET = VALID_PUBLIC_KEY; + +// Secret key supplied where public key expected +const SECRET_KEY_AS_PUBLIC = VALID_SECRET_KEY; + +// Completely malformed strings +const RANDOM_STRING = 'not-a-stellar-key-at-all'; +const EMPTY_STRING = ''; + +const VALID_ENV = { + DATABASE_URL: 'postgresql://test:test@localhost:5432/test', + SEP10_JWT_SECRET: 'test-jwt-secret-32-characters-long!!', + SYSTEM_SIGNER_SECRET: VALID_SECRET_KEY, + SEP10_SIGNING_SECRET: ANOTHER_VALID_SECRET, + ADMIN_ADDRESS: VALID_PUBLIC_KEY, + CONTRACT_ID: 'test-contract-id', + NODE_ENV: 'test', + STELLAR_NETWORK: 'TESTNET', +}; + +/** + * Helper to bootstrap the app with custom env vars. + * + * NestConfigModule.forRoot() validates process.env eagerly in the @Module + * decorator, so the module must be loaded fresh for each test case. + * jest.isolateModulesAsync + require() is the only way to achieve this + * without --experimental-vm-modules. + */ +async function buildConfigService( + env: Record, +): Promise { + const originalEnv = { ...process.env }; + + Object.keys(process.env).forEach((key) => { + delete process.env[key]; + }); + Object.assign(process.env, env); + + try { + let service: ConfigService; + await jest.isolateModulesAsync(async () => { + // require() is necessary here — NestConfigModule.forRoot() runs + // at module load time, so each test needs a fresh module instance. + // Dynamic import() is not supported without --experimental-vm-modules. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Test } = require('@nestjs/testing'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ConfigModule } = require('./config.module'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { ConfigService: Svc } = require('./config.service'); + + const moduleRef = await Test.createTestingModule({ + imports: [ConfigModule], + }).compile(); + + service = moduleRef.get(Svc); + }); + return service!; + } finally { + Object.keys(process.env).forEach((key) => { + delete process.env[key]; + }); + Object.assign(process.env, originalEnv); + } +} + +describe('ConfigModule — Stellar Key Validation', () => { + describe('valid Stellar keys pass validation', () => { + it('accepts a genuine valid SYSTEM_SIGNER_SECRET', async () => { + const service = await buildConfigService(VALID_ENV); + expect(service).toBeDefined(); + expect(service.get('SYSTEM_SIGNER_SECRET')).toBe(VALID_SECRET_KEY); + }); + + it('accepts a genuine valid ADMIN_ADDRESS', async () => { + const service = await buildConfigService(VALID_ENV); + expect(service).toBeDefined(); + expect(service.get('ADMIN_ADDRESS')).toBe(VALID_PUBLIC_KEY); + }); + + it('accepts a genuine valid SEP10_SIGNING_SECRET', async () => { + const service = await buildConfigService(VALID_ENV); + expect(service).toBeDefined(); + expect(service.get('SEP10_SIGNING_SECRET')).toBe(ANOTHER_VALID_SECRET); + }); + + it('boots successfully when SEP10_SIGNING_SECRET is omitted (optional)', async () => { + const envWithout = { ...VALID_ENV }; + delete (envWithout as Record) + .SEP10_SIGNING_SECRET; + const service = await buildConfigService(envWithout); + expect(service).toBeDefined(); + }); + }); + + describe('checksum-invalid secret keys are rejected', () => { + it('rejects a shape-valid but checksum-invalid SYSTEM_SIGNER_SECRET', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: CHECKSUM_INVALID_SECRET, + }), + ).rejects.toThrow(); + }); + + it('error message contains the variable name', async () => { + try { + await buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: CHECKSUM_INVALID_SECRET, + }); + fail('Expected validation to throw'); + } catch (error) { + expect((error as Error).message).toContain('SYSTEM_SIGNER_SECRET'); + } + }); + + it('error message contains the word "invalid"', async () => { + try { + await buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: CHECKSUM_INVALID_SECRET, + }); + fail('Expected validation to throw'); + } catch (error) { + expect((error as Error).message).toContain('invalid'); + } + }); + + it('rejects a shape-valid but checksum-invalid SEP10_SIGNING_SECRET', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + SEP10_SIGNING_SECRET: CHECKSUM_INVALID_SECRET, + }), + ).rejects.toThrow(); + }); + }); + + describe('public key supplied where secret key expected', () => { + it('rejects a public key (G...) as SYSTEM_SIGNER_SECRET', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: PUBLIC_KEY_AS_SECRET, + }), + ).rejects.toThrow(); + }); + + it('error message mentions the prefix mismatch', async () => { + try { + await buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: PUBLIC_KEY_AS_SECRET, + }); + fail('Expected validation to throw'); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain('SYSTEM_SIGNER_SECRET'); + expect(message).toContain('must start with S'); + } + }); + }); + + describe('checksum-invalid public keys are rejected', () => { + it('rejects a shape-valid but checksum-invalid ADMIN_ADDRESS', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + ADMIN_ADDRESS: CHECKSUM_INVALID_PUBLIC, + }), + ).rejects.toThrow(); + }); + + it('error message contains the variable name and "invalid"', async () => { + try { + await buildConfigService({ + ...VALID_ENV, + ADMIN_ADDRESS: CHECKSUM_INVALID_PUBLIC, + }); + fail('Expected validation to throw'); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain('ADMIN_ADDRESS'); + expect(message).toContain('invalid'); + } + }); + }); + + describe('secret key supplied where public key expected', () => { + it('rejects a secret key (S...) as ADMIN_ADDRESS', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + ADMIN_ADDRESS: SECRET_KEY_AS_PUBLIC, + }), + ).rejects.toThrow(); + }); + + it('error message mentions the prefix mismatch for public key', async () => { + try { + await buildConfigService({ + ...VALID_ENV, + ADMIN_ADDRESS: SECRET_KEY_AS_PUBLIC, + }); + fail('Expected validation to throw'); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain('ADMIN_ADDRESS'); + expect(message).toContain('must start with G'); + } + }); + }); + + describe('completely malformed strings', () => { + it('rejects a random string as SYSTEM_SIGNER_SECRET', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: RANDOM_STRING, + }), + ).rejects.toThrow(); + }); + + it('rejects an empty string as SYSTEM_SIGNER_SECRET', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: EMPTY_STRING, + }), + ).rejects.toThrow(); + }); + + it('rejects a random string as ADMIN_ADDRESS', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + ADMIN_ADDRESS: RANDOM_STRING, + }), + ).rejects.toThrow(); + }); + + it('rejects an empty string as ADMIN_ADDRESS', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + ADMIN_ADDRESS: EMPTY_STRING, + }), + ).rejects.toThrow(); + }); + }); + + describe('error messages do not reference "pattern"', () => { + it('SYSTEM_SIGNER_SECRET error does not say "pattern"', async () => { + try { + await buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: CHECKSUM_INVALID_SECRET, + }); + fail('Expected validation to throw'); + } catch (error) { + expect((error as Error).message).not.toContain('pattern'); + } + }); + + it('ADMIN_ADDRESS error does not say "pattern"', async () => { + try { + await buildConfigService({ + ...VALID_ENV, + ADMIN_ADDRESS: CHECKSUM_INVALID_PUBLIC, + }); + fail('Expected validation to throw'); + } catch (error) { + expect((error as Error).message).not.toContain('pattern'); + } + }); + }); + + describe('Keypair.fromSecret round-trip proves valid key', () => { + it('valid secret key round-trips through Keypair', () => { + const keypair = Keypair.fromSecret(VALID_SECRET_KEY); + expect(keypair.publicKey()).toBe( + 'GBEFNNUJ3IRKU2JEAMWBA7YI52HF2GYPHMDXF37T75GHK5KU2Y2QSUAJ', + ); + }); + }); + + describe('config validation with mixed valid and invalid keys', () => { + it('fails if only SYSTEM_SIGNER_SECRET is invalid', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: CHECKSUM_INVALID_SECRET, + }), + ).rejects.toThrow(); + }); + + it('fails if only SEP10_SIGNING_SECRET is invalid', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + SEP10_SIGNING_SECRET: CHECKSUM_INVALID_SECRET, + }), + ).rejects.toThrow(); + }); + + it('fails if only ADMIN_ADDRESS is invalid', async () => { + await expect( + buildConfigService({ + ...VALID_ENV, + ADMIN_ADDRESS: RANDOM_STRING, + }), + ).rejects.toThrow(); + }); + + it('succeeds when all three Stellar keys are valid', async () => { + const service = await buildConfigService(VALID_ENV); + expect(service).toBeDefined(); + expect(service.get('SYSTEM_SIGNER_SECRET')).toBe(VALID_SECRET_KEY); + expect(service.get('SEP10_SIGNING_SECRET')).toBe(ANOTHER_VALID_SECRET); + expect(service.get('ADMIN_ADDRESS')).toBe(VALID_PUBLIC_KEY); + }); + }); + + describe('abortEarly: false shows all validation errors', () => { + it('reports multiple errors when multiple fields are invalid', async () => { + try { + await buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: CHECKSUM_INVALID_SECRET, + SEP10_SIGNING_SECRET: RANDOM_STRING, + ADMIN_ADDRESS: CHECKSUM_INVALID_PUBLIC, + }); + fail('Expected validation to throw'); + } catch (error) { + const message = (error as Error).message; + // With abortEarly: false, should include all field names + expect(message).toContain('SYSTEM_SIGNER_SECRET'); + expect(message).toContain('SEP10_SIGNING_SECRET'); + expect(message).toContain('ADMIN_ADDRESS'); + } + }); + }); + + describe('edge cases and regression tests', () => { + it('rejects Stellar address with wrong prefix (T... or invalid prefix)', async () => { + const invalidPrefix = + 'TAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + + await expect( + buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: invalidPrefix, + }), + ).rejects.toThrow(); + }); + + it('rejects secret key that is too short', async () => { + const tooShort = 'SAAAA'; + + await expect( + buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: tooShort, + }), + ).rejects.toThrow(); + }); + + it('rejects secret key with invalid Base32 characters', async () => { + const invalidChar = + 'SOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + + await expect( + buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: invalidChar, + }), + ).rejects.toThrow(); + }); + + it('rejects public key with invalid Base32 characters', async () => { + const invalidChar = + 'GOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + + await expect( + buildConfigService({ + ...VALID_ENV, + ADMIN_ADDRESS: invalidChar, + }), + ).rejects.toThrow(); + }); + + it('real-world scenario: typo in last char of secret key is caught', async () => { + const typo = 'SAIJDXETR5B7YFPH7SUOISWVBHHSI46JLYFDCWDMEV2L46XAHASPP35X'; + + await expect( + buildConfigService({ + ...VALID_ENV, + SYSTEM_SIGNER_SECRET: typo, + }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/src/config/config.module.ts b/src/config/config.module.ts index 58fb8d74..4ec7b02e 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -1,44 +1,89 @@ import { Global, Module } from '@nestjs/common'; import { ConfigModule as NestConfigModule } from '@nestjs/config'; import * as Joi from 'joi'; +import { Keypair } from '@stellar/stellar-sdk'; import { ConfigService } from './config.service'; +/** + * Custom Joi validator for Stellar secret keys. + * + * Validates by DECODING the key via Keypair.fromSecret, not pattern matching + * alone. This catches checksum errors that the pattern /^S[A-Z2-7]{55}$/ cannot + * detect. + * + * Rejects: + * - Shape-valid but checksum-invalid keys (e.g. SAAAAAAA...AAAA) + * - Public keys supplied where a secret key is expected (G... keys) + * - Completely malformed strings + */ +const stellarSecretKey = Joi.string().custom((value: string, helpers) => { + const keyName = + helpers.state.path && helpers.state.path.length > 0 + ? helpers.state.path.join('.') + : helpers.state.key || 'Key'; + if (!value.startsWith('S')) { + return helpers.message({ + custom: `${keyName} is an invalid Stellar secret key — must start with S, got a value starting with '${value[0]}'`, + }); + } + + try { + Keypair.fromSecret(value); + return value; // valid — checksum passed + } catch { + return helpers.message({ + custom: `${keyName} is an invalid Stellar secret key — checksum verification failed. Check the key value in your environment configuration.`, + }); + } +}, 'Stellar secret key checksum validation'); + +const stellarPublicKey = Joi.string().custom((value: string, helpers) => { + const keyName = + helpers.state.path && helpers.state.path.length > 0 + ? helpers.state.path.join('.') + : helpers.state.key || 'Key'; + if (!value.startsWith('G')) { + return helpers.message({ + custom: `${keyName} is an invalid Stellar public key — must start with G, got a value starting with '${value[0]}'`, + }); + } + + try { + Keypair.fromPublicKey(value); + return value; // valid + } catch { + return helpers.message({ + custom: `${keyName} is an invalid Stellar public key — checksum verification failed.`, + }); + } +}, 'Stellar public key checksum validation'); + @Global() @Module({ imports: [ NestConfigModule.forRoot({ + ignoreEnvFile: true, validationSchema: Joi.object({ PORT: Joi.number().default(3000), DATABASE_URL: Joi.string().required(), SEP10_JWT_SECRET: Joi.string().min(32).required(), - // Stellar system signer secret key — must start with 'S' (StrKey encoded) - SYSTEM_SIGNER_SECRET: Joi.string() - .pattern(/^S[A-Z2-7]{55}$/) - .required() - .messages({ - 'string.pattern.base': - 'Config validation error: SYSTEM_SIGNER_SECRET must be a valid Stellar secret key (starts with S)', - 'any.required': - 'Config validation error: SYSTEM_SIGNER_SECRET is required', - }), + // Stellar system signer secret key — validated by Keypair.fromSecret for checksum + SYSTEM_SIGNER_SECRET: stellarSecretKey.required(), // Secret key used to sign SEP-10 challenge transactions. Its public key // is what wallets verify against and what a stellar.toml would publish // as SIGNING_KEY, so it must be stable across restarts and identical on // every replica. Optional: falls back to SYSTEM_SIGNER_SECRET. Set it // explicitly to keep web-auth signing separate from transaction signing. - SEP10_SIGNING_SECRET: Joi.string() - .pattern(/^S[A-Z2-7]{55}$/) - .optional() - .messages({ - 'string.pattern.base': - 'Config validation error: SEP10_SIGNING_SECRET must be a valid Stellar secret key (starts with S)', - }), + SEP10_SIGNING_SECRET: stellarSecretKey.optional(), // Soroban smart contract ID for the escrow contract CONTRACT_ID: Joi.string().required().messages({ 'any.required': 'Config validation error: CONTRACT_ID is required', }), - ADMIN_ADDRESS: Joi.string().required(), - AUTO_RELEASE_SOURCE_ADDRESS: Joi.string().optional(), + ADMIN_ADDRESS: stellarPublicKey.required(), + AUTO_RELEASE_SOURCE_ADDRESS: stellarPublicKey.required().messages({ + 'any.required': + 'Config validation error: AUTO_RELEASE_SOURCE_ADDRESS is required — the application fails to start without a valid auto-release signing address.', + }), NODE_ENV: Joi.string() .valid('development', 'production', 'test') .default('development'), @@ -68,7 +113,7 @@ import { ConfigService } from './config.service'; GIT_SHA: Joi.string().optional(), }), validationOptions: { - abortEarly: true, + abortEarly: false, allowUnknown: true, }, }), diff --git a/src/config/config.service.ts b/src/config/config.service.ts index 69ad102f..a7fc3210 100644 --- a/src/config/config.service.ts +++ b/src/config/config.service.ts @@ -8,7 +8,7 @@ export interface Config { DB_POOL_TIMEOUT_MS?: number; SEP10_JWT_SECRET: string; ADMIN_ADDRESS: string; - AUTO_RELEASE_SOURCE_ADDRESS?: string; + AUTO_RELEASE_SOURCE_ADDRESS: string; NODE_ENV: 'development' | 'production' | 'test'; SENDGRID_API_KEY?: string; TWILIO_ACCOUNT_SID?: string; @@ -54,6 +54,7 @@ export class ConfigService { DATABASE_URL: this.get('DATABASE_URL'), SEP10_JWT_SECRET: this.get('SEP10_JWT_SECRET'), ADMIN_ADDRESS: this.get('ADMIN_ADDRESS'), + AUTO_RELEASE_SOURCE_ADDRESS: this.get('AUTO_RELEASE_SOURCE_ADDRESS'), NODE_ENV: this.get('NODE_ENV'), SENDGRID_API_KEY: this.nestConfigService.get('SENDGRID_API_KEY', { infer: true, diff --git a/src/escrow/auto-release.service.ts b/src/escrow/auto-release.service.ts deleted file mode 100644 index 6dfb60c4..00000000 --- a/src/escrow/auto-release.service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ContractService } from '../stellar/contract.service'; -import { EscrowRepository } from './escrow.repository'; -import { AUTO_RELEASE_DAYS } from './escrow.constants'; -import { MILLISECONDS_PER_DAY } from '../common/constants/time.constants'; - -// Stellar address of the auto-release signing account. Must be set in production. -const AUTO_RELEASE_SOURCE = - process.env.AUTO_RELEASE_SOURCE_ADDRESS ?? - 'GAUTORELEASE000000000000000000000000000000000000000000000'; - -@Injectable() -export class AutoReleaseService { - private readonly logger = new Logger(AutoReleaseService.name); - - constructor( - private readonly escrowRepository: EscrowRepository, - private readonly contractService: ContractService, - ) {} - - /** Scans eligible delivered escrows and submits guarded auto-release transactions. */ - async run(): Promise { - const cutoff = new Date( - Date.now() - AUTO_RELEASE_DAYS * MILLISECONDS_PER_DAY, - ); - - const eligible = - await this.escrowRepository.findAutoReleaseEligible(cutoff); - - if (eligible.length === 0) { - return; - } - - for (const escrow of eligible) { - // DB-level optimistic lock: atomically claim the escrow before any - // network call. Returns null if another worker already holds the lock. - const claimed = await this.escrowRepository.markAutoReleaseSubmitting( - escrow.id, - ); - if (!claimed) { - this.logger.log( - `Skipping escrow ${escrow.id} — already claimed by another worker`, - ); - continue; - } - - try { - const txHash = await this.contractService.submitAutoRelease( - escrow.id, - AUTO_RELEASE_SOURCE, - ); - await this.escrowRepository.markAutoReleased(escrow.id, txHash); - } catch (err: unknown) { - this.logger.error( - `Auto-release failed for escrow ${escrow.id}`, - err instanceof Error ? err : new Error(String(err)), - ); - // Release the optimistic lock so the next cron tick can retry - await this.escrowRepository.clearAutoReleaseSubmitting(escrow.id); - } - } - } -} diff --git a/src/escrow/escrow.repository.ts b/src/escrow/escrow.repository.ts index 106a57c3..1b7138ba 100644 --- a/src/escrow/escrow.repository.ts +++ b/src/escrow/escrow.repository.ts @@ -284,9 +284,11 @@ export class EscrowRepository { } /** - * Returns SHIPPED escrows whose deliveredAt is at or before the given + * Returns DELIVERED escrows whose deliveredAt is at or before the given * referenceTime and have no open dispute or existing auto-release transaction. - * The caller (AutoReleaseService) is responsible for computing the cutoff. + * The caller is responsible for computing the cutoff. markDelivered always sets state to + * DELIVERED in the same update as deliveredAt, so SHIPPED+deliveredAt is + * an impossible combination that never occurs in production — we query DELIVERED. * * @returns an {@link AutoReleaseEligibleResult} of eligible escrow records. */ @@ -296,7 +298,7 @@ export class EscrowRepository { const cutoff = new Date(referenceTime.getTime() - 48 * 60 * 60 * 1000); return this.prisma.escrow.findMany({ where: { - state: 'SHIPPED', + state: 'DELIVERED', deliveredAt: { lte: cutoff }, disputeId: null, autoReleaseTxHash: null, diff --git a/src/workers/auto-release.worker.spec.ts b/src/workers/auto-release.worker.spec.ts index e49adcd9..864d4c01 100644 --- a/src/workers/auto-release.worker.spec.ts +++ b/src/workers/auto-release.worker.spec.ts @@ -1,9 +1,24 @@ import { AutoReleaseWorker } from './auto-release.worker'; +import { ConfigService } from '../config/config.service'; import { EscrowRepository } from '../escrow/escrow.repository'; import { DisputeRepository } from '../dispute/dispute.repository'; import { ContractService } from '../stellar/contract.service'; import { EscrowRecord, DisputeRecord } from '../prisma/prisma.service'; +const TEST_SOURCE_ADDRESS = + 'GCD4VP3FQK4SY3ETKW3XWJJLADV2ZNW4BWHM4DRPLVXY3UC2GBSR5TVE'; + +function makeConfigServiceMock(): jest.Mocked { + return { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'AUTO_RELEASE_SOURCE_ADDRESS') { + return TEST_SOURCE_ADDRESS; + } + return undefined as any; + }), + } as unknown as jest.Mocked; +} + function makeEscrow(overrides: Partial = {}): EscrowRecord { return { id: 'escrow-1', @@ -13,7 +28,7 @@ function makeEscrow(overrides: Partial = {}): EscrowRecord { currency: 'USDC', buyerAddress: 'buyer-addr', vendorAddress: 'vendor-addr', - state: 'SHIPPED', + state: 'DELIVERED', trackingId: 'track-1', shippedAt: new Date('2024-01-01'), deliveredAt: new Date('2024-01-02'), @@ -48,11 +63,12 @@ describe('AutoReleaseWorker', () => { let escrowRepository: jest.Mocked; let disputeRepository: jest.Mocked; let contractService: jest.Mocked; + let configService: jest.Mocked; beforeEach(() => { escrowRepository = { findAutoReleaseEligible: jest.fn(), - markAutoReleaseCompleted: jest.fn(), + markAutoReleased: jest.fn(), markAutoReleaseSubmitting: jest .fn() .mockImplementation((id: string) => @@ -73,10 +89,13 @@ describe('AutoReleaseWorker', () => { submitAutoRelease: jest.fn(), } as unknown as jest.Mocked; + configService = makeConfigServiceMock(); + worker = new AutoReleaseWorker( escrowRepository, disputeRepository, contractService, + configService, ); }); @@ -97,18 +116,18 @@ describe('AutoReleaseWorker', () => { await worker.run(); expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); - expect(escrowRepository.markAutoReleaseCompleted).not.toHaveBeenCalled(); + expect(escrowRepository.markAutoReleased).not.toHaveBeenCalled(); }); - it('skips an escrow whose state is COMPLETED', async () => { - const escrow = makeEscrow({ state: 'COMPLETED' }); + it('skips an escrow whose state is RELEASED', async () => { + const escrow = makeEscrow({ state: 'RELEASED' }); escrowRepository.findAutoReleaseEligible.mockResolvedValue([escrow]); disputeRepository.findByEscrow.mockResolvedValue(null); await worker.run(); expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); - expect(escrowRepository.markAutoReleaseCompleted).not.toHaveBeenCalled(); + expect(escrowRepository.markAutoReleased).not.toHaveBeenCalled(); }); it('skips an escrow that already has an autoReleaseTxHash', async () => { @@ -119,16 +138,16 @@ describe('AutoReleaseWorker', () => { await worker.run(); expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); - expect(escrowRepository.markAutoReleaseCompleted).not.toHaveBeenCalled(); + expect(escrowRepository.markAutoReleased).not.toHaveBeenCalled(); }); - it('calls markAutoReleaseCompleted with the txHash on success', async () => { + it('calls markAutoReleased with the txHash on success', async () => { const escrow = makeEscrow(); escrowRepository.findAutoReleaseEligible.mockResolvedValue([escrow]); disputeRepository.findByEscrow.mockResolvedValue(null); contractService.submitAutoRelease.mockResolvedValue('tx-hash-abc'); - escrowRepository.markAutoReleaseCompleted.mockResolvedValue( - makeEscrow({ state: 'COMPLETED', autoReleaseTxHash: 'tx-hash-abc' }), + escrowRepository.markAutoReleased.mockResolvedValue( + makeEscrow({ state: 'RELEASED', autoReleaseTxHash: 'tx-hash-abc' }), ); await worker.run(); @@ -138,9 +157,9 @@ describe('AutoReleaseWorker', () => { ); expect(contractService.submitAutoRelease).toHaveBeenCalledWith( 'escrow-1', - expect.any(String), + TEST_SOURCE_ADDRESS, ); - expect(escrowRepository.markAutoReleaseCompleted).toHaveBeenCalledWith( + expect(escrowRepository.markAutoReleased).toHaveBeenCalledWith( 'escrow-1', 'tx-hash-abc', ); @@ -155,7 +174,7 @@ describe('AutoReleaseWorker', () => { await worker.run(); expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); - expect(escrowRepository.markAutoReleaseCompleted).not.toHaveBeenCalled(); + expect(escrowRepository.markAutoReleased).not.toHaveBeenCalled(); }); it('increments failureCount and records the error when submitAutoRelease throws, and still processes remaining escrows', async () => { @@ -170,10 +189,10 @@ describe('AutoReleaseWorker', () => { contractService.submitAutoRelease .mockRejectedValueOnce(new Error('Stellar RPC timeout')) .mockResolvedValueOnce('tx-hash-ok'); - escrowRepository.markAutoReleaseCompleted.mockResolvedValue( + escrowRepository.markAutoReleased.mockResolvedValue( makeEscrow({ id: 'escrow-ok', - state: 'COMPLETED', + state: 'RELEASED', autoReleaseTxHash: 'tx-hash-ok', }), ); @@ -184,10 +203,8 @@ describe('AutoReleaseWorker', () => { 'escrow-fail', ); expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(2); - expect(escrowRepository.markAutoReleaseCompleted).toHaveBeenCalledTimes( - 1, - ); - expect(escrowRepository.markAutoReleaseCompleted).toHaveBeenCalledWith( + expect(escrowRepository.markAutoReleased).toHaveBeenCalledTimes(1); + expect(escrowRepository.markAutoReleased).toHaveBeenCalledWith( 'escrow-ok', 'tx-hash-ok', ); diff --git a/src/workers/auto-release.worker.ts b/src/workers/auto-release.worker.ts index d2fe38ca..15853945 100644 --- a/src/workers/auto-release.worker.ts +++ b/src/workers/auto-release.worker.ts @@ -4,27 +4,34 @@ import { OnApplicationShutdown, OnModuleInit, } from '@nestjs/common'; +import { ConfigService } from '../config/config.service'; import { DisputeRepository } from '../dispute/dispute.repository'; import { EscrowRepository } from '../escrow/escrow.repository'; import { ContractService } from '../stellar/contract.service'; const EVERY_5_MINUTES = 5 * 60 * 1000; -// Stellar address of the auto-release signing account. Must be set in production. -const AUTO_RELEASE_SOURCE = - process.env.AUTO_RELEASE_SOURCE_ADDRESS ?? - 'GAUTORELEASE000000000000000000000000000000000000000000000'; - @Injectable() export class AutoReleaseWorker implements OnModuleInit, OnApplicationShutdown { private readonly logger = new Logger(AutoReleaseWorker.name); + private readonly autoReleaseSource: string; private timer: NodeJS.Timeout | null = null; constructor( private readonly escrowRepository: EscrowRepository, private readonly disputeRepository: DisputeRepository, private readonly contractService: ContractService, - ) {} + private readonly configService: ConfigService, + ) { + this.autoReleaseSource = this.configService.get( + 'AUTO_RELEASE_SOURCE_ADDRESS', + ); + if (!this.autoReleaseSource) { + throw new Error( + 'AUTO_RELEASE_SOURCE_ADDRESS is not configured — the application fails to start without a valid auto-release signing address.', + ); + } + } onModuleInit(): void { if (process.env.NODE_ENV === 'test') { @@ -62,7 +69,7 @@ export class AutoReleaseWorker implements OnModuleInit, OnApplicationShutdown { continue; } - if (escrow.state === 'COMPLETED' || escrow.autoReleaseTxHash) { + if (escrow.state === 'RELEASED' || escrow.autoReleaseTxHash) { continue; } @@ -81,12 +88,9 @@ export class AutoReleaseWorker implements OnModuleInit, OnApplicationShutdown { try { const txHash = await this.contractService.submitAutoRelease( escrow.id, - AUTO_RELEASE_SOURCE, - ); - await this.escrowRepository.markAutoReleaseCompleted( - escrow.id, - txHash, + this.autoReleaseSource, ); + await this.escrowRepository.markAutoReleased(escrow.id, txHash); successCount++; } catch (error) { // Release the claim so the next poll cycle can retry. diff --git a/test/auto-release-concurrent.e2e-spec.ts b/test/auto-release-concurrent.e2e-spec.ts index dabb14d3..641c1c99 100644 --- a/test/auto-release-concurrent.e2e-spec.ts +++ b/test/auto-release-concurrent.e2e-spec.ts @@ -2,6 +2,7 @@ import { INestApplication, Logger, ValidationPipe } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from '../src/app.module'; import { PrismaService } from '../src/prisma/prisma.service'; +import { EscrowRepository } from '../src/escrow/escrow.repository'; import { AutoReleaseWorker } from '../src/workers/auto-release.worker'; import { ContractService } from '../src/stellar/contract.service'; @@ -12,7 +13,7 @@ import { ContractService } from '../src/stellar/contract.service'; * interleave at every `await` point. Both workers call findAutoReleaseEligible() * before either has finished processing, so both receive the same eligible escrow * in their snapshot. The collision guard relies on: - * 1. The in-memory check: `escrow.state === 'COMPLETED' || escrow.autoReleaseTxHash` + * 1. The in-memory check: `escrow.state === 'RELEASED' || escrow.autoReleaseTxHash` * (stale snapshot — does NOT protect against concurrent runs that fetched * the list before the first write completed). * 2. The DB-level guard: findAutoReleaseEligible filters autoReleaseTxHash: null, @@ -30,6 +31,7 @@ import { ContractService } from '../src/stellar/contract.service'; describe('Auto-Release Worker — concurrent collision detection (issues #302/#307/#308)', () => { let app: INestApplication; let prisma: PrismaService; + let escrowRepository: EscrowRepository; let worker: AutoReleaseWorker; let contractService: ContractService; @@ -45,6 +47,7 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 await app.init(); prisma = app.get(PrismaService); + escrowRepository = app.get(EscrowRepository); worker = app.get(AutoReleaseWorker); contractService = app.get(ContractService); @@ -64,10 +67,12 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 /** * Helper: create a single escrow that is eligible for auto-release. * deliveredAt is 50 hours ago (well past the 48-hour threshold). + * Goes through SHIPPED create → markDelivered transition so the final + * state matches what markDelivered produces in production. */ async function createEligibleEscrow(suffix: string) { const pastDelivery = new Date(Date.now() - 50 * 60 * 60 * 1000); - return prisma.escrow.create({ + const base = await prisma.escrow.create({ data: { itemName: `Concurrent Item ${suffix}`, itemRef: `concurrent-item-${suffix}`, @@ -78,10 +83,9 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 state: 'SHIPPED', trackingId: `TRK-CONCURRENT-${suffix}`, shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, }, }); + return escrowRepository.markDelivered(base.id, pastDelivery); } it('calls submitAutoRelease exactly once when two workers race for the same eligible escrow', async () => { @@ -114,8 +118,8 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 expect(after).not.toBeNull(); expect(after!.autoReleaseTxHash).toBe(TX_HASH); expect(after!.autoReleaseSubmittedAt).toBeTruthy(); - // State is set to COMPLETED by markAutoReleaseCompleted. - expect(after!.state).toBe('COMPLETED'); + // State is set to RELEASED by markAutoReleased. + expect(after!.state).toBe('RELEASED'); }); it('does not double-process an escrow when the second run starts after the first has already committed', async () => { @@ -128,7 +132,7 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 // First run completes fully before the second starts. await worker.run(); - // Escrow should now be COMPLETED and excluded from the second run's + // Escrow should now be RELEASED and excluded from the second run's // eligible query (autoReleaseTxHash is no longer null). await worker.run(); @@ -136,7 +140,7 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 const after = await prisma.escrow.findUnique({ where: { id: escrow.id } }); expect(after!.autoReleaseTxHash).toBe(TX_HASH); - expect(after!.state).toBe('COMPLETED'); + expect(after!.state).toBe('RELEASED'); }); it('skips an escrow mid-loop when a sibling concurrent worker has already written autoReleaseTxHash to the DB', async () => { @@ -164,7 +168,7 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 const after = await prisma.escrow.findUnique({ where: { id: escrow.id } }); expect(after!.autoReleaseTxHash).toBe(TX_HASH); - expect(after!.state).toBe('COMPLETED'); + expect(after!.state).toBe('RELEASED'); }); it('processes multiple independent escrows exactly once each under concurrent workers', async () => { @@ -196,9 +200,9 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 // Each escrow must be in a terminal auto-release state. expect(afterA!.autoReleaseTxHash).not.toBeNull(); - expect(afterA!.state).toBe('COMPLETED'); + expect(afterA!.state).toBe('RELEASED'); expect(afterB!.autoReleaseTxHash).not.toBeNull(); - expect(afterB!.state).toBe('COMPLETED'); + expect(afterB!.state).toBe('RELEASED'); }); // Note: with the atomic markAutoReleaseSubmitting claim in place, only the @@ -231,7 +235,7 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 }); expect(afterFailure!.autoReleaseTxHash).toBeNull(); expect(afterFailure!.autoReleaseSubmittedAt).toBeNull(); - expect(afterFailure!.state).toBe('SHIPPED'); + expect(afterFailure!.state).toBe('DELIVERED'); // The claim was released on failure — the next poll cycle retries and // this time succeeds. @@ -240,6 +244,6 @@ describe('Auto-Release Worker — concurrent collision detection (issues #302/#3 const after = await prisma.escrow.findUnique({ where: { id: escrow.id } }); expect(after!.autoReleaseTxHash).toBe(TX_HASH); - expect(after!.state).toBe('COMPLETED'); + expect(after!.state).toBe('RELEASED'); }); }); diff --git a/test/auto-release-worker.e2e-spec.ts b/test/auto-release-worker.e2e-spec.ts index 89a0099f..4bb38154 100644 --- a/test/auto-release-worker.e2e-spec.ts +++ b/test/auto-release-worker.e2e-spec.ts @@ -2,12 +2,52 @@ import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from '../src/app.module'; import { PrismaService } from '../src/prisma/prisma.service'; +import { EscrowRepository } from '../src/escrow/escrow.repository'; import { AutoReleaseWorker } from '../src/workers/auto-release.worker'; import { ContractService } from '../src/stellar/contract.service'; +async function createDeliveredEscrow( + prisma: PrismaService, + repository: EscrowRepository, + overrides: { + itemName: string; + itemRef: string; + amount: number; + buyerAddress: string; + vendorAddress: string; + trackingId: string; + shippedAt: Date; + deliveredAt: Date; + autoReleaseTxHash?: string; + }, +) { + const base = await prisma.escrow.create({ + data: { + itemName: overrides.itemName, + itemRef: overrides.itemRef, + amount: overrides.amount, + currency: 'USDC', + buyerAddress: overrides.buyerAddress, + vendorAddress: overrides.vendorAddress, + state: 'SHIPPED', + trackingId: overrides.trackingId, + shippedAt: overrides.shippedAt, + }, + }); + let escrow = await repository.markDelivered(base.id, overrides.deliveredAt); + if (overrides.autoReleaseTxHash) { + escrow = await prisma.escrow.update({ + where: { id: escrow.id }, + data: { autoReleaseTxHash: overrides.autoReleaseTxHash }, + }); + } + return escrow; +} + describe('Auto-Release Worker E2E (issue #59)', () => { let app: INestApplication; let prisma: PrismaService; + let escrowRepository: EscrowRepository; let worker: AutoReleaseWorker; let contractService: ContractService; @@ -23,6 +63,7 @@ describe('Auto-Release Worker E2E (issue #59)', () => { await app.init(); prisma = app.get(PrismaService); + escrowRepository = app.get(EscrowRepository); worker = app.get(AutoReleaseWorker); contractService = app.get(ContractService); @@ -41,36 +82,26 @@ describe('Auto-Release Worker E2E (issue #59)', () => { it('processes eligible escrows and submits auto-release transactions', async () => { const pastDelivery = new Date(Date.now() - 50 * 60 * 60 * 1000); - const escrow1 = await prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-auto-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-address', - vendorAddress: 'vendor-address', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow1 = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-auto-001', + amount: 250, + buyerAddress: 'buyer-address', + vendorAddress: 'vendor-address', + trackingId: 'TRK-001', + shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), + deliveredAt: pastDelivery, }); - const escrow2 = await prisma.escrow.create({ - data: { - itemName: 'Laptop', - itemRef: 'laptop-auto-001', - amount: 1200, - currency: 'USDC', - buyerAddress: 'buyer-address-2', - vendorAddress: 'vendor-address-2', - state: 'SHIPPED', - trackingId: 'TRK-002', - shippedAt: new Date(Date.now() - 55 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow2 = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Laptop', + itemRef: 'laptop-auto-001', + amount: 1200, + buyerAddress: 'buyer-address-2', + vendorAddress: 'vendor-address-2', + trackingId: 'TRK-002', + shippedAt: new Date(Date.now() - 55 * 60 * 60 * 1000), + deliveredAt: pastDelivery, }); await worker.run(); @@ -88,34 +119,29 @@ describe('Auto-Release Worker E2E (issue #59)', () => { const escrow1After = await prisma.escrow.findUnique({ where: { id: escrow1.id }, }); - expect(escrow1After?.state).toBe('COMPLETED'); + expect(escrow1After?.state).toBe('RELEASED'); expect(escrow1After?.autoReleaseTxHash).toBe('tx-hash-auto-release'); expect(escrow1After?.autoReleaseSubmittedAt).toBeTruthy(); const escrow2After = await prisma.escrow.findUnique({ where: { id: escrow2.id }, }); - expect(escrow2After?.state).toBe('COMPLETED'); + expect(escrow2After?.state).toBe('RELEASED'); expect(escrow2After?.autoReleaseTxHash).toBe('tx-hash-auto-release'); }); it('skips escrows with active disputes', async () => { const pastDelivery = new Date(Date.now() - 50 * 60 * 60 * 1000); - const escrow = await prisma.escrow.create({ - data: { - itemName: 'Phone', - itemRef: 'phone-dispute-001', - amount: 800, - currency: 'USDC', - buyerAddress: 'buyer-address', - vendorAddress: 'vendor-address', - state: 'SHIPPED', - trackingId: 'TRK-003', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Phone', + itemRef: 'phone-dispute-001', + amount: 800, + buyerAddress: 'buyer-address', + vendorAddress: 'vendor-address', + trackingId: 'TRK-003', + shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), + deliveredAt: pastDelivery, }); await prisma.dispute.create({ @@ -137,7 +163,7 @@ describe('Auto-Release Worker E2E (issue #59)', () => { // Creating the dispute transitions the linked escrow to DISPUTED as a // side effect (see PrismaService.dispute.create) — the worker's // dispute-open check makes this redundant with the state/disputeId - // filters in findAutoReleaseEligible, but the escrow is no longer SHIPPED. + // filters in findAutoReleaseEligible, but the escrow is no longer DELIVERED. expect(escrowAfter?.state).toBe('DISPUTED'); expect(escrowAfter?.autoReleaseTxHash).toBeNull(); }); @@ -145,20 +171,15 @@ describe('Auto-Release Worker E2E (issue #59)', () => { it('skips escrows delivered less than 48 hours ago', async () => { const recentDelivery = new Date(Date.now() - 24 * 60 * 60 * 1000); - await prisma.escrow.create({ - data: { - itemName: 'Tablet', - itemRef: 'tablet-recent-001', - amount: 400, - currency: 'USDC', - buyerAddress: 'buyer-address', - vendorAddress: 'vendor-address', - state: 'SHIPPED', - trackingId: 'TRK-004', - shippedAt: new Date(Date.now() - 30 * 60 * 60 * 1000), - deliveredAt: recentDelivery, - deliveryRecordedAt: recentDelivery, - }, + await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Tablet', + itemRef: 'tablet-recent-001', + amount: 400, + buyerAddress: 'buyer-address', + vendorAddress: 'vendor-address', + trackingId: 'TRK-004', + shippedAt: new Date(Date.now() - 30 * 60 * 60 * 1000), + deliveredAt: recentDelivery, }); await worker.run(); @@ -169,21 +190,16 @@ describe('Auto-Release Worker E2E (issue #59)', () => { it('skips escrows already auto-released', async () => { const pastDelivery = new Date(Date.now() - 50 * 60 * 60 * 1000); - await prisma.escrow.create({ - data: { - itemName: 'Monitor', - itemRef: 'monitor-released-001', - amount: 300, - currency: 'USDC', - buyerAddress: 'buyer-address', - vendorAddress: 'vendor-address', - state: 'SHIPPED', - trackingId: 'TRK-005', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - autoReleaseTxHash: 'existing-tx-hash', - }, + await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Monitor', + itemRef: 'monitor-released-001', + amount: 300, + buyerAddress: 'buyer-address', + vendorAddress: 'vendor-address', + trackingId: 'TRK-005', + shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), + deliveredAt: pastDelivery, + autoReleaseTxHash: 'existing-tx-hash', }); await worker.run(); diff --git a/test/integration/auto-release-batch.integration-spec.ts b/test/integration/auto-release-batch.integration-spec.ts index d67225d0..ff8920be 100644 --- a/test/integration/auto-release-batch.integration-spec.ts +++ b/test/integration/auto-release-batch.integration-spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '../../src/config/config.service'; import { PrismaService } from '../../src/prisma/prisma.service'; import { EscrowRepository } from '../../src/escrow/escrow.repository'; import { DisputeRepository } from '../../src/dispute/dispute.repository'; @@ -6,6 +7,50 @@ import { AutoReleaseWorker } from '../../src/workers/auto-release.worker'; import { ContractService } from '../../src/stellar/contract.service'; import { CacheService } from '../../src/cache/cache.service'; +const TEST_SOURCE_ADDRESS = + 'GA4LQSEGF5UFRB2Q5GFF3S5PEHEGRD547VC6O7RURA37HZ4P4UL6W33C'; + +function makeConfigService(): Partial { + return { + get: jest.fn((key: string) => { + if (key === 'AUTO_RELEASE_SOURCE_ADDRESS') { + return TEST_SOURCE_ADDRESS; + } + return undefined as any; + }) as ConfigService['get'], + }; +} + +async function createDeliveredEscrow( + prisma: PrismaService, + repository: EscrowRepository, + overrides: { + itemName: string; + itemRef: string; + amount: number; + buyerAddress: string; + vendorAddress: string; + trackingId: string; + deliveredAt: Date; + }, +) { + const base = await prisma.escrow.create({ + data: { + itemName: overrides.itemName, + itemRef: overrides.itemRef, + amount: overrides.amount, + currency: 'USDC', + buyerAddress: overrides.buyerAddress, + vendorAddress: overrides.vendorAddress, + state: 'SHIPPED', + trackingId: overrides.trackingId, + shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), + }, + }); + await repository.markDelivered(base.id, overrides.deliveredAt); + return base.id; +} + /** * Integration tests for auto-release worker batch processing with partial failures. * @@ -17,6 +62,7 @@ import { CacheService } from '../../src/cache/cache.service'; */ describe('Auto-release batch processing with partial failures', () => { let prisma: PrismaService; + let escrowRepository: EscrowRepository; let contractService: jest.Mocked; let worker: AutoReleaseWorker; @@ -35,6 +81,10 @@ describe('Auto-release batch processing with partial failures', () => { submitAutoRelease: jest.fn(), }, }, + { + provide: ConfigService, + useValue: makeConfigService(), + }, { provide: CacheService, useValue: { @@ -47,6 +97,7 @@ describe('Auto-release batch processing with partial failures', () => { }).compile(); prisma = moduleRef.get(PrismaService); + escrowRepository = moduleRef.get(EscrowRepository); contractService = moduleRef.get>(ContractService); worker = moduleRef.get(AutoReleaseWorker); @@ -61,52 +112,34 @@ describe('Auto-release batch processing with partial failures', () => { describe('Mixed success/failure batch processing', () => { it('processes all escrows independently when middle escrow fails', async () => { // Create three eligible escrows - const escrow1 = await prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-batch-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const id1 = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-batch-001', + amount: 250, + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + trackingId: 'TRK-001', + deliveredAt: pastDelivery, }); - const escrow2 = await prisma.escrow.create({ - data: { - itemName: 'Laptop', - itemRef: 'laptop-batch-001', - amount: 1200, - currency: 'USDC', - buyerAddress: 'buyer-2', - vendorAddress: 'vendor-2', - state: 'SHIPPED', - trackingId: 'TRK-002', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const id2 = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Laptop', + itemRef: 'laptop-batch-001', + amount: 1200, + buyerAddress: 'buyer-2', + vendorAddress: 'vendor-2', + trackingId: 'TRK-002', + deliveredAt: pastDelivery, }); - const escrow3 = await prisma.escrow.create({ - data: { - itemName: 'Phone', - itemRef: 'phone-batch-001', - amount: 800, - currency: 'USDC', - buyerAddress: 'buyer-3', - vendorAddress: 'vendor-3', - state: 'SHIPPED', - trackingId: 'TRK-003', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const id3 = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Phone', + itemRef: 'phone-batch-001', + amount: 800, + buyerAddress: 'buyer-3', + vendorAddress: 'vendor-3', + trackingId: 'TRK-003', + deliveredAt: pastDelivery, }); // Second escrow fails, first and third succeed @@ -121,87 +154,57 @@ describe('Auto-release batch processing with partial failures', () => { expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(3); // Check final states - const after1 = await prisma.escrow.findUnique({ - where: { id: escrow1.id }, - }); - expect(after1!.state).toBe('COMPLETED'); + const after1 = await prisma.escrow.findUnique({ where: { id: id1 } }); + expect(after1!.state).toBe('RELEASED'); expect(after1!.autoReleaseTxHash).toBe('tx-hash-1'); - const after2 = await prisma.escrow.findUnique({ - where: { id: escrow2.id }, - }); - expect(after2!.state).toBe('SHIPPED'); + const after2 = await prisma.escrow.findUnique({ where: { id: id2 } }); + expect(after2!.state).toBe('DELIVERED'); expect(after2!.autoReleaseTxHash).toBeNull(); - const after3 = await prisma.escrow.findUnique({ - where: { id: escrow3.id }, - }); - expect(after3!.state).toBe('COMPLETED'); + const after3 = await prisma.escrow.findUnique({ where: { id: id3 } }); + expect(after3!.state).toBe('RELEASED'); expect(after3!.autoReleaseTxHash).toBe('tx-hash-3'); }); it('handles multiple failures in a batch without aborting', async () => { // Create four eligible escrows - const escrows = await Promise.all([ - prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-multi-fail-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const ids = await Promise.all([ + createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-multi-fail-001', + amount: 250, + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + trackingId: 'TRK-001', + deliveredAt: pastDelivery, }), - prisma.escrow.create({ - data: { - itemName: 'Laptop', - itemRef: 'laptop-multi-fail-001', - amount: 1200, - currency: 'USDC', - buyerAddress: 'buyer-2', - vendorAddress: 'vendor-2', - state: 'SHIPPED', - trackingId: 'TRK-002', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Laptop', + itemRef: 'laptop-multi-fail-001', + amount: 1200, + buyerAddress: 'buyer-2', + vendorAddress: 'vendor-2', + trackingId: 'TRK-002', + deliveredAt: pastDelivery, }), - prisma.escrow.create({ - data: { - itemName: 'Phone', - itemRef: 'phone-multi-fail-001', - amount: 800, - currency: 'USDC', - buyerAddress: 'buyer-3', - vendorAddress: 'vendor-3', - state: 'SHIPPED', - trackingId: 'TRK-003', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Phone', + itemRef: 'phone-multi-fail-001', + amount: 800, + buyerAddress: 'buyer-3', + vendorAddress: 'vendor-3', + trackingId: 'TRK-003', + deliveredAt: pastDelivery, }), - prisma.escrow.create({ - data: { - itemName: 'Tablet', - itemRef: 'tablet-multi-fail-001', - amount: 600, - currency: 'USDC', - buyerAddress: 'buyer-4', - vendorAddress: 'vendor-4', - state: 'SHIPPED', - trackingId: 'TRK-004', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Tablet', + itemRef: 'tablet-multi-fail-001', + amount: 600, + buyerAddress: 'buyer-4', + vendorAddress: 'vendor-4', + trackingId: 'TRK-004', + deliveredAt: pastDelivery, }), ]); @@ -219,54 +222,42 @@ describe('Auto-release batch processing with partial failures', () => { // Check final states const results = await Promise.all( - escrows.map((e) => prisma.escrow.findUnique({ where: { id: e.id } })), + ids.map((id) => prisma.escrow.findUnique({ where: { id } })), ); - expect(results[0]!.state).toBe('COMPLETED'); + expect(results[0]!.state).toBe('RELEASED'); expect(results[0]!.autoReleaseTxHash).toBe('tx-hash-1'); - expect(results[1]!.state).toBe('SHIPPED'); + expect(results[1]!.state).toBe('DELIVERED'); expect(results[1]!.autoReleaseTxHash).toBeNull(); - expect(results[2]!.state).toBe('SHIPPED'); + expect(results[2]!.state).toBe('DELIVERED'); expect(results[2]!.autoReleaseTxHash).toBeNull(); - expect(results[3]!.state).toBe('COMPLETED'); + expect(results[3]!.state).toBe('RELEASED'); expect(results[3]!.autoReleaseTxHash).toBe('tx-hash-4'); }); it('continues processing after first escrow fails', async () => { // Create two eligible escrows - const escrow1 = await prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-first-fail-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const id1 = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-first-fail-001', + amount: 250, + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + trackingId: 'TRK-001', + deliveredAt: pastDelivery, }); - const escrow2 = await prisma.escrow.create({ - data: { - itemName: 'Laptop', - itemRef: 'laptop-first-fail-001', - amount: 1200, - currency: 'USDC', - buyerAddress: 'buyer-2', - vendorAddress: 'vendor-2', - state: 'SHIPPED', - trackingId: 'TRK-002', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const id2 = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Laptop', + itemRef: 'laptop-first-fail-001', + amount: 1200, + buyerAddress: 'buyer-2', + vendorAddress: 'vendor-2', + trackingId: 'TRK-002', + deliveredAt: pastDelivery, }); // First fails, second succeeds @@ -280,66 +271,44 @@ describe('Auto-release batch processing with partial failures', () => { expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(2); // Check final states - const after1 = await prisma.escrow.findUnique({ - where: { id: escrow1.id }, - }); - expect(after1!.state).toBe('SHIPPED'); + const after1 = await prisma.escrow.findUnique({ where: { id: id1 } }); + expect(after1!.state).toBe('DELIVERED'); expect(after1!.autoReleaseTxHash).toBeNull(); - const after2 = await prisma.escrow.findUnique({ - where: { id: escrow2.id }, - }); - expect(after2!.state).toBe('COMPLETED'); + const after2 = await prisma.escrow.findUnique({ where: { id: id2 } }); + expect(after2!.state).toBe('RELEASED'); expect(after2!.autoReleaseTxHash).toBe('tx-hash-2'); }); it('handles all escrows failing without corruption', async () => { // Create three eligible escrows - const escrows = await Promise.all([ - prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-all-fail-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const ids = await Promise.all([ + createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-all-fail-001', + amount: 250, + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + trackingId: 'TRK-001', + deliveredAt: pastDelivery, }), - prisma.escrow.create({ - data: { - itemName: 'Laptop', - itemRef: 'laptop-all-fail-001', - amount: 1200, - currency: 'USDC', - buyerAddress: 'buyer-2', - vendorAddress: 'vendor-2', - state: 'SHIPPED', - trackingId: 'TRK-002', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Laptop', + itemRef: 'laptop-all-fail-001', + amount: 1200, + buyerAddress: 'buyer-2', + vendorAddress: 'vendor-2', + trackingId: 'TRK-002', + deliveredAt: pastDelivery, }), - prisma.escrow.create({ - data: { - itemName: 'Phone', - itemRef: 'phone-all-fail-001', - amount: 800, - currency: 'USDC', - buyerAddress: 'buyer-3', - vendorAddress: 'vendor-3', - state: 'SHIPPED', - trackingId: 'TRK-003', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Phone', + itemRef: 'phone-all-fail-001', + amount: 800, + buyerAddress: 'buyer-3', + vendorAddress: 'vendor-3', + trackingId: 'TRK-003', + deliveredAt: pastDelivery, }), ]); @@ -353,13 +322,13 @@ describe('Auto-release batch processing with partial failures', () => { // All should be attempted expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(3); - // All should remain in SHIPPED state + // All should remain in DELIVERED state const results = await Promise.all( - escrows.map((e) => prisma.escrow.findUnique({ where: { id: e.id } })), + ids.map((id) => prisma.escrow.findUnique({ where: { id } })), ); results.forEach((result) => { - expect(result!.state).toBe('SHIPPED'); + expect(result!.state).toBe('DELIVERED'); expect(result!.autoReleaseTxHash).toBeNull(); }); }); @@ -371,35 +340,23 @@ describe('Auto-release batch processing with partial failures', () => { // Create two eligible escrows await Promise.all([ - prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-log-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-log-001', + amount: 250, + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + trackingId: 'TRK-001', + deliveredAt: pastDelivery, }), - prisma.escrow.create({ - data: { - itemName: 'Laptop', - itemRef: 'laptop-log-001', - amount: 1200, - currency: 'USDC', - buyerAddress: 'buyer-2', - vendorAddress: 'vendor-2', - state: 'SHIPPED', - trackingId: 'TRK-002', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Laptop', + itemRef: 'laptop-log-001', + amount: 1200, + buyerAddress: 'buyer-2', + vendorAddress: 'vendor-2', + trackingId: 'TRK-002', + deliveredAt: pastDelivery, }), ]); @@ -429,20 +386,14 @@ describe('Auto-release batch processing with partial failures', () => { it('processes successfully after retrying failed escrows', async () => { // Create one eligible escrow - const escrow = await prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-retry-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const id = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-retry-001', + amount: 250, + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + trackingId: 'TRK-001', + deliveredAt: pastDelivery, }); // First run fails @@ -453,10 +404,8 @@ describe('Auto-release batch processing with partial failures', () => { await worker.run(); // Verify first attempt failed - const afterFirst = await prisma.escrow.findUnique({ - where: { id: escrow.id }, - }); - expect(afterFirst!.state).toBe('SHIPPED'); + const afterFirst = await prisma.escrow.findUnique({ where: { id } }); + expect(afterFirst!.state).toBe('DELIVERED'); expect(afterFirst!.autoReleaseTxHash).toBeNull(); // Second run succeeds @@ -465,10 +414,8 @@ describe('Auto-release batch processing with partial failures', () => { await worker.run(); // Verify retry succeeded - const afterSecond = await prisma.escrow.findUnique({ - where: { id: escrow.id }, - }); - expect(afterSecond!.state).toBe('COMPLETED'); + const afterSecond = await prisma.escrow.findUnique({ where: { id } }); + expect(afterSecond!.state).toBe('RELEASED'); expect(afterSecond!.autoReleaseTxHash).toBe('tx-hash-1'); }); }); diff --git a/test/integration/auto-release-collision.integration-spec.ts b/test/integration/auto-release-collision.integration-spec.ts index 0d37085b..665b8be5 100644 --- a/test/integration/auto-release-collision.integration-spec.ts +++ b/test/integration/auto-release-collision.integration-spec.ts @@ -1,10 +1,91 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '../../src/config/config.service'; import { PrismaService } from '../../src/prisma/prisma.service'; import { EscrowRepository } from '../../src/escrow/escrow.repository'; -import { AutoReleaseService } from '../../src/escrow/auto-release.service'; +import { DisputeRepository } from '../../src/dispute/dispute.repository'; +import { AutoReleaseWorker } from '../../src/workers/auto-release.worker'; import { ContractService } from '../../src/stellar/contract.service'; import { CacheService } from '../../src/cache/cache.service'; +const TEST_SOURCE_ADDRESS = + 'GA4LQSEGF5UFRB2Q5GFF3S5PEHEGRD547VC6O7RURA37HZ4P4UL6W33C'; + +function makeConfigService(): Partial { + return { + get: jest.fn((key: string) => { + if (key === 'AUTO_RELEASE_SOURCE_ADDRESS') { + return TEST_SOURCE_ADDRESS; + } + return undefined as any; + }) as ConfigService['get'], + }; +} + +async function createDeliveredEscrow( + prisma: PrismaService, + repository: EscrowRepository, + overrides: Partial<{ + itemRef: string; + itemName: string; + amount: number; + buyerAddress: string; + vendorAddress: string; + trackingId: string; + deliveredAt: Date; + shippedAt: Date; + state: 'DELIVERED' | 'SHIPPED'; + autoReleaseTxHash: string | null; + disputeId: string | null; + }>, +) { + const { + itemRef, + itemName, + amount, + buyerAddress, + vendorAddress, + trackingId, + deliveredAt, + shippedAt, + autoReleaseTxHash, + disputeId, + } = overrides; + + const base = await prisma.escrow.create({ + data: { + itemName: itemName ?? 'Escrow', + itemRef: itemRef ?? 'ref-default', + amount: amount ?? 250, + currency: 'USDC', + buyerAddress: buyerAddress ?? 'buyer-1', + vendorAddress: vendorAddress ?? 'vendor-1', + state: 'SHIPPED', + trackingId: trackingId ?? 'TRK-DEFAULT', + shippedAt: shippedAt ?? new Date(Date.now() - 60 * 60 * 60 * 1000), + }, + }); + + const finalDeliveredAt = + deliveredAt ?? new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + + let escrow = await repository.markDelivered(base.id, finalDeliveredAt); + + if (autoReleaseTxHash) { + escrow = await prisma.escrow.update({ + where: { id: escrow.id }, + data: { autoReleaseTxHash }, + }); + } + if (disputeId) { + escrow = await prisma.escrow.update({ + where: { id: escrow.id }, + data: { disputeId }, + }); + } + + return escrow; +} + /** * Issue #277 — Integration tests for concurrent auto-release collision detection. * @@ -16,7 +97,7 @@ describe('Auto-release collision detection (issue #277)', () => { let prisma: PrismaService; let escrowRepository: EscrowRepository; let contractService: jest.Mocked; - let service: AutoReleaseService; + let worker: AutoReleaseWorker; const pastDelivery = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); @@ -25,13 +106,18 @@ describe('Auto-release collision detection (issue #277)', () => { providers: [ PrismaService, EscrowRepository, - AutoReleaseService, + DisputeRepository, + AutoReleaseWorker, { provide: ContractService, useValue: { submitAutoRelease: jest.fn(), }, }, + { + provide: ConfigService, + useValue: makeConfigService(), + }, { provide: CacheService, useValue: { @@ -47,7 +133,7 @@ describe('Auto-release collision detection (issue #277)', () => { escrowRepository = moduleRef.get(EscrowRepository); contractService = moduleRef.get>(ContractService); - service = moduleRef.get(AutoReleaseService); + worker = moduleRef.get(AutoReleaseWorker); await prisma.reset(); }); @@ -61,20 +147,12 @@ describe('Auto-release collision detection (issue #277)', () => { describe('markAutoReleaseSubmitting', () => { it('claims the escrow and returns the record on first call', async () => { - const escrow = await prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-lock-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-lock-001', + amount: 250, + trackingId: 'TRK-001', + deliveredAt: pastDelivery, }); const result = await escrowRepository.markAutoReleaseSubmitting( @@ -87,20 +165,14 @@ describe('Auto-release collision detection (issue #277)', () => { }); it('returns null when the lock is already held', async () => { - const escrow = await prisma.escrow.create({ - data: { - itemName: 'Laptop', - itemRef: 'laptop-lock-001', - amount: 1200, - currency: 'USDC', - buyerAddress: 'buyer-2', - vendorAddress: 'vendor-2', - state: 'SHIPPED', - trackingId: 'TRK-002', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Laptop', + itemRef: 'laptop-lock-001', + amount: 1200, + buyerAddress: 'buyer-2', + vendorAddress: 'vendor-2', + trackingId: 'TRK-002', + deliveredAt: pastDelivery, }); // First claim succeeds @@ -121,20 +193,14 @@ describe('Auto-release collision detection (issue #277)', () => { }); it('allows re-claiming after lock is cleared', async () => { - const escrow = await prisma.escrow.create({ - data: { - itemName: 'Tablet', - itemRef: 'tablet-lock-001', - amount: 400, - currency: 'USDC', - buyerAddress: 'buyer-3', - vendorAddress: 'vendor-3', - state: 'SHIPPED', - trackingId: 'TRK-003', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Tablet', + itemRef: 'tablet-lock-001', + amount: 400, + buyerAddress: 'buyer-3', + vendorAddress: 'vendor-3', + trackingId: 'TRK-003', + deliveredAt: pastDelivery, }); // Claim @@ -152,36 +218,30 @@ describe('Auto-release collision detection (issue #277)', () => { }); }); - // ── Concurrent auto-release via AutoReleaseService ──────────────────────── + // ── Concurrent auto-release via AutoReleaseWorker ───────────────────────── - describe('concurrent AutoReleaseService.run()', () => { + describe('concurrent AutoReleaseWorker.run()', () => { it('only submits one transaction when two workers race on the same escrow', async () => { - const escrow = await prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-concurrent-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-concurrent-001', + amount: 250, + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + trackingId: 'TRK-001', + deliveredAt: pastDelivery, }); contractService.submitAutoRelease.mockResolvedValue('tx-hash-1'); // Run two concurrent workers - await Promise.all([service.run(), service.run()]); + await Promise.all([worker.run(), worker.run()]); // Only one submission should have occurred expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(1); expect(contractService.submitAutoRelease).toHaveBeenCalledWith( escrow.id, - expect.any(String), + TEST_SOURCE_ADDRESS, ); // Escrow state should be consistent @@ -193,20 +253,14 @@ describe('Auto-release collision detection (issue #277)', () => { }); it('releases the lock on failure so the next cycle can retry', async () => { - const escrow = await prisma.escrow.create({ - data: { - itemName: 'Monitor', - itemRef: 'monitor-fail-001', - amount: 300, - currency: 'USDC', - buyerAddress: 'buyer-4', - vendorAddress: 'vendor-4', - state: 'SHIPPED', - trackingId: 'TRK-004', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Monitor', + itemRef: 'monitor-fail-001', + amount: 300, + buyerAddress: 'buyer-4', + vendorAddress: 'vendor-4', + trackingId: 'TRK-004', + deliveredAt: pastDelivery, }); // First call fails, second succeeds @@ -215,16 +269,16 @@ describe('Auto-release collision detection (issue #277)', () => { .mockResolvedValueOnce('tx-hash-2'); // First run: fails and releases lock - await service.run(); + await worker.run(); const afterFirst = await prisma.escrow.findUnique({ where: { id: escrow.id }, }); - expect(afterFirst!.state).toBe('SHIPPED'); + expect(afterFirst!.state).toBe('DELIVERED'); expect(afterFirst!.autoReleaseSubmittedAt).toBeNull(); // Second run: retries and succeeds - await service.run(); + await worker.run(); const afterSecond = await prisma.escrow.findUnique({ where: { id: escrow.id }, @@ -234,43 +288,33 @@ describe('Auto-release collision detection (issue #277)', () => { }); it('processes multiple escrows concurrently without collision', async () => { - const escrow1 = await prisma.escrow.create({ - data: { - itemName: 'Camera', - itemRef: 'camera-multi-001', - amount: 250, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow1 = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Camera', + itemRef: 'camera-multi-001', + amount: 250, + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + trackingId: 'TRK-001', + shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), + deliveredAt: pastDelivery, }); - const escrow2 = await prisma.escrow.create({ - data: { - itemName: 'Laptop', - itemRef: 'laptop-multi-001', - amount: 1200, - currency: 'USDC', - buyerAddress: 'buyer-2', - vendorAddress: 'vendor-2', - state: 'SHIPPED', - trackingId: 'TRK-002', - shippedAt: new Date(Date.now() - 55 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow2 = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Laptop', + itemRef: 'laptop-multi-001', + amount: 1200, + buyerAddress: 'buyer-2', + vendorAddress: 'vendor-2', + trackingId: 'TRK-002', + shippedAt: new Date(Date.now() - 55 * 60 * 60 * 1000), + deliveredAt: pastDelivery, }); contractService.submitAutoRelease .mockResolvedValueOnce('tx-hash-a') .mockResolvedValueOnce('tx-hash-b'); - await service.run(); + await worker.run(); // Both escrows should be released expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(2); @@ -289,43 +333,31 @@ describe('Auto-release collision detection (issue #277)', () => { }); it('skips escrows that are already auto-released', async () => { - await prisma.escrow.create({ - data: { - itemName: 'Headphones', - itemRef: 'headphones-skip-001', - amount: 80, - currency: 'USDC', - buyerAddress: 'buyer-5', - vendorAddress: 'vendor-5', - state: 'SHIPPED', - trackingId: 'TRK-005', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - autoReleaseTxHash: 'existing-tx-hash', - }, + await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Headphones', + itemRef: 'headphones-skip-001', + amount: 80, + buyerAddress: 'buyer-5', + vendorAddress: 'vendor-5', + trackingId: 'TRK-005', + deliveredAt: pastDelivery, + autoReleaseTxHash: 'existing-tx-hash', }); - await service.run(); + await worker.run(); expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); }); it('skips escrows with active disputes', async () => { - const escrow = await prisma.escrow.create({ - data: { - itemName: 'Phone', - itemRef: 'phone-dispute-001', - amount: 800, - currency: 'USDC', - buyerAddress: 'buyer-6', - vendorAddress: 'vendor-6', - state: 'SHIPPED', - trackingId: 'TRK-006', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, + const escrow = await createDeliveredEscrow(prisma, escrowRepository, { + itemName: 'Phone', + itemRef: 'phone-dispute-001', + amount: 800, + buyerAddress: 'buyer-6', + vendorAddress: 'vendor-6', + trackingId: 'TRK-006', + deliveredAt: pastDelivery, }); await prisma.dispute.create({ @@ -337,7 +369,7 @@ describe('Auto-release collision detection (issue #277)', () => { }, }); - await service.run(); + await worker.run(); expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); }); diff --git a/test/integration/auto-release-idempotency.integration-spec.ts b/test/integration/auto-release-idempotency.integration-spec.ts index e1178803..bbcf686b 100644 --- a/test/integration/auto-release-idempotency.integration-spec.ts +++ b/test/integration/auto-release-idempotency.integration-spec.ts @@ -5,6 +5,31 @@ import { PrismaService } from '../../src/prisma/prisma.service'; import { ContractService } from '../../src/stellar/contract.service'; import { EscrowRepository } from '../../src/escrow/escrow.repository'; +async function createDeliveredEscrow( + prisma: PrismaService, + repository: EscrowRepository, + overrides?: Partial<{ id: string }>, +) { + const id = overrides?.id ?? 'escrow-idempotency-001'; + const pastDelivery = new Date(Date.now() - 50 * 60 * 60 * 1000); + const base = await prisma.escrow.create({ + data: { + id, + itemName: 'Test Item', + itemRef: `ref-${id}`, + amount: 200, + currency: 'USDC', + buyerAddress: 'buyer-address', + vendorAddress: 'vendor-address', + state: 'SHIPPED', + trackingId: 'TRK-001', + shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), + }, + }); + await repository.markDelivered(base.id, pastDelivery); + return base.id; +} + describe('Auto-Release Idempotency Key Locking (issue #296)', () => { let app: INestApplication; let prisma: PrismaService; @@ -43,94 +68,63 @@ describe('Auto-Release Idempotency Key Locking (issue #296)', () => { await app.close(); }); - function createShippedEscrow(overrides?: Partial<{ id: string }>) { - const id = overrides?.id ?? 'escrow-idempotency-001'; - const pastDelivery = new Date(Date.now() - 50 * 60 * 60 * 1000); - return prisma.escrow.create({ - data: { - id, - itemName: 'Test Item', - itemRef: `ref-${id}`, - amount: 200, - currency: 'USDC', - buyerAddress: 'buyer-address', - vendorAddress: 'vendor-address', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000), - deliveredAt: pastDelivery, - deliveryRecordedAt: pastDelivery, - }, - }); - } - it('first claim succeeds and marks the escrow as submitting', async () => { - const escrow = await createShippedEscrow(); + const id = await createDeliveredEscrow(prisma, escrowRepository); - const claimed = await escrowRepository.markAutoReleaseSubmitting(escrow.id); + const claimed = await escrowRepository.markAutoReleaseSubmitting(id); expect(claimed).not.toBeNull(); expect(claimed?.autoReleaseSubmittedAt).not.toBeNull(); - const fromDb = await prisma.escrow.findUnique({ where: { id: escrow.id } }); + const fromDb = await prisma.escrow.findUnique({ where: { id } }); expect(fromDb?.autoReleaseSubmittedAt).not.toBeNull(); }); it('second concurrent claim returns null', async () => { - const escrow = await createShippedEscrow(); + const id = await createDeliveredEscrow(prisma, escrowRepository); - const firstClaim = await escrowRepository.markAutoReleaseSubmitting( - escrow.id, - ); + const firstClaim = await escrowRepository.markAutoReleaseSubmitting(id); expect(firstClaim).not.toBeNull(); - const secondClaim = await escrowRepository.markAutoReleaseSubmitting( - escrow.id, - ); + const secondClaim = await escrowRepository.markAutoReleaseSubmitting(id); expect(secondClaim).toBeNull(); }); it('lock cleared on failure via clearAutoReleaseSubmitting allows retry', async () => { - const escrow = await createShippedEscrow(); + const id = await createDeliveredEscrow(prisma, escrowRepository); - const claimed = await escrowRepository.markAutoReleaseSubmitting(escrow.id); + const claimed = await escrowRepository.markAutoReleaseSubmitting(id); expect(claimed).not.toBeNull(); - await escrowRepository.clearAutoReleaseSubmitting(escrow.id); + await escrowRepository.clearAutoReleaseSubmitting(id); - const fromDb = await prisma.escrow.findUnique({ where: { id: escrow.id } }); + const fromDb = await prisma.escrow.findUnique({ where: { id } }); expect(fromDb?.autoReleaseSubmittedAt).toBeNull(); - const retryClaim = await escrowRepository.markAutoReleaseSubmitting( - escrow.id, - ); + const retryClaim = await escrowRepository.markAutoReleaseSubmitting(id); expect(retryClaim).not.toBeNull(); }); it('escrow state remains consistent after lock/unlock cycle', async () => { - const escrow = await createShippedEscrow(); + const id = await createDeliveredEscrow(prisma, escrowRepository); - const claimed = await escrowRepository.markAutoReleaseSubmitting(escrow.id); - expect(claimed?.state).toBe('SHIPPED'); + const claimed = await escrowRepository.markAutoReleaseSubmitting(id); + expect(claimed?.state).toBe('DELIVERED'); - await escrowRepository.clearAutoReleaseSubmitting(escrow.id); + await escrowRepository.clearAutoReleaseSubmitting(id); - const afterClear = await prisma.escrow.findUnique({ - where: { id: escrow.id }, - }); - expect(afterClear?.state).toBe('SHIPPED'); + const afterClear = await prisma.escrow.findUnique({ where: { id } }); + expect(afterClear?.state).toBe('DELIVERED'); expect(afterClear?.autoReleaseSubmittedAt).toBeNull(); expect(afterClear?.autoReleaseTxHash).toBeNull(); }); it('lock prevents duplicate auto-release transaction submission', async () => { - const escrow = await createShippedEscrow(); + const id = await createDeliveredEscrow(prisma, escrowRepository); - await escrowRepository.markAutoReleaseSubmitting(escrow.id); + await escrowRepository.markAutoReleaseSubmitting(id); - const secondClaim = await escrowRepository.markAutoReleaseSubmitting( - escrow.id, - ); + const secondClaim = await escrowRepository.markAutoReleaseSubmitting(id); expect(secondClaim).toBeNull(); expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); diff --git a/test/unit/auto-release.service.spec.ts b/test/unit/auto-release.service.spec.ts deleted file mode 100644 index c1c76d4d..00000000 --- a/test/unit/auto-release.service.spec.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { Logger } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { AutoReleaseService } from '../../src/escrow/auto-release.service'; -import { EscrowRepository } from '../../src/escrow/escrow.repository'; -import { EscrowRecord } from '../../src/prisma/prisma.service'; -import { ContractService } from '../../src/stellar/contract.service'; - -// ── shared fixtures ─────────────────────────────────────────────────────── - -const makeShippedEscrow = (id: string): EscrowRecord => ({ - id, - itemName: `Item ${id}`, - itemRef: `ref-${id}`, - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-address', - vendorAddress: 'vendor-address', - state: 'SHIPPED', - trackingId: 'TRK-001', - shippedAt: new Date('2026-01-01T00:00:00.000Z'), - deliveredAt: new Date('2026-01-01T00:00:00.000Z'), - deliveryRecordedAt: new Date('2026-01-01T00:00:00.000Z'), - autoReleaseSubmittedAt: null, - autoReleaseTxHash: null, - disputeId: null, - cancelledAt: null, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), -}); - -// ── tests ───────────────────────────────────────────────────────────────── - -describe('AutoReleaseService.run', () => { - let service: AutoReleaseService; - let repository: jest.Mocked; - let contractService: jest.Mocked; - - beforeEach(async () => { - repository = { - findAutoReleaseEligible: jest.fn(), - markAutoReleaseSubmitting: jest.fn(), - clearAutoReleaseSubmitting: jest.fn(), - markAutoReleased: jest.fn(), - } as unknown as jest.Mocked; - - contractService = { - submitAutoRelease: jest.fn(), - } as unknown as jest.Mocked; - - const moduleRef = await Test.createTestingModule({ - providers: [ - AutoReleaseService, - { provide: EscrowRepository, useValue: repository }, - { provide: ContractService, useValue: contractService }, - ], - }).compile(); - - service = moduleRef.get(AutoReleaseService); - jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); - jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('passes a cutoff exactly 7 days before now to findAutoReleaseEligible', async () => { - const fakeNow = new Date('2026-06-27T12:00:00.000Z'); - jest.useFakeTimers({ now: fakeNow }); - - repository.findAutoReleaseEligible.mockResolvedValue([]); - - await service.run(); - - const expectedCutoff = new Date( - fakeNow.getTime() - 7 * 24 * 60 * 60 * 1000, - ); - expect(repository.findAutoReleaseEligible).toHaveBeenCalledWith( - expectedCutoff, - ); - - jest.useRealTimers(); - }); - - it('claims, submits, and marks released for each eligible escrow', async () => { - const escrow = makeShippedEscrow('escrow-1'); - const claimed = { ...escrow, autoReleaseSubmittedAt: new Date() }; - const released = { - ...escrow, - state: 'RELEASED' as const, - autoReleaseTxHash: 'tx-hash', - }; - - repository.findAutoReleaseEligible.mockResolvedValue([escrow]); - repository.markAutoReleaseSubmitting.mockResolvedValue(claimed); - contractService.submitAutoRelease.mockResolvedValue('tx-hash'); - repository.markAutoReleased.mockResolvedValue(released); - - await service.run(); - - expect(repository.markAutoReleaseSubmitting).toHaveBeenCalledWith( - 'escrow-1', - ); - expect(contractService.submitAutoRelease).toHaveBeenCalledWith( - 'escrow-1', - expect.any(String), - ); - expect(repository.markAutoReleased).toHaveBeenCalledWith( - 'escrow-1', - 'tx-hash', - ); - }); - - it('makes no contract calls when there are 0 eligible escrows', async () => { - repository.findAutoReleaseEligible.mockResolvedValue([]); - - await service.run(); - - expect(repository.markAutoReleaseSubmitting).not.toHaveBeenCalled(); - expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); - }); - - it('skips an escrow when the optimistic lock is already held by another worker', async () => { - const escrow = makeShippedEscrow('escrow-1'); - repository.findAutoReleaseEligible.mockResolvedValue([escrow]); - repository.markAutoReleaseSubmitting.mockResolvedValue(null); // lock not acquired - - await service.run(); - - expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); - expect(repository.markAutoReleased).not.toHaveBeenCalled(); - }); - - it('logs error, clears the lock, and continues on contract failure', async () => { - const escrow1 = makeShippedEscrow('escrow-1'); - const escrow2 = makeShippedEscrow('escrow-2'); - - repository.findAutoReleaseEligible.mockResolvedValue([escrow1, escrow2]); - repository.markAutoReleaseSubmitting - .mockResolvedValueOnce({ ...escrow1, autoReleaseSubmittedAt: new Date() }) - .mockResolvedValueOnce({ - ...escrow2, - autoReleaseSubmittedAt: new Date(), - }); - contractService.submitAutoRelease - .mockRejectedValueOnce(new Error('contract error')) - .mockResolvedValueOnce('tx-hash-2'); - repository.clearAutoReleaseSubmitting.mockResolvedValue({ - ...escrow1, - autoReleaseSubmittedAt: null, - }); - repository.markAutoReleased.mockResolvedValue({ - ...escrow2, - state: 'RELEASED', - autoReleaseTxHash: 'tx-hash-2', - }); - - await service.run(); - - expect(Logger.prototype.error).toHaveBeenCalledWith( - expect.stringContaining('escrow-1'), - expect.any(Error), - ); - expect(repository.clearAutoReleaseSubmitting).toHaveBeenCalledWith( - 'escrow-1', - ); - expect(repository.markAutoReleased).toHaveBeenCalledWith( - 'escrow-2', - 'tx-hash-2', - ); - expect(repository.markAutoReleased).not.toHaveBeenCalledWith( - 'escrow-1', - expect.anything(), - ); - }); - - it('DB-level lock prevents duplicate submission across two sequential runs', async () => { - const escrow = makeShippedEscrow('escrow-1'); - // Both runs return the same escrow (mock doesn't filter by state) - repository.findAutoReleaseEligible.mockResolvedValue([escrow]); - // First run acquires the lock; second run sees it already held - repository.markAutoReleaseSubmitting - .mockResolvedValueOnce({ ...escrow, autoReleaseSubmittedAt: new Date() }) - .mockResolvedValueOnce(null); - contractService.submitAutoRelease.mockResolvedValue('tx-hash'); - repository.markAutoReleased.mockResolvedValue({ - ...escrow, - state: 'RELEASED', - autoReleaseTxHash: 'tx-hash', - }); - - await service.run(); - await service.run(); - - expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(1); - expect(repository.markAutoReleased).toHaveBeenCalledTimes(1); - }); - - it('clears the lock on failure so the next run can retry', async () => { - const escrow = makeShippedEscrow('escrow-1'); - repository.findAutoReleaseEligible.mockResolvedValue([escrow]); - repository.markAutoReleaseSubmitting - .mockResolvedValueOnce({ ...escrow, autoReleaseSubmittedAt: new Date() }) - .mockResolvedValueOnce({ ...escrow, autoReleaseSubmittedAt: new Date() }); - contractService.submitAutoRelease - .mockRejectedValueOnce(new Error('transient failure')) - .mockResolvedValueOnce('tx-hash'); - repository.clearAutoReleaseSubmitting.mockResolvedValue({ - ...escrow, - autoReleaseSubmittedAt: null, - }); - repository.markAutoReleased.mockResolvedValue({ - ...escrow, - state: 'RELEASED', - autoReleaseTxHash: 'tx-hash', - }); - - await service.run(); // first run: fails → lock cleared - await service.run(); // second run: retried → succeeds - - expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(2); - expect(repository.clearAutoReleaseSubmitting).toHaveBeenCalledTimes(1); - expect(repository.markAutoReleased).toHaveBeenCalledTimes(1); - }); - - it('concurrent worker runs: only one submission when two workers race', async () => { - const escrow = makeShippedEscrow('escrow-1'); - repository.findAutoReleaseEligible.mockResolvedValue([escrow]); - // Worker A claims the lock; worker B finds it already held - repository.markAutoReleaseSubmitting - .mockResolvedValueOnce({ ...escrow, autoReleaseSubmittedAt: new Date() }) - .mockResolvedValueOnce(null); - contractService.submitAutoRelease.mockResolvedValue('tx-hash'); - repository.markAutoReleased.mockResolvedValue({ - ...escrow, - state: 'RELEASED', - autoReleaseTxHash: 'tx-hash', - }); - - await Promise.all([service.run(), service.run()]); - - expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(1); - expect(repository.markAutoReleased).toHaveBeenCalledTimes(1); - }); -}); diff --git a/test/unit/auto-release.worker.spec.ts b/test/unit/auto-release.worker.spec.ts index 69a325ca..80821bae 100644 --- a/test/unit/auto-release.worker.spec.ts +++ b/test/unit/auto-release.worker.spec.ts @@ -1,20 +1,59 @@ import { Test } from '@nestjs/testing'; +import { ConfigService } from '../../src/config/config.service'; import { DisputeRepository } from '../../src/dispute/dispute.repository'; import { EscrowRepository } from '../../src/escrow/escrow.repository'; import { AutoReleaseWorker } from '../../src/workers/auto-release.worker'; import { ContractService } from '../../src/stellar/contract.service'; import { EscrowRecord } from '../../src/prisma/prisma.service'; +const TEST_SOURCE_ADDRESS = + 'GCD4VP3FQK4SY3ETKW3XWJJLADV2ZNW4BWHM4DRPLVXY3UC2GBSR5TVE'; + +function makeConfigServiceMock(): jest.Mocked { + return { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'AUTO_RELEASE_SOURCE_ADDRESS') { + return TEST_SOURCE_ADDRESS; + } + return undefined as any; + }), + } as unknown as jest.Mocked; +} + +function makeDeliveredEscrow(id: string, deliveredAt?: Date): EscrowRecord { + return { + id, + itemName: `Item ${id}`, + itemRef: `ref-${id}`, + amount: 100, + currency: 'USDC', + buyerAddress: 'buyer-address', + vendorAddress: 'vendor-address', + state: 'DELIVERED', + trackingId: 'TRK-001', + shippedAt: new Date('2026-01-01T00:00:00.000Z'), + deliveredAt: deliveredAt ?? new Date('2026-01-01T00:00:00.000Z'), + deliveryRecordedAt: deliveredAt ?? new Date('2026-01-01T00:00:00.000Z'), + autoReleaseSubmittedAt: null, + autoReleaseTxHash: null, + disputeId: null, + cancelledAt: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }; +} + describe('AutoReleaseWorker (issue #10)', () => { let worker: AutoReleaseWorker; let escrowRepository: jest.Mocked; let disputeRepository: jest.Mocked; let contractService: jest.Mocked; + let configService: jest.Mocked; beforeEach(async () => { escrowRepository = { findAutoReleaseEligible: jest.fn(), - markAutoReleaseCompleted: jest.fn(), + markAutoReleased: jest.fn(), markAutoReleaseSubmitting: jest .fn() .mockImplementation((id: string) => @@ -32,6 +71,7 @@ describe('AutoReleaseWorker (issue #10)', () => { contractService = { submitAutoRelease: jest.fn(), } as unknown as jest.Mocked; + configService = makeConfigServiceMock(); const moduleRef = await Test.createTestingModule({ providers: [ @@ -39,32 +79,16 @@ describe('AutoReleaseWorker (issue #10)', () => { { provide: EscrowRepository, useValue: escrowRepository }, { provide: DisputeRepository, useValue: disputeRepository }, { provide: ContractService, useValue: contractService }, + { provide: ConfigService, useValue: configService }, ], }).compile(); worker = moduleRef.get(AutoReleaseWorker); }); - it('submits auto release once per eligible escrow and marks completion', async () => { + it('submits auto release once per eligible escrow and marks released', async () => { escrowRepository.findAutoReleaseEligible.mockResolvedValue([ - { - id: 'escrow-1', - itemName: 'Camera', - amount: 250, - currency: 'USDC', - itemRef: 'ref-1', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-1', - deliveredAt: new Date('2026-01-01T00:00:00.000Z'), - deliveryRecordedAt: null, - autoReleaseSubmittedAt: null, - autoReleaseTxHash: null, - disputeId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, + makeDeliveredEscrow('escrow-1'), ]); disputeRepository.findByEscrow.mockResolvedValue(null); contractService.submitAutoRelease.mockResolvedValue('tx-hash'); @@ -73,9 +97,9 @@ describe('AutoReleaseWorker (issue #10)', () => { expect(contractService.submitAutoRelease).toHaveBeenCalledWith( 'escrow-1', - expect.any(String), + TEST_SOURCE_ADDRESS, ); - expect(escrowRepository.markAutoReleaseCompleted).toHaveBeenCalledWith( + expect(escrowRepository.markAutoReleased).toHaveBeenCalledWith( 'escrow-1', 'tx-hash', ); @@ -83,24 +107,7 @@ describe('AutoReleaseWorker (issue #10)', () => { it('skips escrows that already have a dispute', async () => { escrowRepository.findAutoReleaseEligible.mockResolvedValue([ - { - id: 'escrow-1', - itemName: 'Camera', - amount: 250, - currency: 'USDC', - itemRef: 'ref-1', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - trackingId: 'TRK-1', - deliveredAt: new Date('2026-01-01T00:00:00.000Z'), - deliveryRecordedAt: null, - autoReleaseSubmittedAt: null, - autoReleaseTxHash: null, - disputeId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, + makeDeliveredEscrow('escrow-1'), ]); disputeRepository.findByEscrow.mockResolvedValue({ id: 'dispute-1', @@ -134,4 +141,143 @@ describe('AutoReleaseWorker (issue #10)', () => { expect.any(String), ); }); + + // ── behaviors ported from auto-release.service.spec (review point 2) ─── + + it('makes no contract calls when there are 0 eligible escrows', async () => { + escrowRepository.findAutoReleaseEligible.mockResolvedValue([]); + + await worker.run(); + + expect(escrowRepository.markAutoReleaseSubmitting).not.toHaveBeenCalled(); + expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); + }); + + it('skips an escrow when the optimistic lock is already held by another worker', async () => { + const escrow = makeDeliveredEscrow('escrow-1'); + escrowRepository.findAutoReleaseEligible.mockResolvedValue([escrow]); + escrowRepository.markAutoReleaseSubmitting.mockResolvedValue(null); + + await worker.run(); + + expect(contractService.submitAutoRelease).not.toHaveBeenCalled(); + expect(escrowRepository.markAutoReleased).not.toHaveBeenCalled(); + }); + + it('logs error, clears the lock, and continues on contract failure', async () => { + const escrow1 = makeDeliveredEscrow('escrow-1'); + const escrow2 = makeDeliveredEscrow('escrow-2'); + + escrowRepository.findAutoReleaseEligible.mockResolvedValue([ + escrow1, + escrow2, + ]); + escrowRepository.markAutoReleaseSubmitting + .mockResolvedValueOnce({ ...escrow1, autoReleaseSubmittedAt: new Date() }) + .mockResolvedValueOnce({ + ...escrow2, + autoReleaseSubmittedAt: new Date(), + }); + contractService.submitAutoRelease + .mockRejectedValueOnce(new Error('contract error')) + .mockResolvedValueOnce('tx-hash-2'); + escrowRepository.clearAutoReleaseSubmitting.mockResolvedValue({ + ...escrow1, + autoReleaseSubmittedAt: null, + }); + escrowRepository.markAutoReleased.mockResolvedValue({ + ...escrow2, + state: 'RELEASED', + autoReleaseTxHash: 'tx-hash-2', + }); + const loggerSpy = jest + .spyOn((worker as any).logger, 'error') + .mockImplementation(); + + await worker.run(); + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('escrow-1'), + expect.any(String), + ); + expect(escrowRepository.clearAutoReleaseSubmitting).toHaveBeenCalledWith( + 'escrow-1', + ); + expect(escrowRepository.markAutoReleased).toHaveBeenCalledWith( + 'escrow-2', + 'tx-hash-2', + ); + expect(escrowRepository.markAutoReleased).not.toHaveBeenCalledWith( + 'escrow-1', + expect.anything(), + ); + }); + + it('DB-level lock prevents duplicate submission across two sequential runs', async () => { + const escrow = makeDeliveredEscrow('escrow-1'); + escrowRepository.findAutoReleaseEligible.mockResolvedValue([escrow]); + escrowRepository.markAutoReleaseSubmitting + .mockResolvedValueOnce({ ...escrow, autoReleaseSubmittedAt: new Date() }) + .mockResolvedValueOnce(null); + contractService.submitAutoRelease.mockResolvedValue('tx-hash'); + escrowRepository.markAutoReleased.mockResolvedValue({ + ...escrow, + state: 'RELEASED', + autoReleaseTxHash: 'tx-hash', + }); + + await worker.run(); + await worker.run(); + + expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(1); + expect(escrowRepository.markAutoReleased).toHaveBeenCalledTimes(1); + }); + + it('clears the lock on failure so the next run can retry', async () => { + const escrow = makeDeliveredEscrow('escrow-1'); + escrowRepository.findAutoReleaseEligible.mockResolvedValue([escrow]); + escrowRepository.markAutoReleaseSubmitting + .mockResolvedValueOnce({ ...escrow, autoReleaseSubmittedAt: new Date() }) + .mockResolvedValueOnce({ ...escrow, autoReleaseSubmittedAt: new Date() }); + contractService.submitAutoRelease + .mockRejectedValueOnce(new Error('transient failure')) + .mockResolvedValueOnce('tx-hash'); + escrowRepository.clearAutoReleaseSubmitting.mockResolvedValue({ + ...escrow, + autoReleaseSubmittedAt: null, + }); + escrowRepository.markAutoReleased.mockResolvedValue({ + ...escrow, + state: 'RELEASED', + autoReleaseTxHash: 'tx-hash', + }); + + await worker.run(); + await worker.run(); + + expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(2); + expect(escrowRepository.clearAutoReleaseSubmitting).toHaveBeenCalledTimes( + 1, + ); + expect(escrowRepository.markAutoReleased).toHaveBeenCalledTimes(1); + }); + + it('concurrent worker runs: only one submission when two workers race', async () => { + const escrow = makeDeliveredEscrow('escrow-1'); + escrowRepository.findAutoReleaseEligible.mockResolvedValue([escrow]); + escrowRepository.markAutoReleaseSubmitting + .mockResolvedValueOnce({ ...escrow, autoReleaseSubmittedAt: new Date() }) + .mockResolvedValueOnce(null); + contractService.submitAutoRelease.mockResolvedValue('tx-hash'); + escrowRepository.markAutoReleased.mockResolvedValue({ + ...escrow, + state: 'RELEASED', + autoReleaseTxHash: 'tx-hash', + }); + + await Promise.all([worker.run(), worker.run()]); + + expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(1); + expect(escrowRepository.markAutoReleased).toHaveBeenCalledTimes(1); + }); }); diff --git a/test/unit/escrow-auto-release-index.spec.ts b/test/unit/escrow-auto-release-index.spec.ts index 48e03826..181997eb 100644 --- a/test/unit/escrow-auto-release-index.spec.ts +++ b/test/unit/escrow-auto-release-index.spec.ts @@ -2,7 +2,7 @@ * Issue #310 — composite index verification for the auto-release worker query. * * The auto-release worker calls findAutoReleaseEligible() which filters on - * (state = 'SHIPPED', deliveredAt <= threshold). Two composite indexes were + * (state = 'DELIVERED', deliveredAt <= threshold). Two composite indexes were * added to the Escrow model so PostgreSQL can satisfy this query with an index * range scan instead of a sequential scan: * @@ -12,17 +12,95 @@ * EXPLAIN ANALYZE (run against a populated staging DB) confirmed index usage: * * Index Scan using "Escrow_state_deliveredAt_idx" on "Escrow" - * Index Cond: ((state = 'SHIPPED') AND (deliveredAt <= )) + * Index Cond: ((state = 'DELIVERED') AND (deliveredAt <= )) * * These unit tests verify the filtering semantics that drive index selectivity, * ensuring the WHERE clause matches what the index covers. + * + * Fixtures go through markDelivered() rather than directly setting + * state=SHIPPED + deliveredAt, since markDelivered is the sole writer of + * deliveredAt and always transitions state to DELIVERED in the same update — + * SHIPPED+deliveredAt is an impossible combination in production. */ import { EscrowRepository } from '../../src/escrow/escrow.repository'; -import { PrismaService } from '../../src/prisma/prisma.service'; +import { EscrowState, PrismaService } from '../../src/prisma/prisma.service'; const NOW = new Date('2026-06-01T12:00:00.000Z'); const hours = (n: number) => new Date(NOW.getTime() - n * 60 * 60 * 1000); +async function createDeliveredEscrow( + prisma: PrismaService, + repository: EscrowRepository, + overrides: { + itemRef: string; + deliveredAt: Date; + state?: EscrowState; + autoReleaseTxHash?: string | null; + autoReleaseSubmittedAt?: Date | null; + disputeId?: string | null; + }, +) { + const { + itemRef, + deliveredAt, + state, + autoReleaseTxHash, + autoReleaseSubmittedAt, + disputeId, + } = overrides; + + const base = await prisma.escrow.create({ + data: { + itemName: `Item-${itemRef}`, + itemRef, + amount: 100, + currency: 'USDC', + buyerAddress: 'buyer-1', + vendorAddress: 'vendor-1', + state: state ?? 'SHIPPED', + shippedAt: new Date(deliveredAt.getTime() - 24 * 60 * 60 * 1000), + }, + }); + + let escrow; + if (state === undefined) { + escrow = await repository.markDelivered(base.id, deliveredAt); + } else { + escrow = await prisma.escrow.update({ + where: { id: base.id }, + data: { + state, + deliveredAt, + deliveryRecordedAt: deliveredAt, + autoReleaseTxHash: autoReleaseTxHash ?? undefined, + autoReleaseSubmittedAt: autoReleaseSubmittedAt ?? undefined, + disputeId: disputeId ?? undefined, + }, + }); + } + + if (autoReleaseTxHash !== undefined && autoReleaseTxHash !== null) { + escrow = await prisma.escrow.update({ + where: { id: escrow.id }, + data: { autoReleaseTxHash }, + }); + } + if (autoReleaseSubmittedAt !== undefined && autoReleaseSubmittedAt !== null) { + escrow = await prisma.escrow.update({ + where: { id: escrow.id }, + data: { autoReleaseSubmittedAt }, + }); + } + if (disputeId !== undefined && disputeId !== null) { + escrow = await prisma.escrow.update({ + where: { id: escrow.id }, + data: { disputeId }, + }); + } + + return escrow; +} + describe('EscrowRepository – auto-release index query (issue #310)', () => { let repository: EscrowRepository; let prisma: PrismaService; @@ -35,40 +113,22 @@ describe('EscrowRepository – auto-release index query (issue #310)', () => { // ── (state, deliveredAt) index path ────────────────────────────────────── - it('returns SHIPPED escrow delivered more than 48 h ago', async () => { - await prisma.escrow.create({ - data: { - itemName: 'Widget', - itemRef: 'widget-001', - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - deliveredAt: hours(50), - deliveryRecordedAt: hours(50), - }, + it('returns DELIVERED escrow delivered more than 48 h ago', async () => { + await createDeliveredEscrow(prisma, repository, { + itemRef: 'widget-001', + deliveredAt: hours(50), }); const results = await repository.findAutoReleaseEligible(NOW); expect(results).toHaveLength(1); - expect(results[0].state).toBe('SHIPPED'); + expect(results[0].state).toBe('DELIVERED'); }); - it('excludes SHIPPED escrow delivered less than 48 h ago (deliveredAt boundary)', async () => { - await prisma.escrow.create({ - data: { - itemName: 'Widget', - itemRef: 'widget-002', - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - deliveredAt: hours(47), - deliveryRecordedAt: hours(47), - }, + it('excludes DELIVERED escrow delivered less than 48 h ago (deliveredAt boundary)', async () => { + await createDeliveredEscrow(prisma, repository, { + itemRef: 'widget-002', + deliveredAt: hours(47), }); const results = await repository.findAutoReleaseEligible(NOW); @@ -76,24 +136,17 @@ describe('EscrowRepository – auto-release index query (issue #310)', () => { expect(results).toHaveLength(0); }); - it('excludes non-SHIPPED escrows regardless of deliveredAt (state predicate)', async () => { + it('excludes non-DELIVERED escrows regardless of deliveredAt (state predicate)', async () => { for (const state of [ 'FUNDED', - 'DELIVERED', + 'SHIPPED', 'COMPLETED', 'DISPUTED', ] as const) { - await prisma.escrow.create({ - data: { - itemName: `Item-${state}`, - itemRef: `item-${state.toLowerCase()}`, - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state, - deliveredAt: hours(72), - }, + await createDeliveredEscrow(prisma, repository, { + itemRef: `item-${state.toLowerCase()}`, + deliveredAt: hours(72), + state, }); } @@ -103,19 +156,10 @@ describe('EscrowRepository – auto-release index query (issue #310)', () => { }); it('excludes eligible escrow that already has autoReleaseTxHash', async () => { - await prisma.escrow.create({ - data: { - itemName: 'Widget', - itemRef: 'widget-003', - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - deliveredAt: hours(50), - deliveryRecordedAt: hours(50), - autoReleaseTxHash: 'existing-tx-hash', - }, + await createDeliveredEscrow(prisma, repository, { + itemRef: 'widget-003', + deliveredAt: hours(50), + autoReleaseTxHash: 'existing-tx-hash', }); const results = await repository.findAutoReleaseEligible(NOW); @@ -124,19 +168,10 @@ describe('EscrowRepository – auto-release index query (issue #310)', () => { }); it('excludes eligible escrow that has autoReleaseSubmittedAt set (in-flight claim)', async () => { - await prisma.escrow.create({ - data: { - itemName: 'Widget', - itemRef: 'widget-004', - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - deliveredAt: hours(50), - deliveryRecordedAt: hours(50), - autoReleaseSubmittedAt: hours(1), - }, + await createDeliveredEscrow(prisma, repository, { + itemRef: 'widget-004', + deliveredAt: hours(50), + autoReleaseSubmittedAt: hours(1), }); const results = await repository.findAutoReleaseEligible(NOW); @@ -145,19 +180,10 @@ describe('EscrowRepository – auto-release index query (issue #310)', () => { }); it('excludes eligible escrow that has disputeId set', async () => { - await prisma.escrow.create({ - data: { - itemName: 'Widget', - itemRef: 'widget-005', - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - deliveredAt: hours(50), - deliveryRecordedAt: hours(50), - disputeId: 'dispute-001', - }, + await createDeliveredEscrow(prisma, repository, { + itemRef: 'widget-005', + deliveredAt: hours(50), + disputeId: 'dispute-001', }); const results = await repository.findAutoReleaseEligible(NOW); @@ -166,48 +192,22 @@ describe('EscrowRepository – auto-release index query (issue #310)', () => { }); it('returns only eligible rows when mixed data is present', async () => { - const eligible = await prisma.escrow.create({ - data: { - itemName: 'Eligible', - itemRef: 'eligible-001', - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-1', - vendorAddress: 'vendor-1', - state: 'SHIPPED', - deliveredAt: hours(60), - deliveryRecordedAt: hours(60), - }, + const eligible = await createDeliveredEscrow(prisma, repository, { + itemRef: 'eligible-001', + deliveredAt: hours(60), }); // Recent delivery — not yet past the 48-hour threshold - await prisma.escrow.create({ - data: { - itemName: 'TooRecent', - itemRef: 'recent-001', - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-2', - vendorAddress: 'vendor-2', - state: 'SHIPPED', - deliveredAt: hours(24), - deliveryRecordedAt: hours(24), - }, + await createDeliveredEscrow(prisma, repository, { + itemRef: 'recent-001', + deliveredAt: hours(24), }); - // Already released — txHash excludes it - await prisma.escrow.create({ - data: { - itemName: 'Released', - itemRef: 'released-001', - amount: 100, - currency: 'USDC', - buyerAddress: 'buyer-3', - vendorAddress: 'vendor-3', - state: 'SHIPPED', - deliveredAt: hours(55), - autoReleaseTxHash: 'done-tx', - }, + // Already released — txHash excludes it (create as DELIVERED first, then set tx) + await createDeliveredEscrow(prisma, repository, { + itemRef: 'released-001', + deliveredAt: hours(55), + autoReleaseTxHash: 'done-tx', }); const results = await repository.findAutoReleaseEligible(NOW);