Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions frontend/src/components/calendar/CalendarWritebackSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/* @vitest-environment jsdom */
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';

vi.mock('lucide-react', () => ({
Loader2: () => <svg aria-hidden="true" />,
}));

import { CalendarWritebackSection } from './CalendarWritebackSection';
import type { CalendarWritebackSource } from './types';

const writableSource: CalendarWritebackSource = {
source_id: 'caldav-primary',
provider: 'Customer CalDAV',
protocol: 'caldav',
owner_id: 'user-1',
organization_id: 'org-1',
capabilities: ['read', 'write', 'etag'],
writeback_enabled: true,
etag: 'etag-1',
};

const readOnlySource: CalendarWritebackSource = {
...writableSource,
source_id: 'caldav-readonly',
capabilities: ['read'],
writeback_enabled: false,
etag: null,
};

describe('CalendarWritebackSection accessibility contract', () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;

afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
container?.remove();
container = null;
});

function renderSection(overrides: Partial<React.ComponentProps<typeof CalendarWritebackSection>> = {}) {
const requestWritebackIntent = vi.fn();
const setSelectedSourceId = vi.fn();
const props: React.ComponentProps<typeof CalendarWritebackSection> = {
requestWritebackIntent,
isWritebackActionDisabled: false,
pendingWritebackAction: null,
isProviderExecutionDisabled: false,
writebackSources: [writableSource],
selectedWritebackSource: writableSource,
setSelectedSourceId,
isCustomerOwnedWritableSource: (source) => source.writeback_enabled && source.capabilities.includes('write'),
sourceLoadStatus: 'ready',
writebackStatus: 'idle',
writebackResult: null,
...overrides,
};

container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(<CalendarWritebackSection {...props} />);
});

return { requestWritebackIntent, setSelectedSourceId };
}

function getControlStatus(createButton: HTMLButtonElement | undefined) {
const descriptionId = createButton?.getAttribute('aria-describedby');
expect(descriptionId).toBeTruthy();
const status = descriptionId
? container?.querySelector<HTMLElement>(`[id="${descriptionId}"][role="status"]`)
: null;
expect(status).not.toBeNull();
return { descriptionId, status };
}

it('keeps unavailable async actions natively disabled and exposes the reason as a live status', () => {
const { requestWritebackIntent } = renderSection({
isWritebackActionDisabled: true,
isProviderExecutionDisabled: true,
sourceLoadStatus: 'loading',
writebackSources: [],
selectedWritebackSource: null,
});

const createButton = Array.from(container?.querySelectorAll<HTMLButtonElement>('button') ?? [])
.find((button) => button.textContent?.includes('새 일정 intent 점검'));
const executeButton = Array.from(container?.querySelectorAll<HTMLButtonElement>('button') ?? [])
.find((button) => button.textContent?.includes('ETag 실행 요청'));
const { descriptionId, status } = getControlStatus(createButton);

expect(createButton?.disabled).toBe(true);
expect(executeButton?.disabled).toBe(true);
expect(executeButton?.getAttribute('aria-describedby')).toBe(descriptionId);
expect(status?.getAttribute('aria-live')).toBe('polite');
expect(status?.textContent).toContain('일정 원본을 확인 중이라 반영 의도 점검을 시작할 수 없습니다.');

act(() => {
createButton?.click();
executeButton?.click();
});
expect(requestWritebackIntent).not.toHaveBeenCalled();
});

it('keeps enabled actions operable while describing provider execution readiness', () => {
const { requestWritebackIntent } = renderSection();
const createButton = Array.from(container?.querySelectorAll<HTMLButtonElement>('button') ?? [])
.find((button) => button.textContent?.includes('새 일정 intent 점검'));
const executeButton = Array.from(container?.querySelectorAll<HTMLButtonElement>('button') ?? [])
.find((button) => button.textContent?.includes('ETag 실행 요청'));
const { status } = getControlStatus(createButton);

expect(createButton?.disabled).toBe(false);
expect(executeButton?.disabled).toBe(false);
expect(status?.textContent)
.toContain('선택한 고객 원본 일정에 반영할 의도와 외부 실행 조건을 점검할 수 있습니다.');

act(() => {
createButton?.click();
executeButton?.click();
});
expect(requestWritebackIntent).toHaveBeenNthCalledWith(1, 'create');
expect(requestWritebackIntent).toHaveBeenNthCalledWith(2, 'update', true);
});

