From f42d82c31045b2b5bac665381af14b486a8fac2c Mon Sep 17 00:00:00 2001 From: s6pa1rta3n-lab Date: Tue, 29 Sep 2026 03:18:02 -0400 Subject: [PATCH] test(utils): add coverage for attestation-CSV and PNG-export helpers (#1976) --- src/utils/__tests__/chartExport.test.ts | 456 ++++++++++++++++++++++++ src/utils/chartExport.ts | 281 +++++++++++++++ 2 files changed, 737 insertions(+) create mode 100644 src/utils/__tests__/chartExport.test.ts create mode 100644 src/utils/chartExport.ts diff --git a/src/utils/__tests__/chartExport.test.ts b/src/utils/__tests__/chartExport.test.ts new file mode 100644 index 00000000..a57f76fb --- /dev/null +++ b/src/utils/__tests__/chartExport.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { + buildHealthMetricsCsv, + buildHealthMetricsCsvContent, + buildHealthMetricsFilename, + downloadBlob, + downloadCsvContent, + sanitizeExportFilename, + buildAttestationCsvRows, + buildAttestationCsvContent, + buildAttestationExportFilename, + ATTESTATION_CSV_HEADERS, + loadImage, + exportSvgElementToPng, + exportChartContainerToPng, + type Attestation, + type HealthMetricsTab, +} from '@/utils/chartExport'; + +const sampleData = { + valueHistoryData: [ + { date: 'Jan 1', currentValue: 1000, initialAmount: 900 }, + { date: 'Jan 2', currentValue: 1100 }, + ], + drawdownData: [ + { date: 'Jan 1', drawdownPercent: 0.15 }, + { date: 'Jan 2', drawdownPercent: 2.5 }, + ], + feeGenerationData: [{ date: 'Jan 1', feeAmount: 25 }], + complianceData: [{ date: 'Jan 1', complianceScore: 98 }], +}; + +const sampleAttestation: Attestation = { + id: 'att-001', + title: 'Health Check Passed', + description: 'All metrics within acceptable range', + txHash: 'abcd1234efgh5678', + timestamp: '2026-06-27T12:00:00.000Z', + severity: 'ok', +}; + +const sampleAttestationWithDate: Attestation = { + id: 'att-002', + title: 'Drawdown Warning', + description: 'Drawdown exceeded 50% threshold', + txHash: 'wxyz9876', + timestamp: new Date('2026-06-26T08:30:00.000Z'), + severity: 'warning', +}; + +function createMockSvgElement(width = 100, height = 50): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.getBoundingClientRect = vi.fn().mockReturnValue({ + width, + height, + top: 0, + left: 0, + bottom: height, + right: width, + } as DOMRect); + return svg; +} + +describe('chartExport health metrics helpers', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('builds value history CSV rows', () => { + const { headers, rows } = buildHealthMetricsCsv('value', sampleData); + expect(headers).toEqual(['Date', 'Current Value', 'Initial Amount']); + expect(rows[0]).toEqual(['Jan 1', '1000', '900']); + expect(rows[1]).toEqual(['Jan 2', '1100', '']); + }); + + it('normalizes fractional drawdown values to percent strings', () => { + const { rows } = buildHealthMetricsCsv('drawdown', sampleData); + expect(rows[0]).toEqual(['Jan 1', '15.00%']); + expect(rows[1]).toEqual(['Jan 2', '2.50%']); + }); + + it('builds fee generation CSV rows', () => { + const { headers, rows } = buildHealthMetricsCsv('fee', sampleData); + expect(headers).toEqual(['Date', 'Fee Amount']); + expect(rows[0]).toEqual(['Jan 1', '25']); + }); + + it('builds compliance CSV rows', () => { + const { headers, rows } = buildHealthMetricsCsv('compliance', sampleData); + expect(headers).toEqual(['Date', 'Compliance Score']); + expect(rows[0]).toEqual(['Jan 1', '98']); + }); + + it('returns empty headers and rows for an unrecognized tab', () => { + const result = buildHealthMetricsCsv('unrecognized' as HealthMetricsTab, sampleData); + expect(result).toEqual({ headers: [], rows: [] }); + }); + + it('escapes formula-like CSV values', () => { + const csv = buildHealthMetricsCsvContent('fee', { + ...sampleData, + feeGenerationData: [{ date: '=SUM(A1)', feeAmount: 10 }], + }); + expect(csv).toContain("'=SUM(A1)"); + }); + + it('returns empty CSV content for empty series', () => { + const csv = buildHealthMetricsCsvContent('compliance', { + ...sampleData, + complianceData: [], + }); + expect(csv).toBe('Date,Compliance Score\r\n'); + }); + + it('sanitizes export filenames', () => { + expect(sanitizeExportFilename('bad/name with spaces')).toBe('bad-name-with-spaces'); + expect(sanitizeExportFilename('---already-trimmed---')).toBe('already-trimmed'); + expect(buildHealthMetricsFilename('cmt/001', 'value', 'csv')).toBe( + 'health-metrics-cmt-001-value-history-2026-06-27.csv', + ); + }); + + it('buildHealthMetricsFilename falls back to commitment when id is empty', () => { + expect(buildHealthMetricsFilename('', 'drawdown', 'png')).toBe( + 'health-metrics-commitment-drawdown-2026-06-27.png', + ); + expect(buildHealthMetricsFilename('cmt-01', 'unknown' as HealthMetricsTab, 'csv')).toBe( + 'health-metrics-cmt-01-metrics-2026-06-27.csv', + ); + }); + + it('downloads CSV content via blob link', async () => { + Object.defineProperty(window.URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:test'), + }); + Object.defineProperty(window.URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + await downloadCsvContent('Date,Value\r\nJan,1\r\n', 'metrics.csv'); + expect(window.URL.createObjectURL).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + }); + + it('downloads arbitrary blobs', async () => { + Object.defineProperty(window.URL, 'createObjectURL', { + configurable: true, + value: vi.fn(() => 'blob:png'), + }); + Object.defineProperty(window.URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + await downloadBlob(new Blob(['x'], { type: 'image/png' }), 'chart.png'); + expect(clickSpy).toHaveBeenCalled(); + }); +}); + +describe('Attestation CSV export helpers', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-27T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('ATTESTATION_CSV_HEADERS has the expected columns', () => { + expect(ATTESTATION_CSV_HEADERS).toEqual([ + 'ID', + 'Title', + 'Description', + 'TX Hash', + 'Timestamp', + 'Severity', + ]); + }); + + it('buildAttestationCsvRows handles string timestamps directly', () => { + const rows = buildAttestationCsvRows([sampleAttestation]); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual([ + 'att-001', + 'Health Check Passed', + 'All metrics within acceptable range', + 'abcd1234efgh5678', + '2026-06-27T12:00:00.000Z', + 'ok', + ]); + }); + + it('buildAttestationCsvRows converts Date objects to ISO strings', () => { + const rows = buildAttestationCsvRows([sampleAttestationWithDate]); + expect(rows).toHaveLength(1); + expect(rows[0][4]).toBe('2026-06-26T08:30:00.000Z'); + }); + + it('buildAttestationCsvRows handles mixed string and Date timestamps', () => { + const rows = buildAttestationCsvRows([sampleAttestation, sampleAttestationWithDate]); + expect(rows).toHaveLength(2); + expect(rows[0][4]).toBe('2026-06-27T12:00:00.000Z'); + expect(rows[1][4]).toBe('2026-06-26T08:30:00.000Z'); + }); + + it('buildAttestationCsvContent produces valid CSV with header row', () => { + const csv = buildAttestationCsvContent([sampleAttestation]); + expect(csv).toContain('ID,Title,Description,TX Hash,Timestamp,Severity'); + expect(csv).toContain('att-001'); + expect(csv).toContain('Health Check Passed'); + }); + + it('buildAttestationCsvContent escapes formula-like attestation titles', () => { + const alertAttestation: Attestation = { + id: 'att-003', + title: '=FORMULA_INJECTION', + description: 'Normal description', + txHash: 'hash', + timestamp: '2026-01-01', + severity: 'violation', + }; + const csv = buildAttestationCsvContent([alertAttestation]); + expect(csv).toContain("'=FORMULA_INJECTION"); + }); + + it('buildAttestationCsvContent returns header-only CSV for empty attestations', () => { + const csv = buildAttestationCsvContent([]); + expect(csv).toBe('ID,Title,Description,TX Hash,Timestamp,Severity\r\n'); + }); + + it('buildAttestationExportFilename sanitizes the commitment id', () => { + const filename = buildAttestationExportFilename('cmt/ABC-123'); + expect(filename).toBe('attestations-cmt-ABC-123-2026-06-27.csv'); + }); + + it('buildAttestationExportFilename falls back to "commitment" when id is empty', () => { + const filename = buildAttestationExportFilename(''); + expect(filename).toBe('attestations-commitment-2026-06-27.csv'); + }); +}); + +describe('loadImage helper', () => { + it('resolves image element on successful load', async () => { + const originalImage = globalThis.Image; + globalThis.Image = class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_: string) { + setTimeout(() => this.onload?.(), 0); + } + } as unknown as typeof Image; + + const result = await loadImage('blob:test'); + expect(result).toBeInstanceOf(Object); + + globalThis.Image = originalImage; + }); + + it('rejects on image load error', async () => { + const originalImage = globalThis.Image; + globalThis.Image = class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_: string) { + setTimeout(() => this.onerror?.(), 0); + } + } as unknown as typeof Image; + + await expect(loadImage('blob:invalid')).rejects.toThrow( + 'Failed to load chart SVG for PNG export', + ); + + globalThis.Image = originalImage; + }); +}); + +describe('PNG export helpers', () => { + beforeEach(() => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:svg-mock'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exportSvgElementToPng throws when canvas context is unavailable', async () => { + const svgElement = createMockSvgElement(100, 50); + + const origGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(null); + + const origImage = globalThis.Image; + globalThis.Image = class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_: string) { + setTimeout(() => this.onload?.(), 0); + } + } as unknown as typeof Image; + + await expect(exportSvgElementToPng(svgElement, 'chart.png')).rejects.toThrow( + 'Canvas context unavailable', + ); + + HTMLCanvasElement.prototype.getContext = origGetContext; + globalThis.Image = origImage; + }); + + it('exportSvgElementToPng throws when toBlob returns null', async () => { + const svgElement = createMockSvgElement(100, 50); + + const mockCtx = { + fillRect: vi.fn(), + drawImage: vi.fn(), + } as unknown as CanvasRenderingContext2D; + + const origGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(mockCtx); + + const origToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = vi.fn((cb: (blob: Blob | null) => void) => { + cb(null); + }); + + const origImage = globalThis.Image; + globalThis.Image = class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_: string) { + setTimeout(() => this.onload?.(), 0); + } + } as unknown as typeof Image; + + await expect(exportSvgElementToPng(svgElement, 'chart.png')).rejects.toThrow( + 'Failed to create PNG blob', + ); + + HTMLCanvasElement.prototype.getContext = origGetContext; + HTMLCanvasElement.prototype.toBlob = origToBlob; + globalThis.Image = origImage; + }); + + it('exportSvgElementToPng successfully downloads a PNG blob and sets xmlns if missing', async () => { + const svgElement = createMockSvgElement(200, 100); + + const mockCtx = { + fillRect: vi.fn(), + drawImage: vi.fn(), + } as unknown as CanvasRenderingContext2D; + + const origGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(mockCtx); + + const fakeBlob = new Blob(['fake-png'], { type: 'image/png' }); + const origToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = vi.fn((cb: (blob: Blob | null) => void) => { + cb(fakeBlob); + }); + + const origImage = globalThis.Image; + globalThis.Image = class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_: string) { + setTimeout(() => this.onload?.(), 0); + } + } as unknown as typeof Image; + + await expect(exportSvgElementToPng(svgElement, 'chart.png')).resolves.toBeUndefined(); + + expect(URL.createObjectURL).toHaveBeenCalled(); + + HTMLCanvasElement.prototype.getContext = origGetContext; + HTMLCanvasElement.prototype.toBlob = origToBlob; + globalThis.Image = origImage; + }); + + it('exportSvgElementToPng handles image load failure', async () => { + const svgElement = createMockSvgElement(100, 50); + + const origImage = globalThis.Image; + globalThis.Image = class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_: string) { + setTimeout(() => this.onerror?.(), 0); + } + } as unknown as typeof Image; + + await expect(exportSvgElementToPng(svgElement, 'chart.png')).rejects.toThrow( + 'Failed to load chart SVG for PNG export', + ); + + globalThis.Image = origImage; + }); + + it('exportChartContainerToPng throws when SVG is not found', async () => { + const container = document.createElement('div'); + + await expect(exportChartContainerToPng(container, 'chart.png')).rejects.toThrow( + 'Chart SVG not found', + ); + }); + + it('exportChartContainerToPng finds a valid SVG and delegates to export', async () => { + const container = document.createElement('div'); + const svg = createMockSvgElement(150, 75); + svg.classList.add('recharts-surface'); + container.appendChild(svg); + + const mockCtx = { + fillRect: vi.fn(), + drawImage: vi.fn(), + } as unknown as CanvasRenderingContext2D; + + const origGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(mockCtx); + + const fakeBlob = new Blob(['fake-chart-png'], { type: 'image/png' }); + const origToBlob = HTMLCanvasElement.prototype.toBlob; + HTMLCanvasElement.prototype.toBlob = vi.fn((cb: (blob: Blob | null) => void) => { + cb(fakeBlob); + }); + + const origImage = globalThis.Image; + globalThis.Image = class { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_: string) { + setTimeout(() => this.onload?.(), 0); + } + } as unknown as typeof Image; + + await expect( + exportChartContainerToPng(container, 'container-chart.png'), + ).resolves.toBeUndefined(); + + HTMLCanvasElement.prototype.getContext = origGetContext; + HTMLCanvasElement.prototype.toBlob = origToBlob; + globalThis.Image = origImage; + }); +}); diff --git a/src/utils/chartExport.ts b/src/utils/chartExport.ts new file mode 100644 index 00000000..62c3e335 --- /dev/null +++ b/src/utils/chartExport.ts @@ -0,0 +1,281 @@ +import { buildCsv, type CsvRow } from '@/lib/backend/csv'; + +export type HealthMetricsTab = 'value' | 'drawdown' | 'fee' | 'compliance'; + +export interface HealthMetricsExportData { + valueHistoryData: Array<{ date: string; currentValue: number; initialAmount?: number }>; + drawdownData: Array<{ date: string; drawdownPercent: number }>; + feeGenerationData: Array<{ date: string; feeAmount: number }>; + complianceData: Array<{ date: string; complianceScore: number }>; +} + +export interface Attestation { + id: string; + title: string; + description: string; + txHash: string; + timestamp: string | Date; + severity: 'ok' | 'warning' | 'violation'; +} + +const TAB_LABELS: Record = { + value: 'value-history', + drawdown: 'drawdown', + fee: 'fee-generation', + compliance: 'compliance', +}; + +function normalizeDrawdownPercent(value: number): string { + const percent = value <= 1 ? value * 100 : value; + return `${percent.toFixed(2)}%`; +} + +/** + * Builds structured headers and CSV rows for a given health metrics tab. + * + * @param tab - Active metrics tab + * @param data - Health metrics export dataset + * @returns Headers and formatted rows for CSV generation + */ +export function buildHealthMetricsCsv( + tab: HealthMetricsTab, + data: HealthMetricsExportData, +): { headers: string[]; rows: CsvRow[] } { + switch (tab) { + case 'value': + return { + headers: ['Date', 'Current Value', 'Initial Amount'], + rows: data.valueHistoryData.map((row) => [ + row.date, + String(row.currentValue), + row.initialAmount == null ? '' : String(row.initialAmount), + ]), + }; + case 'drawdown': + return { + headers: ['Date', 'Drawdown Percent'], + rows: data.drawdownData.map((row) => [ + row.date, + normalizeDrawdownPercent(row.drawdownPercent), + ]), + }; + case 'fee': + return { + headers: ['Date', 'Fee Amount'], + rows: data.feeGenerationData.map((row) => [row.date, String(row.feeAmount)]), + }; + case 'compliance': + return { + headers: ['Date', 'Compliance Score'], + rows: data.complianceData.map((row) => [row.date, String(row.complianceScore)]), + }; + default: + return { headers: [], rows: [] }; + } +} + +/** + * Serializes health metrics into escaped CSV text. + * + * @param tab - Active metrics tab + * @param data - Health metrics export dataset + * @returns Escaped CSV string + */ +export function buildHealthMetricsCsvContent( + tab: HealthMetricsTab, + data: HealthMetricsExportData, +): string { + const { headers, rows } = buildHealthMetricsCsv(tab, data); + return buildCsv(headers, rows); +} + +/** + * Sanitizes an export filename by removing illegal filesystem characters. + * + * @param value - Raw filename candidate + * @returns Sanitized safe filename segment + */ +export function sanitizeExportFilename(value: string): string { + return value + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +/** + * Generates a standard formatted filename for a health metrics export. + * + * @param commitmentId - Commitment identifier + * @param tab - Metrics tab + * @param extension - File extension ('csv' or 'png') + * @returns Sanitized full filename + */ +export function buildHealthMetricsFilename( + commitmentId: string, + tab: HealthMetricsTab, + extension: 'csv' | 'png', +): string { + const safeId = sanitizeExportFilename(commitmentId || 'commitment'); + const metric = TAB_LABELS[tab] ?? 'metrics'; + const dateStamp = new Date().toISOString().slice(0, 10); + return sanitizeExportFilename(`health-metrics-${safeId}-${metric}-${dateStamp}.${extension}`); +} + +/** + * Triggers a browser download of an in-memory Blob. + * + * @param blob - In-memory binary blob + * @param filename - Target download file name + */ +export async function downloadBlob(blob: Blob, filename: string): Promise { + const href = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = href; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(href); +} + +/** + * Triggers a browser download of CSV text. + * + * @param content - Escaped CSV string + * @param filename - Target download file name + */ +export async function downloadCsvContent(content: string, filename: string): Promise { + const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }); + await downloadBlob(blob, filename); +} + +/** + * Asynchronously loads an image from an object URL. + * + * @param src - Image source URL + * @returns Loaded HTMLImageElement + */ +export function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('Failed to load chart SVG for PNG export')); + image.src = src; + }); +} + +/** + * Renders an SVG element into a 2D canvas and triggers a PNG download. + * + * @param svgElement - Source SVG DOM element + * @param filename - Target PNG filename + */ +export async function exportSvgElementToPng( + svgElement: SVGSVGElement, + filename: string, +): Promise { + const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement; + if (!clonedSvg.getAttribute('xmlns')) { + clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + } + + const bbox = svgElement.getBoundingClientRect(); + const width = Math.max(1, Math.ceil(bbox.width)); + const height = Math.max(1, Math.ceil(bbox.height)); + clonedSvg.setAttribute('width', String(width)); + clonedSvg.setAttribute('height', String(height)); + + const serialized = new XMLSerializer().serializeToString(clonedSvg); + const svgBlob = new Blob([serialized], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(svgBlob); + + try { + const image = await loadImage(url); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Canvas context unavailable'); + } + context.fillStyle = '#111111'; + context.fillRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + + const pngBlob = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Failed to create PNG blob')); + }, 'image/png'); + }); + + await downloadBlob(pngBlob, filename); + } finally { + URL.revokeObjectURL(url); + } +} + +/** + * Finds a Recharts SVG surface inside a container element and exports it as a PNG image. + * + * @param container - HTML parent containing the chart SVG + * @param filename - Target PNG filename + */ +export async function exportChartContainerToPng( + container: HTMLElement, + filename: string, +): Promise { + const svgElement = container.querySelector('svg.recharts-surface'); + if (!(svgElement instanceof SVGSVGElement)) { + throw new Error('Chart SVG not found'); + } + await exportSvgElementToPng(svgElement, filename); +} + +export const ATTESTATION_CSV_HEADERS = [ + 'ID', + 'Title', + 'Description', + 'TX Hash', + 'Timestamp', + 'Severity', +] as const; + +/** + * Maps attestation records into formatted CSV row arrays. + * + * @param attestations - List of attestation objects + * @returns Array of serialized CSV row arrays + */ +export function buildAttestationCsvRows(attestations: Attestation[]): CsvRow[] { + return attestations.map((a) => [ + a.id, + a.title, + a.description, + a.txHash, + typeof a.timestamp === 'string' ? a.timestamp : a.timestamp.toISOString(), + a.severity, + ]); +} + +/** + * Formats attestation records into an escaped CSV string. + * + * @param attestations - List of attestation objects + * @returns Serialized CSV string including header row + */ +export function buildAttestationCsvContent(attestations: Attestation[]): string { + return buildCsv([...ATTESTATION_CSV_HEADERS], buildAttestationCsvRows(attestations)); +} + +/** + * Generates an attestation CSV export filename with current date stamp. + * + * @param commitmentId - Commitment identifier + * @returns Sanitized filename + */ +export function buildAttestationExportFilename(commitmentId: string): string { + const safeId = sanitizeExportFilename(commitmentId || 'commitment'); + const dateStamp = new Date().toISOString().slice(0, 10); + return sanitizeExportFilename(`attestations-${safeId}-${dateStamp}.csv`); +}