diff --git a/frontend/src/components/wealth/AssetRefreshControl.test.ts b/frontend/src/components/wealth/AssetRefreshControl.test.ts index 691f926..a417fe7 100644 --- a/frontend/src/components/wealth/AssetRefreshControl.test.ts +++ b/frontend/src/components/wealth/AssetRefreshControl.test.ts @@ -1,43 +1,58 @@ import { flushPromises, mount } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import AssetRefreshControl from './AssetRefreshControl.vue' import { getAssetRefreshJob, startAssetRefreshJob, type AssetRefreshJob } from '@/api/marketRefresh' vi.mock('@/api/marketRefresh', () => ({ startAssetRefreshJob: vi.fn(), getAssetRefreshJob: vi.fn() })) afterEach(() => { vi.clearAllMocks(); vi.useRealTimers() }) const job = (status: AssetRefreshJob['status'], sources: AssetRefreshJob['sources']): AssetRefreshJob => ({ job_id: 'job-23', status, requested_at: '2026-08-27T10:00:00Z', completed_at: status === 'running' ? null : '2026-08-27T10:01:00Z', stale_before: '2026-08-26T10:00:00Z', progress: { completed: status === 'running' ? 1 : 3, total: 3 }, sources, wealth_snapshot_created: status !== 'running', audit_recorded: status !== 'running', provider_calls_on_read: false, + successful_assets: sources.reduce((total, item) => total + item.updated_count, 0), fresh_unchanged_assets: 0, + stale_assets: sources.reduce((total, item) => total + item.stale_remaining_count, 0), + failed_assets: sources.reduce((total, item) => total + item.failed_count, 0), next_action: 'Betroffene Quelle erneut versuchen.', }) const source = (name: 'equity' | 'crypto' | 'fx', status: 'pending' | 'running' | 'complete' | 'failed' | 'skipped', error: string | null = null) => ({ source: name, status, stale_candidates: 2, updated_count: status === 'complete' ? 2 : 0, error_code: error, + fresh_unchanged_count: 0, stale_remaining_count: status === 'failed' ? 1 : 0, failed_count: status === 'failed' ? 1 : 0, + diagnostics: error ? ['provider diagnostic'] : [], started_at: status === 'pending' ? null : '2026-08-27T10:00:00Z', completed_at: ['complete', 'failed', 'skipped'].includes(status) ? '2026-08-27T10:01:00Z' : null, }) describe('AssetRefreshControl', () => { it('does not start or query a job on mount', async () => { mount(AssetRefreshControl) await flushPromises() expect(startAssetRefreshJob).not.toHaveBeenCalled() expect(getAssetRefreshJob).not.toHaveBeenCalled() }) it('starts only on action and keeps source progress and errors isolated', async () => { vi.useFakeTimers() vi.mocked(startAssetRefreshJob).mockResolvedValue(job('running', [source('equity', 'running'), source('crypto', 'pending'), source('fx', 'pending')])) vi.mocked(getAssetRefreshJob).mockResolvedValue(job('partial', [source('equity', 'complete'), source('crypto', 'failed', 'provider_unavailable'), source('fx', 'complete')])) const wrapper = mount(AssetRefreshControl) await wrapper.get('[data-testid="asset-refresh-start"]').trigger('click') await flushPromises() expect(startAssetRefreshJob).toHaveBeenCalledTimes(1) expect(wrapper.get('[data-testid="asset-refresh-source-equity"]').text()).toContain('Läuft') await vi.advanceTimersByTimeAsync(1000) await flushPromises() expect(getAssetRefreshJob).toHaveBeenCalledWith('job-23', true) expect(wrapper.get('[data-testid="asset-refresh-source-equity"]').text()).toContain('Abgeschlossen') - expect(wrapper.get('[data-testid="asset-refresh-source-crypto"]').text()).toContain('provider_unavailable') + expect(wrapper.get('[data-testid="asset-refresh-source-crypto"]').text()).toContain('Instrument(e) konnten nicht aktualisiert werden') + expect(wrapper.get('[data-testid="asset-refresh-summary"]').text()).toContain('weiterhin veraltet') expect(wrapper.emitted('completed')).toHaveLength(1) }) + + it('does not expose the historical valuation conflict as primary error text', async () => { + vi.mocked(startAssetRefreshJob).mockRejectedValue(new Error('valuation source observation conflicts with its existing payload')) + const wrapper = mount(AssetRefreshControl) + await wrapper.get('[data-testid="asset-refresh-start"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="asset-refresh-error"]').text()).toContain('nicht sicher abgeschlossen') + expect(wrapper.get('[data-testid="asset-refresh-error"]').text()).not.toContain('valuation source observation') + }) }) diff --git a/frontend/src/components/wealth/WealthCockpitPanel.test.ts b/frontend/src/components/wealth/WealthCockpitPanel.test.ts index 974a54f..c6047c0 100644 --- a/frontend/src/components/wealth/WealthCockpitPanel.test.ts +++ b/frontend/src/components/wealth/WealthCockpitPanel.test.ts @@ -27,76 +27,82 @@ const cockpit = { current: { date: '2026-08-27', value_chf: '1640.00', quality: 'incomplete' }, change_chf: '40.00', change_pct: '2.5000', chart_visible: true, points: [ { date: '2026-08-26', value_chf: '1600.00', quality: 'incomplete', has_confirmed_anchor: true, has_modelled_value: false, excluded_account_count: 1, components: [ { key: 'postfinance', label: 'PostFinance', value_chf: '1000.00', quality: 'confirmed', source_date: '2026-08-26' }, { key: 'truewealth', label: 'True Wealth', value_chf: '500.00', quality: 'confirmed', source_date: '2026-08-26' }, { key: 'crypto', label: 'Krypto', value_chf: null, quality: 'unavailable', source_date: null }, { key: 'bank_cash', label: 'Bankguthaben', value_chf: '100.00', quality: 'confirmed', source_date: '2026-08-26' }, ] }, { date: '2026-08-27', value_chf: '1640.00', quality: 'incomplete', has_confirmed_anchor: false, has_modelled_value: true, excluded_account_count: 1, components: [ { key: 'postfinance', label: 'PostFinance', value_chf: '1030.00', quality: 'modelled', source_date: '2026-08-27' }, { key: 'truewealth', label: 'True Wealth', value_chf: '510.00', quality: 'modelled', source_date: '2026-08-27' }, { key: 'crypto', label: 'Krypto', value_chf: null, quality: 'unavailable', source_date: null }, { key: 'bank_cash', label: 'Bankguthaben', value_chf: '100.00', quality: 'carried', source_date: '2026-08-26' }, ] }, ], components: [ { key: 'postfinance', label: 'PostFinance', current_value_chf: '1030.00', change_chf: '30.00', change_pct: '3.0000', quality: 'modelled', as_of: '2026-08-27', unknown_account_count: 0 }, { key: 'truewealth', label: 'True Wealth', current_value_chf: '510.00', change_chf: '10.00', change_pct: '2.0000', quality: 'modelled', as_of: '2026-08-27', unknown_account_count: 0 }, { key: 'crypto', label: 'Krypto', current_value_chf: null, change_chf: null, change_pct: null, quality: 'unavailable', as_of: null, unknown_account_count: 0 }, { key: 'bank_cash', label: 'Bankguthaben', current_value_chf: '100.00', change_chf: '0.00', change_pct: '0.0000', quality: 'incomplete', as_of: '2026-08-26', unknown_account_count: 1 }, ], correction_markers: [{ date: '2026-08-27', source_key: 'truewealth', confirmed_value_chf: '510.00', predecessor_model_value_chf: '508.00', difference_chf: '2.00' }], unknown_accounts: [{ key: 'unknown-bank-1', label: 'Bankkonto •••• 5031', reason_code: 'confirmed_cash_evidence_missing' }], method: 'modelled_wealth_daily_v1', disclaimer: 'Geschätzte Entwicklung', }, verified_performance: { status: 'not_verified', label: 'Noch nicht verifiziert', ttwror_status: 'not_ready', xirr_status: 'not_ready', ttwror_pct: null, xirr_pct: null }, totals: { captured_wealth_chf: '1640.00', investments_chf: '1540.00', bank_cash_chf: '100.00', complete: false }, kpis: [], history: { points: [], status: 'unavailable', household_cashflow_events: [], investment_cashflow_events: [], reason: 'Nicht relevant' }, distribution: [], policy: { configured: false, version: null, rows: [], contribution: null }, sources: [source({}), source({ key: 'truewealth', label: 'True Wealth', current_value_chf: '510.00', performance_blocker: 'Externe Cashflows unvollständig.' })], planning: { available: false, free_plannable_chf: null, link: '/budget', included_in_wealth: false }, diagnostics: [{ dimension: 'performance', affected_sources: ['PostFinance'], message: 'Cashflow-Coverage fehlt.', action: 'Diagnose prüfen.', reason_code: 'cashflow_coverage_missing', prominent: false }], data_quality: { freshness_status: 'partial', reconciliation_status: 'not_assessable', performance_status: 'unavailable', performance_reasons: [], missing_areas: [], unassigned: [] }, method: { wealth_change: 'Stichtagsvergleich', investment_result: 'Coverage-gesteuert', return: 'Coverage-gesteuert' }, readiness: { dimensions: { current_value: { status: 'partial', reason_code: null }, freshness: { status: 'partial', reason_code: null }, reconciliation: { status: 'partial', reason_code: null }, performance: { status: 'not_ready', reason_code: 'coverage' }, policy: { status: 'not_applicable', reason_code: null } }, metrics: [] }, performance_coverage: { decision_version: 'investment_performance_scope_v1', expected_scopes: ['postfinance', 'truewealth', 'crypto'], status: 'partial', rows: [] }, hints: [], } as WealthCockpit describe('WealthCockpitPanel modelled development', () => { it('shows the model chart with two points while verified performance remains blocked', async () => { vi.mocked(getWealthCockpit).mockResolvedValue(cockpit) const wrapper = mount(WealthCockpitPanel) await flushPromises() expect(getWealthCockpit).toHaveBeenCalledWith('1m', false) expect(wrapper.find('[data-testid="modelled-chart"]').exists()).toBe(true) const chart = wrapper.findComponent(WealthDevelopmentChart) const datasets = (chart.vm as any).chartData.datasets expect(datasets.some((dataset: any) => dataset.borderDash?.join(',') === '6,5')).toBe(true) const chartData = wrapper.get('[data-testid="modelled-chart-data"]') expect(chartData.attributes('open')).toBeUndefined() expect(chartData.text()).toContain('Barrierefreie Datentabelle zur Grafik') expect(chartData.text()).toContain('26.08.2026') expect(chartData.text()).toContain('Unvollständig') expect(chartData.text()).toContain('Importkorrektur') expect(wrapper.get('[data-testid="modelled-current-badge"]').text()).toBe('Unvollständig') expect(wrapper.get('[data-testid="verified-performance"]').attributes('open')).toBeUndefined() + expect(wrapper.text()).toContain('Geschätzte Vermögensentwicklung') + expect(wrapper.text()).toContain('Seit dem letzten bestätigten Stand mit verfügbaren Marktpreisen fortgeschrieben.') + expect(wrapper.text()).toContain('Letzter bestätigter Stand') + expect(wrapper.text()).toContain('Geschätzter aktueller Wert') + expect(wrapper.text()).toContain('Verwendeter Kursstichtag') + expect(wrapper.text()).toContain('Keine Anlagerendite oder Gewinn-/Verlustangabe') expect(wrapper.get('[data-testid="verified-performance"]').text()).toContain('Noch nicht verifiziert') }) it('consolidates PostFinance, uses bank wording and keeps blockers in collapsed diagnostics', async () => { vi.mocked(getWealthCockpit).mockResolvedValue(cockpit) const wrapper = mount(WealthCockpitPanel) await flushPromises() expect(wrapper.findAll('[data-testid="modelled-component-postfinance"]')).toHaveLength(1) expect(wrapper.get('[data-testid="modelled-component-bank_cash"]').text()).toContain('Kontostandentwicklung') expect(wrapper.get('[data-testid="modelled-unknown-accounts"]').text()).toContain('übrige Entwicklung bleibt sichtbar') expect(wrapper.get('[data-testid="wealth-diagnostics"]').attributes('open')).toBeUndefined() expect(wrapper.get('[data-testid="wealth-diagnostics"]').text()).toContain('August-Aktivitäten fehlen') expect(wrapper.get('[data-testid="correction-markers"]').text()).toContain('Importkorrektur') }) }) diff --git a/frontend/src/pages/PortfolioPage.test.ts b/frontend/src/pages/PortfolioPage.test.ts index 39d239c..0248815 100644 --- a/frontend/src/pages/PortfolioPage.test.ts +++ b/frontend/src/pages/PortfolioPage.test.ts @@ -20,112 +20,112 @@ const cockpit = { anchor: { date: '2026-07-31', value_chf: '1000.00', quality: 'confirmed' }, baseline: { date: '2026-07-31', value_chf: '1000.00', quality: 'confirmed' }, current: { date: '2026-08-01', value_chf: '1050.00', quality: 'modelled' }, change_chf: '50.00', change_pct: '5.0000', chart_visible: true, points: [ { date: '2026-07-31', value_chf: '1000.00', quality: 'confirmed', has_confirmed_anchor: true, has_modelled_value: false, excluded_account_count: 0, components: [{ key: 'postfinance', label: 'PostFinance', value_chf: '500.00', quality: 'confirmed', source_date: '2026-07-31' }, { key: 'truewealth', label: 'True Wealth', value_chf: '300.00', quality: 'confirmed', source_date: '2026-07-31' }, { key: 'crypto', label: 'Krypto', value_chf: '100.00', quality: 'modelled', source_date: '2026-07-31' }, { key: 'bank_cash', label: 'Bankguthaben', value_chf: '100.00', quality: 'confirmed', source_date: '2026-07-31' }] }, { date: '2026-08-01', value_chf: '1050.00', quality: 'modelled', has_confirmed_anchor: false, has_modelled_value: true, excluded_account_count: 0, components: [{ key: 'postfinance', label: 'PostFinance', value_chf: '530.00', quality: 'modelled', source_date: '2026-08-01' }, { key: 'truewealth', label: 'True Wealth', value_chf: '310.00', quality: 'modelled', source_date: '2026-08-01' }, { key: 'crypto', label: 'Krypto', value_chf: '105.00', quality: 'modelled', source_date: '2026-08-01' }, { key: 'bank_cash', label: 'Bankguthaben', value_chf: '105.00', quality: 'carried', source_date: '2026-07-31' }] }, ], components: [{ key: 'postfinance', label: 'PostFinance', current_value_chf: '530.00', change_chf: '30.00', change_pct: '6.0000', quality: 'modelled', as_of: '2026-08-01', unknown_account_count: 0 }, { key: 'truewealth', label: 'True Wealth', current_value_chf: '310.00', change_chf: '10.00', change_pct: '3.3333', quality: 'modelled', as_of: '2026-08-01', unknown_account_count: 0 }, { key: 'crypto', label: 'Krypto', current_value_chf: '105.00', change_chf: '5.00', change_pct: '5.0000', quality: 'modelled', as_of: '2026-08-01', unknown_account_count: 0 }, { key: 'bank_cash', label: 'Bankguthaben', current_value_chf: '105.00', change_chf: '5.00', change_pct: '5.0000', quality: 'carried', as_of: '2026-07-31', unknown_account_count: 0 }], correction_markers: [], unknown_accounts: [], method: 'modelled_wealth_daily_v1', disclaimer: 'Geschätzte Entwicklung', }, verified_performance: { status: 'not_verified', label: 'Noch nicht verifiziert', ttwror_status: 'not_ready', xirr_status: 'not_ready', ttwror_pct: null, xirr_pct: null }, kpis: [ { key: 'captured_wealth', label: 'Erfasstes Vermögen heute', value_chf: '1000.00', status: 'complete' }, { key: 'wealth_change', label: 'Veränderung im Zeitraum', value_chf: null, status: 'not_calculable' }, { key: 'investment_result', label: 'Anlageergebnis ohne Einzahlungen', value_chf: '50.00', status: 'available' }, { key: 'return', label: 'Zeitgewichtete Rendite', value_pct: '0.05', status: 'available' }, { key: 'net_contributions', label: 'Nettoeinzahlungen', value_chf: '100.00', status: 'available' }, { key: 'data_as_of', label: 'Datenstand', value_date: '2026-07-31', status: 'available' }, ], totals: { captured_wealth_chf: '1000.00', investments_chf: '700.00', bank_cash_chf: '300.00', complete: true }, history: { status: 'not_calculable', points: [{ at: '2026-08-01', value_chf: '1000.00' }], household_cashflow_events: [], investment_cashflow_events: [{ at: '2026-03-01', kind: 'external_deposit', amount: '100.00' }], reason: 'Gemeinsame Historie fehlt; keine Zwischenwerte.' }, distribution: [ { key: 'cash', label: 'Bankguthaben', value_chf: '300.00' }, { key: 'equity', label: 'Aktien und ETFs', value_chf: '300.00' }, { key: 'truewealth', label: 'True Wealth', value_chf: '300.00' }, { key: 'crypto', label: 'Kryptowährungen', value_chf: '100.00' }, ], sources: [ { key: 'cash-1', label: 'Raiffeisen Privatkonto', provider_label: 'Raiffeisen', kind: 'Bankguthaben', source_role: 'account', performance_scope: null, current_value_chf: '300.00', current_value_status: 'ready', change_chf: null, net_contributions_chf: null, return_pct: null, as_of: '2026-07-31', freshness_status: 'fresh', freshness_reason_code: 'within_bank_update_rhythm', expected_as_of: '2026-08-01', reconciliation_status: 'reconciled', performance_status: 'not_applicable' }, { key: 'truewealth', label: 'True Wealth Gesamtwert', provider_label: 'True Wealth Gesamtwert', kind: 'Anlage', source_role: 'canonical_value', performance_scope: 'truewealth', current_value_chf: '300.00', current_value_status: 'ready', change_chf: null, net_contributions_chf: null, return_pct: null, as_of: '2026-07-31', freshness_status: 'fresh', freshness_reason_code: 'within_managed_update_rhythm', expected_as_of: '2026-08-01', reconciliation_status: 'not_assessable', performance_status: 'ready' }, ], readiness: { dimensions: { current_value: { status: 'ready', reason_code: null }, freshness: { status: 'ready', reason_code: null }, reconciliation: { status: 'ready', reason_code: null }, performance: { status: 'ready', reason_code: null }, policy: { status: 'ready', reason_code: null }, }, metrics: [ { key: 'captured_wealth', label: 'Erfasstes Vermögen heute', status: 'ready', included_sources: ['Raiffeisen Privatkonto', 'True Wealth Gesamtwert'], missing_sources: [], as_of: '2026-07-31', period: { preset: 'ytd', from: '2026-01-01', to: '2026-08-01' }, blocker: null, action: null, reason_code: null }, { key: 'wealth_change', label: 'Vermögensveränderung im Zeitraum', status: 'not_ready', included_sources: [], missing_sources: ['Raiffeisen Privatkonto'], as_of: null, period: { preset: 'ytd', from: '2026-01-01', to: '2026-08-01' }, blocker: 'Anfangswert fehlt.', action: 'Anfangswert bereitstellen.', reason_code: 'household_boundary_values_missing' }, { key: 'investment_result', label: 'Anlageergebnis ohne Einzahlungen', status: 'ready', included_sources: ['True Wealth'], missing_sources: [], as_of: null, period: { preset: 'ytd', from: '2026-01-01', to: '2026-08-01' }, blocker: null, action: null, reason_code: null }, { key: 'ttwror', label: 'Zeitgewichtete Rendite', status: 'ready', included_sources: ['True Wealth'], missing_sources: [], as_of: null, period: { preset: 'ytd', from: '2026-01-01', to: '2026-08-01' }, blocker: null, action: null, reason_code: null }, { key: 'net_contributions', label: 'Nettoeinzahlungen', status: 'ready', included_sources: ['True Wealth'], missing_sources: [], as_of: null, period: { preset: 'ytd', from: '2026-01-01', to: '2026-08-01' }, blocker: null, action: null, reason_code: null }, { key: 'wealth_history', label: 'Vermögensverlaufsreihe', status: 'not_ready', included_sources: [], missing_sources: ['Raiffeisen Privatkonto'], as_of: null, period: { preset: 'ytd', from: '2026-01-01', to: '2026-08-01' }, blocker: 'Gemeinsame Stichtage fehlen.', action: 'Stichtage bereitstellen.', reason_code: 'complete_history_points_missing' }, { key: 'policy_allocation', label: 'Aufteilung gegenüber Portfolioorientierung', status: 'ready', included_sources: ['Raiffeisen Privatkonto'], missing_sources: [], as_of: null, period: { preset: 'ytd', from: '2026-01-01', to: '2026-08-01' }, blocker: null, action: null, reason_code: null }, ], }, diagnostics: [], performance_coverage: { decision_version: 'investment_performance_scope_v1', expected_scopes: ['postfinance', 'truewealth', 'crypto'], status: 'complete', rows: [] }, policy: { configured: true, version: 1, rows: [{ asset_class: 'equity', current_pct: '30.00', target_pct: '35', lower_pct: '25', upper_pct: '45', deviation_pct_points: '-5.00', status: 'within_range' }], contribution: null }, planning: { free_plannable_chf: '500.00', available: true, link: '/planning/budget/planning', included_in_wealth: false }, data_quality: { freshness_status: 'fresh', reconciliation_status: 'reconciled', performance_status: 'complete', performance_reasons: [], missing_areas: ['Hypotheken nicht vollständig erfasst.'], unassigned: [] }, hints: ['True Wealth wird nur als bestätigter Gesamtwert berücksichtigt.'], method: { wealth_change: 'Interne Transfers neutral.', investment_result: 'Endwert minus Anfangswert minus Nettoeinzahlungen.', return: 'Bestehende TTWROR-Engine.' }, } describe('PortfolioPage wealth cockpit', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getWealthCockpit).mockResolvedValue(cockpit as any) }) it('shows the simplified modelled cockpit without trading UI', async () => { const wrapper = mount(PortfolioPage) await flushPromises() - expect(wrapper.get('[data-testid="modelled-kpis"]').findAll('article')).toHaveLength(4) - expect(wrapper.text()).toContain('Modellierte Wertentwicklung') - expect(wrapper.text()).toContain('Aktueller modellierter Gesamtwert') + expect(wrapper.get('[data-testid="modelled-kpis"]').findAll('article')).toHaveLength(5) + expect(wrapper.text()).toContain('Geschätzte Vermögensentwicklung') + expect(wrapper.text()).toContain('Geschätzter aktueller Wert') expect(wrapper.text()).toContain('PostFinance') expect(wrapper.text()).toContain('inklusive Settlement-Cash') expect(wrapper.text()).toContain('Kontostandentwicklung') expect(wrapper.find('[data-testid="modelled-chart"]').exists()).toBe(true) expect(wrapper.get('[data-testid="wealth-diagnostics"]').attributes('open')).toBeUndefined() expect(wrapper.text()).not.toMatch(/BUY|SELL|HOLD|Research|Order-Ausführung|Confidence/) expect(wrapper.text()).not.toMatch(/account-|provider_reason|confidence/) }) it('offers the Sprint-23 periods and keeps the model chart independent of performance verification', async () => { const wrapper = mount(PortfolioPage) await flushPromises() const chartButtons = wrapper.get('[data-testid="wealth-development-chart"]').findAll('button').map((item) => item.text()) expect(chartButtons).toEqual(expect.arrayContaining(['1M', '3M', 'YTD', '1J', 'Max'])) - expect(chartButtons).toEqual(expect.arrayContaining(['Gesamt', 'Komponenten', 'Wertentwicklung (modelliert)'])) + expect(chartButtons).toEqual(expect.arrayContaining(['Gesamt', 'Komponenten', 'Geschätzte Veränderung'])) expect(wrapper.find('[data-testid="modelled-chart"]').exists()).toBe(true) expect(wrapper.get('[data-testid="verified-performance"]').text()).toContain('Noch nicht verifiziert') }) it('renders an unknown component as unavailable rather than CHF zero', async () => { const unknown = structuredClone(cockpit) as any unknown.modelled_development.components.find((row: any) => row.key === 'bank_cash').current_value_chf = null unknown.modelled_development.components.find((row: any) => row.key === 'bank_cash').quality = 'unavailable' vi.mocked(getWealthCockpit).mockResolvedValue(unknown) const wrapper = mount(PortfolioPage) await flushPromises() const bank = wrapper.get('[data-testid="modelled-component-bank_cash"]').text() expect(bank).toContain('Nicht verfügbar') expect(bank).not.toContain('CHF 0.00') }) it('keeps modelled wealth visible while technical blockers remain collapsed', async () => { const missing = structuredClone(cockpit) as any missing.diagnostics = [ { dimension: 'performance', affected_sources: ['True Wealth'], message: 'Für die Rendite fehlt eine Anfangsbewertung.', action: 'Diagnose prüfen.', reason_code: 'opening_valuation_missing_by_source', prominent: true }, ] vi.mocked(getWealthCockpit).mockResolvedValue(missing) const wrapper = mount(PortfolioPage) await flushPromises() expect(wrapper.find('[data-testid="modelled-chart"]').exists()).toBe(true) expect(wrapper.get('[data-testid="wealth-diagnostics"]').attributes('open')).toBeUndefined() expect(wrapper.get('[data-testid="modelled-current-badge"]').text()).toBe('Modelliert') }) }) diff --git a/frontend/src/pages/ReadOnlySurfaces.test.ts b/frontend/src/pages/ReadOnlySurfaces.test.ts index 96e91f2..285291e 100644 --- a/frontend/src/pages/ReadOnlySurfaces.test.ts +++ b/frontend/src/pages/ReadOnlySurfaces.test.ts @@ -1,76 +1,76 @@ import { mount, flushPromises } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import PortfolioPage from './PortfolioPage.vue' import EquityPage from './EquityPage.vue' import WalletsPage from './WalletsPage.vue' import ReportsPage from './ReportsPage.vue' import { getOverview, getPortfolioPolicy, getPortfolioPolicyEvaluation, getPortfolioPolicyHistory, getWealthCockpit } from '@/api/portfolio' import { getCashSummary } from '@/api/cash' import { getEquityPositionDetail, getEquityPositions } from '@/api/equity' import { getWallet, getWallets } from '@/api/crypto' import { getReports } from '@/api/reports' import { getHealth, getRuntimeStatus } from '@/api/runtime' vi.mock('@/api/portfolio', () => ({ getOverview: vi.fn(), getWealthCockpit: vi.fn(), getPortfolioPolicy: vi.fn(), getPortfolioPolicyEvaluation: vi.fn(), getPortfolioPolicyHistory: vi.fn(), previewPortfolioPolicy: vi.fn(), confirmPortfolioPolicy: vi.fn(), getPortfolioPerformance: vi.fn(), getPortfolioPerformanceCoverage: vi.fn(), getPortfolioDataSources: vi.fn(), getPortfolioIngestionHistory: vi.fn(), getPortfolioReconciliation: vi.fn(), previewPortfolioIngestion: vi.fn(), confirmPortfolioIngestion: vi.fn() })) vi.mock('@/api/cash', () => ({ getCashSummary: vi.fn() })) vi.mock('@/api/equity', () => ({ getEquityPositions: vi.fn(), getEquitySummary: vi.fn().mockResolvedValue({ as_of: '2026-07-24', last_successful_run: null }), getEquityPositionDetail: vi.fn() })) vi.mock('@/api/crypto', () => ({ getCryptoPositions: vi.fn(), getWallet: vi.fn(), getWallets: vi.fn() })) vi.mock('@/api/reports', () => ({ getReports: vi.fn() })) vi.mock('@/api/runtime', () => ({ getHealth: vi.fn(), getRuntimeStatus: vi.fn() })) const modelled = { status: 'available', period: { preset: '1m', from: '2026-07-01', to: '2026-08-01' }, anchor: { date: '2026-07-31', value_chf: '1000.00', quality: 'confirmed' }, baseline: { date: '2026-07-31', value_chf: '1000.00', quality: 'confirmed' }, current: { date: '2026-08-01', value_chf: '1010.00', quality: 'modelled' }, change_chf: '10.00', change_pct: '1.0000', chart_visible: true, points: [{ date: '2026-07-31', value_chf: '1000.00', quality: 'confirmed', has_confirmed_anchor: true, has_modelled_value: false, excluded_account_count: 0, components: [] }, { date: '2026-08-01', value_chf: '1010.00', quality: 'modelled', has_confirmed_anchor: false, has_modelled_value: true, excluded_account_count: 0, components: [] }], components: [{ key: 'postfinance', label: 'PostFinance', current_value_chf: '1010.00', change_chf: '10.00', change_pct: '1.0000', quality: 'modelled', as_of: '2026-08-01', unknown_account_count: 0 }], correction_markers: [], unknown_accounts: [], method: 'modelled_wealth_daily_v1', disclaimer: 'Geschätzte Entwicklung' } const wealth = { scope_label: 'Erfasstes Vermögen', not_net_worth: true, period: { preset: 'ytd', from: '2026-01-01', to: '2026-08-01' }, data_cutoff: '2026-08-01T00:00:00Z', modelled_development: modelled, verified_performance: { status: 'not_verified', label: 'Noch nicht verifiziert', ttwror_status: 'not_ready', xirr_status: 'not_ready', ttwror_pct: null, xirr_pct: null }, kpis: [{ key: 'captured_wealth', label: 'Erfasstes Vermögen heute', value_chf: '1000.00', status: 'complete' }, { key: 'wealth_change', label: 'Veränderung im Zeitraum', value_chf: null, status: 'not_calculable' }, { key: 'investment_result', label: 'Anlageergebnis ohne Einzahlungen', value_chf: null, status: 'not_calculable' }, { key: 'return', label: 'Zeitgewichtete Rendite', value_pct: null, status: 'not_calculable' }, { key: 'net_contributions', label: 'Nettoeinzahlungen ins Anlageportfolio', value_chf: null, status: 'not_calculable' }, { key: 'data_as_of', label: 'Datenstand', value_date: '2026-08-01', status: 'available' }], totals: { captured_wealth_chf: '1000.00', investments_chf: '900.00', bank_cash_chf: '100.00', complete: true }, history: { status: 'not_calculable', points: [], household_cashflow_events: [], investment_cashflow_events: [], reason: 'Historie fehlt.' }, distribution: [{ key: 'cash', label: 'Bankguthaben', value_chf: '100.00' }, { key: 'equity', label: 'Aktien und ETFs', value_chf: '600.00' }, { key: 'truewealth', label: 'True Wealth', value_chf: '0.00' }, { key: 'crypto', label: 'Kryptowährungen', value_chf: '300.00' }], sources: [], diagnostics: [], policy: { configured: false, version: null, rows: [], contribution: null }, planning: { free_plannable_chf: null, available: false, link: '/planning/budget/planning', included_in_wealth: false }, data_quality: { freshness_status: 'fresh', reconciliation_status: 'not_assessable', performance_status: 'unavailable', performance_reasons: [], missing_areas: [], unassigned: [] }, hints: [], method: { wealth_change: 'Intern neutral.', investment_result: 'Bereinigt.', return: 'TTWROR.' } } const equity = [{ position_id: 'e1', name: 'Demo ETF', ticker: 'ETF', isin: 'CH1', account: 'True Wealth', asset_class: 'ETF', quantity: '2', currency: 'CHF', price: '10', market_value_chf: '20.00', status: 'Bewertet' }] const cash = { base_currency: 'CHF', cash_chf: '100.00', positions: [{ id: 'c1', platform: 'Raiffeisen', account_label: 'Cash', currency: 'CHF', amount: '100', amount_chf: '100.00', status: 'Aktuell' }] } describe('v1 read-only pages', () => { beforeEach(() => { vi.mocked(getHealth).mockResolvedValue({ status: 'ok', app: 'jarvis-finance-api', api_version: '0', mode: 'read-only' }) vi.mocked(getRuntimeStatus).mockResolvedValue({ db_available: true, db_path: '/runtime/finance.sqlite3', base_currency: 'CHF', runtime_outside_repo: true, write_mode: 'read-only-api-v0' }) vi.mocked(getPortfolioPolicy).mockResolvedValue({ configured: false, policy: null }) vi.mocked(getPortfolioPolicyEvaluation).mockResolvedValue({ configured: false, data_quality_status: 'unavailable', rows: [] }) vi.mocked(getPortfolioPolicyHistory).mockResolvedValue([]) }) it('Portfolio page auto-loads the read-only wealth cockpit without trading surfaces', async () => { vi.mocked(getWealthCockpit).mockResolvedValue(wealth as any) const wrapper = mount(PortfolioPage) await flushPromises() - expect(wrapper.text()).toContain('Modellierte Wertentwicklung') + expect(wrapper.text()).toContain('Geschätzte Vermögensentwicklung') expect(wrapper.text()).toContain('PostFinance') expect(wrapper.text()).not.toMatch(/BUY|SELL|HOLD|Top 10 Positionen/) }) it('Equity page auto-loads data and opens detail drawer', async () => { vi.mocked(getOverview).mockResolvedValue({ total_value_chf: '1000.00', crypto_value_chf: '300.00', equity_value_chf: '600.00', cash_value_chf: '100.00', unpriced_positions_count: 1, critical_alerts_count: 0, last_price_update: null, data_quality_status: 'ok' } as any) vi.mocked(getEquityPositions).mockResolvedValue(equity) vi.mocked(getEquityPositionDetail).mockResolvedValue({ ...equity[0], fx_status: 'not_needed', cost_basis_status: 'ok', day_change_chf: null, last_trade_date: null, provider: null, source_url: null, available_actions: [{ label: 'Kaufen', enabled: true }], price_history: [] } as any) vi.mocked(getCashSummary).mockResolvedValue(cash) const wrapper = mount(EquityPage) await flushPromises() expect(wrapper.text()).toContain('Aktien CHF') await wrapper.get('tbody tr').trigger('click') await flushPromises() expect(wrapper.text()).toContain('Status-Erklärung') }) it('Wallets page auto-loads wallets and fetches wallet detail', async () => { vi.mocked(getWallets).mockResolvedValue([{ wallet_id: 'w1', name: 'Cold Wallet', wallet_type: 'Hardware Wallet', provider: 'Ledger', coin_count: 1, market_value_chf: '10.00', status: 'Aktuell' }]) vi.mocked(getWallet).mockResolvedValue({ wallet_id: 'w1', name: 'Cold Wallet', wallet_type: 'Hardware Wallet', provider: 'Ledger', coin_count: 1, market_value_chf: '10.00', status: 'Aktuell', coins: [{ asset_id: 'btc', name: 'Bitcoin', symbol: 'BTC', quantity: '0.1', market_value_chf: '10.00', status: 'Aktuell' }] }) const wrapper = mount(WalletsPage) await flushPromises() await wrapper.get('tbody tr').trigger('click') await flushPromises() expect(wrapper.text()).toContain('Coins im Wallet') }) it('Reports page lists runtime reports without generation buttons', async () => { vi.mocked(getReports).mockResolvedValue([{ report_id: 'r1', report_type: 'crypto', title: 'Crypto Report', format: 'html', generated_at: '2026-05-16T12:00:00Z', data_quality_status: 'ok', location_label: 'Runtime-Reports-Ordner', path_display: '~/jarvis_runtime/finance-system/reports/report.html' }]) const wrapper = mount(ReportsPage) await flushPromises() expect(wrapper.text()).toContain('Crypto Report') expect(wrapper.text()).not.toContain('Erzeugen') }) }) diff --git a/tests/unit/test_asset_price_refresh.py b/tests/unit/test_asset_price_refresh.py index c70b396..33f2ca7 100644 --- a/tests/unit/test_asset_price_refresh.py +++ b/tests/unit/test_asset_price_refresh.py @@ -92,121 +92,175 @@ def test_concurrent_starts_create_exactly_one_active_job(tmp_path): def worker() -> None: conn = connect(path) conn.execute("PRAGMA busy_timeout=5000") barrier.wait() try: create_asset_price_refresh_job(conn) outcomes.append("created") except ValueError as exc: outcomes.append(str(exc)) finally: conn.close() first = threading.Thread(target=worker) second = threading.Thread(target=worker) first.start() second.start() first.join() second.join() assert sorted(outcomes) == ["asset_price_refresh_job_already_running", "created"] conn = connect(path) assert conn.execute( "SELECT COUNT(*) FROM asset_price_refresh_jobs WHERE status IN ('queued','running')" ).fetchone()[0] == 1 conn.close() def test_concurrent_workers_claim_a_queued_job_exactly_once(tmp_path): path = tmp_path / "finance.sqlite3" conn = database(path) queued, db_path = create_asset_price_refresh_job(conn) conn.close() calls: list[str] = [] barrier = threading.Barrier(2) def runner(source: str) -> Callable[[Connection, str], tuple[int, int]]: def execute(_conn: Connection, _stale_before: str) -> tuple[int, int]: calls.append(source) return 0, 0 return execute runners = {source: runner(source) for source in ("equity", "crypto", "fx")} def worker() -> None: barrier.wait() run_asset_price_refresh(db_path, queued["job_id"], runners=runners) first = threading.Thread(target=worker) second = threading.Thread(target=worker) first.start() second.start() first.join() second.join() assert sorted(calls) == ["crypto", "equity", "fx"] conn = connect(path) assert conn.execute( "SELECT COUNT(*) FROM aggregated_wealth_refresh_snapshots WHERE job_id=?", (queued["job_id"],), - ).fetchone()[0] == 1 + ).fetchone()[0] == 0 + conn.close() + + +def test_all_failed_sources_create_no_false_wealth_snapshot(tmp_path): + path = tmp_path / "finance.sqlite3" + conn = database(path) + queued, db_path = create_asset_price_refresh_job(conn) + conn.close() + + def failed(_conn, _stale_before): + raise RuntimeError("internal provider detail") + + run_asset_price_refresh(db_path, queued["job_id"], runners={source: failed for source in ("equity", "crypto", "fx")}) + conn = connect(path) + status = asset_price_refresh_status(conn, queued["job_id"]) + assert status["status"] == "failed" + assert status["wealth_snapshot_created"] is False + assert status["failed_assets"] == 3 + assert conn.execute("SELECT COUNT(*) FROM aggregated_wealth_refresh_snapshots").fetchone()[0] == 0 + assert "internal provider detail" not in str(status) + conn.close() + + +def test_partial_instrument_failure_preserves_success_and_quality_counts(tmp_path): + path = tmp_path / "finance.sqlite3" + conn = database(path) + queued, db_path = create_asset_price_refresh_job(conn) + conn.close() + + def equity(_conn, _stale_before): + return asset_refresh.SourceRunResult( + stale_candidates=3, + updated_count=2, + stale_remaining_count=1, + failed_count=1, + diagnostics=("provider_error",), + ) + + def current(_conn, _stale_before): + return asset_refresh.SourceRunResult(fresh_unchanged_count=2) + + run_asset_price_refresh( + db_path, + queued["job_id"], + runners={"equity": equity, "crypto": current, "fx": current}, + ) + conn = connect(path) + status = asset_price_refresh_status(conn, queued["job_id"]) + assert status["status"] == "partial" + assert status["successful_assets"] == 2 + assert status["fresh_unchanged_assets"] == 4 + assert status["stale_assets"] == 1 + assert status["failed_assets"] == 1 + assert status["wealth_snapshot_created"] is True conn.close() def test_crypto_refresh_fetches_only_stale_held_asset_ids(tmp_path, monkeypatch): path = tmp_path / "finance.sqlite3" conn = database(path) conn.execute( """INSERT INTO crypto_assets(asset_id,coin_name,symbol,coingecko_id,is_active,created_at) VALUES('btc','Bitcoin','BTC','bitcoin',1,'2026-01-01'), ('eth','Ethereum','ETH','ethereum',1,'2026-01-01')""" ) conn.execute( "INSERT INTO crypto_wallets(wallet_id,wallet_name,wallet_type,created_at) VALUES('wallet','Wallet','exchange','2026-01-01')" ) conn.execute( """INSERT INTO crypto_holdings( crypto_holding_id,asset_id,wallet_id,quantity,last_verified_at,verification_status,created_at ) VALUES('btc-held','btc','wallet','1','2026-08-27','verified','2026-01-01'), ('eth-held','eth','wallet','1','2026-08-27','verified','2026-01-01')""" ) now = datetime.now(UTC) conn.execute( """INSERT INTO crypto_prices( crypto_price_id,asset_id,coingecko_id,price_currency,price,provider, provider_timestamp,fetched_at,quality_status ) VALUES('eth-fresh','eth','ethereum','CHF','100','CoinGecko',?,?, 'fresh')""", (now.isoformat(), now.isoformat()), ) conn.commit() class Provider: calls: list[tuple[str, ...]] = [] def get_crypto_prices(self, coingecko_ids, currency="CHF"): ids = tuple(coingecko_ids) self.calls.append(ids) return { provider_id: PriceQuote( provider_id, currency, Decimal("123.45"), provider_timestamp=now.isoformat(), ) for provider_id in ids } def get_crypto_price(self, coingecko_id, currency="CHF"): return self.get_crypto_prices([coingecko_id], currency)[coingecko_id] provider = Provider() monkeypatch.setattr(asset_refresh, "CoinGeckoClient", lambda: provider) candidates, updated = asset_refresh._crypto_source( conn, (now - timedelta(hours=24)).isoformat(), ) assert (candidates, updated) == (1, 1) assert provider.calls == [("bitcoin",)] assert conn.execute("SELECT COUNT(*) FROM crypto_prices WHERE asset_id='eth'").fetchone()[0] == 1 assert conn.execute("SELECT COUNT(*) FROM crypto_prices WHERE asset_id='btc'").fetchone()[0] == 1 diff --git a/tests/unit/test_market_observation_idempotency.py b/tests/unit/test_market_observation_idempotency.py new file mode 100644 index 0000000..5dadc04 --- /dev/null +++ b/tests/unit/test_market_observation_idempotency.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path + +from jarvis_finance.market_data.prices import store_market_price +from jarvis_finance.services.portfolio_analytics import run_daily_market_valuation +from jarvis_finance.storage.database import connect +from jarvis_finance.storage.migrations import apply_migrations +from tests.unit.test_portfolio_market_analytics_v1 import Fx, database, quotes_for + + +def test_identical_economic_quote_ignores_request_metadata() -> None: + conn = connect(":memory:") + apply_migrations(conn) + conn.execute( + "INSERT INTO instruments(instrument_id,asset_class,name,currency,is_active,created_at) VALUES('i','stock','Synthetic','CHF',1,'2026-01-01')" + ) + first = store_market_price( + conn, + instrument_id="i", + price_date="2026-08-27", + close=Decimal("10.50"), + currency="CHF", + provider="mock", + provider_symbol="SYN", + price_timestamp="2026-08-27T10:00:00+00:00", + fetched_at="2026-08-27T10:00:01+00:00", + run_id="job-a", + ) + second = store_market_price( + conn, + instrument_id="i", + price_date="2026-08-27", + close=Decimal("10.50"), + currency="CHF", + provider="mock", + provider_symbol="SYN", + price_timestamp="2026-08-27T10:00:00+00:00", + fetched_at="2026-08-27T10:05:00+00:00", + run_id="job-b", + ) + assert second == first + assert conn.execute("SELECT COUNT(*) FROM market_price_observations").fetchone()[0] == 1 + stored = conn.execute( + "SELECT economic_payload_json,job_reference FROM market_price_observations" + ).fetchone() + assert stored["job_reference"] == "job-a" + assert "job-a" not in stored["economic_payload_json"] + assert "job-b" not in stored["economic_payload_json"] + + +def test_two_same_day_provider_times_and_correction_are_append_only() -> None: + conn = connect(":memory:") + apply_migrations(conn) + conn.execute( + "INSERT INTO instruments(instrument_id,asset_class,name,currency,is_active,created_at) VALUES('i','stock','Synthetic','CHF',1,'2026-01-01')" + ) + for timestamp, close in ( + ("2026-08-27T10:00:00+00:00", "10"), + ("2026-08-27T11:00:00+00:00", "11"), + ("2026-08-27T11:00:00+00:00", "11.1"), + ): + store_market_price( + conn, + instrument_id="i", + price_date="2026-08-27", + close=Decimal(close), + currency="CHF", + provider="mock", + provider_symbol="SYN", + price_timestamp=timestamp, + ) + rows = conn.execute( + "SELECT observation_id,source_observation_id,payload_version,supersedes_observation_id,close FROM market_price_observations ORDER BY created_at,observation_id" + ).fetchall() + assert len(rows) == 3 + assert rows[0]["source_observation_id"] != rows[1]["source_observation_id"] + assert rows[1]["source_observation_id"] == rows[2]["source_observation_id"] + assert (rows[1]["payload_version"], rows[2]["payload_version"]) == (1, 2) + assert rows[2]["supersedes_observation_id"] == rows[1]["observation_id"] + audit = conn.execute( + "SELECT action,old_values_json,new_values_json FROM audit_log WHERE action='market_price_observation_corrected'" + ).fetchone() + assert audit is not None + assert rows[1]["observation_id"] in str(audit["old_values_json"]) + assert [row["close"] for row in rows] == ["10", "11", "11.1"] + + +def test_partial_daily_run_accepts_new_economic_payload_as_new_version(tmp_path: Path) -> None: + conn = database() + first_provider = quotes_for("2026-07-01", missing={"BENCH.S"}) + first = run_daily_market_valuation( + conn, + as_of="2026-07-01", + price_providers={"mock": first_provider}, + fx_provider=Fx({("EUR", "2026-07-01"): "0.95", ("USD", "2026-07-01"): "0.80"}), + lock_path=tmp_path / "job.lock", + ) + assert first.status == "partial" + store_market_price( + conn, + instrument_id="eur", + price_date="2026-07-01", + close=Decimal("51"), + currency="EUR", + provider="mock", + provider_symbol="EUR.S", + provider_market="SIX", + price_timestamp="2026-07-01T21:00:00+00:00", + run_id="provider-correction", + ) + corrected = quotes_for("2026-07-01") + second = run_daily_market_valuation( + conn, + as_of="2026-07-01", + price_providers={"mock": corrected}, + fx_provider=Fx({("EUR", "2026-07-01"): "0.95", ("USD", "2026-07-01"): "0.80"}), + lock_path=tmp_path / "job.lock", + ) + assert second.run_id == first.run_id + assert second.status == "complete" + versions = conn.execute( + "SELECT snapshot_version,supersedes_snapshot_id,value_original FROM portfolio_valuation_snapshots WHERE scope_kind='instrument' AND scope_id='eur' ORDER BY snapshot_version" + ).fetchall() + assert len(versions) == 2 + assert versions[1]["supersedes_snapshot_id"] is not None + assert [row["value_original"] for row in versions] == ["500", "510"] diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py index 7ba7c6f..98a8d22 100644 --- a/tests/unit/test_schema.py +++ b/tests/unit/test_schema.py @@ -1,97 +1,121 @@ from __future__ import annotations import pytest from jarvis_finance.storage import migrations from jarvis_finance.storage.database import connect_memory from jarvis_finance.storage.migrations import apply_migrations, get_schema_version from jarvis_finance.storage.schema import REQUIRED_TABLES def test_db_schema_can_be_created() -> None: conn = connect_memory() apply_migrations(conn) tables = {row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} assert set(REQUIRED_TABLES).issubset(tables) def test_schema_version_recorded() -> None: conn = connect_memory() apply_migrations(conn) - assert get_schema_version(conn) == 52 + assert get_schema_version(conn) == 53 def test_schema_52_replay_repairs_missing_payload_hash_from_intermediate_build() -> None: conn = connect_memory() apply_migrations(conn) conn.execute("ALTER TABLE manual_snapshot_confirmations DROP COLUMN payload_hash") conn.commit() apply_migrations(conn) apply_migrations(conn) columns = { row["name"] for row in conn.execute("PRAGMA table_info(manual_snapshot_confirmations)") } assert "payload_hash" in columns - assert get_schema_version(conn) == 52 + assert get_schema_version(conn) == 53 + assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + + +def test_schema_52_migrates_additively_to_asset_observation_contract( + monkeypatch: pytest.MonkeyPatch, +) -> None: + conn = connect_memory() + migration = migrations._create_asset_refresh_observation_v2 + monkeypatch.setattr(migrations, "_create_asset_refresh_observation_v2", lambda _conn: None) + monkeypatch.setattr(migrations, "MIGRATION_VERSION", 52) + monkeypatch.setattr(migrations, "MIGRATION_NAME", "052_test_baseline") + migrations.apply_migrations(conn) + + monkeypatch.setattr(migrations, "_create_asset_refresh_observation_v2", migration) + monkeypatch.setattr(migrations, "MIGRATION_VERSION", 53) + monkeypatch.setattr(migrations, "MIGRATION_NAME", "053_asset_refresh_observation_contract_v1") + migrations.apply_migrations(conn) + + assert get_schema_version(conn) == 53 + assert conn.execute("SELECT COUNT(*) FROM market_price_observations").fetchone()[0] == 0 + source_columns = {row["name"] for row in conn.execute("PRAGMA table_info(asset_price_refresh_sources)")} + valuation_columns = {row["name"] for row in conn.execute("PRAGMA table_info(portfolio_valuation_snapshots)")} + assert {"fresh_unchanged_count", "stale_remaining_count", "failed_count", "diagnostics_json"}.issubset(source_columns) + assert {"source_observation_id", "economic_payload_hash"}.issubset(valuation_columns) assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" def test_decimal_sensitive_columns_use_text_affinity() -> None: conn = connect_memory() apply_migrations(conn) tx_cols = {row["name"]: row["type"] for row in conn.execute("PRAGMA table_info(transactions)").fetchall()} crypto_cols = {row["name"]: row["type"] for row in conn.execute("PRAGMA table_info(crypto_holdings)").fetchall()} instrument_cols = {row["name"] for row in conn.execute("PRAGMA table_info(instruments)").fetchall()} assert tx_cols["quantity"].upper() == "TEXT" assert tx_cols["fx_rate_to_chf"].upper() == "TEXT" assert crypto_cols["quantity"].upper() == "TEXT" assert {"position_category", "ter", "distribution_policy", "index_name", "fund_domicile", "benchmark"}.issubset(instrument_cols) def test_broker_bank_mapping_tables_exist_with_required_columns() -> None: conn = connect_memory() apply_migrations(conn) instrument_mapping_cols = {row["name"] for row in conn.execute("PRAGMA table_info(instrument_mappings)").fetchall()} account_mapping_cols = {row["name"] for row in conn.execute("PRAGMA table_info(platform_account_mappings)").fetchall()} dry_run_cols = {row["name"] for row in conn.execute("PRAGMA table_info(broker_import_dry_runs)").fetchall()} assert {"source_name", "source_platform", "source_label", "normalized_name", "isin", "ticker", "exchange", "currency", "asset_class", "instrument_id", "mapping_status", "confidence", "quality_flags_json"}.issubset(instrument_mapping_cols) assert {"source_platform", "source_account_label", "normalized_platform", "normalized_account_name", "internal_platform_id", "internal_account_id", "account_type", "currency", "mapping_status"}.issubset(account_mapping_cols) assert {"source_platform", "source_file_type", "source_filename_hash", "detected_snapshot_date", "snapshot_date_status", "candidate_positions", "candidate_cash_rows", "mapped_positions", "blocked_positions", "quality_flags_json", "summary_json", "session_status", "is_current"}.issubset(dry_run_cols) review_cols = {row["name"] for row in conn.execute("PRAGMA table_info(broker_import_review_items)").fetchall()} assert {"review_item_id", "dry_run_id", "source_platform", "source_row_ref", "row_hash", "source_label", "normalized_name", "detected_asset_class", "detected_currency", "quality_flags_json", "review_status", "import_readiness_status", "reviewer_confirmed", "snapshot_date_confirmed", "ticker_exchange_confirmed", "account_mapping_status"}.issubset(review_cols) execution_cols = {row["name"] for row in conn.execute("PRAGMA table_info(broker_import_execution_plans)").fetchall()} assert {"execution_plan_id", "dry_run_id", "review_item_id", "source_platform", "target_account_id", "target_instrument_id", "transaction_type", "snapshot_date", "payload_status", "payload_quality_flags_json", "source_row_hash", "planned_write_summary_json", "execution_status", "transaction_id"}.issubset(execution_cols) transaction_cols = {row["name"] for row in conn.execute("PRAGMA table_info(transactions)").fetchall()} assert {"is_voided", "voided_at", "void_reason", "voided_by", "correction_of_transaction_id", "correction_reason"}.issubset(transaction_cols) def _schema_50_connection(monkeypatch: pytest.MonkeyPatch): conn = connect_memory() current_migration = migrations._create_current_source_coverage_and_truewealth_activity_v1 monkeypatch.setattr(migrations, "_create_current_source_coverage_and_truewealth_activity_v1", lambda _conn: None) monkeypatch.setattr(migrations, "MIGRATION_VERSION", 50) monkeypatch.setattr(migrations, "MIGRATION_NAME", "050_test_baseline") migrations.apply_migrations(conn) monkeypatch.setattr(migrations, "_create_current_source_coverage_and_truewealth_activity_v1", current_migration) monkeypatch.setattr(migrations, "MIGRATION_VERSION", 51) monkeypatch.setattr(migrations, "MIGRATION_NAME", "051_current_source_coverage_and_truewealth_activity_v1") return conn def _insert_legal_duplicate_cash_snapshots(conn) -> None: conn.execute( "INSERT INTO platforms(platform_id,name,platform_type,created_at) VALUES('p','Bank','bank','2026-01-01')" ) conn.execute( """INSERT INTO accounts(account_id,platform_id,account_name,account_type,created_at) VALUES('a','p','Cash','cash','2026-01-01')""" ) for suffix in ("1", "2"): conn.execute( """INSERT INTO audit_log( audit_id,timestamp,source,action,entity_type,entity_id,created_at ) VALUES(?, '2026-01-01T00:00:00Z','test','confirm','cash_account','a','2026-01-01T00:00:00Z')""", (f"audit{suffix}",), __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/worktrees/FinanceManager-sprint23.1__HERMES_CWD_8d46a20096ed__