it('keeps read-only sources non-interactive and names the write restriction in visible content', () => {
const { setSelectedSourceId } = renderSection({
writebackSources: [readOnlySource],
selectedWritebackSource: readOnlySource,
isProviderExecutionDisabled: true,
});
const sourceButton = container?.querySelector<HTMLButtonElement>('button[aria-label="일정 원본 1 읽기 전용 선택"]');
const createButton = Array.from(container?.querySelectorAll<HTMLButtonElement>('button') ?? [])
.find((button) => button.textContent?.includes('새 일정 intent 점검'));
const { status } = getControlStatus(createButton);

expect(sourceButton?.disabled).toBe(true);
expect(sourceButton?.textContent).toContain('읽기 전용');
expect(sourceButton?.textContent).toContain('외부 쓰기 차단');
expect(status?.textContent)
.toContain('반영 가능한 일정 원본이 없어 반영 의도 점검을 시작할 수 없습니다.');

act(() => {
sourceButton?.click();
});
expect(setSelectedSourceId).not.toHaveBeenCalled();
});

it('disables intent checks when the ready registry has no writable source', () => {
const { requestWritebackIntent } = renderSection({
writebackSources: [readOnlySource],
selectedWritebackSource: readOnlySource,
sourceLoadStatus: 'ready',
isWritebackActionDisabled: false,
isProviderExecutionDisabled: true,
});
const buttons = Array.from(container?.querySelectorAll<HTMLButtonElement>('button') ?? []);
const createButton = buttons.find((button) => button.textContent?.includes('새 일정 intent 점검'));
const updateButton = buttons.find((button) => button.textContent?.includes('ETag 업데이트 점검'));
const executeButton = buttons.find((button) => button.textContent?.includes('ETag 실행 요청'));
const { status } = getControlStatus(createButton);

expect(createButton?.disabled).toBe(true);
expect(updateButton?.disabled).toBe(true);
expect(executeButton?.disabled).toBe(true);
expect(status?.textContent)
.toContain('반영 가능한 일정 원본이 없어 반영 의도 점검을 시작할 수 없습니다.');

act(() => {
createButton?.click();
updateButton?.click();
executeButton?.click();
});
expect(requestWritebackIntent).not.toHaveBeenCalled();
});

it('keeps aria-describedby targets unique when reusable writeback sections share a document', () => {
const requestWritebackIntent = vi.fn();
const setSelectedSourceId = vi.fn();
const props: React.ComponentProps<typeof CalendarWritebackSection> = {
requestWritebackIntent,
isWritebackActionDisabled: false,
pendingWritebackAction: null,
isProviderExecutionDisabled: false,
writebackSources: [writableSource],
selectedWritebackSource: writableSource,
setSelectedSourceId,
isCustomerOwnedWritableSource: (source) => source.writeback_enabled && source.capabilities.includes('write'),
sourceLoadStatus: 'ready',
writebackStatus: 'idle',
writebackResult: null,
};

container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
<CalendarWritebackSection {...props} />
<CalendarWritebackSection {...props} />
</>,
);
});

const sectionElements = Array.from(container.querySelectorAll<HTMLElement>('section[aria-label="일정 반영 의도 점검"]'));
expect(sectionElements).toHaveLength(2);

const descriptionIds = sectionElements.map((section) => {
const createButton = Array.from(section.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.textContent?.includes('새 일정 intent 점검'));
const descriptionId = createButton?.getAttribute('aria-describedby');
expect(descriptionId).toBeTruthy();
expect(section.querySelector<HTMLElement>(`[id="${descriptionId}"][role="status"]`)).not.toBeNull();
return descriptionId;
});

