← 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();
});