diff --git a/src/components/CodeBlock.test.tsx b/src/components/CodeBlock.test.tsx new file mode 100644 index 0000000..dbd79b9 --- /dev/null +++ b/src/components/CodeBlock.test.tsx @@ -0,0 +1,75 @@ +/* CodeBlock.test.tsx — fenced code block: highlight + copy. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +const copyMock = vi.fn() +vi.mock('./Common', async () => { + const actual = await vi.importActual('./Common') + return { ...actual, copyToClipboard: (...a: any[]) => copyMock(...a) } +}) + +import { CodeBlock } from './CodeBlock' + +beforeEach(() => { vi.clearAllMocks() }) +afterEach(() => cleanup()) + +describe('CodeBlock', () => { + it('renders monochrome with no language', () => { + render() + expect(screen.getByText('just text')).toBeTruthy() + expect(document.querySelector('pre')!.getAttribute('data-lang')).toBe('') + expect(document.querySelector('.code-block-lang')).toBeNull() + }) + + it('renders an unknown language as monochrome', () => { + render() + expect(screen.getByText('x = 1')).toBeTruthy() + expect(document.querySelector('pre')!.getAttribute('data-lang')).toBe('') + }) + + it('highlights bash with the lang label', () => { + render() + expect(document.querySelector('.code-block-lang')!.textContent).toBe('bash') + expect(document.querySelector('.tok-comment')).toBeTruthy() + expect(document.querySelector('.tok-keyword')).toBeTruthy() + expect(document.querySelector('.tok-flag')).toBeTruthy() + expect(document.querySelector('.tok-number')).toBeTruthy() + expect(document.querySelector('.tok-string')).toBeTruthy() + }) + + it('highlights json keys, strings, numbers, bools', () => { + render() + expect(document.querySelector('.tok-key')).toBeTruthy() + expect(document.querySelector('.tok-string')).toBeTruthy() + expect(document.querySelector('.tok-number')).toBeTruthy() + expect(document.querySelector('.tok-bool')).toBeTruthy() + }) + + it('highlights yaml keys, comments, numbers, bools, strings', () => { + render() + expect(document.querySelector('.code-block-lang')!.textContent).toBe('yaml') + expect(document.querySelector('.tok-comment')).toBeTruthy() + expect(document.querySelector('.tok-key')).toBeTruthy() + expect(document.querySelector('.tok-number')).toBeTruthy() + expect(document.querySelector('.tok-bool')).toBeTruthy() + expect(document.querySelector('.tok-string')).toBeTruthy() + }) + + it('copies code and flashes "Copied!"', async () => { + copyMock.mockResolvedValue(true) + render() + const btn = screen.getByRole('button', { name: 'Copy code to clipboard' }) + await userEvent.click(btn) + expect(copyMock).toHaveBeenCalledWith('echo hi') + await waitFor(() => expect(screen.getByText('Copied!')).toBeTruthy()) + }) + + it('does not flash when the copy fails', async () => { + copyMock.mockResolvedValue(false) + render() + await userEvent.click(screen.getByRole('button', { name: 'Copy code to clipboard' })) + expect(screen.queryByText('Copied!')).toBeNull() + }) +}) diff --git a/src/components/Common.pills.test.tsx b/src/components/Common.pills.test.tsx new file mode 100644 index 0000000..df03a72 --- /dev/null +++ b/src/components/Common.pills.test.tsx @@ -0,0 +1,114 @@ +/* Common.pills.test.tsx — render coverage for the small Common.tsx + * presentational exports (StatusPill, RolePill, ScopePill, Sparkline, + * Skeleton) and the PromptCard copy paths. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { + StatusPill, + RolePill, + ScopePill, + Sparkline, + Skeleton, + PromptCard, +} from './Common' + +let writeText: ReturnType +beforeEach(() => { + vi.clearAllMocks() + writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, configurable: true, writable: true, + }) +}) +afterEach(() => cleanup()) + +describe('StatusPill', () => { + it('maps running→healthy and deploying→building', () => { + const { rerender } = render() + expect(document.querySelector('.status-pill.healthy')!.textContent).toBe('healthy') + rerender() + expect(document.querySelector('.status-pill.building')!.textContent).toBe('building') + }) + it('renders failed, expired (→stopped) and a fallback', () => { + const { rerender } = render() + expect(document.querySelector('.status-pill.failed')).toBeTruthy() + rerender() + expect(document.querySelector('.status-pill.stopped')!.textContent).toBe('expired') + rerender() + expect(document.querySelector('.status-pill.stopped')).toBeTruthy() + }) +}) + +describe('RolePill', () => { + it('adds the role modifier class only for owner/admin', () => { + const { rerender } = render() + expect(document.querySelector('.role-pill.owner')).toBeTruthy() + rerender() + expect(document.querySelector('.role-pill.admin')).toBeTruthy() + rerender() + expect(document.querySelector('.role-pill')!.className.trim()).toBe('role-pill') + }) +}) + +describe('ScopePill', () => { + it('renders write, agent, and read variants', () => { + const { rerender } = render() + expect(screen.getByText(/clickable/)).toBeTruthy() + rerender() + expect(screen.getByText(/agent surface/)).toBeTruthy() + rerender() + expect(screen.getByText(/mirror/)).toBeTruthy() + }) +}) + +describe('Sparkline + Skeleton', () => { + it('renders a polyline for the given points', () => { + render() + const poly = document.querySelector('svg.sparkline polyline') + expect(poly).toBeTruthy() + expect(poly!.getAttribute('points')!.split(' ').length).toBe(4) + }) + it('renders a single-point sparkline without dividing by zero', () => { + render() + expect(document.querySelector('svg.sparkline polyline')).toBeTruthy() + }) + it('renders a skeleton with default and custom sizes', () => { + const { rerender } = render() + expect(document.querySelector('span.skel')).toBeTruthy() + rerender() + expect(document.querySelector('span.skel')).toBeTruthy() + }) +}) + +describe('PromptCard copy', () => { + it('copies the fallback prompt when no promptText is given', async () => { + render() + await userEvent.click(screen.getByTestId('copy-prompt')) + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/db/new')) + await waitFor(() => expect(screen.getByTestId('copy-prompt').textContent).toContain('copied')) + }) + + it('copies explicit promptText and the curl command', async () => { + render() + await userEvent.click(screen.getByTestId('copy-prompt')) + expect(writeText).toHaveBeenLastCalledWith('custom prompt') + await userEvent.click(screen.getByTestId('copy-curl')) + expect(writeText).toHaveBeenLastCalledWith(expect.stringContaining('curl -X GET')) + await waitFor(() => expect(screen.getByTestId('copy-curl').textContent).toContain('copied')) + }) + + it('warns and does not flash when copy fails', async () => { + writeText.mockRejectedValue(new Error('denied')) + // Force the execCommand fallback to also fail. + ;(document as any).execCommand = vi.fn(() => false) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + render() + await userEvent.click(screen.getByTestId('copy-curl')) + await waitFor(() => expect(warn).toHaveBeenCalled()) + expect(screen.getByTestId('copy-curl').textContent).toContain('copy curl') + warn.mockRestore() + }) +}) diff --git a/src/components/CustomDomainPanel.test.tsx b/src/components/CustomDomainPanel.test.tsx new file mode 100644 index 0000000..20e0888 --- /dev/null +++ b/src/components/CustomDomainPanel.test.tsx @@ -0,0 +1,255 @@ +/* CustomDomainPanel.test.tsx — Pro+ custom-domain binding lifecycle. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import type { CustomDomain, CustomDomainStatus } from '../api' + +const copyMock = vi.fn() +vi.mock('./Common', async () => { + const actual = await vi.importActual('./Common') + return { ...actual, copyToClipboard: (...a: any[]) => copyMock(...a) } +}) + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api') + return { + ...actual, + listCustomDomains: vi.fn(), + createCustomDomain: vi.fn(), + verifyCustomDomain: vi.fn(), + deleteCustomDomain: vi.fn(), + } +}) + +import * as api from '../api' +import { CustomDomainPanel } from './CustomDomainPanel' + +function makeDomain(over: Partial = {}): CustomDomain { + return { + id: 'cd_1', + hostname: 'app.acme.com', + status: 'pending_verification' as CustomDomainStatus, + verified: false, + certificate_ready: false, + verification: { + txt: { record_type: 'TXT', record_name: '_instanode.app.acme.com', record_value: 'verify=abc' }, + cname: { record_type: 'CNAME', record_name: 'app.acme.com', record_value: 'ingress.instanode.dev' }, + }, + last_check_err: null, + ...over, + } +} + +function renderPanel(slug = 'my-stack') { + return render( + + + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() + ;(api.listCustomDomains as any).mockResolvedValue([]) +}) +afterEach(() => cleanup()) + +describe('CustomDomainPanel — load states', () => { + it('shows the loading state then the empty state', async () => { + let resolve!: (v: CustomDomain[]) => void + ;(api.listCustomDomains as any).mockReturnValue(new Promise((r) => { resolve = r })) + renderPanel() + expect(screen.getByText('loading custom domains…')).toBeTruthy() + resolve([]) + await waitFor(() => expect(screen.getByText(/No custom domains yet/)).toBeTruthy()) + }) + + it('surfaces a load error', async () => { + ;(api.listCustomDomains as any).mockRejectedValue(new Error('boom load')) + renderPanel() + await waitFor(() => expect(screen.getByText('boom load')).toBeTruthy()) + }) + + it('renders a list of domains', async () => { + ;(api.listCustomDomains as any).mockResolvedValue([makeDomain()]) + renderPanel() + await waitFor(() => expect(screen.getByText('app.acme.com')).toBeTruthy()) + expect(screen.getByText('Awaiting TXT')).toBeTruthy() + }) +}) + +describe('CustomDomainPanel — add domain', () => { + it('opens and cancels the add form', async () => { + renderPanel() + await waitFor(() => screen.getByText('+ add domain')) + await userEvent.click(screen.getByText('+ add domain')) + expect(screen.getByLabelText('hostname')).toBeTruthy() + await userEvent.click(screen.getByText('cancel')) + await waitFor(() => expect(screen.queryByLabelText('hostname')).toBeNull()) + }) + + it('creates a domain and prepends it to the list', async () => { + ;(api.createCustomDomain as any).mockResolvedValue(makeDomain({ id: 'cd_new', hostname: 'new.acme.com' })) + renderPanel() + await waitFor(() => screen.getByText('+ add domain')) + await userEvent.click(screen.getByText('+ add domain')) + await userEvent.type(screen.getByLabelText('hostname'), 'New.Acme.com') + await userEvent.click(screen.getByRole('button', { name: 'add domain' })) + await waitFor(() => expect(api.createCustomDomain).toHaveBeenCalledWith('my-stack', 'new.acme.com')) + await waitFor(() => expect(screen.getByText('new.acme.com')).toBeTruthy()) + }) + + it('does not submit a blank hostname', async () => { + renderPanel() + await waitFor(() => screen.getByText('+ add domain')) + await userEvent.click(screen.getByText('+ add domain')) + const form = screen.getByLabelText('hostname').closest('form')! + fireEvent.submit(form) + expect(api.createCustomDomain).not.toHaveBeenCalled() + }) + + it('shows the upgrade banner on a 402', async () => { + ;(api.createCustomDomain as any).mockRejectedValue({ status: 402 }) + renderPanel() + await waitFor(() => screen.getByText('+ add domain')) + await userEvent.click(screen.getByText('+ add domain')) + await userEvent.type(screen.getByLabelText('hostname'), 'x.acme.com') + await userEvent.click(screen.getByRole('button', { name: 'add domain' })) + await waitFor(() => expect(screen.getByTestId('custom-domain-upgrade-banner')).toBeTruthy()) + }) + + it('shows hostname_taken error', async () => { + ;(api.createCustomDomain as any).mockRejectedValue({ code: 'hostname_taken' }) + renderPanel() + await waitFor(() => screen.getByText('+ add domain')) + await userEvent.click(screen.getByText('+ add domain')) + await userEvent.type(screen.getByLabelText('hostname'), 'x.acme.com') + await userEvent.click(screen.getByRole('button', { name: 'add domain' })) + await waitFor(() => expect(screen.getByText(/already bound/)).toBeTruthy()) + }) + + it('shows invalid_hostname error', async () => { + ;(api.createCustomDomain as any).mockRejectedValue({ code: 'invalid_hostname' }) + renderPanel() + await waitFor(() => screen.getByText('+ add domain')) + await userEvent.click(screen.getByText('+ add domain')) + await userEvent.type(screen.getByLabelText('hostname'), 'x.acme.com') + await userEvent.click(screen.getByRole('button', { name: 'add domain' })) + await waitFor(() => expect(screen.getByText(/not valid/)).toBeTruthy()) + }) + + it('shows a generic error otherwise', async () => { + ;(api.createCustomDomain as any).mockRejectedValue({ message: 'weird' }) + renderPanel() + await waitFor(() => screen.getByText('+ add domain')) + await userEvent.click(screen.getByText('+ add domain')) + await userEvent.type(screen.getByLabelText('hostname'), 'x.acme.com') + await userEvent.click(screen.getByRole('button', { name: 'add domain' })) + await waitFor(() => expect(screen.getByText('weird')).toBeTruthy()) + }) +}) + +describe('CustomDomainPanel — verify / delete', () => { + it('verifies a domain and updates its status', async () => { + ;(api.listCustomDomains as any).mockResolvedValue([makeDomain()]) + ;(api.verifyCustomDomain as any).mockResolvedValue(makeDomain({ status: 'verified', verified: true })) + renderPanel() + await waitFor(() => screen.getByText('app.acme.com')) + await userEvent.click(screen.getByRole('button', { name: 'verify' })) + await waitFor(() => expect(screen.getByText('TXT verified — issuing cert')).toBeTruthy()) + }) + + it('records a last_check_err on a failed verify', async () => { + ;(api.listCustomDomains as any).mockResolvedValue([makeDomain()]) + ;(api.verifyCustomDomain as any).mockRejectedValue(new Error('TXT not found')) + renderPanel() + await waitFor(() => screen.getByText('app.acme.com')) + await userEvent.click(screen.getByRole('button', { name: 'verify' })) + await waitFor(() => expect(screen.getByText(/last check: TXT not found/)).toBeTruthy()) + }) + + it('deletes a domain after confirmation', async () => { + ;(api.listCustomDomains as any).mockResolvedValue([makeDomain()]) + ;(api.deleteCustomDomain as any).mockResolvedValue(undefined) + vi.spyOn(window, 'confirm').mockReturnValue(true) + renderPanel() + await waitFor(() => screen.getByText('app.acme.com')) + await userEvent.click(screen.getByRole('button', { name: 'delete' })) + await waitFor(() => expect(screen.queryByText('app.acme.com')).toBeNull()) + }) + + it('does not delete when confirmation is cancelled', async () => { + ;(api.listCustomDomains as any).mockResolvedValue([makeDomain()]) + vi.spyOn(window, 'confirm').mockReturnValue(false) + renderPanel() + await waitFor(() => screen.getByText('app.acme.com')) + await userEvent.click(screen.getByRole('button', { name: 'delete' })) + expect(api.deleteCustomDomain).not.toHaveBeenCalled() + }) + + it('surfaces a delete error', async () => { + ;(api.listCustomDomains as any).mockResolvedValue([makeDomain()]) + ;(api.deleteCustomDomain as any).mockRejectedValue(new Error('cannot delete')) + vi.spyOn(window, 'confirm').mockReturnValue(true) + renderPanel() + await waitFor(() => screen.getByText('app.acme.com')) + await userEvent.click(screen.getByRole('button', { name: 'delete' })) + await waitFor(() => expect(screen.getByText('cannot delete')).toBeTruthy()) + }) + + it('hides verify button and shows CNAME for live domains', async () => { + ;(api.listCustomDomains as any).mockResolvedValue([ + makeDomain({ status: 'live', verified: true }), + ]) + renderPanel() + await waitFor(() => screen.getByText('✓ Live')) + expect(screen.queryByRole('button', { name: 'verify' })).toBeNull() + expect(screen.getByText('ingress.instanode.dev')).toBeTruthy() + }) +}) + +describe('CustomDomainPanel — status pill variants', () => { + it('renders ingress_ready and failed pills', async () => { + ;(api.listCustomDomains as any).mockResolvedValue([ + makeDomain({ id: 'a', hostname: 'a.acme.com', status: 'ingress_ready', verified: true }), + makeDomain({ id: 'b', hostname: 'b.acme.com', status: 'failed', last_check_err: 'cert issue' }), + ]) + renderPanel() + await waitFor(() => screen.getByText('Ingress live — issuing cert')) + expect(screen.getByText('Failed')).toBeTruthy() + }) + + it('renders an unknown status label via the default branch', async () => { + ;(api.listCustomDomains as any).mockResolvedValue([ + makeDomain({ status: 'mystery' as CustomDomainStatus }), + ]) + renderPanel() + await waitFor(() => expect(screen.getByText('mystery')).toBeTruthy()) + }) +}) + +describe('CustomDomainPanel — DNS row copy', () => { + it('copies a DNS value and flips the label', async () => { + copyMock.mockResolvedValue(true) + ;(api.listCustomDomains as any).mockResolvedValue([makeDomain()]) + renderPanel() + await waitFor(() => screen.getByText('app.acme.com')) + const copyBtn = screen.getByLabelText('copy value') + await userEvent.click(copyBtn) + expect(copyMock).toHaveBeenCalledWith('verify=abc') + await waitFor(() => expect(copyBtn.textContent).toBe('copied')) + }) + + it('warns when the clipboard is unavailable', async () => { + copyMock.mockResolvedValue(false) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + ;(api.listCustomDomains as any).mockResolvedValue([makeDomain()]) + renderPanel() + await waitFor(() => screen.getByText('app.acme.com')) + await userEvent.click(screen.getByLabelText('copy value')) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/components/CustomerDetailDrawer.test.tsx b/src/components/CustomerDetailDrawer.test.tsx new file mode 100644 index 0000000..661e14e --- /dev/null +++ b/src/components/CustomerDetailDrawer.test.tsx @@ -0,0 +1,183 @@ +/* CustomerDetailDrawer.test.tsx — admin customer detail slide-in. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import type { + AdminCustomerDetailResponse, + AdminCustomerSummary, +} from '../api/types' +import { formatBytes, CustomerDetailDrawer } from './CustomerDetailDrawer' + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api') + return { ...actual, getAdminCustomer: vi.fn() } +}) + +// Replace heavy modals with markers + a button that fires the success cb. +vi.mock('./IssuePromoModal', () => ({ + IssuePromoModal: ({ onClose, onIssued }: any) => ( +
+ + +
+ ), +})) +vi.mock('./TierChangeModal', () => ({ + TierChangeModal: ({ onClose, onChanged }: any) => ( +
+ + +
+ ), +})) + +import * as api from '../api' + +const SUMMARY: AdminCustomerSummary = { + team_id: 'team_abcdef0123', + primary_email: 'founder@acme.dev', + name: 'Acme', + tier: 'pro', + mrr_monthly: 4900, + mrr_yearly: 49000, + storage_bytes: 1024 * 1024 * 5, + deployments_active: 2, + last_active: '2026-05-21T00:00:00Z', + created_at: '2026-04-01T00:00:00Z', +} + +function makeDetail(over: Partial = {}): AdminCustomerDetailResponse { + return { + ok: true, + team: { id: 'team_abcdef0123', name: 'acme', slug: 'acme', owner_id: 'u1', member_count: 1, tier: 'pro', created_at: '2026-04-01T00:00:00Z', display_name: 'Acme', default_env: 'production' } as any, + users: [{ id: 'u1' } as any], + resources: [], + audit_log: [], + deploys: [], + subscription: { status: 'active', razorpay_subscription_id: 'sub_xyz', next_renewal_at: '2026-06-01T00:00:00Z' }, + promos: [], + ...over, + } +} + +function renderDrawer(onClose = vi.fn()) { + return render( + + + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() + ;(api.getAdminCustomer as any).mockResolvedValue(makeDetail()) +}) +afterEach(() => cleanup()) + +describe('formatBytes', () => { + it('handles nullish and non-positive', () => { + expect(formatBytes(null)).toBe('0 B') + expect(formatBytes(undefined)).toBe('0 B') + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(NaN)).toBe('0 B') + }) + it('scales through units', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2.0 KB') + expect(formatBytes(1024 * 1024 * 200)).toBe('200 MB') + }) +}) + +describe('CustomerDetailDrawer', () => { + it('loads then renders the overview tab', async () => { + renderDrawer() + expect(screen.getByTestId('drawer-loading')).toBeTruthy() + await waitFor(() => expect(screen.getByTestId('drawer-overview')).toBeTruthy()) + expect(screen.getByTestId('drawer-email').textContent).toBe('founder@acme.dev') + expect(screen.getByTestId('drawer-mrr')).toBeTruthy() + }) + + it('shows an error when the fetch fails', async () => { + ;(api.getAdminCustomer as any).mockRejectedValue(new Error('nope')) + renderDrawer() + await waitFor(() => expect(screen.getByTestId('drawer-error').textContent).toBe('nope')) + }) + + it('closes on overlay click, close button, and Escape', async () => { + const onClose = vi.fn() + renderDrawer(onClose) + await waitFor(() => screen.getByTestId('drawer-overview')) + fireEvent.click(screen.getByTestId('customer-drawer-overlay')) + fireEvent.click(screen.getByTestId('drawer-close')) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(3) + }) + + it('switches between tabs', async () => { + renderDrawer() + await waitFor(() => screen.getByTestId('drawer-overview')) + await userEvent.click(screen.getByTestId('drawer-tab-resources')) + expect(screen.getByTestId('drawer-resources-empty')).toBeTruthy() + await userEvent.click(screen.getByTestId('drawer-tab-activity')) + expect(screen.getByTestId('drawer-activity-empty')).toBeTruthy() + await userEvent.click(screen.getByTestId('drawer-tab-promos')) + expect(screen.getByTestId('drawer-promos-empty')).toBeTruthy() + }) + + it('renders populated resources/activity/promos', async () => { + ;(api.getAdminCustomer as any).mockResolvedValue(makeDetail({ + resources: [{ id: 'r1', token: 'tok_1', resource_type: 'postgres', tier: 'pro', status: 'active', name: 'db', env: 'production', storage_bytes: 2048, storage_limit_bytes: 1e9, storage_exceeded: false, expires_at: '2026-07-01T00:00:00Z', created_at: '2026-05-01T00:00:00Z' } as any], + audit_log: [{ id: 'a1', kind: 'provision', summary: 'created db', at: '2026-05-20T00:00:00Z' }], + promos: [ + { id: 'p1', code: 'WELCOME15', kind: 'percent_off', value: 15, applies_to: 3, valid_for_days: 30, expires_at: '2026-06-01T00:00:00Z', created_at: '2026-05-01T00:00:00Z' }, + { id: 'p2', code: 'FREE1', kind: 'first_month_free', value: 0, applies_to: 0, valid_for_days: 30, expires_at: null, created_at: '2026-05-01T00:00:00Z' }, + { id: 'p3', code: 'OFF49', kind: 'amount_off', value: 49, applies_to: 0, valid_for_days: 30, expires_at: null, created_at: '2026-05-01T00:00:00Z' }, + ], + })) + renderDrawer() + await waitFor(() => screen.getByTestId('drawer-overview')) + await userEvent.click(screen.getByTestId('drawer-tab-resources')) + expect(screen.getByTestId('drawer-resource-r1')).toBeTruthy() + await userEvent.click(screen.getByTestId('drawer-tab-activity')) + expect(screen.getByTestId('drawer-activity-row-a1')).toBeTruthy() + await userEvent.click(screen.getByTestId('drawer-tab-promos')) + expect(screen.getByText('WELCOME15')).toBeTruthy() + expect(screen.getByText('15% off · first 3 mo')).toBeTruthy() + expect(screen.getByText('first month free · ongoing')).toBeTruthy() + expect(screen.getByText('$49 off · ongoing')).toBeTruthy() + }) + + it('renders "none" subscription + missing renewal when no subscription', async () => { + ;(api.getAdminCustomer as any).mockResolvedValue(makeDetail({ subscription: null })) + renderDrawer() + await waitFor(() => expect(screen.getByText('none')).toBeTruthy()) + }) + + it('opens the tier-change modal and refetches on success', async () => { + renderDrawer() + await waitFor(() => screen.getByTestId('drawer-overview')) + await userEvent.click(screen.getByTestId('drawer-change-tier')) + expect(screen.getByTestId('tier-change-modal')).toBeTruthy() + await userEvent.click(screen.getByText('fire-changed')) + await waitFor(() => expect(api.getAdminCustomer).toHaveBeenCalledTimes(2)) + await userEvent.click(screen.getByText('close-tier')) + await waitFor(() => expect(screen.queryByTestId('tier-change-modal')).toBeNull()) + }) + + it('opens the issue-promo modal (header + promos tab) and refetches', async () => { + renderDrawer() + await waitFor(() => screen.getByTestId('drawer-overview')) + await userEvent.click(screen.getByTestId('drawer-issue-promo')) + expect(screen.getByTestId('issue-promo-modal')).toBeTruthy() + await userEvent.click(screen.getByText('fire-issued')) + await waitFor(() => expect(api.getAdminCustomer).toHaveBeenCalledTimes(2)) + await userEvent.click(screen.getByText('close-promo')) + await waitFor(() => expect(screen.queryByTestId('issue-promo-modal')).toBeNull()) + // also via the promos-tab "Issue new" button + await userEvent.click(screen.getByTestId('drawer-tab-promos')) + await userEvent.click(screen.getByTestId('drawer-promos-issue-new')) + expect(screen.getByTestId('issue-promo-modal')).toBeTruthy() + }) +}) diff --git a/src/components/IssuePromoModal.test.tsx b/src/components/IssuePromoModal.test.tsx new file mode 100644 index 0000000..af37156 --- /dev/null +++ b/src/components/IssuePromoModal.test.tsx @@ -0,0 +1,115 @@ +/* IssuePromoModal.test.tsx — founder promo-issuance dialog. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api') + return { ...actual, issueAdminCustomerPromo: vi.fn() } +}) + +import * as api from '../api' +import { IssuePromoModal } from './IssuePromoModal' + +function renderModal(over: Partial[0]> = {}) { + const onClose = vi.fn() + const onIssued = vi.fn() + render( + , + ) + return { onClose, onIssued } +} + +let writeText: ReturnType +beforeEach(() => { + vi.clearAllMocks() + writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true }) +}) +afterEach(() => cleanup()) + +describe('IssuePromoModal', () => { + it('renders the form with the value field for percent_off', () => { + renderModal() + expect(screen.getByText('Issue promo to f@acme.dev')).toBeTruthy() + expect(screen.getByTestId('promo-value')).toBeTruthy() + }) + + it('hides the value field for first_month_free', async () => { + renderModal() + await userEvent.selectOptions(screen.getByTestId('promo-kind'), 'first_month_free') + expect(screen.queryByTestId('promo-value')).toBeNull() + }) + + it('closes on Escape, overlay click, and Cancel', async () => { + const { onClose } = renderModal() + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.click(screen.getByTestId('issue-promo-modal')) + await userEvent.click(screen.getByTestId('promo-cancel')) + expect(onClose).toHaveBeenCalledTimes(3) + }) + + it('disables submit on invalid numeric input', async () => { + renderModal() + fireEvent.change(screen.getByTestId('promo-value'), { target: { value: '0' } }) + expect((screen.getByTestId('promo-submit') as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(screen.getByTestId('promo-value'), { target: { value: '15' } }) + fireEvent.change(screen.getByTestId('promo-valid-days'), { target: { value: '0' } }) + expect((screen.getByTestId('promo-submit') as HTMLButtonElement).disabled).toBe(true) + }) + + it('issues a promo and shows the issued code with an expiry date', async () => { + ;(api.issueAdminCustomerPromo as any).mockResolvedValue({ ok: true, code: 'SAVE15', expires_at: '2026-07-01T00:00:00Z' }) + const { onIssued } = renderModal() + await userEvent.click(screen.getByTestId('promo-submit')) + await waitFor(() => expect(screen.getByTestId('promo-issued')).toBeTruthy()) + expect(screen.getByText('SAVE15')).toBeTruthy() + expect(onIssued).toHaveBeenCalled() + expect(api.issueAdminCustomerPromo).toHaveBeenCalledWith('team_1', { + kind: 'percent_off', value: 15, applies_to: 3, valid_for_days: 30, + }) + }) + + it('shows "as configured" when there is no expiry', async () => { + ;(api.issueAdminCustomerPromo as any).mockResolvedValue({ ok: true, code: 'FOREVER', expires_at: null }) + renderModal() + await userEvent.click(screen.getByTestId('promo-submit')) + await waitFor(() => expect(screen.getByText(/as configured/)).toBeTruthy()) + }) + + it('surfaces an issuance error', async () => { + ;(api.issueAdminCustomerPromo as any).mockRejectedValue(new Error('rejected')) + renderModal() + await userEvent.click(screen.getByTestId('promo-submit')) + await waitFor(() => expect(screen.getByTestId('promo-error').textContent).toBe('rejected')) + }) + + it('copies the issued code', async () => { + ;(api.issueAdminCustomerPromo as any).mockResolvedValue({ ok: true, code: 'COPYME', expires_at: null }) + renderModal() + await userEvent.click(screen.getByTestId('promo-submit')) + await waitFor(() => screen.getByTestId('promo-copy')) + await userEvent.click(screen.getByTestId('promo-copy')) + expect(writeText).toHaveBeenCalledWith('COPYME') + await waitFor(() => expect(screen.getByTestId('promo-copy').textContent).toBe('Copied')) + }) + + it('closes from the issued "Done" button', async () => { + ;(api.issueAdminCustomerPromo as any).mockResolvedValue({ ok: true, code: 'X', expires_at: null }) + const { onClose } = renderModal() + await userEvent.click(screen.getByTestId('promo-submit')) + await waitFor(() => screen.getByTestId('promo-done')) + await userEvent.click(screen.getByTestId('promo-done')) + expect(onClose).toHaveBeenCalled() + }) + + it('submits amount_off without a percent label', async () => { + ;(api.issueAdminCustomerPromo as any).mockResolvedValue({ ok: true, code: 'AMT', expires_at: null }) + renderModal() + await userEvent.selectOptions(screen.getByTestId('promo-kind'), 'amount_off') + fireEvent.change(screen.getByTestId('promo-value'), { target: { value: '49' } }) + await userEvent.click(screen.getByTestId('promo-submit')) + await waitFor(() => expect(api.issueAdminCustomerPromo).toHaveBeenCalledWith('team_1', expect.objectContaining({ kind: 'amount_off', value: 49 }))) + }) +}) diff --git a/src/pages/BillingPage.test.tsx b/src/pages/BillingPage.test.tsx index 2b8b64d..88b9cc1 100644 --- a/src/pages/BillingPage.test.tsx +++ b/src/pages/BillingPage.test.tsx @@ -19,7 +19,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { BillingPage } from './BillingPage' +import { BillingPage, formatAsOf } from './BillingPage' import type { BillingDetails, DashboardTeam, Invoice, User } from '../api' // §10.21: test-only data lives here, inlined and minimal. @@ -77,6 +77,7 @@ vi.mock('../api', async () => { createCheckout: vi.fn(), cancelSubscription: vi.fn(), validatePromotion: vi.fn(), + updatePaymentMethod: vi.fn(), } }) @@ -1012,3 +1013,122 @@ describe('BillingPage — Change plan button', () => { }) }) }) + +// ─── checkout error / verify-email gate ───────────────────────────────── +describe('BillingPage — checkout error handling', () => { + it('surfaces a verify-email banner when checkout 403s email_not_verified', async () => { + mockTier = 'hobby' + mockHappyBilling() + ;(api.createCheckout as any).mockRejectedValue({ status: 403, code: 'email_not_verified' }) + const user = userEvent.setup() + render() + await waitForLoaded() + await user.click(screen.getByTestId('upgrade-button')) + await waitFor(() => expect(screen.getByTestId('verify-email-banner')).toBeTruthy()) + expect(screen.queryByTestId('checkout-error')).toBeNull() + }) + + it('shows "checkout returned no url" when short_url is missing', async () => { + mockTier = 'hobby' + mockHappyBilling() + ;(api.createCheckout as any).mockResolvedValue({ ok: true }) + const user = userEvent.setup() + render() + await waitForLoaded() + await user.click(screen.getByTestId('upgrade-button')) + await waitFor(() => expect(screen.getByTestId('checkout-error').textContent).toContain('no url')) + }) + + it('shows a generic checkout error on a non-email failure', async () => { + mockTier = 'hobby' + mockHappyBilling() + ;(api.createCheckout as any).mockRejectedValue(new Error('rzp down')) + const user = userEvent.setup() + render() + await waitForLoaded() + await user.click(screen.getByTestId('upgrade-button')) + await waitFor(() => expect(screen.getByTestId('checkout-error').textContent).toContain('rzp down')) + }) +}) + +// ─── UpdatePaymentButton ──────────────────────────────────────────────── +describe('BillingPage — UpdatePaymentButton', () => { + it('redirects to the Razorpay short_url on success', async () => { + mockTier = 'pro' + mockHappyBilling() + ;(api.updatePaymentMethod as any).mockResolvedValue({ short_url: 'https://rzp.io/update' }) + const user = userEvent.setup() + render() + await waitForLoaded() + await user.click(screen.getByTestId('contact-support-update-payment')) + await waitFor(() => expect(hrefSetTo).toBe('https://rzp.io/update')) + }) + + it('falls back to a support mailto when the api errors', async () => { + mockTier = 'pro' + mockHappyBilling() + ;(api.updatePaymentMethod as any).mockRejectedValue(new Error('nope')) + const user = userEvent.setup() + render() + await waitForLoaded() + await user.click(screen.getByTestId('contact-support-update-payment')) + await waitFor(() => { + const link = screen.getByTestId('contact-support-update-payment') as HTMLAnchorElement + expect(link.href.toLowerCase()).toContain('mailto:') + }) + }) + + it('falls back to mailto when the response has no short_url', async () => { + mockTier = 'pro' + mockHappyBilling() + ;(api.updatePaymentMethod as any).mockResolvedValue({}) + const user = userEvent.setup() + render() + await waitForLoaded() + await user.click(screen.getByTestId('contact-support-update-payment')) + await waitFor(() => { + const link = screen.getByTestId('contact-support-update-payment') as HTMLAnchorElement + expect(link.href.toLowerCase()).toContain('mailto:') + }) + }) +}) + +// ─── invoice period rendering ─────────────────────────────────────────── +describe('BillingPage — invoice period column', () => { + it('renders a period range when period_start/end + plan + pdf are present', async () => { + mockTier = 'pro' + mockHappyBilling() + ;(api.listInvoices as any).mockResolvedValue({ + ok: true, + invoices: [ + { + id: 'inv_period', + issued_at: '2026-05-22T00:00:00Z', + period_start: '2026-05-01T00:00:00Z', + period_end: '2026-05-31T00:00:00Z', + plan: 'pro', + amount_cents: 4900, + currency: 'USD', + status: 'paid', + pdf_url: 'https://example.com/inv.pdf', + } as any, + ], + }) + const { container } = render() + await waitForLoaded() + await waitFor(() => expect(container.textContent).toContain('→')) + expect(container.querySelector('a.dl')).toBeTruthy() + }) +}) + +describe('formatAsOf', () => { + it('handles invalid, just-now, seconds, minutes, hours, and skew', () => { + expect(formatAsOf('not-a-date')).toBe('unknown') + const now = Date.now() + expect(formatAsOf(new Date(now).toISOString())).toBe('just now') + expect(formatAsOf(new Date(now - 5_000).toISOString())).toMatch(/\ds ago/) + expect(formatAsOf(new Date(now - 5 * 60_000).toISOString())).toMatch(/\dm ago/) + expect(formatAsOf(new Date(now - 3 * 3_600_000).toISOString())).toMatch(/\dh ago/) + expect(formatAsOf(new Date(now + 60_000).toISOString())).toBe('just now') + }) +}) diff --git a/src/pages/BlogPage.test.tsx b/src/pages/BlogPage.test.tsx new file mode 100644 index 0000000..589225d --- /dev/null +++ b/src/pages/BlogPage.test.tsx @@ -0,0 +1,60 @@ +/* BlogPage.test.tsx — coverage for the public /blog index. */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { BlogPage } from './BlogPage' +import { POSTS } from '../content/posts' + +let originalTitle = '' +beforeEach(() => { + originalTitle = document.title + document.title = 'instanode · home' +}) +afterEach(() => { + document.title = originalTitle + cleanup() +}) + +function renderPage() { + return render( + + + , + ) +} + +describe('BlogPage', () => { + it('renders the hero heading', () => { + renderPage() + expect(screen.getByRole('heading', { level: 1, name: 'Blog' })).toBeTruthy() + }) + + it('renders one card per post with a link to /blog/:slug', () => { + renderPage() + const list = screen.getByRole('list', { name: 'Posts' }) + expect(list).toBeTruthy() + // Every post slug should appear as a card link. + for (const p of POSTS) { + const link = document.querySelector(`a[href="/blog/${p.slug}"]`) + expect(link).toBeTruthy() + } + }) + + it('formats post dates as human-readable (Month Day, Year)', () => { + renderPage() + const times = document.querySelectorAll('time.blog-card-date') + expect(times.length).toBe(POSTS.length) + // formatDate emits e.g. "May 22, 2026" — assert at least one matches. + if (times.length > 0) { + expect(times[0].textContent).toMatch(/[A-Z][a-z]{2} \d{1,2}, \d{4}/) + } + }) + + it('sets document.title on mount and restores on unmount', () => { + const { unmount } = renderPage() + expect(document.title).toBe('Blog · instanode') + unmount() + expect(document.title).toBe('instanode · home') + }) +}) diff --git a/src/pages/CheckoutPage.test.tsx b/src/pages/CheckoutPage.test.tsx new file mode 100644 index 0000000..898e33f --- /dev/null +++ b/src/pages/CheckoutPage.test.tsx @@ -0,0 +1,90 @@ +/* CheckoutPage.test.tsx — /app/checkout Razorpay session bootstrap. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, cleanup } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { CheckoutPage } from './CheckoutPage' + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api') + return { ...actual, createCheckout: vi.fn() } +}) + +vi.mock('../hooks/useDashboardCtx', () => ({ + useDashboardCtx: () => ({ me: { user: { email: 'founder@acme.dev' } } }), +})) + +import * as api from '../api' + +function renderAt(search: string) { + return render( + + + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() + delete (window as any).location + ;(window as any).location = { assign: vi.fn(), origin: 'http://localhost' } +}) +afterEach(() => cleanup()) + +describe('CheckoutPage', () => { + it('rejects a missing plan param', async () => { + ;(api.createCheckout as any).mockReturnValue(new Promise(() => {})) + renderAt('') + await waitFor(() => expect(screen.getByTestId('checkout-invalid')).toBeTruthy()) + expect(screen.getByTestId('checkout-invalid').textContent).toContain('Missing required') + expect(api.createCheckout).not.toHaveBeenCalled() + }) + + it('rejects an unknown plan', async () => { + renderAt('?plan=enterprise') + await waitFor(() => expect(screen.getByTestId('checkout-invalid').textContent).toContain('Unknown plan')) + }) + + it('rejects an unknown frequency', async () => { + renderAt('?plan=pro&frequency=weekly') + await waitFor(() => expect(screen.getByTestId('checkout-invalid').textContent).toContain('Unknown frequency')) + }) + + it('redirects to the Razorpay short_url on success', async () => { + ;(api.createCheckout as any).mockResolvedValue({ short_url: 'https://rzp.io/x' }) + renderAt('?plan=pro&frequency=monthly') + await waitFor(() => expect(screen.getByTestId('checkout-redirecting')).toBeTruthy()) + expect((window as any).location.assign).toHaveBeenCalledWith('https://rzp.io/x') + expect(api.createCheckout).toHaveBeenCalledWith('pro', 'monthly') + }) + + it('defaults frequency to monthly when omitted', async () => { + ;(api.createCheckout as any).mockResolvedValue({ short_url: 'https://rzp.io/y' }) + renderAt('?plan=hobby') + await waitFor(() => expect(api.createCheckout).toHaveBeenCalledWith('hobby', 'monthly')) + }) + + it('errors when the response is missing short_url', async () => { + ;(api.createCheckout as any).mockResolvedValue({}) + renderAt('?plan=pro') + await waitFor(() => expect(screen.getByTestId('checkout-error').textContent).toContain('missing short_url')) + }) + + it('renders the fallback panel on 503 billing_not_configured', async () => { + ;(api.createCheckout as any).mockRejectedValue({ status: 503, code: 'billing_not_configured' }) + renderAt('?plan=pro') + await waitFor(() => expect(screen.getByTestId('checkout-fallback')).toBeTruthy()) + }) + + it('renders the verify-email banner on a 403 email_not_verified error', async () => { + ;(api.createCheckout as any).mockRejectedValue({ status: 403, code: 'email_not_verified' }) + renderAt('?plan=pro') + await waitFor(() => expect(screen.getByTestId('checkout-email-not-verified')).toBeTruthy()) + }) + + it('surfaces a generic error otherwise', async () => { + ;(api.createCheckout as any).mockRejectedValue({ message: 'boom' }) + renderAt('?plan=team') + await waitFor(() => expect(screen.getByTestId('checkout-error').textContent).toContain('boom')) + }) +}) diff --git a/src/pages/ContractsPage.test.tsx b/src/pages/ContractsPage.test.tsx new file mode 100644 index 0000000..09a1792 --- /dev/null +++ b/src/pages/ContractsPage.test.tsx @@ -0,0 +1,44 @@ +/* ContractsPage.test.tsx — static API-contract inventory page. */ + +import { describe, it, expect, afterEach } from 'vitest' +import { render, screen, cleanup } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { ContractsPage } from './ContractsPage' + +function renderPage() { + return render( + + + , + ) +} + +afterEach(() => cleanup()) + +describe('ContractsPage', () => { + it('renders the page heading', () => { + renderPage() + expect(screen.getByText('API contracts & gaps')).toBeTruthy() + }) + + it('renders the four summary stats', () => { + renderPage() + expect(screen.getByText('locked')).toBeTruthy() + expect(screen.getByText('blocked')).toBeTruthy() + expect(screen.getByText('needs lock')).toBeTruthy() + expect(screen.getByText('delegated')).toBeTruthy() + }) + + it('lists representative endpoint contract lines', () => { + renderPage() + expect(screen.getByText('/api/v1/resources')).toBeTruthy() + expect(screen.getByText('/api/v1/billing/checkout')).toBeTruthy() + }) + + it('surfaces the agent-api delegated section', () => { + renderPage() + expect( + screen.getByText(/Anonymous calls, claim, healthz live on/i), + ).toBeTruthy() + }) +}) diff --git a/src/pages/DeployDetailPage.test.tsx b/src/pages/DeployDetailPage.test.tsx index a6f6470..590797c 100644 --- a/src/pages/DeployDetailPage.test.tsx +++ b/src/pages/DeployDetailPage.test.tsx @@ -72,6 +72,8 @@ vi.mock('../api', async () => { // Mocked here so the audit-tab tests can drive load/empty states // without firing a real network call. fetchResourceAudit: vi.fn(), + // Stack-view chrome renders CustomDomainPanel for Pro+ tiers. + listCustomDomains: vi.fn(), } }) @@ -97,6 +99,7 @@ beforeEach(() => { mockFetchFamily.mockResolvedValue({ ok: false, reason: 'unknown' }) mockListStacks.mockResolvedValue({ ok: true, items: [], total: 0 }) mockListResources.mockResolvedValue({ ok: true, items: [], total: 0 }) + ;(api.listCustomDomains as any).mockResolvedValue([]) }) afterEach(() => { cleanup() @@ -903,3 +906,71 @@ describe('DeployDetailPage — Failure Autopsy panel (Phase 0)', () => { expect(container.querySelector('[data-testid="failure-autopsy-pending"]')).toBeNull() }) }) + +// ─── stack-kind view chrome + tabs ────────────────────────────────────── +describe('DeployDetailPage — stack-kind view', () => { + function stackItem(over: Record = {}) { + return { + id: 'stk-1', + slug: 'demo-stack', + name: 'demo', + status: 'running', + url: 'https://demo.deployment.instanode.dev', + created_at: 'x', + team_id: '', + env: 'production', + tier: 'pro', + ...over, + } + } + + function mountStack(over: Record = {}) { + mockGetDeployment.mockResolvedValueOnce({ ok: true, deployment: null }) + mockListStacks.mockResolvedValueOnce({ ok: true, total: 1, items: [stackItem(over) as any] }) + return renderPage('/deployments/stk-1') + } + + it('renders the stack chrome with the custom-domain panel for Pro+ tier', async () => { + mountStack() + await waitFor(() => expect(screen.getByTestId('deploy-detail-name')).toBeTruthy()) + // CustomDomainPanel mounts and lists domains for stack views. + await waitFor(() => expect(api.listCustomDomains).toHaveBeenCalledWith('demo-stack')) + }) + + it('renders the env-vars stack hint on the Env vars tab', async () => { + const { getByText } = mountStack() + await waitFor(() => expect(screen.getByTestId('deploy-detail-name')).toBeTruthy()) + fireEvent.click(getByText('Env vars')) + await waitFor(() => expect(screen.getByTestId('env-vars-stack-hint')).toBeTruthy()) + }) + + it('renders the bound-resources stack hint on the Resources tab', async () => { + const { getByText } = mountStack() + await waitFor(() => expect(screen.getByTestId('deploy-detail-name')).toBeTruthy()) + fireEvent.click(getByText('Resources')) + await waitFor(() => expect(screen.getByTestId('bound-resources-stack-hint')).toBeTruthy()) + }) + + it('subscribes to the legacy stack log SSE path on the Logs tab', async () => { + const { getByText } = mountStack() + await waitFor(() => expect(screen.getByTestId('deploy-detail-name')).toBeTruthy()) + fireEvent.click(getByText('Logs')) + await waitFor(() => expect(sseCalls.some((p) => p.includes('/stacks/demo-stack/logs'))).toBe(true)) + }) +}) + +// ─── EnvironmentsGrid PromoteUpsell (hobby tier) ──────────────────────── +describe('DeployDetailPage — Environments tier gate', () => { + it('renders the Promote upsell card when fetchStackFamily returns upgrade_required', async () => { + mockGetDeployment.mockResolvedValueOnce({ ok: true, deployment: null }) + mockListStacks.mockResolvedValueOnce({ + ok: true, total: 1, + items: [{ id: 'stk-up', slug: 'demo-up', name: 'demo', status: 'running', url: null, created_at: 'x', team_id: '', env: 'production', tier: 'pro' } as any], + }) + mockFetchFamily.mockResolvedValue({ ok: false, reason: 'upgrade_required' }) + renderPage('/deployments/stk-up') + await waitFor(() => expect(screen.getByTestId('deploy-detail-name')).toBeTruthy()) + // PromoteUpsell renders an UpgradePromptCard for the family_bindings feature. + await waitFor(() => expect(mockFetchFamily).toHaveBeenCalled()) + }) +}) diff --git a/src/pages/DeploymentsPage.test.tsx b/src/pages/DeploymentsPage.test.tsx index 457577c..e8e9fb7 100644 --- a/src/pages/DeploymentsPage.test.tsx +++ b/src/pages/DeploymentsPage.test.tsx @@ -273,3 +273,58 @@ describe('DeploymentsPage — private deploy section, tier-gated', () => { expect(screen.queryByTestId('private-deploy-upsell')).toBeNull() }) }) + +describe('DeploymentsPage — filter + sort', () => { + const items = [ + dep({ id: 'r1', app_id: 'zeta', name: 'zeta', status: 'running' }), + dep({ id: 'b1', app_id: 'alpha', name: 'alpha', status: 'building' }), + dep({ id: 'f1', app_id: 'mid', name: 'mid', status: 'failed' }), + dep({ id: 'e1', app_id: 'old', name: 'old', status: 'expired', last_deploy_at: undefined }), + dep({ id: 's1', app_id: 'stp', name: 'stp', status: 'stopped', last_deploy_at: undefined }), + ] + + it('filters by status bucket (failed)', async () => { + mockListDeployments.mockResolvedValue({ ok: true, items, total: items.length }) + render(withRouter()) + await waitFor(() => screen.getByTestId('deployment-row-name-f1')) + fireEvent.click(screen.getByTestId('status-filter-failed')) + await waitFor(() => expect(screen.getByTestId('deployment-row-name-f1')).toBeTruthy()) + expect(screen.queryByTestId('deployment-row-name-r1')).toBeNull() + }) + + it('buckets stopped + expired under the "expired" filter', async () => { + mockListDeployments.mockResolvedValue({ ok: true, items, total: items.length }) + render(withRouter()) + await waitFor(() => screen.getByTestId('deployment-row-name-e1')) + fireEvent.click(screen.getByTestId('status-filter-expired')) + await waitFor(() => expect(screen.getByTestId('deployment-row-name-e1')).toBeTruthy()) + expect(screen.getByTestId('deployment-row-name-s1')).toBeTruthy() + expect(screen.queryByTestId('deployment-row-name-b1')).toBeNull() + }) + + it('shows the no-match empty state when the filter matches nothing', async () => { + mockListDeployments.mockResolvedValue({ + ok: true, total: 1, + items: [dep({ id: 'only', app_id: 'only', name: 'only', status: 'running' })], + }) + render(withRouter()) + await waitFor(() => screen.getByTestId('deployment-row-name-only')) + fireEvent.click(screen.getByTestId('status-filter-failed')) + await waitFor(() => expect(screen.getByTestId('deployments-no-match')).toBeTruthy()) + }) + + it('sorts by name and by status', async () => { + mockListDeployments.mockResolvedValue({ ok: true, items, total: items.length }) + const { container } = render(withRouter()) + await waitFor(() => screen.getByTestId('deployment-row-name-b1')) + + fireEvent.change(screen.getByTestId('deployments-sort'), { target: { value: 'name' } }) + const names = Array.from(container.querySelectorAll('[data-testid^="deployment-row-name-"]')) + .map((n) => n.textContent ?? '') + expect(names[0]).toContain('alpha') + + fireEvent.change(screen.getByTestId('deployments-sort'), { target: { value: 'status' } }) + const statusSorted = Array.from(container.querySelectorAll('[data-testid^="deployment-row-name-"]')) + expect(statusSorted.length).toBe(items.length) + }) +}) diff --git a/src/pages/DocsPage.test.tsx b/src/pages/DocsPage.test.tsx new file mode 100644 index 0000000..83bc32d --- /dev/null +++ b/src/pages/DocsPage.test.tsx @@ -0,0 +1,90 @@ +/* DocsPage.test.tsx — docs index: search, sidebar toggle, `/` shortcut. */ + +import { describe, it, expect, afterEach } from 'vitest' +import { render, screen, fireEvent, cleanup } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { DocsPage } from './DocsPage' + +function renderPage() { + return render( + + + , + ) +} + +afterEach(() => cleanup()) + +describe('DocsPage', () => { + it('renders the hero and the openapi reference link', () => { + renderPage() + expect(screen.getByRole('heading', { level: 1, name: 'Documentation' })).toBeTruthy() + const ref = document.querySelector('a[href="https://api.instanode.dev/openapi.json"]') + expect(ref).toBeTruthy() + }) + + it('renders the default section TOC list (one anchor per loaded section)', () => { + renderPage() + // The docs corpus is glob-loaded from .content/docs/*.md, which is only + // populated by the fetch-content prebuild step. In a bare `vitest run` + // (no build) the corpus may be empty — assert the TOC
    exists and + // its link count matches whatever SECTIONS resolved to. + const tocList = document.querySelector('.docs-toc ol') + expect(tocList).toBeTruthy() + const tocLinks = document.querySelectorAll('.docs-toc ol li a') + expect(tocLinks.length).toBeGreaterThanOrEqual(0) + }) + + it('toggles the sidebar open and closed', () => { + renderPage() + const toggle = document.querySelector('button.docs-sidebar-toggle') as HTMLButtonElement + expect(toggle).toBeTruthy() + expect(toggle.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + }) + + it('filters sections via the search box', () => { + renderPage() + const input = screen.getByLabelText('Search documentation') as HTMLInputElement + fireEvent.change(input, { target: { value: 'zzzznotathing' } }) + // Garbage query yields the empty-results branch. + expect(screen.getByText('No matches.')).toBeTruthy() + }) + + it('renders a results list for a matching query', () => { + renderPage() + const input = screen.getByLabelText('Search documentation') as HTMLInputElement + // Search for a likely-present token; if no docs match we still exercise + // the results branch (empty or populated). + fireEvent.change(input, { target: { value: 'a' } }) + const resultsList = document.querySelector('.docs-toc-results') + expect(resultsList).toBeTruthy() + }) + + it('focuses the search input when "/" is pressed outside an input', () => { + renderPage() + const input = screen.getByLabelText('Search documentation') as HTMLInputElement + input.blur() + fireEvent.keyDown(window, { key: '/' }) + expect(document.activeElement).toBe(input) + }) + + it('does not hijack "/" while typing in an input', () => { + renderPage() + const input = screen.getByLabelText('Search documentation') as HTMLInputElement + fireEvent.keyDown(input, { key: '/', target: input }) + // No crash; input still present. + expect(input).toBeTruthy() + }) + + it('ignores "/" with a modifier key', () => { + renderPage() + const input = screen.getByLabelText('Search documentation') as HTMLInputElement + input.blur() + fireEvent.keyDown(window, { key: '/', metaKey: true }) + expect(document.activeElement).not.toBe(input) + }) +}) diff --git a/src/pages/ForAgentsPage.test.tsx b/src/pages/ForAgentsPage.test.tsx new file mode 100644 index 0000000..2f74822 --- /dev/null +++ b/src/pages/ForAgentsPage.test.tsx @@ -0,0 +1,83 @@ +/* ForAgentsPage.test.tsx — agent integration page: cards, copy, highlighters. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' + +const copyMock = vi.fn() +vi.mock('../components/Common', async () => { + const actual = await vi.importActual('../components/Common') + return { ...actual, copyToClipboard: (...args: any[]) => copyMock(...args) } +}) + +import { ForAgentsPage } from './ForAgentsPage' + +function renderPage() { + return render( + + + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() +}) +afterEach(() => cleanup()) + +describe('ForAgentsPage', () => { + it('renders the hero and three integration cards', () => { + renderPage() + expect(screen.getByRole('heading', { level: 1 }).textContent).toContain('Built for agents') + expect(screen.getByText('Claude Code')).toBeTruthy() + expect(screen.getByText('Cursor')).toBeTruthy() + expect(screen.getByText('MCP server config')).toBeTruthy() + }) + + it('renders the openapi CTA link', () => { + renderPage() + const cta = document.querySelector('a.fa-final-cta[href="https://api.instanode.dev/openapi.json"]') + expect(cta).toBeTruthy() + }) + + it('copies the command and flips the button label to "copied"', async () => { + copyMock.mockResolvedValue(true) + renderPage() + const btn = screen.getByLabelText('Copy Claude Code command') + expect(btn.textContent).toBe('copy') + await userEvent.click(btn) + expect(copyMock).toHaveBeenCalled() + await waitFor(() => expect(btn.textContent).toBe('copied')) + }) + + it('leaves the label as "copy" when clipboard is unavailable', async () => { + copyMock.mockResolvedValue(false) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + renderPage() + const btn = screen.getByLabelText('Copy Cursor command') + await userEvent.click(btn) + expect(btn.textContent).toBe('copy') + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('reverts "copied" back to "copy" after the timeout', async () => { + copyMock.mockResolvedValue(true) + renderPage() + const btn = screen.getByLabelText('Copy Claude Code command') + await userEvent.click(btn) + await waitFor(() => expect(btn.textContent).toBe('copied')) + // setTimeout(1600) reverts the label. + await waitFor(() => expect(btn.textContent).toBe('copy'), { timeout: 3000 }) + }) + + it('syntax-highlights shell and JSON commands (renders code spans)', () => { + renderPage() + // JSON highlighter should produce key spans for the MCP config card. + expect(document.querySelector('.c-key')).toBeTruthy() + // Shell highlighter should produce flag/str spans for the curl playground. + expect(document.querySelector('.c-flag, .c-str')).toBeTruthy() + }) +}) diff --git a/src/pages/IncidentsPage.test.tsx b/src/pages/IncidentsPage.test.tsx new file mode 100644 index 0000000..b22d633 --- /dev/null +++ b/src/pages/IncidentsPage.test.tsx @@ -0,0 +1,108 @@ +/* IncidentsPage.test.tsx — public incident log: loading/empty/active/resolved. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, cleanup } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { IncidentsPage, fetchIncidents, type Incident } from './IncidentsPage' + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api') + return { ...actual, getAPIBaseURL: vi.fn(() => '') } +}) + +function renderPage() { + return render( + + + , + ) +} + +const ACTIVE: Incident = { + id: 'inc_1', title: 'Postgres provisioning slow', severity: 'major', + state: 'investigating', started_at: '2026-05-22T00:00:00Z', resolved_at: null, + summary: 'New /db/new calls are queueing.', +} +const RESOLVED: Incident = { + id: 'inc_2', title: 'Redis latency spike', severity: 'minor', + state: 'resolved', started_at: '2026-05-20T00:00:00Z', resolved_at: '2026-05-20T01:00:00Z', + summary: 'Resolved after node restart.', +} + +beforeEach(() => { + vi.clearAllMocks() +}) +afterEach(() => cleanup()) + +describe('IncidentsPage', () => { + it('shows a loading state before data resolves', () => { + ;(globalThis as any).fetch = vi.fn(() => new Promise(() => {})) + renderPage() + expect(screen.getByTestId('incidents-loading')).toBeTruthy() + }) + + it('shows the empty state when no incidents exist', async () => { + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, json: async () => ({ ok: true, items: [] }), + }) + renderPage() + await waitFor(() => expect(screen.getByTestId('incidents-empty')).toBeTruthy()) + }) + + it('renders active and resolved incident sections', async () => { + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, json: async () => ({ ok: true, items: [ACTIVE, RESOLVED] }), + }) + renderPage() + await waitFor(() => expect(screen.getByTestId('incidents-active')).toBeTruthy()) + expect(screen.getByTestId('incidents-resolved')).toBeTruthy() + expect(screen.getByText('Postgres provisioning slow')).toBeTruthy() + expect(screen.getByText('Redis latency spike')).toBeTruthy() + }) + + it('renders only active section when nothing is resolved', async () => { + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, json: async () => ({ ok: true, items: [ACTIVE] }), + }) + renderPage() + await waitFor(() => expect(screen.getByTestId('incidents-active')).toBeTruthy()) + expect(screen.queryByTestId('incidents-resolved')).toBeNull() + }) +}) + +describe('fetchIncidents', () => { + beforeEach(() => { vi.clearAllMocks() }) + + it('returns [] on a non-ok response', async () => { + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ ok: false }) + expect(await fetchIncidents()).toEqual([]) + }) + + it('returns [] on a fetch throw', async () => { + ;(globalThis as any).fetch = vi.fn().mockRejectedValue(new Error('net')) + expect(await fetchIncidents()).toEqual([]) + }) + + it('returns [] when body has no items array', async () => { + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, json: async () => ({ ok: true }), + }) + expect(await fetchIncidents()).toEqual([]) + }) + + it('returns items when the body is well-formed', async () => { + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, json: async () => ({ ok: true, items: [ACTIVE] }), + }) + const out = await fetchIncidents() + expect(out).toHaveLength(1) + expect(out[0].id).toBe('inc_1') + }) + + it('returns [] when json parsing throws', async () => { + ;(globalThis as any).fetch = vi.fn().mockResolvedValue({ + ok: true, json: async () => { throw new Error('bad json') }, + }) + expect(await fetchIncidents()).toEqual([]) + }) +}) diff --git a/src/pages/LoginCallbackPage.test.tsx b/src/pages/LoginCallbackPage.test.tsx new file mode 100644 index 0000000..19d86af --- /dev/null +++ b/src/pages/LoginCallbackPage.test.tsx @@ -0,0 +1,80 @@ +/* LoginCallbackPage.test.tsx — OAuth/magic-link session-token callback. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, cleanup } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { LoginCallbackPage } from './LoginCallbackPage' + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api') + return { + ...actual, + setToken: vi.fn(), + fetchMe: vi.fn(), + } +}) + +import * as api from '../api' + +function renderAt(url: string) { + return render( + + + } /> + APP HOME} /> + TEAM PAGE} /> + + , + ) +} + +beforeEach(() => { + vi.clearAllMocks() + try { localStorage.clear() } catch {} +}) +afterEach(() => cleanup()) + +describe('LoginCallbackPage', () => { + it('shows the verifying state initially', () => { + ;(api.fetchMe as any).mockReturnValue(new Promise(() => {})) + renderAt('/login/callback?session_token=tok_abc') + expect(screen.getByText('Signing you in.')).toBeTruthy() + expect(api.setToken).toHaveBeenCalledWith('tok_abc') + }) + + it('errors when no session_token is present', () => { + renderAt('/login/callback') + expect(screen.getByText('Sign-in failed.')).toBeTruthy() + expect(screen.getByText('No session token in callback URL.')).toBeTruthy() + const retry = document.querySelector('a[href="/login"]') + expect(retry).toBeTruthy() + }) + + it('navigates to /app on successful verification', async () => { + ;(api.fetchMe as any).mockResolvedValue({ ok: true }) + renderAt('/login/callback?session_token=tok_ok') + await waitFor(() => expect(screen.getByText('APP HOME')).toBeTruthy()) + }) + + it('honors a stored /app return_to destination', async () => { + localStorage.setItem('instanode.return_to', '/app/team') + ;(api.fetchMe as any).mockResolvedValue({ ok: true }) + renderAt('/login/callback?session_token=tok_ok') + await waitFor(() => expect(screen.getByText('TEAM PAGE')).toBeTruthy()) + expect(localStorage.getItem('instanode.return_to')).toBeNull() + }) + + it('ignores a non-/app return_to and defaults to /app', async () => { + localStorage.setItem('instanode.return_to', 'https://evil.example/phish') + ;(api.fetchMe as any).mockResolvedValue({ ok: true }) + renderAt('/login/callback?session_token=tok_ok') + await waitFor(() => expect(screen.getByText('APP HOME')).toBeTruthy()) + }) + + it('surfaces a rejected token as an error', async () => { + ;(api.fetchMe as any).mockRejectedValue(new Error('token bad')) + renderAt('/login/callback?session_token=tok_bad') + await waitFor(() => expect(screen.getByText('Sign-in failed.')).toBeTruthy()) + expect(screen.getByText('token bad')).toBeTruthy() + }) +}) diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..c9bc2f5 --- /dev/null +++ b/src/pages/LoginPage.test.tsx @@ -0,0 +1,131 @@ +/* LoginPage.test.tsx — sign-in surface: GitHub OAuth, magic-link, PAT. */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { LoginPage } from './LoginPage' + +vi.mock('../api', async () => { + const actual = await vi.importActual('../api') + return { + ...actual, + setToken: vi.fn(), + clearToken: vi.fn(), + fetchMe: vi.fn(), + } +}) + +import * as api from '../api' + +function renderAt(url = '/login') { + return render( + + + } /> + APP HOME} /> + + , + ) +} + +const originalHref = window.location.href + +beforeEach(() => { + vi.clearAllMocks() + // stub fetch for magic-link + ;(globalThis as any).fetch = vi.fn() + // stub location.href setter + delete (window as any).location + ;(window as any).location = { href: originalHref, origin: 'http://localhost', search: '' } +}) +afterEach(() => { + cleanup() +}) + +describe('LoginPage', () => { + it('renders the GitHub OAuth button and triggers redirect', async () => { + renderAt() + const btn = screen.getByTestId('oauth-github') + await userEvent.click(btn) + expect(window.location.href).toContain('/auth/github/start?return_to=') + }) + + it('shows session-expired banner when ?session_expired=1', () => { + ;(window as any).location.search = '?session_expired=1' + renderAt() + expect(screen.getByTestId('session-expired-banner')).toBeTruthy() + }) + + it('validates email before sending magic link', async () => { + renderAt() + const input = screen.getByTestId('email-input') as HTMLInputElement + fireEvent.change(input, { target: { value: 'notanemail' } }) + fireEvent.submit(input.closest('form')!) + await waitFor(() => expect(screen.getByTestId('email-error').textContent).toContain('valid email')) + expect((globalThis as any).fetch).not.toHaveBeenCalled() + }) + + it('sends a magic link and shows the sent confirmation', async () => { + ;(globalThis as any).fetch.mockResolvedValue({ status: 202 }) + renderAt() + await userEvent.type(screen.getByTestId('email-input'), 'founder@acme.dev') + await userEvent.click(screen.getByTestId('email-submit')) + await waitFor(() => expect(screen.getByTestId('magic-link-sent')).toBeTruthy()) + expect(screen.getByTestId('magic-link-sent').textContent).toContain('founder@acme.dev') + }) + + it('surfaces a magic-link API error', async () => { + ;(globalThis as any).fetch.mockResolvedValue({ + status: 500, + json: async () => ({ message: 'server boom' }), + }) + renderAt() + await userEvent.type(screen.getByTestId('email-input'), 'founder@acme.dev') + await userEvent.click(screen.getByTestId('email-submit')) + await waitFor(() => expect(screen.getByTestId('email-error').textContent).toContain('server boom')) + }) + + it('toggles the PAT token form', async () => { + renderAt() + expect(screen.queryByTestId('token-input')).toBeNull() + await userEvent.click(screen.getByTestId('toggle-token-form')) + expect(screen.getByTestId('token-input')).toBeTruthy() + }) + + it('requires a token before submitting the PAT form', async () => { + renderAt() + await userEvent.click(screen.getByTestId('toggle-token-form')) + await userEvent.click(screen.getByTestId('login-submit')) + expect(screen.getByTestId('login-error').textContent).toContain('Paste a Personal Access Token') + }) + + it('logs in with a valid PAT and navigates to /app', async () => { + ;(api.fetchMe as any).mockResolvedValue({ ok: true }) + renderAt() + await userEvent.click(screen.getByTestId('toggle-token-form')) + await userEvent.type(screen.getByTestId('token-input'), 'ink_secret') + await userEvent.click(screen.getByTestId('login-submit')) + await waitFor(() => expect(screen.getByText('APP HOME')).toBeTruthy()) + expect(api.setToken).toHaveBeenCalledWith('ink_secret') + }) + + it('shows a 401 rejection message and clears the token', async () => { + ;(api.fetchMe as any).mockRejectedValue({ status: 401 }) + renderAt() + await userEvent.click(screen.getByTestId('toggle-token-form')) + await userEvent.type(screen.getByTestId('token-input'), 'ink_bad') + await userEvent.click(screen.getByTestId('login-submit')) + await waitFor(() => expect(screen.getByTestId('login-error').textContent).toContain('Token rejected')) + expect(api.clearToken).toHaveBeenCalled() + }) + + it('shows a generic error for non-401 failures', async () => { + ;(api.fetchMe as any).mockRejectedValue({ message: 'network down' }) + renderAt() + await userEvent.click(screen.getByTestId('toggle-token-form')) + await userEvent.type(screen.getByTestId('token-input'), 'ink_x') + await userEvent.click(screen.getByTestId('login-submit')) + await waitFor(() => expect(screen.getByTestId('login-error').textContent).toContain('network down')) + }) +})