Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,7 @@ CONTACT_ENCRYPTION_KEY="00000000000000000000000000000000000000000000000000000000
# Must be exactly 64 hex characters (32 bytes). Required at startup.
# Generate with: openssl rand -hex 32
CREDENTIAL_ENCRYPTION_KEY="1111111111111111111111111111111111111111111111111111111111111111"

# SOROBAN_POLLER_ENABLED – start the background Soroban event poller.
# Default: true. Set false to boot the app without polling (tests, CLI tasks).
SOROBAN_POLLER_ENABLED=true
10 changes: 10 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,13 @@ CREDENTIAL_ENCRYPTION_KEY=000000000000000000000000000000000000000000000000000000
# Without it encryptContact will throw at startup and every test will fail.
SYSTEM_SIGNER_SECRET=SAIJDXETR5B7YFPH7SUOISWVBHHSI46JLYFDCWDMEV2L46XAHASPP35C
CONTRACT_ID=test-contract-id

# Evidence-upload throttler. Small values keep the rate-limit tests fast.
# Set here rather than in the spec: ConfigModule and the route decorator both
# read process.env at import time, before any statement in a spec body runs.
EVIDENCE_UPLOAD_LIMIT=5
EVIDENCE_UPLOAD_TTL=2000

# Background Soroban poller. Disabled in tests so specs that boot AppModule
# do not issue real RPC requests or hold the process open after the run.
SOROBAN_POLLER_ENABLED=false
4 changes: 4 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ jobs:
with:
context: .
push: false
# Without `load`, buildx leaves the image in its own cache and never
# hands it to the Docker daemon, so the verify step below fails with
# "Unable to find image 'trust-link-backend:ci' locally".
load: true
tags: trust-link-backend:ci
cache-from: type=gha
cache-to: type=gha,mode=max
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10

# prisma.config.ts reads DATABASE_URL at load, so `prisma generate` needs it
# even though generation never connects.
env:
DATABASE_URL: postgresql://trustlink:trustlink@localhost:5432/trustlink_test

steps:
- uses: actions/checkout@v4

Expand All @@ -21,6 +27,12 @@ jobs:
- name: Install dependencies
run: npm ci

# Typed lint rules resolve types through @prisma/client. Without the
# generated client every `prisma.x.update()` is an unresolved type and
# the no-unsafe-* rules fire in their hundreds — 421 errors on this repo.
- name: Generate Prisma client
run: npx prisma generate

