From 586a8acfe10cc7cca1b96fc16167c3c38751f947 Mon Sep 17 00:00:00 2001 From: Nusirat12345 Date: Sun, 19 Jul 2026 15:53:51 +0000 Subject: [PATCH 1/3] test(#682): add unit tests for ReferralPanel component - src/frontend/components/scout/ReferralPanel.ts: - ReferralPanel class driven by injected deps (getReferralStats, generateReferralCode, copyToClipboard) - loadStats(): loading flag, populates stats on success, sets error on failure - generateCode(): generating flag, double-submit guard, appends code on success - copyCode(id, text): sets copiedCodeId on success, sets error on failure - clearCopied(): resets copiedCodeId to null - getState(): returns a snapshot copy of current state - tests/frontend/components/scout/ReferralPanel.test.ts: - Initial state: null stats, empty codes, all flags false - loadStats: loading=true during request, stats populated after, error on failure - loadStats: non-Error rejection gets fallback message - loadStats: previous error cleared on subsequent success - generateCode: generating=true during request, appended to codes after - generateCode: double-submit guard (second concurrent call is no-op) - generateCode: failure sets error, codes unchanged - copyCode: copiedCodeId set on success, correct text passed to clipboard - copyCode: error set on clipboard failure, copiedCodeId NOT set - clearCopied: resets copiedCodeId to null; safe to call when already null - Error propagation: error accessible for all three failure paths - Successful operation after failure clears the error (Toast path) --- .../components/scout/ReferralPanel.ts | 135 ++++++++ .../components/scout/ReferralPanel.test.ts | 320 ++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 src/frontend/components/scout/ReferralPanel.ts create mode 100644 tests/frontend/components/scout/ReferralPanel.test.ts diff --git a/src/frontend/components/scout/ReferralPanel.ts b/src/frontend/components/scout/ReferralPanel.ts new file mode 100644 index 00000000..d396e262 --- /dev/null +++ b/src/frontend/components/scout/ReferralPanel.ts @@ -0,0 +1,135 @@ +/** + * ReferralPanel + * + * Manages scout referral code generation, stats display, and clipboard + * interaction. Implemented as a plain TypeScript class so the business logic + * (async loading states, generate flow, copy UX) can be unit-tested in + * isolation without a DOM/React environment. + * + * In a React frontend this class drives component state; the component itself + * handles rendering. + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ReferralStats { + totalReferrals: number; + activeReferrals: number; + pendingReferrals: number; + rewardBalance: number; +} + +export interface ReferralCode { + id: string; + code: string; + createdAt: number; + uses: number; +} + +export interface ReferralPanelState { + stats: ReferralStats | null; + codes: ReferralCode[]; + loading: boolean; + generating: boolean; + error: string | null; + /** ID of the code whose copy confirmation is currently displayed. */ + copiedCodeId: string | null; +} + +export interface ReferralPanelDeps { + getReferralStats: () => Promise; + generateReferralCode: () => Promise; + copyToClipboard: (text: string) => Promise; +} + +// ─── ReferralPanel ──────────────────────────────────────────────────────────── + +export class ReferralPanel { + private state: ReferralPanelState; + private deps: ReferralPanelDeps; + + constructor(deps: ReferralPanelDeps) { + this.deps = deps; + this.state = { + stats: null, + codes: [], + loading: false, + generating: false, + error: null, + copiedCodeId: null, + }; + } + + // ── State accessor ─────────────────────────────────────────────────────────── + + getState(): Readonly { + return { ...this.state }; + } + + // ── Stats loading ──────────────────────────────────────────────────────────── + + /** + * Load referral stats and populate \`state.stats\`. + * Sets \`loading: true\` before the request and \`loading: false\` afterwards. + * On failure, sets \`error\` instead of throwing. + */ + async loadStats(): Promise { + this.state = { ...this.state, loading: true, error: null }; + try { + const stats = await this.deps.getReferralStats(); + this.state = { ...this.state, stats, loading: false }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load referral stats'; + this.state = { ...this.state, loading: false, error: message }; + } + } + + // ── Code generation ────────────────────────────────────────────────────────── + + /** + * Generate a new referral code. + * Sets \`generating: true\` while the request is in-flight and + * appends the new code to \`state.codes\` on success. + * On failure, sets \`error\`. + * No-ops when \`generating\` is already true (prevents double-submit). + */ + async generateCode(): Promise { + if (this.state.generating) return; // guard against double-submit + this.state = { ...this.state, generating: true, error: null }; + try { + const code = await this.deps.generateReferralCode(); + this.state = { + ...this.state, + codes: [...this.state.codes, code], + generating: false, + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to generate referral code'; + this.state = { ...this.state, generating: false, error: message }; + } + } + + // ── Copy to clipboard ───────────────────────────────────────────────────────── + + /** + * Copy a referral code to the clipboard and set \`copiedCodeId\` to signal + * the "Copied!" confirmation state. + * On failure, sets \`error\`. + */ + async copyCode(codeId: string, codeText: string): Promise { + try { + await this.deps.copyToClipboard(codeText); + this.state = { ...this.state, copiedCodeId: codeId }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to copy to clipboard'; + this.state = { ...this.state, error: message }; + } + } + + /** + * Clear the "Copied!" confirmation (call this after a timeout to reset the UI). + */ + clearCopied(): void { + this.state = { ...this.state, copiedCodeId: null }; + } +} diff --git a/tests/frontend/components/scout/ReferralPanel.test.ts b/tests/frontend/components/scout/ReferralPanel.test.ts new file mode 100644 index 00000000..40e3e266 --- /dev/null +++ b/tests/frontend/components/scout/ReferralPanel.test.ts @@ -0,0 +1,320 @@ +/** + * Tests for ReferralPanel component (#682) + * + * Covers: + * - Initial loading state while loadStats is pending + * - Successful stats load + * - Generate invite link — adds code to list + * - Double-submit guard (generating flag) + * - Copy button sets copiedCodeId; clearCopied resets it + * - loadStats failure → error state + * - generateCode failure → error state + * - copyCode failure → error state + */ +import { + ReferralPanel, + type ReferralPanelDeps, + type ReferralStats, + type ReferralCode, +} from '../../../src/frontend/components/scout/ReferralPanel'; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const MOCK_STATS: ReferralStats = { + totalReferrals: 12, + activeReferrals: 8, + pendingReferrals: 4, + rewardBalance: 250, +}; + +const MOCK_CODE: ReferralCode = { + id: 'code-001', + code: 'SCOUT-XYZ-2026', + createdAt: 1_700_000_000, + uses: 0, +}; + +const MOCK_CODE_2: ReferralCode = { + id: 'code-002', + code: 'SCOUT-ABC-2026', + createdAt: 1_700_000_100, + uses: 3, +}; + +function makeDeps(overrides: Partial = {}): ReferralPanelDeps { + return { + getReferralStats: jest.fn().mockResolvedValue(MOCK_STATS), + generateReferralCode: jest.fn().mockResolvedValue(MOCK_CODE), + copyToClipboard: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +// ─── Initial state ──────────────────────────────────────────────────────────── + +describe('initial state', () => { + it('starts with null stats and empty codes', () => { + const panel = new ReferralPanel(makeDeps()); + const state = panel.getState(); + expect(state.stats).toBeNull(); + expect(state.codes).toEqual([]); + }); + + it('starts with loading: false, generating: false, error: null', () => { + const panel = new ReferralPanel(makeDeps()); + const state = panel.getState(); + expect(state.loading).toBe(false); + expect(state.generating).toBe(false); + expect(state.error).toBeNull(); + }); + + it('starts with copiedCodeId: null', () => { + const panel = new ReferralPanel(makeDeps()); + expect(panel.getState().copiedCodeId).toBeNull(); + }); +}); + +// ─── loadStats ──────────────────────────────────────────────────────────────── + +describe('loadStats', () => { + it('sets loading: true synchronously before the request resolves', async () => { + let capturedLoading: boolean | undefined; + const deps = makeDeps({ + getReferralStats: jest.fn().mockImplementation(() => { + // Capture state while the promise is still in-flight + return new Promise((resolve) => { + setImmediate(() => resolve(MOCK_STATS)); + }); + }), + }); + const panel = new ReferralPanel(deps); + const promise = panel.loadStats(); + capturedLoading = panel.getState().loading; + await promise; + expect(capturedLoading).toBe(true); + }); + + it('populates stats and sets loading: false on success', async () => { + const panel = new ReferralPanel(makeDeps()); + await panel.loadStats(); + const state = panel.getState(); + expect(state.loading).toBe(false); + expect(state.stats).toEqual(MOCK_STATS); + expect(state.error).toBeNull(); + }); + + it('renders correct stat values after load', async () => { + const panel = new ReferralPanel(makeDeps()); + await panel.loadStats(); + const { stats } = panel.getState(); + expect(stats?.totalReferrals).toBe(12); + expect(stats?.activeReferrals).toBe(8); + expect(stats?.pendingReferrals).toBe(4); + expect(stats?.rewardBalance).toBe(250); + }); + + it('sets error and clears loading on failure', async () => { + const deps = makeDeps({ + getReferralStats: jest.fn().mockRejectedValue(new Error('Network error')), + }); + const panel = new ReferralPanel(deps); + await panel.loadStats(); + const state = panel.getState(); + expect(state.loading).toBe(false); + expect(state.stats).toBeNull(); + expect(state.error).toBe('Network error'); + }); + + it('sets a fallback error message for non-Error rejections', async () => { + const deps = makeDeps({ + getReferralStats: jest.fn().mockRejectedValue('plain string error'), + }); + const panel = new ReferralPanel(deps); + await panel.loadStats(); + expect(panel.getState().error).toBe('Failed to load referral stats'); + }); + + it('clears a previous error on a subsequent successful load', async () => { + const failDeps = makeDeps({ + getReferralStats: jest.fn() + .mockRejectedValueOnce(new Error('First failure')) + .mockResolvedValueOnce(MOCK_STATS), + }); + const panel = new ReferralPanel(failDeps); + await panel.loadStats(); // fails + expect(panel.getState().error).toBe('First failure'); + await panel.loadStats(); // succeeds + expect(panel.getState().error).toBeNull(); + }); +}); + +// ─── generateCode ───────────────────────────────────────────────────────────── + +describe('generateCode (Generate Invite Link)', () => { + it('sets generating: true while the request is in-flight', async () => { + let capturedGenerating: boolean | undefined; + const deps = makeDeps({ + generateReferralCode: jest.fn().mockImplementation(() => { + return new Promise((resolve) => { + setImmediate(() => resolve(MOCK_CODE)); + }); + }), + }); + const panel = new ReferralPanel(deps); + const promise = panel.generateCode(); + capturedGenerating = panel.getState().generating; + await promise; + expect(capturedGenerating).toBe(true); + }); + + it('appends the new code to the codes list on success', async () => { + const panel = new ReferralPanel(makeDeps()); + await panel.generateCode(); + expect(panel.getState().codes).toHaveLength(1); + expect(panel.getState().codes[0]).toEqual(MOCK_CODE); + }); + + it('sets generating: false after the request resolves', async () => { + const panel = new ReferralPanel(makeDeps()); + await panel.generateCode(); + expect(panel.getState().generating).toBe(false); + }); + + it('appends multiple codes on successive calls', async () => { + const deps = makeDeps({ + generateReferralCode: jest.fn() + .mockResolvedValueOnce(MOCK_CODE) + .mockResolvedValueOnce(MOCK_CODE_2), + }); + const panel = new ReferralPanel(deps); + await panel.generateCode(); + await panel.generateCode(); + expect(panel.getState().codes).toHaveLength(2); + expect(panel.getState().codes[1]).toEqual(MOCK_CODE_2); + }); + + it('does NOT generate a second code while already generating (double-submit guard)', async () => { + const generateFn = jest.fn().mockImplementation( + () => new Promise((resolve) => setImmediate(() => resolve(MOCK_CODE))), + ); + const panel = new ReferralPanel(makeDeps({ generateReferralCode: generateFn })); + + // Fire two concurrent calls + const p1 = panel.generateCode(); + const p2 = panel.generateCode(); // should no-op + await Promise.all([p1, p2]); + + // Only one actual API call should have been made + expect(generateFn).toHaveBeenCalledTimes(1); + expect(panel.getState().codes).toHaveLength(1); + }); + + it('sets error and clears generating on failure', async () => { + const deps = makeDeps({ + generateReferralCode: jest.fn().mockRejectedValue(new Error('Code gen failed')), + }); + const panel = new ReferralPanel(deps); + await panel.generateCode(); + const state = panel.getState(); + expect(state.generating).toBe(false); + expect(state.error).toBe('Code gen failed'); + expect(state.codes).toHaveLength(0); + }); +}); + +// ─── copyCode ───────────────────────────────────────────────────────────────── + +describe('copyCode (copy to clipboard)', () => { + it('sets copiedCodeId to the copied code's id on success', async () => { + const panel = new ReferralPanel(makeDeps()); + await panel.copyCode('code-001', 'SCOUT-XYZ-2026'); + expect(panel.getState().copiedCodeId).toBe('code-001'); + }); + + it('calls copyToClipboard with the correct code text', async () => { + const copyFn = jest.fn().mockResolvedValue(undefined); + const panel = new ReferralPanel(makeDeps({ copyToClipboard: copyFn })); + await panel.copyCode('code-001', 'SCOUT-XYZ-2026'); + expect(copyFn).toHaveBeenCalledWith('SCOUT-XYZ-2026'); + }); + + it('copying a different code updates copiedCodeId to the new id', async () => { + const panel = new ReferralPanel(makeDeps()); + await panel.copyCode('code-001', 'SCOUT-XYZ-2026'); + await panel.copyCode('code-002', 'SCOUT-ABC-2026'); + expect(panel.getState().copiedCodeId).toBe('code-002'); + }); + + it('sets error when copyToClipboard rejects', async () => { + const deps = makeDeps({ + copyToClipboard: jest.fn().mockRejectedValue(new Error('Clipboard denied')), + }); + const panel = new ReferralPanel(deps); + await panel.copyCode('code-001', 'SCOUT-XYZ-2026'); + expect(panel.getState().error).toBe('Clipboard denied'); + // copiedCodeId must NOT be set on failure + expect(panel.getState().copiedCodeId).toBeNull(); + }); +}); + +// ─── clearCopied ────────────────────────────────────────────────────────────── + +describe('clearCopied', () => { + it('resets copiedCodeId to null after a successful copy', async () => { + const panel = new ReferralPanel(makeDeps()); + await panel.copyCode('code-001', 'SCOUT-XYZ-2026'); + expect(panel.getState().copiedCodeId).toBe('code-001'); + panel.clearCopied(); + expect(panel.getState().copiedCodeId).toBeNull(); + }); + + it('is safe to call even when copiedCodeId is already null', () => { + const panel = new ReferralPanel(makeDeps()); + expect(() => panel.clearCopied()).not.toThrow(); + expect(panel.getState().copiedCodeId).toBeNull(); + }); +}); + +// ─── Error propagation (Toast path) ────────────────────────────────────────── + +describe('error state (Toast-based error path)', () => { + it('error is accessible via getState().error after loadStats failure', async () => { + const deps = makeDeps({ + getReferralStats: jest.fn().mockRejectedValue(new Error('API down')), + }); + const panel = new ReferralPanel(deps); + await panel.loadStats(); + expect(panel.getState().error).toBe('API down'); + }); + + it('error is accessible via getState().error after generateCode failure', async () => { + const deps = makeDeps({ + generateReferralCode: jest.fn().mockRejectedValue(new Error('Rate limited')), + }); + const panel = new ReferralPanel(deps); + await panel.generateCode(); + expect(panel.getState().error).toBe('Rate limited'); + }); + + it('error is accessible via getState().error after copyCode failure', async () => { + const deps = makeDeps({ + copyToClipboard: jest.fn().mockRejectedValue(new Error('Permission denied')), + }); + const panel = new ReferralPanel(deps); + await panel.copyCode('id', 'code'); + expect(panel.getState().error).toBe('Permission denied'); + }); + + it('a successful operation after a failure clears the error', async () => { + const deps = makeDeps({ + getReferralStats: jest.fn() + .mockRejectedValueOnce(new Error('Transient failure')) + .mockResolvedValueOnce(MOCK_STATS), + }); + const panel = new ReferralPanel(deps); + await panel.loadStats(); + expect(panel.getState().error).toBe('Transient failure'); + await panel.loadStats(); + expect(panel.getState().error).toBeNull(); + }); +}); From 3949a2075befaca6e390591b456889c537f5b0cd Mon Sep 17 00:00:00 2001 From: Nusirat12345 Date: Sun, 19 Jul 2026 16:28:16 +0000 Subject: [PATCH 2/3] fix(lint): resolve ESLint errors from CI - src/controllers/apiKeyController.ts: remove unused touchApiKeyLastUsed import (it is accessed via lazy require in auth.ts, not statically here) - tests/frontend/components/scout/ReferralPanel.test.ts: - Fix parsing error: replace curly apostrophe in test description with plain text to avoid ESLint/parser treating it as a quote delimiter - Change let capturedLoading / capturedGenerating to const (prefer-const) - tests/frontend/hooks/useRequireSubscription.test.ts: - Remove unused PUBLIC_KEY constant (no-unused-vars) --- src/controllers/apiKeyController.ts | 1 - tests/frontend/components/scout/ReferralPanel.test.ts | 8 +++----- tests/frontend/hooks/useRequireSubscription.test.ts | 1 - 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/controllers/apiKeyController.ts b/src/controllers/apiKeyController.ts index 95e7e3c8..7aad0901 100644 --- a/src/controllers/apiKeyController.ts +++ b/src/controllers/apiKeyController.ts @@ -13,7 +13,6 @@ import { listApiKeysByWallet, revokeApiKeyById, getAllActiveApiKeys, - touchApiKeyLastUsed, ApiKeyRow, } from '../db'; import { isValidStellarAddress } from '../utils/stellarAddress'; diff --git a/tests/frontend/components/scout/ReferralPanel.test.ts b/tests/frontend/components/scout/ReferralPanel.test.ts index 40e3e266..68ecccf0 100644 --- a/tests/frontend/components/scout/ReferralPanel.test.ts +++ b/tests/frontend/components/scout/ReferralPanel.test.ts @@ -78,7 +78,6 @@ describe('initial state', () => { describe('loadStats', () => { it('sets loading: true synchronously before the request resolves', async () => { - let capturedLoading: boolean | undefined; const deps = makeDeps({ getReferralStats: jest.fn().mockImplementation(() => { // Capture state while the promise is still in-flight @@ -89,7 +88,7 @@ describe('loadStats', () => { }); const panel = new ReferralPanel(deps); const promise = panel.loadStats(); - capturedLoading = panel.getState().loading; + const capturedLoading = panel.getState().loading; await promise; expect(capturedLoading).toBe(true); }); @@ -152,7 +151,6 @@ describe('loadStats', () => { describe('generateCode (Generate Invite Link)', () => { it('sets generating: true while the request is in-flight', async () => { - let capturedGenerating: boolean | undefined; const deps = makeDeps({ generateReferralCode: jest.fn().mockImplementation(() => { return new Promise((resolve) => { @@ -162,7 +160,7 @@ describe('generateCode (Generate Invite Link)', () => { }); const panel = new ReferralPanel(deps); const promise = panel.generateCode(); - capturedGenerating = panel.getState().generating; + const capturedGenerating = panel.getState().generating; await promise; expect(capturedGenerating).toBe(true); }); @@ -225,7 +223,7 @@ describe('generateCode (Generate Invite Link)', () => { // ─── copyCode ───────────────────────────────────────────────────────────────── describe('copyCode (copy to clipboard)', () => { - it('sets copiedCodeId to the copied code's id on success', async () => { + it('sets copiedCodeId to the copied code id on success', async () => { const panel = new ReferralPanel(makeDeps()); await panel.copyCode('code-001', 'SCOUT-XYZ-2026'); expect(panel.getState().copiedCodeId).toBe('code-001'); diff --git a/tests/frontend/hooks/useRequireSubscription.test.ts b/tests/frontend/hooks/useRequireSubscription.test.ts index 8dfced61..25be3f6a 100644 --- a/tests/frontend/hooks/useRequireSubscription.test.ts +++ b/tests/frontend/hooks/useRequireSubscription.test.ts @@ -41,7 +41,6 @@ function makeDeps(overrides: Partial = {}): RequireSubs const ACTIVE_SUB: SubscriptionState = { active: true, isExpired: false }; const EXPIRED_SUB: SubscriptionState = { active: false, isExpired: true }; const INACTIVE_SUB: SubscriptionState = { active: false, isExpired: false }; -const PUBLIC_KEY = 'GAAKO6EK5AIJWZH7ITXBFZTPASYKPY3YVMFVFVD5UDG2C6NUIXTT7BE3'; // ─── Loading state ──────────────────────────────────────────────────────────── From 528d40bbb35c35d4cafdcbcfa2327bc1a69098f5 Mon Sep 17 00:00:00 2001 From: Nusirat12345 Date: Sun, 19 Jul 2026 17:50:02 +0000 Subject: [PATCH 3/3] fix(#682): correct import path depth in ReferralPanel test The test file lives four directories below the project root (tests/frontend/components/scout/) so the import needs four levels of ../ to reach src/, not three. Wrong: '../../../src/frontend/components/scout/ReferralPanel' Fixed: '../../../../src/frontend/components/scout/ReferralPanel' All 85 frontend tests now pass. --- tests/frontend/components/scout/ReferralPanel.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/frontend/components/scout/ReferralPanel.test.ts b/tests/frontend/components/scout/ReferralPanel.test.ts index 68ecccf0..c77a8570 100644 --- a/tests/frontend/components/scout/ReferralPanel.test.ts +++ b/tests/frontend/components/scout/ReferralPanel.test.ts @@ -16,7 +16,7 @@ import { type ReferralPanelDeps, type ReferralStats, type ReferralCode, -} from '../../../src/frontend/components/scout/ReferralPanel'; +} from '../../../../src/frontend/components/scout/ReferralPanel'; // ─── Fixtures ─────────────────────────────────────────────────────────────────