From 13722314ddcb6c67eee1df9331a05da8d14b4027 Mon Sep 17 00:00:00 2001 From: Ndifreke000 Date: Fri, 24 Jul 2026 18:50:12 +0100 Subject: [PATCH] Fix recurring-date drift; add lib/e2e/a11y test coverage (#293, #296, #299, #300) - lib/recurring.ts: buildNextRunAt now adds real calendar months/quarters (with end-of-month clamping) instead of fixed 30/90-day offsets, fixing renewal-date drift around February and 31-day months. (#293) - Add unit tests for previously-untested lib/ utilities named in the coverage audit: __tests__/lib/recurring.test.ts (calendar-month edge cases, persistence, upcoming-renewal sorting) and __tests__/lib/address-book.test.ts (CRUD + touch/persist behavior). lib/csv-parser.ts, lib/error-messages.ts, and lib/receipt-utils.ts already have comprehensive tests in __tests__/lib/. (#296) - Add e2e/settings.spec.ts (display-preference persistence, webhook register/toggle/remove/validate, persistence across reload) and e2e/analytics.spec.ts (chart rendering against the app's built-in mock data store, range-filter behavior, empty-state copy). (#299) - Expand e2e/accessibility.spec.ts with axe scans for the new settings/analytics pages, complementing the existing automated a11y scans and focus-trap/keyboard-nav coverage for dashboard, create-stream, and the cancel-stream / tx-preview / fee dialogs. (#300) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GTXYLUrw8kW1yWzcpDq8Cn --- __tests__/lib/address-book.test.ts | 137 +++++++++++++++++++++ __tests__/lib/recurring.test.ts | 187 +++++++++++++++++++++++++++++ e2e/accessibility.spec.ts | 14 +++ e2e/analytics.spec.ts | 113 +++++++++++++++++ e2e/settings.spec.ts | 143 ++++++++++++++++++++++ lib/recurring.ts | 22 +++- 6 files changed, 614 insertions(+), 2 deletions(-) create mode 100644 __tests__/lib/address-book.test.ts create mode 100644 __tests__/lib/recurring.test.ts create mode 100644 e2e/analytics.spec.ts create mode 100644 e2e/settings.spec.ts diff --git a/__tests__/lib/address-book.test.ts b/__tests__/lib/address-book.test.ts new file mode 100644 index 0000000..b37977e --- /dev/null +++ b/__tests__/lib/address-book.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + addAddressBookEntry, + deleteAddressBookEntry, + getAddressBookEntries, + touchAddressBookEntry, + updateAddressBookEntry, +} from '@/lib/address-book' + +beforeEach(() => { + window.localStorage.clear() +}) + +describe('getAddressBookEntries', () => { + it('returns an empty array when nothing is stored', () => { + expect(getAddressBookEntries()).toEqual([]) + }) + + it('returns entries sorted by lastUsed descending', () => { + addAddressBookEntry({ label: 'Alice', address: 'GALICE' }) + addAddressBookEntry({ label: 'Bob', address: 'GBOB' }) + const entries = getAddressBookEntries() + expect(entries).toHaveLength(2) + // Most recently added (Bob) should come first. + expect(entries[0].label).toBe('Bob') + expect(entries[1].label).toBe('Alice') + }) + + it('returns [] when stored JSON is malformed', () => { + window.localStorage.setItem('flowstar:address-book', 'not-json{') + expect(getAddressBookEntries()).toEqual([]) + }) +}) + +describe('addAddressBookEntry', () => { + it('trims label and address', () => { + const entry = addAddressBookEntry({ label: ' Alice ', address: ' GALICE ' }) + expect(entry.label).toBe('Alice') + expect(entry.address).toBe('GALICE') + }) + + it('assigns a unique id and lastUsed timestamp', () => { + const entry = addAddressBookEntry({ label: 'Alice', address: 'GALICE' }) + expect(entry.id).toBeTruthy() + expect(typeof entry.lastUsed).toBe('number') + }) + + it('replaces an existing entry with the same address instead of duplicating', () => { + addAddressBookEntry({ label: 'Alice', address: 'GALICE' }) + addAddressBookEntry({ label: 'Alice V2', address: 'GALICE' }) + const entries = getAddressBookEntries() + expect(entries).toHaveLength(1) + expect(entries[0].label).toBe('Alice V2') + }) + + it('caps stored entries at 50', () => { + for (let i = 0; i < 55; i += 1) { + addAddressBookEntry({ label: `Person ${i}`, address: `GADDR${i}` }) + } + expect(getAddressBookEntries()).toHaveLength(50) + }) +}) + +describe('updateAddressBookEntry', () => { + it('updates the matching entry and returns it', () => { + const created = addAddressBookEntry({ label: 'Alice', address: 'GALICE' }) + const updated = updateAddressBookEntry(created.id, { label: 'Alice Updated' }) + expect(updated?.label).toBe('Alice Updated') + expect(getAddressBookEntries()[0].label).toBe('Alice Updated') + }) + + it('returns null when the id does not exist', () => { + const result = updateAddressBookEntry('nonexistent', { label: 'x' }) + expect(result).toBeNull() + }) + + it('leaves other entries unchanged', () => { + const alice = addAddressBookEntry({ label: 'Alice', address: 'GALICE' }) + addAddressBookEntry({ label: 'Bob', address: 'GBOB' }) + updateAddressBookEntry(alice.id, { label: 'Alice Updated' }) + const entries = getAddressBookEntries() + const bob = entries.find((e) => e.label.startsWith('Bob')) + expect(bob?.label).toBe('Bob') + }) +}) + +describe('deleteAddressBookEntry', () => { + it('removes only the matching entry', () => { + const alice = addAddressBookEntry({ label: 'Alice', address: 'GALICE' }) + addAddressBookEntry({ label: 'Bob', address: 'GBOB' }) + deleteAddressBookEntry(alice.id) + const entries = getAddressBookEntries() + expect(entries).toHaveLength(1) + expect(entries[0].label).toBe('Bob') + }) + + it('is a no-op when the id does not exist', () => { + addAddressBookEntry({ label: 'Alice', address: 'GALICE' }) + deleteAddressBookEntry('nonexistent') + expect(getAddressBookEntries()).toHaveLength(1) + }) +}) + +describe('touchAddressBookEntry', () => { + it('updates lastUsed and optional label for an existing address', async () => { + addAddressBookEntry({ label: 'Alice', address: 'GALICE' }) + const before = getAddressBookEntries()[0].lastUsed + await new Promise((resolve) => setTimeout(resolve, 2)) + const touched = touchAddressBookEntry('GALICE', 'Alice Renamed') + expect(touched?.label).toBe('Alice Renamed') + expect(touched!.lastUsed).toBeGreaterThanOrEqual(before) + }) + + it('keeps the existing label when no label override is given', () => { + addAddressBookEntry({ label: 'Alice', address: 'GALICE' }) + const touched = touchAddressBookEntry('GALICE') + expect(touched?.label).toBe('Alice') + }) + + it('creates a new entry when the address is not already saved', () => { + const touched = touchAddressBookEntry('GNEWADDR', 'New Contact') + expect(touched?.address).toBe('GNEWADDR') + expect(touched?.label).toBe('New Contact') + expect(getAddressBookEntries()).toHaveLength(1) + }) + + it('defaults label to "Saved recipient" when creating without a label', () => { + const touched = touchAddressBookEntry('GNEWADDR2') + expect(touched?.label).toBe('Saved recipient') + }) + + it('returns null for an empty/whitespace address with no existing entry', () => { + const touched = touchAddressBookEntry(' ') + expect(touched).toBeNull() + expect(getAddressBookEntries()).toHaveLength(0) + }) +}) diff --git a/__tests__/lib/recurring.test.ts b/__tests__/lib/recurring.test.ts new file mode 100644 index 0000000..edad076 --- /dev/null +++ b/__tests__/lib/recurring.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + buildNextRunAt, + createRenewalPreset, + getRecurringRules, + getUpcomingRenewals, + removeRecurringRule, + saveRecurringRule, + type RecurringRule, +} from '@/lib/recurring' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeRule(overrides: Partial = {}): RecurringRule { + return { + cadence: 'monthly', + nextRunAt: Date.now() + 1000, + lastCreatedAt: Date.now(), + streamId: 'stream-1', + recipient: 'GRECIPIENT', + tokenSymbol: 'USDC', + amount: '1000', + ...overrides, + } +} + +beforeEach(() => { + window.localStorage.clear() +}) + +// ─── buildNextRunAt ─────────────────────────────────────────────────────────── + +describe('buildNextRunAt', () => { + it('adds exactly 7 days for weekly cadence', () => { + const start = new Date('2026-01-10T12:00:00Z').getTime() + const next = buildNextRunAt(start, 'weekly') + expect(next - start).toBe(7 * 24 * 60 * 60 * 1000) + }) + + it('adds a real calendar month for monthly cadence', () => { + const start = new Date('2026-01-15T00:00:00').getTime() + const next = new Date(buildNextRunAt(start, 'monthly')) + expect(next.getMonth()).toBe(1) // February + expect(next.getDate()).toBe(15) + }) + + it('adds a real calendar quarter (3 months) for quarterly cadence', () => { + const start = new Date('2026-01-15T00:00:00').getTime() + const next = new Date(buildNextRunAt(start, 'quarterly')) + expect(next.getMonth()).toBe(3) // April + expect(next.getDate()).toBe(15) + }) + + it('does not drift to a fixed 30 days for monthly cadence', () => { + // Jan has 31 days; a fixed +30d would land on Feb 10 instead of Feb 15. + const start = new Date('2026-01-15T00:00:00').getTime() + const next = buildNextRunAt(start, 'monthly') + const thirtyDaysLater = start + 30 * 24 * 60 * 60 * 1000 + expect(next).not.toBe(thirtyDaysLater) + }) + + it('clamps Jan 31 + 1 month to the last day of February (non-leap year)', () => { + const start = new Date('2025-01-31T00:00:00').getTime() + const next = new Date(buildNextRunAt(start, 'monthly')) + expect(next.getMonth()).toBe(1) // February + expect(next.getDate()).toBe(28) // 2025 is not a leap year + }) + + it('clamps Jan 31 + 1 month to Feb 29 on a leap year', () => { + const start = new Date('2024-01-31T00:00:00').getTime() + const next = new Date(buildNextRunAt(start, 'monthly')) + expect(next.getMonth()).toBe(1) // February + expect(next.getDate()).toBe(29) // 2024 is a leap year + }) + + it('clamps Nov 30 + 1 quarter to Feb 28/29 when landing in February', () => { + const start = new Date('2025-11-30T00:00:00').getTime() + const next = new Date(buildNextRunAt(start, 'quarterly')) + expect(next.getMonth()).toBe(1) // February + expect(next.getDate()).toBe(28) + }) + + it('preserves time-of-day across the month rollover', () => { + const start = new Date('2026-03-15T09:30:00').getTime() + const next = new Date(buildNextRunAt(start, 'monthly')) + expect(next.getHours()).toBe(9) + expect(next.getMinutes()).toBe(30) + }) +}) + +// ─── saveRecurringRule / getRecurringRules / removeRecurringRule ───────────── + +describe('saveRecurringRule / getRecurringRules', () => { + it('persists a rule and returns it from getRecurringRules', () => { + saveRecurringRule(makeRule({ streamId: 'a' })) + const rules = getRecurringRules() + expect(rules).toHaveLength(1) + expect(rules[0].streamId).toBe('a') + }) + + it('returns an empty array when nothing is stored', () => { + expect(getRecurringRules()).toEqual([]) + }) + + it('replaces an existing rule for the same streamId instead of duplicating', () => { + saveRecurringRule(makeRule({ streamId: 'a', amount: '100' })) + saveRecurringRule(makeRule({ streamId: 'a', amount: '200' })) + const rules = getRecurringRules() + expect(rules).toHaveLength(1) + expect(rules[0].amount).toBe('200') + }) + + it('caps stored rules at 25 entries', () => { + for (let i = 0; i < 30; i += 1) { + saveRecurringRule(makeRule({ streamId: `stream-${i}` })) + } + expect(getRecurringRules()).toHaveLength(25) + }) + + it('returns [] when stored JSON is malformed', () => { + window.localStorage.setItem('flowstar:recurring-streams', '{not valid json') + expect(getRecurringRules()).toEqual([]) + }) +}) + +describe('removeRecurringRule', () => { + it('removes only the matching rule', () => { + saveRecurringRule(makeRule({ streamId: 'a' })) + saveRecurringRule(makeRule({ streamId: 'b' })) + removeRecurringRule('a') + const rules = getRecurringRules() + expect(rules).toHaveLength(1) + expect(rules[0].streamId).toBe('b') + }) +}) + +// ─── getUpcomingRenewals ────────────────────────────────────────────────────── + +describe('getUpcomingRenewals', () => { + it('excludes rules whose nextRunAt is in the past', () => { + saveRecurringRule(makeRule({ streamId: 'past', nextRunAt: Date.now() - 1000 })) + saveRecurringRule(makeRule({ streamId: 'future', nextRunAt: Date.now() + 1000 })) + const upcoming = getUpcomingRenewals() + expect(upcoming.map((r) => r.streamId)).toEqual(['future']) + }) + + it('sorts remaining rules by nextRunAt ascending', () => { + saveRecurringRule(makeRule({ streamId: 'later', nextRunAt: Date.now() + 5000 })) + saveRecurringRule(makeRule({ streamId: 'sooner', nextRunAt: Date.now() + 1000 })) + const upcoming = getUpcomingRenewals() + expect(upcoming.map((r) => r.streamId)).toEqual(['sooner', 'later']) + }) +}) + +// ─── createRenewalPreset ────────────────────────────────────────────────────── + +describe('createRenewalPreset', () => { + it('builds and persists a preset from a stream', () => { + const stream = { + id: 'stream-9', + recipient: 'GRECIPIENT9', + token: { symbol: 'XLM' }, + depositedAmount: 500n, + } + const preset = createRenewalPreset(stream, 'weekly') + expect(preset.streamId).toBe('stream-9') + expect(preset.recipient).toBe('GRECIPIENT9') + expect(preset.tokenSymbol).toBe('XLM') + expect(preset.amount).toBe('500') + expect(preset.cadence).toBe('weekly') + + const stored = getRecurringRules() + expect(stored).toHaveLength(1) + expect(stored[0].streamId).toBe('stream-9') + }) + + it('sets nextRunAt in the future relative to lastCreatedAt', () => { + const stream = { + id: 'stream-10', + recipient: 'GRECIPIENT10', + token: { symbol: 'XLM' }, + depositedAmount: 1n, + } + const preset = createRenewalPreset(stream, 'monthly') + expect(preset.nextRunAt).toBeGreaterThan(preset.lastCreatedAt) + }) +}) diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts index b0664c5..59844a1 100644 --- a/e2e/accessibility.spec.ts +++ b/e2e/accessibility.spec.ts @@ -106,6 +106,20 @@ test.describe('Axe automated scans', () => { await page.waitForLoadState('networkidle') await checkA11y(page) }) + + test('settings page passes axe wcag2a/2aa', async ({ page }) => { + await withWallet(page) + await page.goto('/app/settings') + await expect(page.locator('h1:has-text("Settings")')).toBeVisible() + await checkA11y(page) + }) + + test('analytics page passes axe wcag2a/2aa', async ({ page }) => { + await page.goto('/app/analytics') + await expect(page.locator('h1:has-text("Platform analytics")')).toBeVisible() + await page.waitForLoadState('networkidle') + await checkA11y(page) + }) }) // ───────────────────────────────────────────────────────────────────────────── diff --git a/e2e/analytics.spec.ts b/e2e/analytics.spec.ts new file mode 100644 index 0000000..531b993 --- /dev/null +++ b/e2e/analytics.spec.ts @@ -0,0 +1,113 @@ +import { test, expect, type Page } from '@playwright/test' + +// The app ships with a built-in mock data store (lib/mock-data.ts) used +// whenever no on-chain contract id is configured. Its streams are keyed to +// DEMO_ADDRESS as sender/recipient, so connecting a mock wallet with this +// exact address surfaces deterministic "mock data" for chart rendering — +// the same convention e2e/visual.spec.ts uses for dashboard screenshots. +const DEMO_ADDRESS = 'GBQ2X7KFY3R4VZ6N5LJ7WQH3M2PD8C9SAUTV4EXAMPLE0WALLET00ADDR' + +async function withWallet(page: Page, address: string = DEMO_ADDRESS) { + await page.addInitScript((walletAddress) => { + localStorage.setItem('walletId', 'xbull') + ;(window as any).xBullSDK = { + connect: async () => ({ publicKey: walletAddress }), + signXDR: async () => 'AAAAAgAAAAA...dummy-signature...', + } + }, address) +} + +test.describe('Analytics page — unauthenticated (no data)', () => { + test('renders page structure with zero-state stats', async ({ page }) => { + await page.goto('/app/analytics') + await expect(page.locator('h1:has-text("Platform analytics")')).toBeVisible() + await expect(page.locator('text=Total volume streamed')).toBeVisible() + await expect(page.locator('text=Active streams')).toBeVisible() + await expect(page.locator('text=Total streams created')).toBeVisible() + await expect(page.locator('text=Average duration')).toBeVisible() + }) + + test('shows empty-state copy in the charts when there is no stream activity', async ({ + page, + }) => { + await page.goto('/app/analytics') + await expect(page.locator('text=No stream activity yet for this period.')).toBeVisible() + await expect(page.locator('text=No volume data yet.')).toBeVisible() + }) + + test('"Back to dashboard" link navigates to /app', async ({ page }) => { + await page.goto('/app/analytics') + await page.locator('a:has-text("Back to dashboard")').click() + await expect(page).toHaveURL(/\/app$/) + }) +}) + +test.describe('Analytics page — chart rendering with mock data', () => { + test.beforeEach(async ({ page }) => { + await withWallet(page) + await page.goto('/app/analytics') + await page.waitForLoadState('networkidle') + }) + + test('default 30-day range reflects the mock streams within that window', async ({ + page, + }) => { + // Of the 5 seeded mock streams, only 3 fall within the last 30 days + // (one is +90d old, one is +120d old); 2 of those 3 are active (one is + // cancelled). + const totalCard = page + .locator('div') + .filter({ has: page.locator('text=Total streams created') }) + .last() + await expect(totalCard.locator('text=3')).toBeVisible() + + const activeCard = page + .locator('div') + .filter({ has: page.locator('text=Active streams') }) + .last() + await expect(activeCard.locator('text=2')).toBeVisible() + }) + + test('switching range to "All time" includes all seeded mock streams', async ({ + page, + }) => { + await page.locator('button:has-text("30 days")').click() + await page.locator('text=All time').click() + + const totalCard = page + .locator('div') + .filter({ has: page.locator('text=Total streams created') }) + .last() + await expect(totalCard.locator('text=5')).toBeVisible() + + const activeCard = page + .locator('div') + .filter({ has: page.locator('text=Active streams') }) + .last() + await expect(activeCard.locator('text=3')).toBeVisible() + }) + + test('renders the "Streams created over time" chart with data bars', async ({ + page, + }) => { + await expect(page.locator('text=Streams created over time')).toBeVisible() + await expect(page.locator('text=No stream activity yet for this period.')).not.toBeVisible() + }) + + test('renders "Top tokens by volume" with the seeded token symbols', async ({ + page, + }) => { + await expect(page.locator('text=Top tokens by volume')).toBeVisible() + // The default 30d window includes USDC and XLM denominated streams. + await expect(page.locator('text=USDC').first()).toBeVisible() + }) + + test('renders "Token distribution" section', async ({ page }) => { + await expect(page.locator('text=Token distribution')).toBeVisible() + }) + + test('network context card lists available tokens', async ({ page }) => { + await expect(page.locator('text=Network context')).toBeVisible() + await expect(page.locator('text=XLM').first()).toBeVisible() + }) +}) diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000..2fe0597 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,143 @@ +import { test, expect, type Page } from '@playwright/test' + +// ─── Shared helper: inject xBull mock so the wallet-gated settings page renders +async function withWallet(page: Page) { + await page.addInitScript(() => { + localStorage.setItem('walletId', 'xbull') + ;(window as any).xBullSDK = { + connect: async () => ({ + publicKey: 'GBQTESTWALLETADDRESS000000000000000000000000000000000000', + }), + signXDR: async () => 'AAAAAgAAAAA...dummy-signature...', + } + }) +} + +test.describe('Settings page — wallet gate', () => { + test('shows connect-wallet prompt when not connected', async ({ page }) => { + await page.goto('/app/settings') + await expect( + page.locator('text=Connect your wallet').or(page.locator('text=Connect wallet')).first(), + ).toBeVisible() + }) +}) + +test.describe('Settings page — display preferences', () => { + test.beforeEach(async ({ page }) => { + await withWallet(page) + await page.goto('/app/settings') + await page.waitForLoadState('networkidle') + }) + + test('renders the Settings heading and sections', async ({ page }) => { + await expect(page.locator('h1:has-text("Settings")')).toBeVisible() + await expect(page.locator('h2:has-text("Display")')).toBeVisible() + await expect(page.locator('h2:has-text("Webhooks")')).toBeVisible() + }) + + test('"Show USD values" toggle defaults to checked', async ({ page }) => { + const usdToggle = page.locator('input[aria-label="Show USD values"]') + await expect(usdToggle).toBeChecked() + }) + + test('toggling "Show USD values" persists to localStorage', async ({ page }) => { + const usdToggle = page.locator('input[aria-label="Show USD values"]') + await expect(usdToggle).toBeChecked() + + await usdToggle.click() + await expect(usdToggle).not.toBeChecked() + + const stored = await page.evaluate(() => localStorage.getItem('flowstar-show-usd')) + expect(stored).toBe('false') + }) + + test('USD toggle preference survives a page reload', async ({ page }) => { + const usdToggle = page.locator('input[aria-label="Show USD values"]') + await usdToggle.click() + await expect(usdToggle).not.toBeChecked() + + await page.reload() + await page.waitForLoadState('networkidle') + + const reloadedToggle = page.locator('input[aria-label="Show USD values"]') + await expect(reloadedToggle).not.toBeChecked() + }) +}) + +test.describe('Settings page — webhook management', () => { + test.beforeEach(async ({ page }) => { + await withWallet(page) + await page.goto('/app/settings') + await page.waitForLoadState('networkidle') + }) + + test('registering a webhook with an invalid URL shows a validation error', async ({ + page, + }) => { + await page.locator('#webhook-url').fill('not-a-valid-url') + await page.locator('button:has-text("Register webhook")').click() + await expect(page.locator('text=Invalid URL')).toBeVisible() + }) + + test('registers a webhook and shows it in the registered list', async ({ page }) => { + await page.locator('#webhook-url').fill('https://example.com/webhook') + await page.locator('button:has-text("Register webhook")').click() + + await expect(page.locator('text=Registered webhooks')).toBeVisible() + await expect(page.locator('p.font-mono:has-text("https://example.com/webhook")')).toBeVisible() + }) + + test('registered webhook persists across reload', async ({ page }) => { + await page.locator('#webhook-url').fill('https://example.com/persisted-hook') + await page.locator('button:has-text("Register webhook")').click() + await expect(page.locator('text=https://example.com/persisted-hook')).toBeVisible() + + await page.reload() + await page.waitForLoadState('networkidle') + + await expect(page.locator('text=https://example.com/persisted-hook')).toBeVisible() + + const stored = await page.evaluate(() => localStorage.getItem('flowstar_webhooks')) + expect(stored).toContain('https://example.com/persisted-hook') + }) + + test('toggling a registered webhook off updates its enabled state', async ({ page }) => { + await page.locator('#webhook-url').fill('https://example.com/toggle-hook') + await page.locator('button:has-text("Register webhook")').click() + await expect(page.locator('text=https://example.com/toggle-hook')).toBeVisible() + + const hookRow = page + .locator('div') + .filter({ hasText: 'https://example.com/toggle-hook' }) + .first() + const disableBtn = hookRow.locator('button[title="Disable"]') + await disableBtn.click() + + await expect(hookRow.locator('button[title="Enable"]')).toBeVisible() + }) + + test('removing a registered webhook deletes it from the list', async ({ page }) => { + await page.locator('#webhook-url').fill('https://example.com/remove-hook') + await page.locator('button:has-text("Register webhook")').click() + await expect(page.locator('text=https://example.com/remove-hook')).toBeVisible() + + const hookRow = page + .locator('div') + .filter({ hasText: 'https://example.com/remove-hook' }) + .first() + await hookRow.locator('button[title="Remove"]').click() + + await expect(page.locator('text=https://example.com/remove-hook')).not.toBeVisible() + }) + + test('deselecting all event types blocks registration with an error', async ({ page }) => { + // All 6 event-type pills start selected except "Topped Up" and "Transferred"; + // deselect the four that start selected to reach zero selected events. + for (const label of ['Stream Created', 'Withdrawal', 'Cancelled', 'Completed']) { + await page.locator(`button:has-text("${label}")`).click() + } + await page.locator('#webhook-url').fill('https://example.com/no-events') + await page.locator('button:has-text("Register webhook")').click() + await expect(page.locator('text=Select at least one event type.')).toBeVisible() + }) +}) diff --git a/lib/recurring.ts b/lib/recurring.ts index a7ec5f0..a212103 100644 --- a/lib/recurring.ts +++ b/lib/recurring.ts @@ -44,8 +44,26 @@ export function getUpcomingRenewals(): RecurringRule[] { } export function buildNextRunAt(startTime: number, cadence: Exclude) { - const ms = { weekly: 7, monthly: 30, quarterly: 90 }[cadence] * 24 * 60 * 60 * 1000 - return startTime + ms + if (cadence === 'weekly') { + return startTime + 7 * 24 * 60 * 60 * 1000 + } + + // 'monthly' and 'quarterly' use real calendar-month arithmetic instead of + // fixed day counts so renewal dates don't drift across months of varying + // length (e.g. Feb, or 31-day months). + const monthsToAdd = cadence === 'monthly' ? 1 : 3 + const date = new Date(startTime) + const originalDay = date.getDate() + + date.setDate(1) // avoid month-length overflow while shifting months + date.setMonth(date.getMonth() + monthsToAdd) + + // Clamp to the last day of the target month if the original day doesn't + // exist there (e.g. Jan 31 + 1 month -> Feb 28/29, not Mar 3). + const daysInTargetMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate() + date.setDate(Math.min(originalDay, daysInTargetMonth)) + + return date.getTime() } export function createRenewalPreset(stream: { id: string; recipient: string; token: { symbol: string }; depositedAmount: bigint }, cadence: Exclude) {