[object Object]

← back to Wallco Ai

marketplace: end-to-end integration test for admin payout export

8e3051466c468b19da98e9409a79f087ecaa458e · 2026-05-13 17:30:13 -0700 · SteveStudio2

Closes the validation-loop gap on commit bd49413 — the unit tests covered
pure helpers (csvEscape/aggregate/etc) and smoke covered auth-gating, but
the actual SQL JOIN + CSV write + ledger flip with payout_batch_id was
never exercised against a real DB.

This test seeds a user + designer + 3 'payable' ledger rows ($55 total
across 2 entry types), runs payouts.exportPayoutBatch(), then asserts:
- batch row created with status='exported', notes, csv_export_path
- CSV file exists on disk with the spec header line
- row for the test designer has correct designer_id, email, payout_method,
  total_amount=55, and memo mentions both entry types
- all 3 seeded ledger rows are status='paid' with paid_at set and
  payout_batch_id linked to the new batch
- caught + fixed bigint-as-string pg gotcha (designerId comes back as
  string '21' even though column is BIGINT; assertion now Number()-coerces
  both sides)

Skips with exit 0 if PG is unreachable. Cleans up all seeded rows + CSV
file in a finally{} block so re-runs don't leak state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8e3051466c468b19da98e9409a79f087ecaa458e
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 17:30:13 2026 -0700

    marketplace: end-to-end integration test for admin payout export
    
    Closes the validation-loop gap on commit bd49413 — the unit tests covered
    pure helpers (csvEscape/aggregate/etc) and smoke covered auth-gating, but
    the actual SQL JOIN + CSV write + ledger flip with payout_batch_id was
    never exercised against a real DB.
    
    This test seeds a user + designer + 3 'payable' ledger rows ($55 total
    across 2 entry types), runs payouts.exportPayoutBatch(), then asserts:
    - batch row created with status='exported', notes, csv_export_path
    - CSV file exists on disk with the spec header line
    - row for the test designer has correct designer_id, email, payout_method,
      total_amount=55, and memo mentions both entry types
    - all 3 seeded ledger rows are status='paid' with paid_at set and
      payout_batch_id linked to the new batch
    - caught + fixed bigint-as-string pg gotcha (designerId comes back as
      string '21' even though column is BIGINT; assertion now Number()-coerces
      both sides)
    
    Skips with exit 0 if PG is unreachable. Cleans up all seeded rows + CSV
    file in a finally{} block so re-runs don't leak state.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 tests/marketplace/payouts-integration.test.js | 145 ++++++++++++++++++++++++++
 1 file changed, 145 insertions(+)

