mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
feat(web): Notifications section in account settings (TASK-3940) (#2276)
* feat(web): Notifications section in account settings (TASK-3940) Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE * fix(web): notification toggles keep focus while saving; #notifications deep link scrolls (TASK-3940 r1) Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE
This commit is contained in:
@@ -104,6 +104,7 @@ import type {
|
||||
TutorialsResponse,
|
||||
UIDismissalKey,
|
||||
UIDismissalsResponse,
|
||||
NotificationSettings,
|
||||
ImportOutcome,
|
||||
ArchivedCollection,
|
||||
CollectionFieldUsage
|
||||
@@ -1290,6 +1291,10 @@ export interface AuthSession {
|
||||
// replace "we emailed you a link" with host-recovery guidance, since no
|
||||
// email can actually be sent. Absent on older servers — treat as true.
|
||||
email_configured?: boolean;
|
||||
// push_available is true when this server can deliver push notifications
|
||||
// (a gateway is configured). Sent only to signed-in callers (TASK-3938);
|
||||
// absent means off, so the Notifications settings section stays hidden.
|
||||
push_available?: boolean;
|
||||
// account_disabled is true when the request's credential belongs to a
|
||||
// disabled account (BUG-3349): authenticated is false, and the sign-in
|
||||
// page explains why instead of offering a sign-in that cannot work.
|
||||
@@ -2778,6 +2783,17 @@ export const api = {
|
||||
request<UIDismissalsResponse>(`/me/ui-dismissals/${encodeURIComponent(key)}`, { method: 'PUT' })
|
||||
},
|
||||
|
||||
// Which events send the caller a push notification (TASK-3940). PUT is a
|
||||
// full replace: the server refuses a body that omits or adds a key.
|
||||
notificationSettings: {
|
||||
get: () => request<NotificationSettings>('/me/notification-settings'),
|
||||
put: (settings: NotificationSettings) =>
|
||||
request<NotificationSettings>('/me/notification-settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings)
|
||||
})
|
||||
},
|
||||
|
||||
activity: {
|
||||
list: (
|
||||
ws: string,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import type { NotificationSettings } from '$lib/types';
|
||||
|
||||
// Which events send the caller a push notification (TASK-3940). Every save
|
||||
// is a full replace of all seven values, so the body is built from KEYS
|
||||
// rather than spread from the response, and writes are serialized: two
|
||||
// overlapping full-replace PUTs would lose one of the edits.
|
||||
const uid = $props.id();
|
||||
const id = `notification-settings-${uid}`;
|
||||
|
||||
const ROWS: { key: keyof NotificationSettings; label: string; hint?: string }[] = [
|
||||
{ key: 'push_enabled', label: 'Push notifications' },
|
||||
{ key: 'notify_assigned', label: 'Assigned to you' },
|
||||
{ key: 'notify_comments', label: 'Comments on your items' },
|
||||
{ key: 'notify_replies', label: 'Replies to your comments' },
|
||||
{ key: 'notify_reminders', label: 'Reminders' },
|
||||
{ key: 'notify_mentions', label: 'Mentions' },
|
||||
{
|
||||
key: 'include_agent_activity',
|
||||
label: 'Include activity by agents',
|
||||
hint: "Off by default, so agents working in your workspace don't buzz your phone."
|
||||
}
|
||||
];
|
||||
|
||||
let settings = $state<NotificationSettings | null>(null);
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
let saveError = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = '';
|
||||
try {
|
||||
settings = await api.notificationSettings.get();
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : 'Could not load notification settings.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(key: keyof NotificationSettings, input: HTMLInputElement) {
|
||||
if (!settings) return;
|
||||
// aria-disabled does not block activation, so the guard lives here. The
|
||||
// box has already flipped in the DOM and `checked` is one-way, so put it
|
||||
// back to the stored value.
|
||||
if (saving) {
|
||||
input.checked = settings[key];
|
||||
return;
|
||||
}
|
||||
const checked = input.checked;
|
||||
const before = settings;
|
||||
const next = {} as NotificationSettings;
|
||||
for (const row of ROWS) next[row.key] = row.key === key ? checked : before[row.key];
|
||||
settings = next;
|
||||
saving = true;
|
||||
saveError = '';
|
||||
try {
|
||||
const saved = await api.notificationSettings.put(next);
|
||||
const confirmed = {} as NotificationSettings;
|
||||
for (const row of ROWS) confirmed[row.key] = saved[row.key];
|
||||
settings = confirmed;
|
||||
} catch (e) {
|
||||
settings = before;
|
||||
saveError = e instanceof Error ? e.message : 'Could not save notification settings.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// The page renders this section after its own load, so the browser has
|
||||
// already resolved the hash by the time the target exists. Scroll once.
|
||||
if (location.hash === '#notifications') {
|
||||
document.getElementById('notifications')?.scrollIntoView();
|
||||
}
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<p class="lead">
|
||||
Choose what sends a push notification to your phone. Notifications never include item titles or
|
||||
comment text.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
<p class="status" aria-busy="true">Loading notification settings...</p>
|
||||
{:else if loadError || !settings}
|
||||
<p class="error" role="alert">{loadError || 'Could not load notification settings.'}</p>
|
||||
<button type="button" class="retry" onclick={load}>Retry</button>
|
||||
{:else}
|
||||
<div class="rows" class:off={!settings.push_enabled}>
|
||||
{#each ROWS as row, i (row.key)}
|
||||
<div class="toggle" class:master={i === 0}>
|
||||
<input
|
||||
id="{id}-{row.key}"
|
||||
type="checkbox"
|
||||
checked={settings[row.key]}
|
||||
aria-disabled={saving}
|
||||
aria-describedby={row.hint ? `${id}-${row.key}-hint` : undefined}
|
||||
onchange={(e) => toggle(row.key, e.currentTarget)}
|
||||
/>
|
||||
<div>
|
||||
<label for="{id}-{row.key}">{row.label}</label>
|
||||
{#if row.hint}
|
||||
<p id="{id}-{row.key}-hint" class="desc">{row.hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if i === 0 && !settings.push_enabled}
|
||||
<p class="desc note">Push is off, so nothing is sent to your devices.</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if saveError}
|
||||
<p class="error" role="alert">{saveError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.lead,
|
||||
.status {
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.rows.off .toggle:not(.master) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.retry {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
|
||||
|
||||
/**
|
||||
* TASK-3940: the Notifications settings section. Every save is a full replace
|
||||
* of seven values, so these tests read the PUT bodies the component sent.
|
||||
*/
|
||||
const KEYS = [
|
||||
'push_enabled',
|
||||
'notify_assigned',
|
||||
'notify_comments',
|
||||
'notify_replies',
|
||||
'notify_reminders',
|
||||
'notify_mentions',
|
||||
'include_agent_activity'
|
||||
] as const;
|
||||
const LABELS: Record<(typeof KEYS)[number], string> = {
|
||||
push_enabled: 'Push notifications',
|
||||
notify_assigned: 'Assigned to you',
|
||||
notify_comments: 'Comments on your items',
|
||||
notify_replies: 'Replies to your comments',
|
||||
notify_reminders: 'Reminders',
|
||||
notify_mentions: 'Mentions',
|
||||
include_agent_activity: 'Include activity by agents'
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
server: {} as Record<string, boolean>,
|
||||
puts: [] as Record<string, boolean>[],
|
||||
failPut: false,
|
||||
failGet: false,
|
||||
hold: null as null | { release: () => void }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
api: {
|
||||
notificationSettings: {
|
||||
get: vi.fn(async () => {
|
||||
if (state.failGet) throw new Error('Load refused');
|
||||
return { ...state.server };
|
||||
}),
|
||||
put: vi.fn(async (body: Record<string, boolean>) => {
|
||||
state.puts.push(body);
|
||||
if (state.hold) await new Promise<void>((r) => (state.hold!.release = r));
|
||||
if (state.failPut) throw new Error('Save refused');
|
||||
state.server = { ...body };
|
||||
return { ...body };
|
||||
})
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const { default: NotificationSettings } = await import('./NotificationSettings.svelte');
|
||||
|
||||
beforeEach(() => {
|
||||
state.server = Object.fromEntries(KEYS.map((k) => [k, k !== 'include_agent_activity']));
|
||||
state.puts.length = 0;
|
||||
state.failPut = false;
|
||||
state.failGet = false;
|
||||
state.hold = null;
|
||||
});
|
||||
|
||||
const box = (key: (typeof KEYS)[number]) =>
|
||||
screen.getByRole('checkbox', { name: LABELS[key] }) as HTMLInputElement;
|
||||
|
||||
async function shown() {
|
||||
render(NotificationSettings);
|
||||
await waitFor(() => expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0));
|
||||
}
|
||||
|
||||
describe('NotificationSettings', () => {
|
||||
it('shows all seven toggles with the server values', async () => {
|
||||
await shown();
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(7);
|
||||
for (const k of KEYS) expect(box(k).checked).toBe(k !== 'include_agent_activity');
|
||||
expect(screen.getByText(/never include item titles or comment text/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(KEYS)('toggling %s sends that key flipped and the rest unchanged', async (key) => {
|
||||
await shown();
|
||||
const was = box(key).checked;
|
||||
await fireEvent.click(box(key));
|
||||
await waitFor(() => expect(state.puts).toHaveLength(1));
|
||||
const expected = Object.fromEntries(KEYS.map((k) => [k, k === key ? !was : k !== 'include_agent_activity']));
|
||||
expect(state.puts[0]).toEqual(expected);
|
||||
await waitFor(() => expect(box(key).checked).toBe(!was));
|
||||
});
|
||||
|
||||
it('every PUT carries exactly the seven keys, all booleans', async () => {
|
||||
await shown();
|
||||
await fireEvent.click(box('notify_mentions'));
|
||||
await waitFor(() => expect(state.puts).toHaveLength(1));
|
||||
expect(Object.keys(state.puts[0]).sort()).toEqual([...KEYS].sort());
|
||||
expect(Object.values(state.puts[0]).every((v) => typeof v === 'boolean')).toBe(true);
|
||||
});
|
||||
|
||||
it('a failed save reverts the toggle, says why, and is not wedged', async () => {
|
||||
await shown();
|
||||
state.failPut = true;
|
||||
await fireEvent.click(box('notify_assigned'));
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Save refused'));
|
||||
expect(box('notify_assigned').checked).toBe(true);
|
||||
state.failPut = false;
|
||||
await fireEvent.click(box('notify_assigned'));
|
||||
await waitFor(() => expect(box('notify_assigned').checked).toBe(false));
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks every toggle aria-disabled while a save is in flight, and a second toggle sends no PUT and snaps back', async () => {
|
||||
await shown();
|
||||
state.hold = { release: () => {} };
|
||||
await fireEvent.click(box('notify_assigned'));
|
||||
await waitFor(() => expect(box('notify_replies').getAttribute('aria-disabled')).toBe('true'));
|
||||
const stored = box('notify_replies').checked;
|
||||
await fireEvent.click(box('notify_replies'));
|
||||
expect(state.puts).toHaveLength(1);
|
||||
expect(box('notify_replies').checked).toBe(stored);
|
||||
state.hold.release();
|
||||
await waitFor(() => expect(box('notify_replies').getAttribute('aria-disabled')).toBe('false'));
|
||||
});
|
||||
|
||||
it('keeps focus on the toggled input across a pending save', async () => {
|
||||
await shown();
|
||||
state.hold = { release: () => {} };
|
||||
box('notify_assigned').focus();
|
||||
await fireEvent.click(box('notify_assigned'));
|
||||
await waitFor(() => expect(box('notify_assigned').getAttribute('aria-disabled')).toBe('true'));
|
||||
expect(document.activeElement).toBe(box('notify_assigned'));
|
||||
state.hold.release();
|
||||
await waitFor(() => expect(box('notify_assigned').getAttribute('aria-disabled')).toBe('false'));
|
||||
expect(document.activeElement).toBe(box('notify_assigned'));
|
||||
});
|
||||
|
||||
describe('#notifications deep link', () => {
|
||||
let section: HTMLElement;
|
||||
let spy: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
section = document.createElement('section');
|
||||
section.id = 'notifications';
|
||||
spy = vi.fn();
|
||||
section.scrollIntoView = spy;
|
||||
document.body.appendChild(section);
|
||||
});
|
||||
afterEach(() => {
|
||||
section.remove();
|
||||
history.replaceState(null, '', location.pathname);
|
||||
});
|
||||
|
||||
it('scrolls the section into view exactly once when the hash matches, not again on retry', async () => {
|
||||
history.replaceState(null, '', '#notifications');
|
||||
state.failGet = true;
|
||||
render(NotificationSettings);
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Load refused'));
|
||||
state.failGet = false;
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
await waitFor(() => expect(screen.getAllByRole('checkbox')).toHaveLength(7));
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('never scrolls without the hash', async () => {
|
||||
history.replaceState(null, '', '#other');
|
||||
await shown();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('a failed load shows the error and Retry, with no toggles; Retry loads', async () => {
|
||||
state.failGet = true;
|
||||
render(NotificationSettings);
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Load refused'));
|
||||
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
||||
state.failGet = false;
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
await waitFor(() => expect(screen.getAllByRole('checkbox')).toHaveLength(7));
|
||||
});
|
||||
|
||||
it('with push off, the other toggles stay editable and a note says nothing is sent', async () => {
|
||||
state.server.push_enabled = false;
|
||||
await shown();
|
||||
expect(screen.getByText('Push is off, so nothing is sent to your devices.')).toBeInTheDocument();
|
||||
for (const k of KEYS) expect(box(k).getAttribute('aria-disabled')).not.toBe('true');
|
||||
});
|
||||
|
||||
it('with push on there is no off note', async () => {
|
||||
await shown();
|
||||
expect(screen.queryByText(/Push is off/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,7 @@ export const EACH_KEY_CENSUS: Record<string, { reason: EachKeyReason; note: stri
|
||||
"lib/components/search/CommandPalette.svelte :: each group.results as r (r.item.id || r.item.slug)": { reason: 'deduped', note: "results; Load more appends through uniqueBy on the item id" },
|
||||
"lib/components/search/CommandPalette.svelte :: each recentSearches as recent, i (recent)": { reason: 'deduped', note: "`recentSearches` from localStorage | the only writer, `saveRecentSearch` (line 884), always dedupes before writing (and has since it was added). Caveat: `loadRecentSearches` does n" },
|
||||
"lib/components/search/CommandPalette.svelte :: each results as r, i (r.item.id || r.item.slug)": { reason: 'deduped', note: "Load more appends through uniqueBy on the item id" },
|
||||
"lib/components/settings/NotificationSettings.svelte :: each ROWS as row, i (row.key)": { reason: 'static-literal', note: "`ROWS` literal (l.10) | seven distinct notification setting keys." },
|
||||
"lib/components/settings/StorageTab.svelte :: each attachments as att (att.id)": { reason: 'db-primary-key', note: "`WorkspaceAttachments` page, replaced per page (not appended) | Primary key; the LEFT JOIN to items is 1:1." },
|
||||
"lib/components/settings/StorageTab.svelte :: each collections as coll (coll.id)": { reason: 'db-primary-key', note: "settings page `api.collections.list` | Primary key from a single SELECT." },
|
||||
"lib/components/settings/apps/AppArtifactReview.svelte :: each artifact.changes as ch (ch)": { reason: 'db-primary-key', note: "`norm.Warnings` \u2190 `preprocessArtifactFields` (handlers_artifact_import.go:249) | Each warning names its own field key. The first hit clears the value, so a repeated schema key can'" },
|
||||
|
||||
@@ -217,6 +217,9 @@ export const authStore = {
|
||||
// before the session loads) so reset/invite flows keep their normal copy
|
||||
// unless the server explicitly says email is off. The /forgot-password
|
||||
// page swaps to host-recovery guidance when this is false.
|
||||
// pushAvailable gates the Notifications settings section. The server sends
|
||||
// it only to signed-in callers; absent means off (TASK-3940).
|
||||
get pushAvailable() { return session?.push_available ?? false; },
|
||||
get emailConfigured() { return session?.email_configured ?? true; },
|
||||
// emailVerified is false ONLY for a Pad Cloud self-serve signup that hasn't
|
||||
// confirmed its email yet (PLAN-1933 DR-3 / TASK-1940). Defaults to TRUE
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
/** TASK-3940: authStore.pushAvailable follows the session's push_available; absent is off. */
|
||||
|
||||
const session = vi.hoisted(() => ({ value: null as unknown }));
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
api: { auth: { session: vi.fn(async () => session.value) } }
|
||||
}));
|
||||
|
||||
async function store(extra: Record<string, unknown>) {
|
||||
session.value = { authenticated: true, user: { id: 'u1', email: 'u1@example.com' }, ...extra };
|
||||
const { authStore } = await import('./auth.svelte');
|
||||
await authStore.load();
|
||||
return authStore;
|
||||
}
|
||||
|
||||
describe('authStore.pushAvailable', () => {
|
||||
beforeEach(() => vi.resetModules());
|
||||
|
||||
it('is true when the session says push is available', async () => {
|
||||
expect((await store({ push_available: true })).pushAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the session omits it', async () => {
|
||||
expect((await store({})).pushAvailable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3409,6 +3409,20 @@ export interface TutorialsResponse {
|
||||
/** One-time UI suggestions the user dismissed (models.UIDismissalKeys). */
|
||||
export type UIDismissalKey = 'tutorials.console' | 'tutorials.launchpad';
|
||||
|
||||
/**
|
||||
* The caller's push notification settings (models.NotificationSettings).
|
||||
* PUT replaces all seven; the server refuses a body missing or adding a key.
|
||||
*/
|
||||
export interface NotificationSettings {
|
||||
push_enabled: boolean;
|
||||
notify_assigned: boolean;
|
||||
notify_comments: boolean;
|
||||
notify_replies: boolean;
|
||||
notify_reminders: boolean;
|
||||
notify_mentions: boolean;
|
||||
include_agent_activity: boolean;
|
||||
}
|
||||
|
||||
export interface UIDismissalsResponse {
|
||||
dismissed: UIDismissalKey[];
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { exportAndDownloadAccountData } from '$lib/utils/artifacts';
|
||||
import Button from '$lib/components/common/Button.svelte';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import NotificationSettings from '$lib/components/settings/NotificationSettings.svelte';
|
||||
import CharacterShortcutsToggle from '$lib/components/common/CharacterShortcutsToggle.svelte';
|
||||
import type { User, APIToken, APITokenWithSecret, TOTPSetupResponse } from '$lib/types';
|
||||
import { describeTokenScopes } from '$lib/settings/tokenScopes';
|
||||
@@ -684,6 +685,15 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if authStore.pushAvailable}
|
||||
<section class="card" id="notifications">
|
||||
<h2 class="card-title">Notifications</h2>
|
||||
<div class="card-body">
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Password -->
|
||||
<section class="card">
|
||||
<h2 class="card-title">Password</h2>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
|
||||
/** TASK-3940: the account settings page shows Notifications only where push is available. */
|
||||
const state = vi.hoisted(() => ({ push: false, gets: 0 }));
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
PadApiError: class extends Error {},
|
||||
isPlanLimitError: () => false,
|
||||
api: {
|
||||
notificationSettings: {
|
||||
get: vi.fn(async () => {
|
||||
state.gets++;
|
||||
return {
|
||||
push_enabled: true,
|
||||
notify_assigned: true,
|
||||
notify_comments: true,
|
||||
notify_replies: true,
|
||||
notify_reminders: true,
|
||||
notify_mentions: true,
|
||||
include_agent_activity: false
|
||||
};
|
||||
}),
|
||||
put: vi.fn()
|
||||
},
|
||||
auth: {
|
||||
me: vi.fn(async () => ({ id: 'u1', name: 'Pat', email: 'pat@example.com', username: 'pat', password_set: true })),
|
||||
totp: { disable: vi.fn(), setup: vi.fn(), verify: vi.fn() },
|
||||
tokens: { list: vi.fn(async () => []), create: vi.fn(), rotate: vi.fn(), delete: vi.fn() },
|
||||
updateProfile: vi.fn(),
|
||||
unlinkProvider: vi.fn(),
|
||||
deleteAccount: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
forgotPassword: vi.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
vi.mock('$lib/stores/auth.svelte', () => ({
|
||||
authStore: {
|
||||
get cloudMode() { return false; },
|
||||
get emailConfigured() { return true; },
|
||||
get pushAvailable() { return state.push; },
|
||||
get user() { return { id: 'u1', name: 'Pat', email: 'pat@example.com' }; },
|
||||
get userId() { return 'u1'; },
|
||||
get identityEpoch() { return 0; },
|
||||
identityFence() { return () => true; },
|
||||
onIdentityChange() { return () => {}; },
|
||||
load: vi.fn(async () => {}),
|
||||
ensureLoaded: vi.fn(async () => {})
|
||||
}
|
||||
}));
|
||||
|
||||
const { default: AccountSettings } = await import('./+page.svelte');
|
||||
|
||||
beforeEach(() => {
|
||||
state.push = false;
|
||||
state.gets = 0;
|
||||
});
|
||||
|
||||
describe('TASK-3940: Notifications section gating', () => {
|
||||
it('is absent, and loads nothing, when push is not available', async () => {
|
||||
render(AccountSettings);
|
||||
await waitFor(() => expect(screen.getByText('Keyboard')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('heading', { name: 'Notifications' })).toBeNull();
|
||||
expect(state.gets).toBe(0);
|
||||
});
|
||||
|
||||
it('shows the heading and seven toggles when push is available', async () => {
|
||||
state.push = true;
|
||||
const { container } = render(AccountSettings);
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Notifications' })).toBeInTheDocument());
|
||||
const section = container.querySelector('#notifications') as HTMLElement;
|
||||
await waitFor(() => expect(section.querySelectorAll('input[type=checkbox]')).toHaveLength(7));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user