diff --git a/internal/cloudcp/portal/frontend/src/services_view.test.ts b/internal/cloudcp/portal/frontend/src/services_view.test.ts new file mode 100644 index 000000000..673f2728f --- /dev/null +++ b/internal/cloudcp/portal/frontend/src/services_view.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + renderDeletePanel, + renderExportPanel, + renderExportResult, + renderManagePanel, + renderOpenPanels, + renderRefundPanel, + renderRetrievePanel, + renderStatus, +} from './services_view'; +import type { PortalBootstrapData, RefundState, ServiceStatus, VerificationFlowState } from './types'; + +function createBootstrap(overrides: Partial = {}): PortalBootstrapData { + return { + authenticated: true, + email: 'owner@example.com', + public_site_url: 'https://pulserelay.pro', + support_email: 'support@pulserelay.pro', + commercial_api_base_url: 'https://license.pulserelay.pro', + portal_path: '/portal', + bootstrap_path: '/api/portal/bootstrap', + magic_link_request_path: '/auth/magic-link', + signup_path: '/signup', + logout_path: '/auth/logout', + account_api_base_path: '/api/accounts', + portal_api_base_path: '/api/portal', + accounts: [], + ...overrides, + }; +} + +function createFlowState(overrides: Partial = {}): VerificationFlowState { + return { + pendingEmail: '', + requesting: false, + confirming: false, + step2Visible: false, + status: { + visible: false, + message: '', + error: false, + }, + result: null, + emailValue: '', + codeValue: '', + checkboxChecked: false, + ...overrides, + }; +} + +function createRefundState(overrides: Partial = {}): RefundState { + return { + emailValue: '', + tokenValue: '', + submitting: false, + status: { + visible: false, + message: '', + error: false, + }, + ...overrides, + }; +} + +describe('services view', function() { + beforeEach(function() { + document.body.innerHTML = ''; + }); + + it('toggles the visible service panel by id', function() { + document.body.innerHTML = + '
' + + '
' + + '
' + + '
'; + + renderOpenPanels('retrieve-service-panel'); + + expect(document.getElementById('retrieve-service-panel')?.classList.contains('visible')).toBe(true); + expect(document.getElementById('manage-service-panel')?.classList.contains('visible')).toBe(false); + expect(document.getElementById('refund-service-panel')?.classList.contains('visible')).toBe(false); + }); + + it('renders retrieve panel result state with invoice and token metadata', function() { + document.body.innerHTML = '
'; + + renderRetrievePanel( + createFlowState({ + emailValue: 'buyer@example.com', + codeValue: '123456', + step2Visible: true, + result: { + invoice_url: 'https://license.pulserelay.pro/invoices/inv_123', + token: 'pulse_key_123', + tier: 'Relay', + issued_at: '2026-03-26T10:00:00Z', + expires_at: null, + email: 'buyer@example.com', + }, + }) + ); + + var copyButton = document.getElementById('retrieve-inline-copy') as HTMLButtonElement; + var invoiceLink = document.getElementById('retrieve-inline-invoice') as HTMLAnchorElement; + var tokenArea = document.getElementById('retrieve-inline-token') as HTMLTextAreaElement; + + expect(copyButton.style.display).toBe('inline-block'); + expect(invoiceLink.href).toBe('https://license.pulserelay.pro/invoices/inv_123'); + expect(invoiceLink.style.display).toBe('inline-block'); + expect(tokenArea.value).toBe('pulse_key_123'); + expect(document.getElementById('retrieve-inline-email-value')?.textContent).toBe('buyer@example.com'); + expect(document.getElementById('retrieve-inline-result')?.style.display).toBe('block'); + }); + + it('renders refund and delete panels from owned state', function() { + document.body.innerHTML = + '
' + + '
'; + + renderRefundPanel( + createRefundState({ + emailValue: 'owner@example.com', + tokenValue: 'pulse_token', + }), + createBootstrap() + ); + renderDeletePanel( + createFlowState({ + emailValue: 'owner@example.com', + codeValue: '654321', + step2Visible: true, + checkboxChecked: true, + }) + ); + + var refundEmail = document.getElementById('refund-inline-email') as HTMLInputElement; + var refundToken = document.getElementById('refund-inline-token') as HTMLInputElement; + var deleteCheck = document.getElementById('data-delete-confirm-check') as HTMLInputElement; + + expect(refundEmail.value).toBe('owner@example.com'); + expect(refundToken.value).toBe('pulse_token'); + expect(document.getElementById('refund-service-root')?.innerHTML).toContain('/refund.html?email=owner%40example.com'); + expect(deleteCheck.checked).toBe(true); + expect(document.getElementById('data-delete-step2')?.style.display).toBe('block'); + }); + + it('renders export panel and updates export result payload visibility', function() { + document.body.innerHTML = + '
' + + '' + + ''; + + renderExportPanel( + createFlowState({ + emailValue: 'owner@example.com', + step2Visible: true, + result: { + accounts: 2, + }, + }) + ); + + var payload = { accounts: 3, workspaces: 5 }; + renderExportResult(payload); + + expect((document.getElementById('data-export-email') as HTMLInputElement).value).toBe('owner@example.com'); + expect(document.getElementById('data-export-step2')?.style.display).toBe('block'); + expect(document.getElementById('data-export-result')?.style.display).toBe('block'); + expect((document.getElementById('data-export-payload') as HTMLTextAreaElement).value).toBe( + JSON.stringify(payload, null, 2) + ); + }); + + it('renders manage panel and status classes from service state', function() { + document.body.innerHTML = + '
' + + '
'; + + renderManagePanel( + createFlowState({ + emailValue: 'owner@example.com', + codeValue: '123456', + step2Visible: true, + }) + ); + renderStatus('manage-inline-status', { + visible: true, + message: 'Code sent.', + error: false, + } satisfies ServiceStatus); + + expect((document.getElementById('manage-inline-email') as HTMLInputElement).value).toBe('owner@example.com'); + expect((document.getElementById('manage-inline-code') as HTMLInputElement).value).toBe('123456'); + expect(document.getElementById('manage-inline-step2')?.style.display).toBe('block'); + expect(document.getElementById('manage-inline-status')?.className).toContain('success'); + expect(document.getElementById('manage-inline-status')?.textContent).toBe('Code sent.'); + }); +}); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts new file mode 100644 index 000000000..e132fd562 --- /dev/null +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest'; + +import type { PortalBootstrapData, PortalLoginState } from './types'; +import { + renderAccountsHTML, + renderAuthenticatedPortalHTML, + renderHeaderHTML, + renderSignedOutPortalHTML, + type ShellViewContext, +} from './shell_view'; + +function createBootstrap(overrides: Partial = {}): PortalBootstrapData { + return { + authenticated: true, + email: 'owner@example.com', + public_site_url: 'https://pulserelay.pro', + support_email: 'support@pulserelay.pro', + commercial_api_base_url: 'https://license.pulserelay.pro', + portal_path: '/portal', + bootstrap_path: '/api/portal/bootstrap', + magic_link_request_path: '/auth/magic-link', + signup_path: '/signup', + logout_path: '/auth/logout', + account_api_base_path: '/api/accounts', + portal_api_base_path: '/api/portal', + accounts: [], + ...overrides, + }; +} + +function createLoginState(overrides: Partial = {}): PortalLoginState { + return { + emailValue: '', + sending: false, + success: false, + error: '', + ...overrides, + }; +} + +function createContext(overrides: Partial = {}): ShellViewContext { + return { + bootstrap: createBootstrap(), + loginState: createLoginState(), + signupPath: '/signup', + accountAPIBasePath: '/api/accounts', + ...overrides, + }; +} + +describe('shell view', function() { + it('renders authenticated header with account email and sign-out button', function() { + var html = renderHeaderHTML(createContext()); + + expect(html).toContain('owner@example.com'); + expect(html).toContain('id="logout-btn"'); + expect(html).toContain('Sign out'); + }); + + it('renders signed-out header with create-account link', function() { + var html = renderHeaderHTML( + createContext({ + bootstrap: createBootstrap({ authenticated: false, email: '' }), + }) + ); + + expect(html).toContain('href="/signup"'); + expect(html).toContain('Create account'); + expect(html).not.toContain('logout-btn" id="logout-btn"'); + }); + + it('renders empty accounts state with support contact', function() { + var html = renderAccountsHTML(createContext()); + + expect(html).toContain('No workspaces found.'); + expect(html).toContain('mailto:support@pulserelay.pro'); + expect(html).toContain('support@pulserelay.pro'); + }); + + it('renders authenticated portal accounts, workspaces, and service entrypoints', function() { + var html = renderAuthenticatedPortalHTML( + createContext({ + bootstrap: createBootstrap({ + accounts: [ + { + id: 'acct_1', + name: 'Acme MSP', + kind: 'msp', + kind_label: 'MSP', + role: 'owner', + can_manage: true, + has_billing: true, + workspaces: [ + { + id: 'ws_active', + display_name: 'Alpha Workspace', + state: 'active', + healthy: true, + created_at: '2026-03-26T10:00:00Z', + }, + { + id: 'ws_failed', + display_name: 'Beta Workspace', + state: 'failed', + healthy: false, + }, + ], + }, + ], + }), + }) + ); + + expect(html).toContain('

Pulse Account

'); + expect(html).toContain('id="accounts-root"'); + expect(html).toContain('Acme MSP'); + expect(html).toContain('Alpha Workspace'); + expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff'); + expect(html).toContain('data-action="workspace-manage"'); + expect(html).toContain('service-card-button'); + expect(html).toContain('id="open-retrieve-service"'); + expect(html).toContain('id="data-service-panel"'); + }); + + it('renders signed-out portal with error and success login states', function() { + var errorHTML = renderSignedOutPortalHTML( + createContext({ + bootstrap: createBootstrap({ authenticated: false, email: '' }), + loginState: createLoginState({ + emailValue: 'buyer@example.com', + error: 'Invalid email', + }), + }) + ); + var successHTML = renderSignedOutPortalHTML( + createContext({ + bootstrap: createBootstrap({ authenticated: false, email: '' }), + loginState: createLoginState({ + emailValue: 'buyer@example.com', + success: true, + sending: false, + }), + }) + ); + + expect(errorHTML).toContain('value="buyer@example.com"'); + expect(errorHTML).toContain('Invalid email'); + expect(successHTML).toContain('Magic link sent.'); + expect(successHTML).toContain('data-portal-action="resend-magic-link"'); + }); +});