- name: Run ESLint
run: npm run lint:check
env:
Expand Down
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"check-coverage": "node scripts/check_coverage.js",
"openapi:generate": "ts-node scripts/generate-openapi.ts",
"check-test-match": "node scripts/check_test_match.js",
"db:generate": "prisma generate",
"db:push": "prisma db push",
Expand Down Expand Up @@ -111,7 +112,16 @@
"rootDir": ".",
"testRegex": "(src|test)/.*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": {
"module": "commonjs",
"moduleResolution": "node",
"resolvePackageJsonExports": false
}
}
]
},
"collectCoverageFrom": [
"src/**/*.ts",
Expand Down
8 changes: 7 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ model Dispute {
reason String
description String @default("")
evidenceUrls String[] @default([])
status DisputeStatus @default(OPEN)
// Stored as text, not the DisputeStatus enum. Migration
// 20260529120000_add_state_deliveredat_composite_index deliberately
// converted this column with `ALTER COLUMN "status" TYPE TEXT`, but the
// model was never updated to match. The resulting drift made the generated
// client compare a text column against an enum, which Postgres rejects with
// `operator does not exist: text = "DisputeStatus"`.
status String @default("OPEN")
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down
65 changes: 46 additions & 19 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
NotificationType,
PrismaService,
} from '../src/prisma/prisma.service';
import { PrismaService } from '../src/prisma/prisma.service';

type EscrowState =
| 'CREATED'
Expand All @@ -14,7 +11,8 @@ type EscrowState =
| 'REFUNDED'
| 'CANCELLED';

type DisputeState = 'OPEN' | 'UNDER_REVIEW' | 'RESOLVED' | 'CANCELLED' | 'ABANDONED';
type DisputeState =
'OPEN' | 'UNDER_REVIEW' | 'RESOLVED' | 'CANCELLED' | 'ABANDONED';

// Deterministic Stellar-like public keys for vendors and buyers
const VENDORS = [
Expand Down Expand Up @@ -72,7 +70,13 @@ async function seedEscrows(
const amount = (100.5 + i * 50).toFixed(4);
const itemRef = `REF-DET-${1000 + i}`;

const existing = await p.escrow.findFirst({ where: { itemRef } });
// Matched on the full unique key. `itemRef` alone is not unique — the
// schema declares @@unique([vendorAddress, itemRef]) — so looking it up on
// its own could match another vendor's row, skip the wrong escrow, and
// then violate the constraint on create.
const existing = await p.escrow.findUnique({
where: { vendorAddress_itemRef: { vendorAddress, itemRef } },
});

if (existing) {
escrowRefs.push(existing.id);
Expand Down Expand Up @@ -113,11 +117,16 @@ async function seedDisputes(
p: PrismaService,
escrowIds: string[],
): Promise<{ created: number; updated: number }> {
const disputes: { escrowId: string; status: DisputeState; reason: string }[] = [
{ escrowId: escrowIds[0], status: 'OPEN', reason: 'Item not received' },
{ escrowId: escrowIds[1], status: 'OPEN', reason: 'Damaged packaging' },
{ escrowId: escrowIds[2], status: 'RESOLVED', reason: 'Defective item, resolved by refund' },
];
const disputes: { escrowId: string; status: DisputeState; reason: string }[] =
[
{ escrowId: escrowIds[0], status: 'OPEN', reason: 'Item not received' },
{ escrowId: escrowIds[1], status: 'OPEN', reason: 'Damaged packaging' },
{
escrowId: escrowIds[2],
status: 'RESOLVED',
reason: 'Defective item, resolved by refund',
},
];

let created = 0;
let updated = 0;
Expand Down Expand Up @@ -163,7 +172,7 @@ async function seedNotifications(
await p.notification.create({
data: {
escrowId,
type: 'SHIPPED' as NotificationType,
type: 'SHIPPED',
channel: 'EMAIL',
recipientAddress,
message,
Expand All @@ -184,17 +193,26 @@ export async function main(p?: PrismaService) {
await seedVendors(prisma);
console.log(`Vendors: ${VENDORS.length} ensured`);

const { created: escrowsCreated, updated: escrowsUpdated, ids: escrowIds } =
await seedEscrows(prisma);
console.log(`Escrows: ${escrowsCreated} created, ${escrowsUpdated} updated`);
const {
created: escrowsCreated,
updated: escrowsUpdated,
ids: escrowIds,
} = await seedEscrows(prisma);
console.log(
`Escrows: ${escrowsCreated} created, ${escrowsUpdated} updated`,
);

const { created: disputesCreated, updated: disputesUpdated } =
await seedDisputes(prisma, escrowIds);
console.log(`Disputes: ${disputesCreated} created, ${disputesUpdated} updated`);
console.log(
`Disputes: ${disputesCreated} created, ${disputesUpdated} updated`,
);

const { created: notificationsCreated, updated: notificationsUpdated } =
await seedNotifications(prisma, escrowIds);
console.log(`Notifications: ${notificationsCreated} created, ${notificationsUpdated} updated`);
console.log(
`Notifications: ${notificationsCreated} created, ${notificationsUpdated} updated`,
);

const [escrows, disputes, notifications] = await Promise.all([
prisma.escrow.findMany(),
Expand All @@ -208,9 +226,18 @@ export async function main(p?: PrismaService) {
console.log(` Notifications: ${notifications.length}`);
console.log('Seeding completed successfully!');
} catch (error) {
// Rethrown rather than exiting: main() is imported and called by
// test/seed.spec.ts, and process.exit there kills the Jest worker
// mid-run, taking down unrelated suites with no summary. Exiting is the
// CLI entrypoint's job, below.
console.error('Seeding failed:', error);
process.exit(1);
throw error;
}
}

main();
// Only run when invoked directly (`npm run db:seed`). Importing this module —
// as the spec does, to call main(prisma) with its own client — must not
// trigger a second, concurrent seed against the default connection.
if (require.main === module) {
main().catch(() => process.exit(1));
}
7 changes: 6 additions & 1 deletion src/admin/api-keys/api-keys.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
Patch,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtGuard } from '../../auth/guards/jwt.guard';
import { AdminGuard } from '../guards/admin.guard';
Expand Down
17 changes: 15 additions & 2 deletions src/admin/dispute/dispute.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { Body, Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Get,
Param,
Patch,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtGuard } from '../../auth/guards/jwt.guard';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
Expand Down
3 changes: 2 additions & 1 deletion src/admin/dispute/dispute.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DisputeState,
EscrowRecord,
PrismaService,
toDisputeRecord,
} from '../../prisma/prisma.service';
import { EscrowRepository } from '../../escrow/escrow.repository';
import { ContractService } from '../../stellar/contract.service';
Expand Down Expand Up @@ -47,7 +48,7 @@ export class DisputeService {
}),
this.prisma.dispute.count({ where }),
]);
return { data, total, page, limit };
return { data: data.map(toDisputeRecord), total, page, limit };
}

/** Resolves a dispute by submitting the contract action and finalizing escrow state. */
Expand Down
7 changes: 6 additions & 1 deletion src/admin/queues/queue-dashboard.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtGuard } from '../../auth/guards/jwt.guard';
import { AdminGuard } from '../guards/admin.guard';
Expand Down
7 changes: 6 additions & 1 deletion src/admin/stats/admin-stats.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtGuard } from '../../auth/guards/jwt.guard';
import { AdminGuard } from '../guards/admin.guard';
Expand Down
32 changes: 31 additions & 1 deletion src/admin/stats/admin-stats.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AdminStatsService } from './admin-stats.service';
import { PrismaService } from '../../prisma/prisma.service';
import { ensureVendors } from '../../../test/prisma-helpers';

describe('AdminStatsService', () => {
let service: AdminStatsService;
Expand All @@ -8,9 +9,24 @@ describe('AdminStatsService', () => {
beforeEach(async () => {
prisma = new PrismaService();
await prisma.reset();
// Escrow.vendorAddress is a foreign key onto VendorProfile.address (#475).
await ensureVendors(
prisma,
'GVENDOR1',
'GVENDOR2',
'GVENDOR_A',
'GVENDOR_B',
);
service = new AdminStatsService(prisma);
});

afterEach(async () => {
// Each `new PrismaService()` opens its own connection pool. Constructed in
// beforeEach across ~100 suites, undisconnected clients exhaust Postgres
// (`sorry, too many clients already`) partway through a full run.
await prisma?.$disconnect();
});

afterEach(async () => {
await prisma.reset();
});
Expand Down Expand Up @@ -206,9 +222,23 @@ describe('AdminStatsService', () => {
},
});

// A third dispute needs a third escrow: Dispute.escrowId is unique, so a
// second dispute on `escrow` is impossible against the real schema (#475).
const escrow3 = await prisma.escrow.create({
data: {
itemName: 'Item 3',
itemRef: 'ref-stats-3',
amount: 50,
currency: 'USDC',
buyerAddress: 'GBUYER1',
vendorAddress: 'GVENDOR1',
state: 'COMPLETED',
},
});

await prisma.dispute.create({
data: {
escrowId: escrow.id,
escrowId: escrow3.id,
reason: 'Already resolved',
status: 'RESOLVED',
},
Expand Down
Loading
Loading