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
49 changes: 49 additions & 0 deletions .github/workflows/pnpm-version-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: pnpm version check

# Catches ERR_PNPM_LOCKFILE_BREAKING_CHANGE before contributors waste 30 min
# debugging a pnpm 8 vs pnpm 9 lockfile mismatch (Issue #1458).

on:
pull_request:
push:
branches: [main]

jobs:
# ── Required version passes ─────────────────────────────────────────────────
pnpm-version-ok:
name: pnpm >=9.12 (required range)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20

- name: Enable Corepack
run: corepack enable

- name: Activate pnpm 9.12 via Corepack
run: corepack prepare pnpm@9.12.0 --activate

- name: Verify pnpm version satisfies requirement
run: node scripts/check-pnpm-version.js

# ── pnpm 8 fails fast with a clear message ───────────────────────────────────
pnpm-v8-rejected:
name: pnpm 8 rejected with clear error
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20

- name: Install pnpm 8 (the bad version)
run: npm install -g pnpm@8

- name: Expect check script to exit non-zero for pnpm 8
# The script must fail β€” if it exits 0 the version guard is broken.
run: |
node scripts/check-pnpm-version.js && echo "ERROR: script should have failed" && exit 1 || echo "OK: pnpm 8 correctly rejected"
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,30 @@ For a cross-layer view of ownership boundaries, API flow maps, event propagation

### Quick Start

> **Node β‰₯ 20 and pnpm β‰₯ 9.12 are required.** The lockfile format changed between pnpm 8 and pnpm 9; using an older version produces `ERR_PNPM_LOCKFILE_BREAKING_CHANGE`. Use [Corepack](https://nodejs.org/api/corepack.html) (bundled with Node β‰₯ 16) to pin the right version automatically:
>
> ```bash
> corepack enable
> corepack prepare pnpm@9.12.0 --activate
> ```

1. Start the backend:

```bash
cd backend
cp .env.example .env
npm install
pnpm install
npx prisma migrate dev
npm run dev
pnpm dev
```

2. Start the frontend in a second terminal:

```bash
cd frontend
cp .env.example .env
npm install
npm run dev
pnpm install
pnpm dev
```

3. Optional: run contract tests from the repo root:
Expand Down
60 changes: 58 additions & 2 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,15 @@
"example": 0
},
"apy": {
"type": "number",
"example": 0
"type": ["number", "null"],
"example": 8.45,
"description": "Annualised APY as a decimal percentage. null when the vault has insufficient price history (e.g. zero shares or fewer than 2 snapshots)."
},
"apyStatus": {
"type": "string",
"enum": ["ok", "insufficient_data"],
"example": "ok",
"description": "ok when apy is a valid number; insufficient_data when apy is null."
},
"timestamp": {
"type": "string",
Expand Down Expand Up @@ -371,6 +378,55 @@
}
}
},
"/api/v1/vaults/{id}/apy": {
"get": {
"tags": [
"Vault"
],
"summary": "Vault APY",
"description": "Returns the annualised APY for the specified vault. Returns `apy: null` with `apyStatus: 'insufficient_data'` when the vault has zero shares or fewer than two price snapshots (e.g. a newly created vault).",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Vault identifier"
}
],
"responses": {
"200": {
"description": "Vault APY result",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["apy", "apyStatus", "timestamp"],
"properties": {
"apy": {
"type": ["number", "null"],
"example": 8.45,
"description": "Annualised APY as a decimal percentage. null when insufficient data."
},
"apyStatus": {
"type": "string",
"enum": ["ok", "insufficient_data"],
"example": "ok"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
}
}
}
}
}
}
},
"/api/v1/vault/deposits": {
"post": {
"tags": [
Expand Down
26 changes: 17 additions & 9 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file modified backend/prisma/dev.db
Binary file not shown.
108 changes: 108 additions & 0 deletions backend/src/__tests__/vaultApy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Tests for the APY calculation service (Issue #1456).
*
* Verifies that a vault with zero shares (or insufficient price history)
* returns apy: null / apyStatus: 'insufficient_data' instead of Infinity.
*
* Integration tests for GET /api/v1/vaults/:id/apy are in vaultApyEndpoint.test.ts.
*/

import { computeVaultApy } from '../services/apy';

// ─── Mocks ───────────────────────────────────────────────────────────────────

jest.mock('../prismaClient', () => ({
getPrismaClient: () => mockPrisma,
}));

const mockVaultState = {
findUnique: jest.fn(),
};
const mockSharePriceSnapshot = {
findMany: jest.fn(),
};
const mockPrisma = {
vaultState: mockVaultState,
sharePriceSnapshot: mockSharePriceSnapshot,
};

beforeEach(() => {
jest.clearAllMocks();
});

// ─── Unit: computeVaultApy ────────────────────────────────────────────────────

describe('computeVaultApy()', () => {
it('returns null + insufficient_data when totalShares is 0', async () => {
mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '0', totalAssets: '0' });

const result = await computeVaultApy(1);

expect(result.apy).toBeNull();
expect(result.apyStatus).toBe('insufficient_data');
// Snapshot never queried β€” we bail early
expect(mockSharePriceSnapshot.findMany).not.toHaveBeenCalled();
});

it('returns null + insufficient_data when vaultState is missing', async () => {
mockVaultState.findUnique.mockResolvedValue(null);

const result = await computeVaultApy(1);

expect(result.apy).toBeNull();
expect(result.apyStatus).toBe('insufficient_data');
});

it('returns null + insufficient_data when fewer than 2 snapshots exist', async () => {
mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '1000', totalAssets: '1000' });
mockSharePriceSnapshot.findMany.mockResolvedValue([
{ sharePrice: '1.010000', recordedAt: new Date() },
]);

const result = await computeVaultApy(1);

expect(result.apy).toBeNull();
expect(result.apyStatus).toBe('insufficient_data');
});

it('returns null + insufficient_data when priceLast is 0', async () => {
mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '1000', totalAssets: '1000' });
mockSharePriceSnapshot.findMany.mockResolvedValue([
{ sharePrice: '1.010000', recordedAt: new Date() },
{ sharePrice: '0.000000', recordedAt: new Date(Date.now() - 86400000) },
]);

const result = await computeVaultApy(1);

expect(result.apy).toBeNull();
expect(result.apyStatus).toBe('insufficient_data');
});

it('returns a finite apy and ok status with 2 valid snapshots', async () => {
mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '1000', totalAssets: '1050' });
mockSharePriceSnapshot.findMany.mockResolvedValue([
{ sharePrice: '1.010000', recordedAt: new Date() },
{ sharePrice: '1.000000', recordedAt: new Date(Date.now() - 86400000) },
]);

const result = await computeVaultApy(1);

expect(result.apyStatus).toBe('ok');
expect(typeof result.apy).toBe('number');
expect(Number.isFinite(result.apy)).toBe(true);
// (1.01 - 1.00) / 1.00 * 365 = 3.65
expect(result.apy).toBeCloseTo(3.65, 5);
});

it('result is JSON-serialisable (no Infinity or NaN) for zero-shares vault', async () => {
mockVaultState.findUnique.mockResolvedValue({ id: 1, totalShares: '0', totalAssets: '0' });

const result = await computeVaultApy(1);

// JSON.stringify(Infinity) => 'null', which loses information silently.
// Ensure the value is intentionally null so stringify is lossless.
const serialised = JSON.stringify(result);
const parsed = JSON.parse(serialised);
expect(parsed.apy).toBeNull();
});
});
Loading