From 6e37a4eedca6d9cbe5891d949b30dac017771b94 Mon Sep 17 00:00:00 2001 From: busya Date: Sat, 24 Oct 2026 23:01:30 +0300 Subject: [PATCH] Show verified personal run notifications in the existing inbox --- frontend/src/lib/api/run-notifications.ts | 81 +++++++++++ .../RunNotificationInbox.svelte | 51 +++++++ .../__tests__/RunNotificationInbox.test.ts | 67 +++++++++ .../i18n/locales/en/dashboard-testing.json | 11 +- .../i18n/locales/ru/dashboard-testing.json | 11 +- .../lib/models/RunNotificationModel.svelte.ts | 116 ++++++++++++++++ .../__tests__/RunNotificationModel.test.ts | 127 ++++++++++++++++++ .../analytics/notifications/+page.svelte | 74 +++++++++- 8 files changed, 529 insertions(+), 9 deletions(-) create mode 100644 frontend/src/lib/api/run-notifications.ts create mode 100644 frontend/src/lib/components/scenario-analytics/RunNotificationInbox.svelte create mode 100644 frontend/src/lib/components/scenario-analytics/__tests__/RunNotificationInbox.test.ts create mode 100644 frontend/src/lib/models/RunNotificationModel.svelte.ts create mode 100644 frontend/src/lib/models/__tests__/RunNotificationModel.test.ts diff --git a/frontend/src/lib/api/run-notifications.ts b/frontend/src/lib/api/run-notifications.ts new file mode 100644 index 000000000..a45067e78 --- /dev/null +++ b/frontend/src/lib/api/run-notifications.ts @@ -0,0 +1,81 @@ +// #region ScenarioAnalytics.RunNotificationApi [C:3] [TYPE Module] +// @BRIEF Closed current personal run notification wire contract using the authenticated API wrapper. +// @INVARIANT Malformed response data never becomes a visible success or mismatch label. +import { api } from '$lib/api'; + +export type RunNotice = { + id: string; run_id: string; scenario_id: string; environment_id: string; + outcome: 'passed' | 'mismatch' | 'check_failed'; occurred_at: string; read: boolean; +}; +export type RunNoticePage = { items: RunNotice[]; next_cursor: string | null }; + +// #region ScenarioAnalytics.RunNotificationApi.Object [C:2] [TYPE Function] +// @POST Only a non-null record with the exact requested key set survives. +function record(raw: unknown, keys: string[]): Record { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error('RUN_NOTICE_INVALID'); + const result = raw as Record; + if (Object.keys(result).length !== keys.length) throw new Error('RUN_NOTICE_INVALID'); + for (const key of keys) if (!Object.hasOwn(result, key)) throw new Error('RUN_NOTICE_INVALID'); + return result; +} +// #endregion ScenarioAnalytics.RunNotificationApi.Object + +// #region ScenarioAnalytics.RunNotificationApi.Text [C:2] [TYPE Function] +// @POST An explicit nonempty string is returned without trimming, coercion or identity substitution. +function text(raw: unknown): string { + if (typeof raw !== 'string' || !raw || raw !== raw.trim()) throw new Error('RUN_NOTICE_INVALID'); + return raw; +} +// #endregion ScenarioAnalytics.RunNotificationApi.Text + +// #region ScenarioAnalytics.RunNotificationApi.Notice [C:3] [TYPE Function] +// @POST Closed outcome/date/read fields are validated before rendering or read-state replacement. +export function decodeRunNotice(raw: unknown): RunNotice { + const row = record(raw, ['id', 'run_id', 'scenario_id', 'environment_id', 'outcome', 'occurred_at', 'read']); + const outcome = row.outcome; + if (outcome !== 'passed' && outcome !== 'mismatch' && outcome !== 'check_failed') throw new Error('RUN_NOTICE_INVALID'); + const occurredAt = text(row.occurred_at); + if (!Number.isFinite(Date.parse(occurredAt)) || typeof row.read !== 'boolean') throw new Error('RUN_NOTICE_INVALID'); + if (!/(Z|\+00:00)$/.test(occurredAt)) throw new Error('RUN_NOTICE_INVALID'); + if (new Date(occurredAt).toISOString().slice(0, 19) !== occurredAt.slice(0, 19)) throw new Error('RUN_NOTICE_INVALID'); + return { id: text(row.id), run_id: text(row.run_id), scenario_id: text(row.scenario_id), + environment_id: text(row.environment_id), outcome, occurred_at: occurredAt, read: row.read }; +} +// #endregion ScenarioAnalytics.RunNotificationApi.Notice + +// #region ScenarioAnalytics.RunNotificationApi.Page [C:3] [TYPE Function] +// @POST A page contains only distinct complete notices and an explicit nullable continuation cursor. +export function decodeRunNoticePage(raw: unknown): RunNoticePage { + const page = record(raw, ['items', 'next_cursor']); + if (!Array.isArray(page.items) || page.items.length > 20) throw new Error('RUN_NOTICE_INVALID'); + const items = page.items.map(decodeRunNotice); + const ids = new Set(); + for (const item of items) { + if (ids.has(item.id)) throw new Error('RUN_NOTICE_INVALID'); + ids.add(item.id); + } + return { items, next_cursor: page.next_cursor === null ? null : text(page.next_cursor) }; +} +// #endregion ScenarioAnalytics.RunNotificationApi.Page + +// #region ScenarioAnalytics.RunNotificationApi.Load [C:2] [TYPE Function] +// @POST Fetch at most twenty recipient-scoped rows through the authenticated application API. +export async function loadRunNotices(cursor: string | null): Promise { + const query = new URLSearchParams({ limit: '20' }); + if (cursor !== null) query.set('cursor', cursor); + return decodeRunNoticePage(await api.fetchApi(`/scenario-run-notifications?${query}`, + { suppressToast: true, signal: AbortSignal.timeout(15000) })); +} +// #endregion ScenarioAnalytics.RunNotificationApi.Load + +// #region ScenarioAnalytics.RunNotificationApi.Read [C:2] [TYPE Function] +// @POST Require the response to acknowledge exactly the requested receipt and read state. +export async function setRunNoticeRead(id: string, read: boolean): Promise { + const row = decodeRunNotice(await api.requestApi( + `/scenario-run-notifications/${encodeURIComponent(id)}/read`, 'PUT', { read }, + { suppressToast: true, signal: AbortSignal.timeout(15000) })); + if (row.id !== id || row.read !== read) throw new Error('RUN_NOTICE_ACK_CONFLICT'); + return row; +} +// #endregion ScenarioAnalytics.RunNotificationApi.Read +// #endregion ScenarioAnalytics.RunNotificationApi diff --git a/frontend/src/lib/components/scenario-analytics/RunNotificationInbox.svelte b/frontend/src/lib/components/scenario-analytics/RunNotificationInbox.svelte new file mode 100644 index 000000000..596bc48a7 --- /dev/null +++ b/frontend/src/lib/components/scenario-analytics/RunNotificationInbox.svelte @@ -0,0 +1,51 @@ + + + + + + + + +
+
+

