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
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Contributing

## Bug fixes require a regression test

A bug-fix PR is **not done** until a corresponding regression test exists.

1. Add or extend a `*.regression.spec.ts` under `src/` (colocated with the feature).
2. Reference the originating issue and/or PR in the `describe`/`it` title or a header comment, e.g. `issue #52 / PR #54`.
3. The test must fail if the fixed bug is re-introduced locally.
4. Keep tests independent: no shared mutable state across files; mock Supabase and Trustless Work (no live network).

### Convention

| Rule | Detail |
| --- | --- |
| Naming | `src/<feature>/*.regression.spec.ts` (e.g. `webhook-status-mapping.regression.spec.ts`) |
| Discovery | Jest `testRegex` already matches `*.spec.ts`, so regression specs run in `pnpm test` and CI |
| Run only regressions | `pnpm exec jest regression --runInBand` |

See [docs/integration-tests.md](docs/integration-tests.md#regression-test-suite-issue-69) for the suite index (test → issue/PR).

## Before opening a PR

- Keep formatting and lint green (`pnpm run format:check`, `pnpm run lint:check`).
- Run `pnpm test` (or at least the specs you touched) and ensure CI stays green.
- Prefer atomic commits with Conventional Commit prefixes (`fix:`, `test:`, `docs:`, …).
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ The server listens on port **3001** by default with a global `v1` prefix.
| `pnpm run start` | Start without watch |
| `pnpm run build` | Compile to `dist/` |
| `pnpm run start:prod` | Run the compiled build (`node dist/main`) |
| `pnpm test` | Run Jest unit + integration + regression specs |
| `pnpm exec jest regression --runInBand` | Run only `*.regression.spec.ts` |

There is also a `smoke-test-backend.ps1` PowerShell script for a quick end-to-end check.

Expand Down Expand Up @@ -192,5 +194,7 @@ left off, instead of losing in-flight work.

## Docs

- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contribution guide; **bug fixes require a regression test**.
- [`docs/integration-tests.md`](docs/integration-tests.md) — integration fixtures, KYC/KYB suite, and [regression suite index](docs/integration-tests.md#regression-test-suite-issue-69).
- [`docs/SCOPE.md`](docs/SCOPE.md) — closed scope decisions.
- [`docs/EMAIL_NOTIFICATIONS_PLAN.md`](docs/EMAIL_NOTIFICATIONS_PLAN.md) — event-driven email notifications plan (epic + tickets).
43 changes: 43 additions & 0 deletions docs/integration-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,41 @@ This suite should stay green before merging compliance-related changes. Adding a
`IdentityProvider` implementation only requires a new `MockIdentityProvider({ name, ... })` binding
in the existing cases — no rewrite of the HTTP assertions.

## Regression Test Suite (issue #69)

Dedicated regression specs guard previously fixed production bugs so they cannot silently
reappear. Naming: `*.regression.spec.ts`, colocated under `src/<feature>/`. Each `describe`/`it`
(or a header comment) must cite the originating issue and/or PR.

**Policy:** a bug-fix PR is not done until a matching regression test exists. See
[CONTRIBUTING.md](../CONTRIBUTING.md).

### Convention

| Rule | Detail |
| --- | --- |
| File name | `src/<feature>/<topic>.regression.spec.ts` |
| Traceability | Issue/PR in title or header comment |
| Independence | No shared mutable state; mock Supabase / Trustless Work |
| CI | Included automatically by Jest `testRegex: .*\.spec\.ts$` |

Run only regression specs:

```bash
pnpm exec jest regression --runInBand
```

### Index (test → issue/PR)

| Regression file | Guards | Issue / PR |
| --- | --- | --- |
| `src/webhooks/webhook-status-mapping.regression.spec.ts` | `escrow.released` → `completed` (not stuck `funded`) | #52 / PR #54 |
| `src/disputes/dispute-percentages.regression.spec.ts` | Dispute resolve percentages must sum to 100 | #12 / PR #49 |
| `src/wallets/stellar-address.regression.spec.ts` | Invalid Stellar address rejected | #27 |
| `src/agreements/status-transitions.regression.spec.ts` | Illegal status transitions blocked | #59 / #67 · PR #110 / #76 |
| `src/agreements/agreement-activity.regression.spec.ts` | Dispute/status events land in `agreement_activity` with states | #58 / #61 · PR #100 / #104 |
| `src/integration/api-edge-cases.regression.spec.ts` | Invalid JWT, not-found IDs, unauthorized `by-wallet` | #15 / #51 · PR #57 |

## Running Locally

Install dependencies:
Expand All @@ -90,6 +125,12 @@ Run only the migrated flow integration suite:
pnpm run test:integration
```

Run only regression specs:

```bash
pnpm exec jest regression --runInBand
```

## CI

`.github/workflows/ci.yml` installs with `pnpm install --frozen-lockfile`, checks formatting and
Expand All @@ -98,3 +139,5 @@ linting, then runs:
```bash
pnpm exec jest --runInBand
```

Regression specs are included in that Jest run (no separate CI job).
248 changes: 248 additions & 0 deletions src/agreements/agreement-activity.regression.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/**
* Regression: issue #58 / #61 · PR #100 / #104
* Bug: dispute open/resolve and status changes did not land in agreement_activity
* with previous_state / new_state columns populated.
*/
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AgreementsService } from './agreements.service';
import { AgreementActivityService } from './agreement-activity.service';
import { DisputesService } from '../disputes/disputes.service';

type Row = Record<string, unknown>;

function buildDb(seed: {
agreements: Row[];
auth_users?: Row[];
agreement_participants?: Row[];
disputes?: Row[];
dispute_resolutions?: Row[];
agreement_activity?: Row[];
}) {
const tables: Record<string, Row[]> = {
agreements: seed.agreements.map((r) => ({ ...r })),
auth_users: (seed.auth_users ?? []).map((r) => ({ ...r })),
agreement_participants: (seed.agreement_participants ?? []).map((r) => ({ ...r })),
disputes: (seed.disputes ?? []).map((r) => ({ ...r })),
dispute_resolutions: (seed.dispute_resolutions ?? []).map((r) => ({ ...r })),
agreement_activity: (seed.agreement_activity ?? []).map((r) => ({ ...r })),
};

function chain(table: string) {
const rows = tables[table] ?? [];
const filters: Array<(r: Row) => boolean> = [];
let mode: 'select' | 'insert' | 'update' = 'select';
let payload: Row | Row[] | null = null;
let wantSingle = false;
let wantMaybe = false;

const api: Record<string, unknown> = {};
const self = () => api;
api.select = () => self();
api.eq = (col: string, val: unknown) => {
filters.push((r) => r[col] === val);
return self();
};
api.in = (col: string, vals: unknown[]) => {
filters.push((r) => vals.includes(r[col]));
return self();
};
api.limit = () => self();
api.order = () => self();
api.insert = (data: Row | Row[]) => {
mode = 'insert';
payload = data;
return self();
};
api.update = (data: Row) => {
mode = 'update';
payload = data;
return self();
};
api.single = () => {
wantSingle = true;
return finalize();
};
api.maybeSingle = () => {
wantMaybe = true;
return finalize();
};

const finalize = () => {
if (mode === 'insert') {
const items = Array.isArray(payload) ? payload : [payload as Row];
const created = items.map((item, i) => ({
id: (item.id as string) || `${table}-${tables[table].length + i + 1}`,
created_at: new Date().toISOString(),
...item,
}));
tables[table].push(...created);
return Promise.resolve({
data: created.length === 1 ? created[0] : created,
error: null,
});
}
let matched = rows.filter((r) => filters.every((f) => f(r)));
if (mode === 'update') {
matched = matched.map((r) => Object.assign(r, payload as Row));
}
if (wantSingle || wantMaybe) {
const data = matched[0] ?? null;
if (wantSingle && !data) {
return Promise.resolve({ data: null, error: { message: 'not found' } });
}
return Promise.resolve({ data, error: null });
}
return Promise.resolve({ data: matched, error: null });
};

(api as { then?: unknown }).then = (resolve: (v: unknown) => unknown) =>
finalize().then(resolve);
return api;
}

return { tables, client: { from: (table: string) => chain(table) } };
}

const USER = 'user-1';
const WALLET = 'GWALLET-PAYER';
const RESOLVER = 'GWALLET-RESOLVER';
const AGREEMENT_ID = 'agr-activity-1';

describe('regression: agreement activity logging (issue #58 / #61 · PR #100 / #104)', () => {
it('openDispute writes dispute_opened + status change with previous/new state', async () => {
const db = buildDb({
agreements: [
{
id: AGREEMENT_ID,
status: 'active',
title: 'Escrow job',
amount: '100',
asset: 'USDC',
created_by: WALLET,
milestones: [],
},
],
auth_users: [
{ id: USER, wallet_public_key: WALLET },
{ id: 'user-resolver', wallet_public_key: RESOLVER },
],
agreement_participants: [
{ agreement_id: AGREEMENT_ID, wallet_address: WALLET, role: 'payer' },
{ agreement_id: AGREEMENT_ID, wallet_address: 'GWALLET-PAYEE', role: 'payee' },
],
disputes: [],
dispute_resolutions: [],
agreement_activity: [],
});

const supabase = { getClient: () => db.client } as never;
const emitter = new EventEmitter2();
const activity = new AgreementActivityService(supabase);
const logSpy = jest.spyOn(activity, 'logActivity');
const agreements = new AgreementsService(supabase, emitter, activity);
const disputes = new DisputesService(supabase, agreements, emitter, activity);

const result = await disputes.openDispute(USER, {
agreement_id: AGREEMENT_ID,
opened_by: WALLET,
reason: 'Work incomplete',
evidence_urls: [],
});

expect(result.error).toBeNull();

const actions = logSpy.mock.calls.map((c) => c[2]);
expect(actions).toContain('dispute_opened');
expect(actions).toContain('status_changed_to_disputed');

const statusCall = logSpy.mock.calls.find((c) => c[2] === 'status_changed_to_disputed');
expect(statusCall?.[4]).toEqual(
expect.objectContaining({ previousState: 'active', newState: 'disputed' }),
);

const persistedStatus = db.tables.agreement_activity.find(
(r) => r.action === 'status_changed_to_disputed',
);
expect(persistedStatus).toEqual(
expect.objectContaining({
previous_state: 'active',
new_state: 'disputed',
}),
);

const persistedDispute = db.tables.agreement_activity.find(
(r) => r.action === 'dispute_opened',
);
expect(persistedDispute).toBeTruthy();
});

it('resolveDispute writes dispute_resolved + status change with previous/new state', async () => {
const db = buildDb({
agreements: [
{
id: AGREEMENT_ID,
status: 'disputed',
title: 'Escrow job',
amount: '100',
asset: 'USDC',
created_by: WALLET,
milestones: [],
},
],
auth_users: [
{ id: USER, wallet_public_key: WALLET },
{ id: 'user-resolver', wallet_public_key: RESOLVER },
],
agreement_participants: [
{ agreement_id: AGREEMENT_ID, wallet_address: WALLET, role: 'payer' },
],
disputes: [
{
id: 'disp-1',
agreement_id: AGREEMENT_ID,
opened_by: WALLET,
reason: 'x',
evidence_urls: [],
status: 'under_review',
resolver_wallet: RESOLVER,
},
],
dispute_resolutions: [],
agreement_activity: [],
});

const supabase = { getClient: () => db.client } as never;
const emitter = new EventEmitter2();
const activity = new AgreementActivityService(supabase);
const logSpy = jest.spyOn(activity, 'logActivity');
const agreements = new AgreementsService(supabase, emitter, activity);
const disputes = new DisputesService(supabase, agreements, emitter, activity);

const result = await disputes.resolveDispute('user-resolver', 'disp-1', {
resolved_by: RESOLVER,
payer_percentage: 40,
payee_percentage: 60,
resolution_notes: 'Split',
});

expect(result.error).toBeNull();

const actions = logSpy.mock.calls.map((c) => c[2]);
expect(actions).toContain('dispute_resolved');
expect(actions).toContain('status_changed_to_resolved');

const statusCall = logSpy.mock.calls.find((c) => c[2] === 'status_changed_to_resolved');
expect(statusCall?.[4]).toEqual(
expect.objectContaining({ previousState: 'disputed', newState: 'resolved' }),
);

expect(
db.tables.agreement_activity.find((r) => r.action === 'status_changed_to_resolved'),
).toEqual(
expect.objectContaining({
previous_state: 'disputed',
new_state: 'resolved',
}),
);
});
});
Loading
Loading