diff --git a/README.md b/README.md index a5eae5a..18f66ad 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,27 @@ docker-compose ps curl http://localhost:8000/health ``` +### docker-compose.override.yml + +A `docker-compose.override.yml` file is included in the repository. Docker +Compose [automatically merges] +(https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/) this +file with `docker-compose.yml` when you run `docker-compose up`. The override +adds the **backend** and **frontend** services so you can run the full stack +locally without modifying the base compose file. + +Developers commonly edit this file to: + +- Point `VITE_API_URL` / `VITE_SOROBAN_RPC` to a different environment +- Adjust port mappings if the defaults conflict with other services +- Mount local source directories for hot-reloading during development + +To inspect the effective compose configuration that Docker will use: + +```sh +docker-compose config +``` + ### Deploying Contracts Locally ```sh diff --git a/dashboard/abi-explorer.html b/dashboard/abi-explorer.html index 05ede5b..6911e44 100644 --- a/dashboard/abi-explorer.html +++ b/dashboard/abi-explorer.html @@ -32,6 +32,10 @@ overflow: hidden; } .fn-list.open { display: block; } + .contract-header:focus-visible { + outline: 2px solid #4f46e5; + outline-offset: 2px; + } .fn-row { display: flex; align-items: baseline; @@ -50,14 +54,15 @@ + Skip to main content

ABI Explorer

-

+

Read-only reference for deployed COMEBACKHERE Protocol contracts.

