Add case follow-up queues and personal update notifications
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||
import { fromStore, writable } from 'svelte/store';
|
||||
import CaseFollowUp from '../../src/lib/ui/components/moderation/CaseFollowUp.svelte';
|
||||
import CaseUpdates from '../../src/lib/ui/components/moderation/CaseUpdates.svelte';
|
||||
import type {
|
||||
ModerationCase,
|
||||
ModerationCaseAction,
|
||||
ModerationCaseNotification
|
||||
} from '../../src/lib/moderation.js';
|
||||
|
||||
let component: ReturnType<typeof mount> | undefined;
|
||||
afterEach(async () => {
|
||||
if (component) await unmount(component);
|
||||
component = undefined;
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
const item: ModerationCase = {
|
||||
id: 'case-1',
|
||||
game: 't5',
|
||||
guid: '123',
|
||||
title: 'Chat review',
|
||||
status: 'resolved',
|
||||
priority: 'normal',
|
||||
assignee: null,
|
||||
assigneeId: null,
|
||||
actor: 'Moderator One',
|
||||
createdAt: '2026-10-16T10:00:00Z',
|
||||
updatedAt: '2026-10-16T10:00:00Z',
|
||||
revision: 6,
|
||||
evidence: []
|
||||
};
|
||||
const mute: ModerationCaseAction = {
|
||||
id: 'mute-1',
|
||||
type: 'restriction',
|
||||
kind: 'mute',
|
||||
reason: 'Earlier chat abuse',
|
||||
actor: 'Moderator One',
|
||||
createdAt: '2026-10-16T10:00:00Z',
|
||||
state: 'active',
|
||||
expiresAt: '2026-10-17T10:00:00Z'
|
||||
};
|
||||
function show(initial = item, permitted = true) {
|
||||
const saved = vi.fn();
|
||||
component = mount(CaseFollowUp, {
|
||||
target: document.body,
|
||||
props: {
|
||||
item: initial,
|
||||
actions: [mute, { ...mute, id: 'permanent', expiresAt: null }],
|
||||
checkedAt: '2026-10-17T12:00:00Z',
|
||||
permitted,
|
||||
onsubmitted: saved
|
||||
}
|
||||
});
|
||||
flushSync();
|
||||
return saved;
|
||||
}
|
||||
const button = (label: string, dialog = false) =>
|
||||
[...document.querySelectorAll(dialog ? 'dialog button' : 'button')].find(
|
||||
(node) => node.textContent?.trim() === label
|
||||
)! as HTMLButtonElement;
|
||||
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||
label: string
|
||||
): T {
|
||||
const node = [...document.querySelectorAll('dialog label')].find(
|
||||
(node) => node.textContent?.replace('*', '').trim() === label
|
||||
)!;
|
||||
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
|
||||
}
|
||||
function enter(label: string, value: string) {
|
||||
const node = field<HTMLInputElement | HTMLTextAreaElement>(label);
|
||||
node.value = value;
|
||||
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
flushSync();
|
||||
}
|
||||
function select(label: string, value: string) {
|
||||
const node = field<HTMLSelectElement>(label);
|
||||
const query = node.querySelector.bind(node);
|
||||
node.querySelector = ((selector: string) =>
|
||||
selector === ':checked'
|
||||
? (node.selectedOptions[0] ?? null)
|
||||
: query(selector)) as typeof node.querySelector;
|
||||
node.value = value;
|
||||
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
flushSync();
|
||||
}
|
||||
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
|
||||
|
||||
it('schedules a closed case using a browser-local date converted to canonical ISO', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const saved = show();
|
||||
button('Schedule review').click();
|
||||
await tick();
|
||||
enter('Review date (browser local time)', '2026-10-19T14:30');
|
||||
enter('Follow-up reason', 'Check for additional reports');
|
||||
button('Save follow-up').click();
|
||||
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||
op: 'schedule-case-review',
|
||||
id: 'case-1',
|
||||
revision: 6,
|
||||
reviewAt: new Date('2026-10-19T14:30').toISOString(),
|
||||
reason: 'Check for additional reports',
|
||||
idempotencyKey: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the linked finite mute ID to schedule an exact expiration review', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const saved = show();
|
||||
button('Schedule review').click();
|
||||
await tick();
|
||||
expect(
|
||||
[...field<HTMLSelectElement>('Review timing').options].map((option) => option.value)
|
||||
).toEqual(['custom', 'mute-1']);
|
||||
select('Review timing', 'mute-1');
|
||||
enter('Follow-up reason', 'Review after mute expires');
|
||||
button('Save follow-up').click();
|
||||
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||
const payload = JSON.parse(fetch.mock.calls[0][1].body);
|
||||
expect(payload).toMatchObject({
|
||||
op: 'schedule-case-review',
|
||||
restrictionId: 'mute-1',
|
||||
revision: 6
|
||||
});
|
||||
expect(payload).not.toHaveProperty('reviewAt');
|
||||
});
|
||||
|
||||
it('shows overdue reviews and clears the date with a recorded result', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const saved = show({
|
||||
...item,
|
||||
reviewAt: '2026-10-17T10:00:00Z',
|
||||
reviewReason: 'Check once the mute ends',
|
||||
reviewSanctionId: 'mute-1'
|
||||
});
|
||||
expect(document.body.textContent).toContain('Overdue review');
|
||||
expect(document.body.textContent).toContain('Review after linked mute expires.');
|
||||
button('Complete or clear review').click();
|
||||
await tick();
|
||||
enter('Review result or clearing reason', 'Review complete; no further reports');
|
||||
button('Complete or clear review', true).click();
|
||||
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||
op: 'schedule-case-review',
|
||||
id: 'case-1',
|
||||
revision: 6,
|
||||
reviewAt: null,
|
||||
reason: 'Review complete; no further reports'
|
||||
});
|
||||
});
|
||||
|
||||
it('shows overdue follow-up details without edit controls for read-only viewers', () => {
|
||||
show({ ...item, reviewAt: '2026-10-17T10:00:00Z', reviewReason: 'Review reports' }, false);
|
||||
expect(document.body.textContent).toContain('Overdue review');
|
||||
expect(document.body.textContent).toContain('Review reports');
|
||||
expect(button('Reschedule review')).toBeUndefined();
|
||||
expect(button('Complete or clear review')).toBeUndefined();
|
||||
});
|
||||
|
||||
const notification = (id: number): ModerationCaseNotification => ({
|
||||
id,
|
||||
caseId: 'case-1',
|
||||
caseTitle: item.title,
|
||||
kind: id === 3 ? 'assignment' : 'evidence',
|
||||
createdAt: '2026-10-17T10:00:00Z',
|
||||
readAt: null
|
||||
});
|
||||
it('acknowledges only loaded updates and preserves the read-through ID on an uncertain retry', async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const values = writable([notification(3), notification(9)]);
|
||||
const state = fromStore(values);
|
||||
const saved = vi.fn();
|
||||
component = mount(CaseUpdates, {
|
||||
target: document.body,
|
||||
props: {
|
||||
caseId: 'case-1',
|
||||
get notifications() {
|
||||
return state.current;
|
||||
},
|
||||
unread: 2,
|
||||
permitted: true,
|
||||
onsubmitted: saved
|
||||
}
|
||||
});
|
||||
flushSync();
|
||||
expect(document.body.textContent).toContain('Assignment changed');
|
||||
expect(document.body.textContent).toContain('Evidence or note added');
|
||||
button('Mark updates as seen').click();
|
||||
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
|
||||
values.set([notification(12), notification(9), notification(3)]);
|
||||
flushSync();
|
||||
button('Retry same request').click();
|
||||
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||
op: 'read-case-updates',
|
||||
id: 'case-1',
|
||||
throughId: 9,
|
||||
idempotencyKey: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('does not offer notification acknowledgement to read-only viewers', () => {
|
||||
component = mount(CaseUpdates, {
|
||||
target: document.body,
|
||||
props: {
|
||||
caseId: 'case-1',
|
||||
notifications: [notification(3)],
|
||||
unread: 1,
|
||||
permitted: false,
|
||||
onsubmitted: vi.fn()
|
||||
}
|
||||
});
|
||||
flushSync();
|
||||
expect(document.body.textContent).toContain('1 unread');
|
||||
expect(button('Mark updates as seen')).toBeUndefined();
|
||||
});
|
||||
Reference in New Issue
Block a user