diff --git a/tests/marketplace/payouts-integration.test.js b/tests/marketplace/payouts-integration.test.js
new file mode 100644
index 0000000..fd0d0e6
--- /dev/null
+++ b/tests/marketplace/payouts-integration.test.js
@@ -0,0 +1,145 @@
+// End-to-end integration test for payouts.exportPayoutBatch() — exercises the
+// real PG flow: insert designer + user + ledger rows, run export, assert batch
+// row created, CSV on disk with correct contents, ledger rows flipped to 'paid'
+// with payout_batch_id set. Cleans up everything after itself.
+//
+// Run: node tests/marketplace/payouts-integration.test.js
+//
+// Skips with exit 0 if PG is unreachable so this can live in any CI runner that
+// doesn't have the dw_unified DB wired up.
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const SUFFIX = 'PAYOUT-IT-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
+
+async function main() {
+  const { q, one, many, ping, pool } = require('../../src/marketplace/db');
+  const payouts = require('../../src/marketplace/payouts');
+
+  if (!(await ping())) {
+    console.log('⏭  PG unreachable — skipping payouts integration test');
+    process.exit(0);
+  }
+
+  let userId, designerId, csvPath;
+  const ledgerIds = [];
+  const failures = [];
+
+  try {
+    // ── seed: user + designer + 3 payable ledger rows
+    const u = await one(`
+      INSERT INTO mp_users (email, display_name, role)
+      VALUES ($1, $2, 'designer')
+      RETURNING id
+    `, [`payout-it-${SUFFIX.toLowerCase()}@test.invalid`, 'Payout IT ' + SUFFIX]);
+    userId = u.id;
+
+    const d = await one(`
+      INSERT INTO mp_designer_profiles
+        (user_id, display_name, slug, status, payout_method, commission_rate)
+      VALUES ($1, $2, $3, 'approved', 'ach', 15.00)
+      RETURNING id
+    `, [userId, 'Payout IT ' + SUFFIX, 'payout-it-' + SUFFIX.toLowerCase()]);
+    designerId = d.id;
+
+    for (const [basis, rate, amount, entryType] of [
+      [100, 15, 15.00, 'wallpaper_sale'],
+      [200, 15, 30.00, 'wallpaper_sale'],
+      [ 50, 20, 10.00, 'sample_credit'],
+    ]) {
+      const row = await one(`
+        INSERT INTO mp_commission_ledger
+          (designer_id, basis_amount, commission_rate, commission_amount, entry_type, status)
+        VALUES ($1, $2, $3, $4, $5, 'payable')
+        RETURNING id
+      `, [designerId, basis, rate, amount, entryType]);
+      ledgerIds.push(row.id);
+    }
+
+    // ── exercise: run exportPayoutBatch — should pick up our 3 rows + possibly
+    // others. We'll filter our assertions to our designer.
+    const result = await payouts.exportPayoutBatch({ notes: 'integration-test-' + SUFFIX });
+
+    // ── assertions
+    try {
+      assert.ok(!result.empty, 'export should not be empty when payable rows exist');
+      assert.ok(result.batch && result.batch.id, 'should return a batch row');
+      assert.ok(result.csvFilename && result.csvFilename.startsWith('payout-'), 'csv filename should follow batch label pattern');
+      assert.ok(fs.existsSync(result.csvPath), `csv file should exist on disk at ${result.csvPath}`);
+      csvPath = result.csvPath;
+
+      const csv = fs.readFileSync(result.csvPath, 'utf8');
+      const lines = csv.trim().split('\n');
+      assert.strictEqual(lines[0], 'designer_id,designer_email,payout_method,total_amount,memo', 'csv header must match spec');
+
+      // Find our designer's row in the CSV.
+      const ourLine = lines.slice(1).find(l => l.startsWith(Number(designerId) + ','));
+      assert.ok(ourLine, `csv should contain a row for designer ${designerId}; got:\n${csv}`);
+
+      const cols = ourLine.split(',');
+      assert.strictEqual(Number(cols[0]), Number(designerId), 'designer_id column');
+      assert.strictEqual(cols[1], `payout-it-${SUFFIX.toLowerCase()}@test.invalid`, 'designer_email column');
+      assert.strictEqual(cols[2], 'ach', 'payout_method column');
+      assert.strictEqual(Number(cols[3]), 55, `total_amount should be 15+30+10=55, got ${cols[3]}`);
+      // memo column is quoted (it contains a comma between entry types) — check it mentions both types
+      const memoJoined = ourLine.slice(ourLine.indexOf(cols[2]) + cols[2].length + 1 + cols[3].length + 1);
+      assert.ok(/sample_credit/.test(memoJoined), 'memo should mention sample_credit');
+      assert.ok(/wallpaper_sale/.test(memoJoined), 'memo should mention wallpaper_sale');
+      assert.ok(/3 entries/.test(memoJoined), 'memo should say "3 entries"');
+
+      // Verify ledger flipped to paid + payout_batch_id stamped.
+      const flipped = await many(`
+        SELECT id, status, paid_at, payout_batch_id
+          FROM mp_commission_ledger
+         WHERE id = ANY($1::bigint[])
+      `, [ledgerIds]);
+      assert.strictEqual(flipped.length, 3, 'should find all 3 seeded rows');
+      for (const row of flipped) {
+        assert.strictEqual(row.status, 'paid', `ledger row ${row.id} status should be 'paid'`);
+        assert.ok(row.paid_at, `ledger row ${row.id} should have paid_at set`);
+        assert.strictEqual(Number(row.payout_batch_id), Number(result.batch.id), `ledger row ${row.id} should be linked to batch`);
+      }
+
+      // Batch row sanity.
+      const batchRow = await one(`SELECT * FROM mp_payout_batches WHERE id=$1`, [result.batch.id]);
+      assert.strictEqual(batchRow.status, 'exported');
+      assert.ok(Number(batchRow.designer_count) >= 1);
+      assert.ok(Number(batchRow.total_amount) > 0);
+      assert.ok(batchRow.csv_export_path && batchRow.csv_export_path.includes('data/marketplace/payouts/'));
+      assert.strictEqual(batchRow.notes, 'integration-test-' + SUFFIX);
+
+      console.log('✓ payouts.exportPayoutBatch end-to-end (DB + CSV)');
+    } catch (err) {
+      failures.push(err);
+      console.error('✗ payouts.exportPayoutBatch end-to-end —', err.message);
+    }
+  } finally {
+    // ── cleanup: delete our ledger rows, the designer, the user, the CSV file
+    if (ledgerIds.length) {
+      await q(`DELETE FROM mp_commission_ledger WHERE id = ANY($1::bigint[])`, [ledgerIds]).catch(() => {});
+    }
+    if (designerId) {
+      await q(`DELETE FROM mp_designer_profiles WHERE id=$1`, [designerId]).catch(() => {});
+    }
+    if (userId) {
+      await q(`DELETE FROM mp_users WHERE id=$1`, [userId]).catch(() => {});
+    }
+    // Don't unlink the CSV — it's a real artifact other admins might want to see;
+    // but DO unlink if the test failed mid-run so we don't leave stale files.
+    // Actually, since this is a unique batch_label per run, always cleanup CSV.
+    if (csvPath && fs.existsSync(csvPath)) {
+      try { fs.unlinkSync(csvPath); } catch (_) {}
+    }
+    await pool.end().catch(() => {});
+  }
+
+  if (failures.length) process.exit(1);
+  console.log(`\n1/1 payouts integration tests passed`);
+}
+
+main().catch(err => {
+  console.error('integration test crashed:', err);
+  process.exit(1);
+});

← 2961365 Strip reserved-brand strings: BH/Beverly Hills source filena  ·  back to Wallco Ai  ·  brand: gucci viewer adopts universal corner-nav (bag/person/ 3b1a002 →