-
+
diff --git a/dashboard/app.test.js b/dashboard/app.test.js new file mode 100644 index 0000000..554ac10 --- /dev/null +++ b/dashboard/app.test.js @@ -0,0 +1,316 @@ +/** + * Unit tests for dashboard/app.js + * + * Covers data-fetching, rendering, sorting, filtering, and summary logic. + * Uses jsdom (jest-environment-jsdom) for DOM simulation. + * + * The script is loaded via eval() in the global scope so that `function` + * declarations become accessible as globals, while `let`/`const` module-scoped + * variables remain within the eval closure but are accessible to those functions. + */ + +const fs = require('fs'); +const path = require('path'); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function setupDOM() { + document.body.innerHTML = ` +
+ + + + + + + + + + + + + +
IDMerchantCustomerAmountStatusCreatedExpires
+ + + + `; +} + +/** + * Load app.js by evaluating it in the global scope so that function + * declarations (getDemoInvoices, render, renderTable, etc.) become + * available on the global object. + */ +function loadAppScript() { + const script = fs.readFileSync(path.join(__dirname, 'app.js'), 'utf-8'); + // Use indirect eval so function declarations become global properties + const indirectEval = eval; + indirectEval(script); +} + +/** + * Populate the module-scoped `invoices` array by calling fetchInvoices() + * which falls back to getDemoInvoices() when fetch is unavailable. + * Must be called after loadAppScript(). + */ +async function populateInvoices() { + // Override fetch to simulate API failure → demo fallback + global.fetch = () => Promise.reject(new Error('mock network error')); + // Manually call fetchInvoices to populate the module-scoped `invoices` array + await fetchInvoices(); + // Small delay for the render() inside fetchInvoices to complete + await new Promise(r => setTimeout(r, 0)); +} + +// ── Test suites ───────────────────────────────────────────────────────────── + +describe('getDemoInvoices()', () => { + beforeAll(() => { + setupDOM(); + loadAppScript(); + }); + + it('returns an array of 6 demo invoices', () => { + const invoices = getDemoInvoices(); + expect(Array.isArray(invoices)).toBe(true); + expect(invoices).toHaveLength(6); + }); + + it('each invoice has expected shape', () => { + const invoices = getDemoInvoices(); + for (const inv of invoices) { + expect(inv).toHaveProperty('id'); + expect(inv).toHaveProperty('merchant'); + expect(inv).toHaveProperty('customer'); + expect(inv).toHaveProperty('amount'); + expect(inv).toHaveProperty('token'); + expect(inv).toHaveProperty('status'); + expect(inv).toHaveProperty('created_at'); + expect(inv).toHaveProperty('expires_at'); + } + }); + + it('contains at least one invoice of each known status', () => { + const invoices = getDemoInvoices(); + const statuses = invoices.map(i => i.status); + expect(statuses).toContain('Pending'); + expect(statuses).toContain('Paid'); + expect(statuses).toContain('Expired'); + expect(statuses).toContain('Cancelled'); + expect(statuses).toContain('RefundRequested'); + expect(statuses).toContain('Released'); + }); + + it('amounts are numeric strings', () => { + const invoices = getDemoInvoices(); + for (const inv of invoices) { + expect(/\d+/.test(inv.amount)).toBe(true); + } + }); +}); + +describe('formatTimestamp()', () => { + beforeAll(() => { + setupDOM(); + loadAppScript(); + }); + + it('returns a short date string for a known epoch', () => { + const result = formatTimestamp(1719000000); + // June 21/22 2024 (timezone-dependent) + expect(result).toMatch(/Jun 2[0-2], 2024/); + }); + + it('produces different strings for different timestamps', () => { + const a = formatTimestamp(1719000000); + const b = formatTimestamp(1718800000); + expect(a).not.toBe(b); + }); +}); + +describe('getStatusBadge()', () => { + beforeAll(() => { + setupDOM(); + loadAppScript(); + }); + + it('returns a span with badge-pending for unknown statuses', () => { + const badge = getStatusBadge('Unknown'); + expect(badge).toContain('badge-pending'); + expect(badge).toContain(' { + const badge = getStatusBadge('RefundRequested'); + expect(badge).toContain('Refund Requested'); + expect(badge).not.toContain('RefundRequested'); + }); +}); + +describe('renderTable()', () => { + beforeAll(() => { + setupDOM(); + loadAppScript(); + }); + + beforeEach(() => { + setupDOM(); + }); + + it('populates tbody with rows for each item', () => { + const invoices = getDemoInvoices(); + renderTable(invoices); + const rows = document.querySelectorAll('#invoiceBody tr'); + expect(rows.length).toBe(invoices.length); + }); + + it('shows empty-state when items is empty', () => { + renderTable([]); + const tbody = document.getElementById('invoiceBody'); + expect(tbody.innerHTML).toContain('No invoices found'); + expect(tbody.innerHTML).toContain('empty-state'); + }); + + it('each row contains the invoice id', () => { + const invoices = getDemoInvoices(); + renderTable(invoices); + const rows = document.querySelectorAll('#invoiceBody tr'); + for (let i = 0; i < invoices.length; i++) { + expect(rows[i].textContent).toContain(String(invoices[i].id)); + } + }); +}); + +describe('renderSummary()', () => { + beforeAll(() => { + setupDOM(); + loadAppScript(); + }); + + beforeEach(async () => { + setupDOM(); + await populateInvoices(); + }); + + it('renders a summary card for each status plus total', () => { + render(); + const summary = document.getElementById('summary'); + expect(summary.querySelectorAll('.summary-card').length).toBe(7); // 6 statuses + total + expect(summary.innerHTML).toContain('Total'); + }); + + it('total count matches the invoice count', () => { + const invoices = getDemoInvoices(); + render(); + const summary = document.getElementById('summary'); + const cards = summary.querySelectorAll('.summary-card'); + const totalCard = cards[cards.length - 1]; + expect(totalCard.querySelector('.count').textContent).toBe(String(invoices.length)); + }); + + it('renders individual status counts correctly', () => { + render(); + const summary = document.getElementById('summary'); + expect(summary.innerHTML).toContain('Pending'); + expect(summary.innerHTML).toContain('Paid'); + expect(summary.innerHTML).toContain('Expired'); + }); +}); + +describe('render() filtering and sorting', () => { + beforeAll(async () => { + setupDOM(); + loadAppScript(); + await populateInvoices(); + }); + + beforeEach(async () => { + setupDOM(); + await populateInvoices(); + }); + + it('filters by status when statusFilter is set', () => { + const statusFilter = document.getElementById('statusFilter'); + statusFilter.value = 'Paid'; + render(); + const rows = document.querySelectorAll('#invoiceBody tr'); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.textContent).toContain('Paid'); + } + }); + + it('filters by search term matching invoice id', () => { + const invoices = getDemoInvoices(); + const firstId = String(invoices[0].id); + const search = document.getElementById('search'); + search.value = firstId; + render(); + const rows = document.querySelectorAll('#invoiceBody tr'); + expect(rows.length).toBeGreaterThan(0); + // All visible rows should contain the search term + for (const row of rows) { + expect(row.textContent).toContain(firstId); + } + }); + + it('shows empty state when no invoices match filter', () => { + const statusFilter = document.getElementById('statusFilter'); + statusFilter.value = 'Paid'; + const search = document.getElementById('search'); + search.value = 'ZZZZNONEXISTENT'; + render(); + const tbody = document.getElementById('invoiceBody'); + expect(tbody.innerHTML).toContain('No invoices found'); + }); +}); + +describe('updateSortIndicators()', () => { + beforeAll(() => { + setupDOM(); + loadAppScript(); + }); + + beforeEach(() => { + setupDOM(); + }); + + it('adds sorted-asc or sorted-desc to the active sort column after render', () => { + // render() calls updateSortIndicators internally + render(); + const sortedHeaders = document.querySelectorAll('.sorted-asc, .sorted-desc'); + // At least one header should have a sort indicator + expect(sortedHeaders.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe('fetchInvoices() error handling', () => { + it('falls back to demo data when fetch fails and renders table', async () => { + setupDOM(); + loadAppScript(); + await populateInvoices(); + + const tbody = document.getElementById('invoiceBody'); + expect(tbody.innerHTML).not.toBe(''); + expect(tbody.querySelectorAll('tr').length).toBeGreaterThan(0); + }); + + it('populates summary cards on fallback', async () => { + setupDOM(); + loadAppScript(); + await populateInvoices(); + + const summary = document.getElementById('summary'); + expect(summary.querySelectorAll('.summary-card').length).toBe(7); + }); +}); diff --git a/dashboard/index.html b/dashboard/index.html index 75a91dc..c55bcb0 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -7,11 +7,14 @@ + Skip to main content

Invoice Status Dashboard

-
+
+ +