expect(new Set(descriptionIds).size).toBe(2);
});
});
40 changes: 36 additions & 4 deletions frontend/src/components/calendar/CalendarWritebackSection.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useId } from 'react';
import type { CalendarWritebackActionKey, CalendarWritebackSource, CalendarWritebackIntentResponse, WritebackStatus } from './types';
import { getCalendarSourceLabel, getProtocolLabel, getCapabilityLabel, getEtagLabel, getWritebackModeLabel, getIntentProtocolLabel, getProviderExecutionLabel, getProviderRetryLabel } from './helpers';
import { Loader2 } from 'lucide-react';
Expand Down Expand Up @@ -29,9 +30,27 @@ export function CalendarWritebackSection({
writebackStatus,
writebackResult,
}: Props) {
const writebackControlStatusId = useId();
const isCreatePending = pendingWritebackAction === 'create';
const isUpdatePending = pendingWritebackAction === 'update';
const isExecutePending = pendingWritebackAction === 'execute';
const hasWritableSelectedSource = selectedWritebackSource !== null
&& isCustomerOwnedWritableSource(selectedWritebackSource);
const areIntentActionsDisabled = isWritebackActionDisabled || !hasWritableSelectedSource;
const isExecutionActionDisabled = isProviderExecutionDisabled || !hasWritableSelectedSource;
const writebackControlStatus = isWritebackActionDisabled
? pendingWritebackAction
? '일정 반영 요청을 처리 중이라 새 점검을 시작할 수 없습니다.'
: sourceLoadStatus === 'loading'
? '일정 원본을 확인 중이라 반영 의도 점검을 시작할 수 없습니다.'
: sourceLoadStatus === 'error'
? '일정 원본을 확인할 수 없어 반영 의도 점검을 시작할 수 없습니다.'
: '일정 원본 준비가 끝나야 반영 의도 점검을 시작할 수 있습니다.'
: !hasWritableSelectedSource
? '반영 가능한 일정 원본이 없어 반영 의도 점검을 시작할 수 없습니다.'
: isProviderExecutionDisabled
? '반영 의도 점검은 가능하지만 선택한 원본에 충돌 토큰이 없어 외부 실행 요청은 사용할 수 없습니다.'
: '선택한 고객 원본 일정에 반영할 의도와 외부 실행 조건을 점검할 수 있습니다.';

return (
<section aria-label="일정 반영 의도 점검" className="rounded-2xl border border-border bg-card p-4 shadow-sm md:p-5">
Expand All @@ -48,7 +67,8 @@ export function CalendarWritebackSection({
<button
type="button"
onClick={() => void requestWritebackIntent('create')}
disabled={isWritebackActionDisabled}
disabled={areIntentActionsDisabled}
aria-describedby={writebackControlStatusId}
aria-busy={isCreatePending}
className="inline-flex items-center justify-center rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-60"
>
Expand All @@ -58,7 +78,8 @@ export function CalendarWritebackSection({
<button
type="button"
onClick={() => void requestWritebackIntent('update')}
disabled={isWritebackActionDisabled}
disabled={areIntentActionsDisabled}
aria-describedby={writebackControlStatusId}
aria-busy={isUpdatePending}
className="inline-flex items-center justify-center rounded-xl border border-border bg-background px-4 py-2 text-sm font-bold hover:bg-secondary disabled:cursor-wait disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
Expand All @@ -68,7 +89,8 @@ export function CalendarWritebackSection({
<button
type="button"
onClick={() => void requestWritebackIntent('update', true)}
disabled={isProviderExecutionDisabled}
disabled={isExecutionActionDisabled}
aria-describedby={writebackControlStatusId}
aria-busy={isExecutePending}
className="inline-flex items-center justify-center rounded-xl border border-primary/40 bg-primary/10 px-4 py-2 text-sm font-bold text-primary hover:bg-primary/15 disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
>
Expand All @@ -78,10 +100,20 @@ export function CalendarWritebackSection({
</div>
</div>

<p
id={writebackControlStatusId}
role="status"
aria-live="polite"
className="mt-3 text-xs font-semibold leading-5 text-muted-foreground"
>
{writebackControlStatus}
</p>

<div className="mt-4 grid gap-3 md:grid-cols-2">
{writebackSources.map((source, index) => {
const sourceWritable = isCustomerOwnedWritableSource(source);
const sourceSelected = selectedWritebackSource?.source_id === source.source_id;
const sourceSelected = sourceWritable
&& selectedWritebackSource?.source_id === source.source_id;
const sourceLabel = getCalendarSourceLabel(index);
return (
<button
Expand Down
37 changes: 37 additions & 0 deletions frontend/tests/e2e/dashboard-branding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,43 @@ test('renders calendar writeback intent status without direct provider writes',
await page.screenshot({ path: testInfo.outputPath('calendar-writeback-intent-mobile-scroll.png'), fullPage: false });
});

test('keeps stale read-only calendar selection unavailable', async ({ page }, testInfo) => {
const writebackRequests: string[] = [];
page.on('request', (request) => {
if (new URL(request.url()).pathname === '/api/calendar/writeback-intent') {
writebackRequests.push(request.method());
}
});
await mockDashboardApi(page);
await page.route('**/api/calendar/writeback-sources', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{
source_id: 'caldav-read-only',
provider: 'Customer CalDAV',
protocol: 'caldav',
owner_id: 'default',
organization_id: 'org-acme',
capabilities: ['read'],
writeback_enabled: false,
etag: null,
}]),
});
});

await page.goto('/calendar');

await expect(page.getByRole('button', { name: '새 일정 intent 점검' })).toBeDisabled();
await expect(page.getByRole('button', { name: 'ETag 업데이트 점검' })).toBeDisabled();
await expect(page.getByRole('button', { name: 'ETag 실행 요청' })).toBeDisabled();
await expect(page.getByRole('button', { name: '일정 원본 1 읽기 전용 선택' })).toBeDisabled();
await expect(page.getByText('반영 가능한 일정 원본이 없어 반영 의도 점검을 시작할 수 없습니다.')).toBeVisible();
await expect(page.getByText('선택됨')).toHaveCount(0);
expect(writebackRequests).toEqual([]);
await page.screenshot({ path: testInfo.outputPath('calendar-writeback-read-only.png'), fullPage: false });
});

test('renders data WebDAV writeback intent and document materialization status', async ({ page }, testInfo) => {
const expectedNaruonToken = 'signed-webdav.e2e.token';
const publicIdentityHeaders = [
Expand Down