{dt.run_inbox_title}

+ +
+ {#if model.failed} + + {:else if model.loading && !model.items.length} +

{dt.case_loading}

+ {:else if !model.items.length} +

{dt.run_inbox_empty}

+ {/if} + {#each model.items as item (item.id)} + +
+
+

{dt[`run_inbox_${item.outcome}`]}

+

{new Date(item.occurred_at).toLocaleString()}

+ {dt.run_inbox_open_result} +
+ +
+
+ {/each} + {#if model.nextCursor !== null} + + {/if} +
+ diff --git a/frontend/src/lib/components/scenario-analytics/__tests__/RunNotificationInbox.test.ts b/frontend/src/lib/components/scenario-analytics/__tests__/RunNotificationInbox.test.ts new file mode 100644 index 000000000..0c29cdec0 --- /dev/null +++ b/frontend/src/lib/components/scenario-analytics/__tests__/RunNotificationInbox.test.ts @@ -0,0 +1,67 @@ +// #region Test.ScenarioAnalytics.RunNotificationInbox [C:3] [TYPE Module] +// @BRIEF Render actual localized inbox states and follow the exact retained run link without a browser lab. +// @RELATION VERIFIES -> [ScenarioAnalytics.RunNotificationInbox] +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { api } from '$lib/api'; +import { locale } from '$lib/i18n/index.svelte.js'; +import RunNotificationInbox from '../RunNotificationInbox.svelte'; + +const notice = { id: 'notice-1', run_id: 'run-1', scenario_id: 'scenario-1', environment_id: 'preprod', + outcome: 'mismatch', occurred_at: '2026-10-24T12:00:00+00:00', read: false }; + +// #region Test.ScenarioAnalytics.RunNotificationInbox.Language [C:1] [TYPE Function] +beforeEach(function useRussian() { locale.current = 'ru'; }); +// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Language + +// #region Test.ScenarioAnalytics.RunNotificationInbox.Cleanup [C:1] [TYPE Function] +afterEach(function reset() { cleanup(); vi.restoreAllMocks(); }); +// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Cleanup + +// #region Test.ScenarioAnalytics.RunNotificationInbox.Empty [C:2] [TYPE Function] +// @TEST_INVARIANT Empty success is rendered distinctly from an unavailable API. +it('renders the explicit empty state', async function rendersEmpty() { + vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [], next_cursor: null }); + render(RunNotificationInbox); + expect(await screen.findByText('Уведомлений о результатах тестов пока нет.')).toBeTruthy(); + expect(screen.queryByRole('alert')).toBeNull(); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Empty + +// #region Test.ScenarioAnalytics.RunNotificationInbox.Unavailable [C:2] [TYPE Function] +// @TEST_INVARIANT API failure shows localized recovery, never the raw backend error or a successful empty state. +it('shows recovery without raw error details', async function rendersUnavailable() { + vi.spyOn(api, 'fetchApi').mockRejectedValue(new Error('secret internal failure')); + render(RunNotificationInbox); + expect((await screen.findByRole('alert')).textContent).toContain('Уведомления недоступны.'); + expect(screen.queryByText('secret internal failure')).toBeNull(); + expect(screen.getByRole('button', { name: 'Обновить' })).toBeTruthy(); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Unavailable + +// #region Test.ScenarioAnalytics.RunNotificationInbox.Link [C:2] [TYPE Function] +// @TEST_INVARIANT A mismatch links to precisely the server-owned scenario and run, without starting a new run. +it('links the mismatch to its exact run and acknowledges read', async function rendersMismatch() { + vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null }); + const put = vi.spyOn(api, 'requestApi').mockResolvedValue({ ...notice, read: true }); + render(RunNotificationInbox); + expect(await screen.findByText('Обнаружено расхождение')).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Открыть результат' }).getAttribute('href')).toBe( + '/dashboard-testing/scenarios/scenario-1/runs/run-1'); + const button = screen.getByRole('button', { name: /прочитан/i }); + await fireEvent.click(button); + expect(put).toHaveBeenCalledWith('/scenario-run-notifications/notice-1/read', 'PUT', { read: true }, expect.objectContaining({ suppressToast: true })); + expect(await screen.findByRole('button', { name: 'Не прочитано' })).toBeTruthy(); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationInbox.Link + +// #region Test.ScenarioAnalytics.RunNotificationInbox.CheckFailure [C:2] [TYPE Function] +// @TEST_INVARIANT An infrastructure/check failure never renders as a baseline mismatch. +it('distinguishes a check failure from mismatch', async function rendersCheckFailure() { + vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [{ ...notice, outcome: 'check_failed' }], next_cursor: null }); + render(RunNotificationInbox); + expect(await screen.findByText('Проверка не завершена — результат требует внимания')).toBeTruthy(); + expect(screen.queryByText('Обнаружено расхождение')).toBeNull(); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationInbox.CheckFailure +// #endregion Test.ScenarioAnalytics.RunNotificationInbox diff --git a/frontend/src/lib/i18n/locales/en/dashboard-testing.json b/frontend/src/lib/i18n/locales/en/dashboard-testing.json index d0d617688..35ddb595e 100644 --- a/frontend/src/lib/i18n/locales/en/dashboard-testing.json +++ b/frontend/src/lib/i18n/locales/en/dashboard-testing.json @@ -1257,5 +1257,14 @@ "authoring_metric_error_DRAFT_METRIC_CAPTURE_INVALID": "Captured outcomes do not match the acknowledged selection. Approval is unavailable.", "authoring_metric_restart": "Start again", "authoring_metric_capture_recovery": "Start a new draft to inspect and select again. The selection in this draft is already locked.", - "authoring_metric_error_CAPACITY_UNAVAILABLE": "Baseline capture capacity is unavailable. Selection is acknowledged, but no baseline was captured." + "authoring_metric_error_CAPACITY_UNAVAILABLE": "Baseline capture capacity is unavailable. Selection is acknowledged, but no baseline was captured.", + "run_inbox_title": "Test results", + "run_inbox_refresh": "Refresh", + "run_inbox_unavailable": "Notifications are unavailable. Refresh the list to try again.", + "run_inbox_empty": "No test result notifications yet.", + "run_inbox_passed": "Test passed", + "run_inbox_mismatch": "Mismatch detected", + "run_inbox_check_failed": "Check incomplete — the result needs attention", + "run_inbox_open_result": "Open result", + "run_inbox_more": "Show more" } diff --git a/frontend/src/lib/i18n/locales/ru/dashboard-testing.json b/frontend/src/lib/i18n/locales/ru/dashboard-testing.json index d1669b005..5073070eb 100644 --- a/frontend/src/lib/i18n/locales/ru/dashboard-testing.json +++ b/frontend/src/lib/i18n/locales/ru/dashboard-testing.json @@ -1259,5 +1259,14 @@ "authoring_metric_error_DRAFT_METRIC_CAPTURE_INVALID": "Результат снятия эталона не соответствует подтверждённому выбору. Утверждение недоступно.", "authoring_metric_restart": "Начать заново", "authoring_metric_capture_recovery": "Для нового просмотра и выбора начните новый черновик. В этом черновике выбор уже зафиксирован.", - "authoring_metric_error_CAPACITY_UNAVAILABLE": "Сейчас нет ресурсов для снятия эталона. Выбор подтверждён, но эталон не снят." + "authoring_metric_error_CAPACITY_UNAVAILABLE": "Сейчас нет ресурсов для снятия эталона. Выбор подтверждён, но эталон не снят.", + "run_inbox_title": "Результаты тестов", + "run_inbox_refresh": "Обновить", + "run_inbox_unavailable": "Уведомления недоступны. Обновите список, чтобы повторить попытку.", + "run_inbox_empty": "Уведомлений о результатах тестов пока нет.", + "run_inbox_passed": "Тест пройден", + "run_inbox_mismatch": "Обнаружено расхождение", + "run_inbox_check_failed": "Проверка не завершена — результат требует внимания", + "run_inbox_open_result": "Открыть результат", + "run_inbox_more": "Показать ещё" } diff --git a/frontend/src/lib/models/RunNotificationModel.svelte.ts b/frontend/src/lib/models/RunNotificationModel.svelte.ts new file mode 100644 index 000000000..01649202f --- /dev/null +++ b/frontend/src/lib/models/RunNotificationModel.svelte.ts @@ -0,0 +1,116 @@ +// #region ScenarioAnalytics.RunNotificationModel [C:3] [TYPE Module] +// @BRIEF Personal test-result inbox state with bounded paging and generation-safe read updates. +// @RELATION DEPENDS_ON -> [ScenarioAnalytics.RunNotificationApi] +import { loadRunNotices, setRunNoticeRead, type RunNotice } from '$lib/api/run-notifications'; +import { log } from '$lib/cot-logger'; + +// #region ScenarioAnalytics.RunNotificationModel.State [C:3] [TYPE Class] +// @INVARIANT Errors clear retained verdicts; a failed refresh is never represented as an empty successful inbox. +// @INVARIANT Only the newest request may publish rows; acknowledgement never optimistically changes read state. +export class RunNotificationModel { + items: RunNotice[] = $state([]); + nextCursor: string | null = $state(null); + loading = $state(false); + saving = $state(false); + failed = $state(false); + private generation = 0; + + // #region ScenarioAnalytics.RunNotificationModel.Reload [C:2] [TYPE Function] + // @POST Clear potentially revoked receipts before requesting the current recipient page. + async reload(): Promise { + this.items = []; + this.nextCursor = null; + await this.fetchPage(null); + } + // #endregion ScenarioAnalytics.RunNotificationModel.Reload + + // #region ScenarioAnalytics.RunNotificationModel.More [C:2] [TYPE Function] + // @POST A continuation is requested only when the server supplied it and no operation is pending. + async more(): Promise { + if (this.loading || this.saving || this.nextCursor === null) return; + await this.fetchPage(this.nextCursor); + } + // #endregion ScenarioAnalytics.RunNotificationModel.More + + // #region ScenarioAnalytics.RunNotificationModel.Fetch [C:3] [TYPE Function] + // @POST A response from an obsolete load cannot replace newer rows or the newer loading state. + private async fetchPage(cursor: string | null): Promise { + const generation = ++this.generation; + this.loading = true; + this.failed = false; + try { + const page = await loadRunNotices(cursor); + if (generation !== this.generation) return; + if (cursor !== null && cursor === page.next_cursor) throw new Error('RUN_NOTICE_CURSOR_CONFLICT'); + this.items = cursor === null ? page.items : this.append(page.items); + this.nextCursor = page.next_cursor; + } catch { + if (generation === this.generation) this.unavailable(); + } finally { + if (generation === this.generation) this.loading = false; + } + } + // #endregion ScenarioAnalytics.RunNotificationModel.Fetch + + // #region ScenarioAnalytics.RunNotificationModel.Append [C:2] [TYPE Function] + // @POST Overlapping continuation pages refuse rather than hide duplicate or conflicting receipts. + private append(incoming: RunNotice[]): RunNotice[] { + const ids = new Set(); + for (const item of this.items) ids.add(item.id); + for (const item of incoming) if (ids.has(item.id)) throw new Error('RUN_NOTICE_PAGE_CONFLICT'); + return [...this.items, ...incoming]; + } + // #endregion ScenarioAnalytics.RunNotificationModel.Append + + // #region ScenarioAnalytics.RunNotificationModel.Read [C:3] [TYPE Function] + // @POST Read state changes only from an exact current server acknowledgement; revoked access removes retained rows. + async toggle(item: RunNotice): Promise { + if (this.saving || this.loading) return; + const generation = this.generation; + this.saving = true; + try { + const updated = await setRunNoticeRead(item.id, !item.read); + if (generation !== this.generation) return; + this.replace(updated); + } catch { + if (generation === this.generation) this.unavailable(); + } finally { + this.saving = false; + } + } + // #endregion ScenarioAnalytics.RunNotificationModel.Read + + // #region ScenarioAnalytics.RunNotificationModel.Replace [C:2] [TYPE Function] + // @POST Replace only the acknowledged row, preserving the server's order. + private replace(updated: RunNotice): void { + const rows = []; + for (const item of this.items) { + if (item.id === updated.id) this.requireSameOutcome(item, updated); + rows.push(item.id === updated.id ? updated : item); + } + this.items = rows; + } + // #endregion ScenarioAnalytics.RunNotificationModel.Replace + + // #region ScenarioAnalytics.RunNotificationModel.SameOutcome [C:2] [TYPE Function] + // @POST A read-state acknowledgement cannot replace immutable result/navigation facts. + private requireSameOutcome(before: RunNotice, after: RunNotice): void { + for (const key of ['run_id', 'scenario_id', 'environment_id', 'outcome', 'occurred_at'] as const) { + if (before[key] !== after[key]) throw new Error('RUN_NOTICE_ACK_CONFLICT'); + } + } + // #endregion ScenarioAnalytics.RunNotificationModel.SameOutcome + + // #region ScenarioAnalytics.RunNotificationModel.Unavailable [C:2] [TYPE Function] + // @POST Show an explicit unavailable state without leaking raw backend errors or stale verdicts. + private unavailable(): void { + this.items = []; + this.nextCursor = null; + this.failed = true; + log('ScenarioAnalytics.RunNotificationModel', 'EXPLORE', 'Personal run inbox unavailable', {}, + 'Current authorized notifications could not be read', { error_code: 'RUN_INBOX_UNAVAILABLE' }); + } + // #endregion ScenarioAnalytics.RunNotificationModel.Unavailable +} +// #endregion ScenarioAnalytics.RunNotificationModel.State +// #endregion ScenarioAnalytics.RunNotificationModel diff --git a/frontend/src/lib/models/__tests__/RunNotificationModel.test.ts b/frontend/src/lib/models/__tests__/RunNotificationModel.test.ts new file mode 100644 index 000000000..97f44de5f --- /dev/null +++ b/frontend/src/lib/models/__tests__/RunNotificationModel.test.ts @@ -0,0 +1,127 @@ +// #region Test.ScenarioAnalytics.RunNotificationModel [C:3] [TYPE Module] +// @BRIEF Current inbox rendering state never trusts malformed, stale or foreign acknowledgement data. +// @RELATION VERIFIES -> [ScenarioAnalytics.RunNotificationModel.State] +import { afterEach, expect, it, vi } from 'vitest'; +import { api } from '$lib/api'; +import { RunNotificationModel } from '../RunNotificationModel.svelte'; +import { decodeRunNotice, decodeRunNoticePage } from '$lib/api/run-notifications'; + +const notice = { id: 'notice-1', run_id: 'run-1', scenario_id: 'scenario-1', environment_id: 'preprod', + outcome: 'passed', occurred_at: '2026-10-24T12:00:00+00:00', read: false }; + +// #region Test.ScenarioAnalytics.RunNotificationModel.Cleanup [C:1] [TYPE Function] +afterEach(function restoreMocks() { vi.restoreAllMocks(); }); +// #endregion Test.ScenarioAnalytics.RunNotificationModel.Cleanup + +// #region Test.ScenarioAnalytics.RunNotificationModel.Load [C:2] [TYPE Function] +// @TEST_INVARIANT A complete current page exposes exact server facts and uses the bounded personal endpoint. +it('loads the bounded personal inbox', async function loadsCurrentPage() { + const fetch = vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null }); + const model = new RunNotificationModel(); + await model.reload(); + expect(fetch).toHaveBeenCalledWith('/scenario-run-notifications?limit=20', expect.objectContaining({ suppressToast: true })); + expect(model.items).toEqual([notice]); + expect(model.failed).toBe(false); + expect(model.loading).toBe(false); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationModel.Load + +// #region Test.ScenarioAnalytics.RunNotificationModel.Read [C:2] [TYPE Function] +// @TEST_INVARIANT Server acknowledgement is required before read state changes. +it('uses the exact read acknowledgement', async function acknowledgesRead() { + vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null }); + const put = vi.spyOn(api, 'requestApi').mockResolvedValue({ ...notice, read: true }); + const model = new RunNotificationModel(); + await model.reload(); + await model.toggle(model.items[0]); + expect(put).toHaveBeenCalledWith('/scenario-run-notifications/notice-1/read', 'PUT', { read: true }, expect.objectContaining({ suppressToast: true })); + expect(model.items[0].read).toBe(true); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationModel.Read + +// #region Test.ScenarioAnalytics.RunNotificationModel.Revocation [C:2] [TYPE Function] +// @TEST_INVARIANT A revoked/unavailable acknowledgement clears old verdicts and offers explicit retry. +it('clears stale outcomes after access is revoked', async function clearsAfterDenial() { + vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null }); + vi.spyOn(api, 'requestApi').mockRejectedValue(new Error('not accessible')); + const model = new RunNotificationModel(); + await model.reload(); + await model.toggle(model.items[0]); + expect(model.items).toEqual([]); + expect(model.failed).toBe(true); + expect(model.saving).toBe(false); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationModel.Revocation + +// #region Test.ScenarioAnalytics.RunNotificationModel.Conflict [C:2] [TYPE Function] +// @TEST_INVARIANT An acknowledgement with the same notice ID cannot swap its referenced scenario/run. +it('rejects a changed outcome in a read response', async function rejectsForeignAck() { + vi.spyOn(api, 'fetchApi').mockResolvedValue({ items: [notice], next_cursor: null }); + vi.spyOn(api, 'requestApi').mockResolvedValue({ ...notice, read: true, run_id: 'foreign' }); + const model = new RunNotificationModel(); + await model.reload(); + await model.toggle(model.items[0]); + expect(model.failed).toBe(true); + expect(model.items).toEqual([]); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationModel.Conflict + +// #region Test.ScenarioAnalytics.RunNotificationModel.DuplicatePage [C:2] [TYPE Function] +// @TEST_INVARIANT An overlapping next page cannot produce duplicated visible receipts. +it('rejects duplicate pages', async function rejectsDuplicatePage() { + vi.spyOn(api, 'fetchApi').mockResolvedValueOnce({ items: [notice], next_cursor: 'cursor-1' }) + .mockResolvedValueOnce({ items: [notice], next_cursor: null }); + const model = new RunNotificationModel(); + await model.reload(); + await model.more(); + expect(model.failed).toBe(true); + expect(model.items).toEqual([]); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationModel.DuplicatePage + +// #region Test.ScenarioAnalytics.RunNotificationModel.Deferred [C:2] [TYPE Function] +// @BRIEF Hold one API transport reply until the test explicitly releases it. +function deferredPage() { + let resolve!: (value: unknown) => void; + // #region Test.ScenarioAnalytics.RunNotificationModel.Deferred.Executor [C:1] [TYPE Function] + function retainResolver(accept: (value: unknown) => void): void { resolve = accept; } + // #endregion Test.ScenarioAnalytics.RunNotificationModel.Deferred.Executor + const promise = new Promise(retainResolver); + return { promise, resolve }; +} +// #endregion Test.ScenarioAnalytics.RunNotificationModel.Deferred + +// #region Test.ScenarioAnalytics.RunNotificationModel.StaleLoad [C:2] [TYPE Function] +// @TEST_INVARIANT A late old response cannot restore rows cleared by a newer authoritative refresh. +it('ignores a stale load response', async function ignoresOlderPage() { + const pending = deferredPage(); + vi.spyOn(api, 'fetchApi').mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce({ items: [], next_cursor: null }); + const model = new RunNotificationModel(); + const older = model.reload(); + await model.reload(); + pending.resolve({ items: [notice], next_cursor: null }); + await older; + expect(model.items).toEqual([]); + expect(model.loading).toBe(false); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationModel.StaleLoad + +// #region Test.ScenarioAnalytics.RunNotificationModel.Wire [C:2] [TYPE Function] +// @TEST_INVARIANT Unknown states, coerced booleans, implicit local time and missing facts refuse. +it.each([{ ...notice, outcome: 'success' }, { ...notice, read: 'false' }, + { ...notice, occurred_at: '2026-10-24T12:00:00' }, { ...notice, run_id: '' }, + { ...notice, occurred_at: '2026-02-31T12:00:00Z' }, + { ...notice, recipient_user_id: 'foreign' }])('refuses malformed wire data', function refusesWire(raw) { + expect(decodeRunNotice.bind(null, raw)).toThrow('RUN_NOTICE_INVALID'); +}); +// #endregion Test.ScenarioAnalytics.RunNotificationModel.Wire + +// #region Test.ScenarioAnalytics.RunNotificationModel.PageWire [C:2] [TYPE Function] +// @TEST_INVARIANT Duplicate or partial pages cannot synthesize an empty successful response. +it.each([{ items: [notice, notice], next_cursor: null }, { items: [] }, null])( + 'refuses a partial page', function refusesPage(raw) { + expect(decodeRunNoticePage.bind(null, raw)).toThrow('RUN_NOTICE_INVALID'); + }); +// #endregion Test.ScenarioAnalytics.RunNotificationModel.PageWire +// #endregion Test.ScenarioAnalytics.RunNotificationModel diff --git a/frontend/src/routes/dashboard-testing/analytics/notifications/+page.svelte b/frontend/src/routes/dashboard-testing/analytics/notifications/+page.svelte index 75e0b6988..8ba24b4b2 100644 --- a/frontend/src/routes/dashboard-testing/analytics/notifications/+page.svelte +++ b/frontend/src/routes/dashboard-testing/analytics/notifications/+page.svelte @@ -6,9 +6,10 @@ import {onMount} from 'svelte'; import {t} from '$lib/i18n/index.svelte.js'; import {ROUTES} from '$lib/routes'; - import {Button,PageHeader,Input,Select} from '$lib/ui'; - import {InvestigationSubscriptionModel,type TargetType} from '$lib/models/InvestigationSubscriptionModel.svelte'; + import {Button,PageHeader,Input,Select,Card} from '$lib/ui'; + import {InvestigationSubscriptionModel,type TargetType,type InboxNotification,type Subscription} from '$lib/models/InvestigationSubscriptionModel.svelte'; import InvestigationSubscriptionPanel from '$lib/components/scenario-analytics/InvestigationSubscriptionPanel.svelte'; + import RunNotificationInbox from '$lib/components/scenario-analytics/RunNotificationInbox.svelte'; const model=new InvestigationSubscriptionModel();const dt=$derived($t.dashboard_testing??{}); let selectedTarget=$state(''); let targetType:TargetType=$state('scenario');let targetId=$state('');let activeTarget=$state('');let activeType:TargetType=$state('scenario'); @@ -16,16 +17,75 @@ async function refresh():Promise{await Promise.all([model.inbox(),model.load()]);await model.resolveTargets();} // #endregion ScenarioAnalytics.Route.Inbox.Refresh // #region ScenarioAnalytics.Route.Inbox.TargetLabel [C:2] [TYPE Function] [SEMANTICS inbox,subscription,authorization] - function targetLabel(type:TargetType,id:string):string{return model.targets.find(target=>target.key===`${type}:${id}`)?.label||dt.investigation_target_unavailable;} + function targetLabel(type:TargetType,id:string):string{ + for(const target of model.targets) if(target.key===`${type}:${id}` && target.label) return target.label; + return dt.investigation_target_unavailable; + } // #endregion ScenarioAnalytics.Route.Inbox.TargetLabel - onMount(()=>{void refresh();}); + const options=$derived.by(namedOptions); + const types=$derived.by(typeOptions); + const inherited=$derived.by(inheritedCoverage); + // #region ScenarioAnalytics.Route.Inbox.NamedOptions [C:2] [TYPE Function] + // @BRIEF Build options from existing authorized target labels only. + function namedOptions(){ + const result=[{value:'',label:dt.investigation_select_target}]; + for(const target of model.targets) result.push({value:target.key,label:target.label}); + return result; + } + // #endregion ScenarioAnalytics.Route.Inbox.NamedOptions + // #region ScenarioAnalytics.Route.Inbox.TypeOptions [C:2] [TYPE Function] + // @BRIEF Localize the four supported explicit subscription scopes. + function typeOptions(){ + const result=[]; + for(const value of ['dashboard','scenario','environment','case']) result.push({value,label:dt[`investigation_${value}`]}); + return result; + } + // #endregion ScenarioAnalytics.Route.Inbox.TypeOptions + // #region ScenarioAnalytics.Route.Inbox.InheritedCoverage [C:2] [TYPE Function] + // @BRIEF Show each target once when an actual subscription match is inherited. + function inheritedCoverage(){ + const result=[]; + for(const coverage of model.coverage){ + for(const match of coverage.matches){ + if(match.inherited){result.push(coverage);break;} + } + } + return result; + } + // #endregion ScenarioAnalytics.Route.Inbox.InheritedCoverage + // #region ScenarioAnalytics.Route.Inbox.SelectNamed [C:2] [TYPE Function] + // @POST Retain the exact selected target identity and type from the authorized list. + function selectNamedTarget():void{ + for(const target of model.targets){ + if(target.key===selectedTarget){activeTarget=target.id;activeType=target.type;return;} + } + } + // #endregion ScenarioAnalytics.Route.Inbox.SelectNamed + // #region ScenarioAnalytics.Route.Inbox.SelectManual [C:1] [TYPE Function] + // @BRIEF Forward the advanced target; the subscription API owns authorization. + function selectManualTarget():void{activeTarget=targetId.trim();activeType=targetType;} + // #endregion ScenarioAnalytics.Route.Inbox.SelectManual + // #region ScenarioAnalytics.Route.Inbox.Read [C:1] [TYPE Function] + // @BRIEF Delegate an existing investigation receipt read-state change. + function readNotification(notification:InboxNotification):void{void model.read(notification);} + // #endregion ScenarioAnalytics.Route.Inbox.Read + // #region ScenarioAnalytics.Route.Inbox.Remove [C:1] [TYPE Function] + // @BRIEF Remove only the selected explicit subscription. + function removeSubscription(subscription:Subscription):void{void model.remove(subscription);} + // #endregion ScenarioAnalytics.Route.Inbox.Remove + // #region ScenarioAnalytics.Route.Inbox.Mount [C:1] [TYPE Function] + // @BRIEF Preserve the existing client-side investigation page initialization. + function mounted():void{void refresh();} + // #endregion ScenarioAnalytics.Route.Inbox.Mount + onMount(mounted); {dt.investigation_notifications}
{dt.case_page_back} - {#if model.error}{/if} + + {#if model.error}{/if} {#if model.loading}

{dt.case_loading}

{:else if !model.notifications.length}

{dt.investigation_notification_empty}

{/if} - {#each model.notifications as notification (notification.id)}

{dt[`investigation_${notification.kind}`] || notification.kind} · {new Date(notification.created_at).toLocaleString()}

{#each notification.events as event (event.id)}

{dt[`investigation_${event.category}`]} · {dt[`investigation_${event.kind}`] || event.kind}{#if typeof event.target_references?.case==='string'} · {targetLabel('case',event.target_references.case)}{/if}

{/each}
{/each} -

{dt.investigation_subscriptions}

{#each model.subscriptions as subscription (subscription.id)}

{dt[`investigation_${subscription.target_type}`]} · {targetLabel(subscription.target_type,subscription.target_id)}

{/each}{#each model.coverage.filter(coverage=>coverage.matches.some(match=>match.inherited)) as coverage (`${coverage.target_type}:${coverage.target_id}`)}

{dt.investigation_inherited}: {targetLabel(coverage.target_type,coverage.target_id)}

{/each}
({value,label:dt[`investigation_${value}`]}))}/>
{#if activeTarget}{#key `${activeType}:${activeTarget}`}{/key}{/if}
+ {#each model.notifications as notification (notification.id)}

{dt[`investigation_${notification.kind}`] || notification.kind} · {new Date(notification.created_at).toLocaleString()}

{#each notification.events as event (event.id)}

{dt[`investigation_${event.category}`]} · {dt[`investigation_${event.kind}`] || event.kind}{#if typeof event.target_references?.case==='string'} · {targetLabel('case',event.target_references.case)}{/if}

{/each}
{/each} +

{dt.investigation_subscriptions}

{#each model.subscriptions as subscription (subscription.id)}

{dt[`investigation_${subscription.target_type}`]} · {targetLabel(subscription.target_type,subscription.target_id)}

{/each}{#each inherited as coverage (`${coverage.target_type}:${coverage.target_id}`)}

{dt.investigation_inherited}: {targetLabel(coverage.target_type,coverage.target_id)}

{/each}
{#if activeTarget}{#key `${activeType}:${activeTarget}`}{/key}{/if}