← back to Lifestyle Asset Intel
tests/portfolio.test.js
123 lines
// portfolio.test.js — exercises the /api/portfolio CRUD surface against a real DB.
//
// Requires the same seeded DB as smoke.test.js. Uses a unique owner email per
// run (test+<ts>@example.com) so reruns never collide.
'use strict';
const { test, after } = require('node:test');
const assert = require('node:assert/strict');
process.env.NODE_ENV = process.env.NODE_ENV || 'test';
process.env.PGDATABASE = process.env.PGDATABASE || 'lifestyle_asset_intel';
const app = require('../server');
const { pool } = require('../lib/db');
let server;
let baseUrl;
function ready() {
return new Promise((resolve) => {
server = app.listen(0, '127.0.0.1', () => {
const { port } = server.address();
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
});
}
async function req(method, path, body) {
const init = { method, headers: {} };
if (body !== undefined) {
init.headers['content-type'] = 'application/json';
init.body = JSON.stringify(body);
}
const r = await fetch(baseUrl + path, init);
const text = await r.text();
let json = null;
try { json = JSON.parse(text); } catch (e) { /* not json (e.g. 204) */ }
return { status: r.status, json, text };
}
const OWNER = `test+${Date.now()}@example.com`;
const ASSET = 'birkin-30-togo-gold-ghw-us';
let createdId = null;
test('boots for portfolio tests', async () => {
await ready();
});
test('GET /api/portfolio without owner -> 400', async () => {
const r = await req('GET', '/api/portfolio');
assert.equal(r.status, 400);
assert.equal(r.json.error, 'owner_query_required');
});
test('POST /api/portfolio with bad email -> 400 invalid_email', async () => {
const r = await req('POST', '/api/portfolio', { owner_email: 'not-an-email', asset_slug: ASSET });
assert.equal(r.status, 400);
assert.equal(r.json.error, 'invalid_email');
});
test('POST /api/portfolio with bogus asset_slug -> 400 invalid_asset_slug', async () => {
const r = await req('POST', '/api/portfolio', { owner_email: OWNER, asset_slug: 'not-a-real-slug-xyz' });
assert.equal(r.status, 400);
assert.equal(r.json.error, 'invalid_asset_slug');
});
test('POST /api/portfolio happy path -> 201 + id', async () => {
const r = await req('POST', '/api/portfolio', {
owner_email: OWNER,
asset_slug: ASSET,
acquired_at: '2024-01-15',
acquisition_basis: 9000,
notes: 'test fixture'
});
assert.equal(r.status, 201);
assert.ok(r.json.id, 'returned row has id');
assert.equal(r.json.owner_email, OWNER);
createdId = r.json.id;
});
test('GET /api/portfolio?owner=... lists the new row with unrealized_pl numeric', async () => {
const r = await req('GET', `/api/portfolio?owner=${encodeURIComponent(OWNER)}`);
assert.equal(r.status, 200);
assert.equal(r.json.owner, OWNER);
assert.ok(Array.isArray(r.json.holdings));
const found = r.json.holdings.find((h) => h.id === createdId);
assert.ok(found, 'new holding present in list');
assert.equal(found.asset_slug, ASSET);
assert.equal(found.brand, 'Hermès');
assert.equal(typeof found.unrealized_pl, 'number', 'unrealized_pl is numeric');
assert.equal(typeof found.pl_pct, 'number', 'pl_pct is numeric');
});
test('PATCH /api/portfolio/:id with empty body -> 400', async () => {
const r = await req('PATCH', `/api/portfolio/${createdId}`, {});
assert.equal(r.status, 400);
assert.equal(r.json.error, 'no_fields_to_update');
});
test('PATCH /api/portfolio/:id with acquisition_basis -> 200 updated', async () => {
const r = await req('PATCH', `/api/portfolio/${createdId}`, { acquisition_basis: 10000 });
assert.equal(r.status, 200);
assert.equal(r.json.id, createdId);
assert.equal(parseFloat(r.json.acquisition_basis), 10000);
});
test('DELETE /api/portfolio/:id -> 204', async () => {
const r = await req('DELETE', `/api/portfolio/${createdId}`);
assert.equal(r.status, 204);
});
test('DELETE /api/portfolio/:id again -> 404', async () => {
const r = await req('DELETE', `/api/portfolio/${createdId}`);
assert.equal(r.status, 404);
});
after(async () => {
if (server) await new Promise((res) => server.close(res));
await pool.end();
});