-
{sourceEvidenceError ? '일정 원본 확인 필요' : `일정 원본 ${sourceEvidenceLoading ? '-' : calendarSources.length}개`}
+
{calendarSourceError ? '일정 원본 확인 필요' : `일정 원본 ${calendarSourceLoading ? '-' : calendarSources.length}개`}
- {sourceEvidenceError ? '일정 원본 목록 응답을 확인할 수 없습니다.' : sourceEvidenceLoading ? '일정 원본 목록을 확인하는 중입니다.' : `${writableCalendarSourceCount}개 일정 반영 가능 · 원본 목록 확인됨`}
+ {calendarSourceError ? '일정 원본 목록 응답을 확인할 수 없습니다.' : calendarSourceLoading ? '일정 원본 목록을 확인하는 중입니다.' : `${writableCalendarSourceCount}개 일정 반영 가능 · 원본 목록 확인됨`}
- {!sourceEvidenceError && calendarSources[0] ? (
+ {!calendarSourceError && calendarSources[0] ? (
{getCalendarSourceLabel(0)} · {getCalendarConflictLabel(calendarSources[0])}
@@ -402,8 +588,8 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
-
완료 가능 작업 {loading ? '-' : pendingTasks.length}건
-
오늘 마감 전 완료해보세요.
+
완료 가능 작업 {taskUnavailable ? '확인 필요' : taskLoading ? '-' : `${pendingTasks.length}건`}
+
{taskUnavailable ? '작업 현황을 확인할 수 없습니다.' : '오늘 마감 전 완료해보세요.'}
작업 바로가기
@@ -419,8 +605,10 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
답변 대기 메일
- {loading ? (
+ {pendingReplyLoading ? (
답변 대기 메일을 불러오는 중...
+ ) : pendingReplyUnavailable ? (
+
답변 대기 메일을 확인하지 못했습니다.
) : pendingReplies.length === 0 ? (
답변 대기 중인 보낸 메일이 없습니다.
) : pendingReplies.map((reply) => {
@@ -448,8 +636,10 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
대기 작업 {pendingTasks.length > 0 && {pendingTasks.length}건}
- {loading ? (
+ {taskLoading ? (
작업을 불러오는 중...
+ ) : taskUnavailable ? (
+
작업 현황을 확인하지 못했습니다.
) : pendingTasks.length === 0 ? (
대기 작업이 없습니다.
) : pendingTasks.slice(0, 3).map((task) => {
@@ -487,7 +677,7 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
일정 충돌{' '}
{calendarCandidateEvidence.status === 'success'
?
일정 조율 후보 {calendarCandidateEvidence.results.length}건
- : !sourceEvidenceError && calendarSources.length > 0
+ : !calendarSourceError && calendarSources.length > 0
?
일정 원본 {calendarSources.length}개
: null}
@@ -515,9 +705,9 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
);
})
- ) : sourceEvidenceError ? (
+ ) : calendarSourceError ? (
일정 원본 목록 확인에 실패했습니다.
- ) : sourceEvidenceLoading ? (
+ ) : calendarSourceLoading ? (
일정 원본 목록을 확인하는 중입니다.
) : calendarSources.length === 0 ? (
맥락 검색된 일정 조율 후보와 연결된 일정 원본이 없습니다.
@@ -549,8 +739,10 @@ function StartupDashboard({ onOpenView }: { onOpenView: (view: WorkspaceStartupV
최근 메일 {unreadCount > 0 && 새 메일 {unreadCount}}
- {loading ? (
+ {emailLoading ? (
메일을 불러오는 중...
+ ) : emailUnavailable ? (
+
최근 메일을 확인하지 못했습니다.
) : emails.length === 0 ? (
수신된 메일이 없습니다.
) : emails.slice(0, 5).map((mail) => (
@@ -650,7 +842,7 @@ export function WorkspaceHome({
const [mobileWorkspaceOverride, setMobileWorkspaceOverride] = useState(false);
const [mobileWorkspaceOverrideReady, setMobileWorkspaceOverrideReady] = useState(false);
const activeStartupView = startupViewOverride ?? startupView;
- const showMobileDashboard = activeStartupView === 'dashboard' && mobileWorkspaceOverrideReady && !mobileWorkspaceOverride;
+ const showMobileDashboard = activeStartupView === 'dashboard' && mobileWorkspaceOverrideReady && isMobileViewport && !mobileWorkspaceOverride;
const mobileView = useMobileWorkspaceView();
const effectiveMobileView = mobileView === 'detail' && selectedEmail === null ? 'inbox' : mobileView;
diff --git a/frontend/src/components/mobile-workspace-panels.test.tsx b/frontend/src/components/mobile-workspace-panels.test.tsx
index 80b3c2c7b..9652f026a 100644
--- a/frontend/src/components/mobile-workspace-panels.test.tsx
+++ b/frontend/src/components/mobile-workspace-panels.test.tsx
@@ -98,8 +98,8 @@ describe('mobile workspace API panels', () => {
expect(container.textContent).toContain('일정 후보가 없습니다.');
});
- it('renders the search error state when the API fails', async () => {
- vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ detail: 'failed' }, false)));
+ it.each([false, true])('renders the search error state for malformed or failed response (HTTP OK: %s)', async (httpOk) => {
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [null] }, httpOk)));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
diff --git a/frontend/src/components/mobile-workspace-panels.tsx b/frontend/src/components/mobile-workspace-panels.tsx
index 0416f361b..2644acc9a 100644
--- a/frontend/src/components/mobile-workspace-panels.tsx
+++ b/frontend/src/components/mobile-workspace-panels.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
+import { isMailListItem } from '@/lib/mail-response';
import { toSafeReactText } from '@/lib/safe-text';
type MobileSearchResult = {
@@ -67,6 +68,7 @@ function MobileApiPanel({ copy }: { copy: MobilePanelCopy }) {
void apiClient.post
('/api/search', { query: copy.query, limit: copy.limit }, { signal: controller.signal })
.then((response) => {
if (cancelled) return;
+ if (!Array.isArray(response.results) || !response.results.every(isMailListItem)) throw new Error('Invalid search response');
setResults(response.results);
setStatus(response.results.length > 0 ? 'success' : 'empty');
})
diff --git a/frontend/src/lib/mail-response.test.ts b/frontend/src/lib/mail-response.test.ts
new file mode 100644
index 000000000..a6c4abce0
--- /dev/null
+++ b/frontend/src/lib/mail-response.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest';
+import { isMailListItem } from './mail-response';
+
+const validMail = { id: 1, subject: null, sender: 'example@example.com', snippet: '' };
+
+describe('mail response boundary', () => {
+ it('preserves nullable subjects and optional API display fields', () => {
+ expect(isMailListItem(validMail)).toBe(true);
+ expect(isMailListItem({ ...validMail, reply_count: null, is_read: false, date: '' })).toBe(true);
+ });
+ it.each([null, [], {}, 42, { ...validMail, subject: 42 }, { ...validMail, date: {} },
+ { ...validMail, reply_count: {} }, { ...validMail, unread: 'false' },
+ { ...validMail, id: Number.NaN }, { ...validMail, sender: null }, { ...validMail, snippet: [] },
+ ])('rejects malformed mail %j', (mailValue) => {
+ expect(isMailListItem(mailValue)).toBe(false);
+ });
+});
diff --git a/frontend/src/lib/mail-response.ts b/frontend/src/lib/mail-response.ts
new file mode 100644
index 000000000..89bc6f1c4
--- /dev/null
+++ b/frontend/src/lib/mail-response.ts
@@ -0,0 +1,13 @@
+/** Validate consumed mail fields before publishing inbox, sent, or search data. */
+export function isMailListItem(itemValue: unknown): boolean {
+ if (typeof itemValue !== 'object' || itemValue === null || Array.isArray(itemValue)) return false;
+ const mailRecord = itemValue as Record;
+ return typeof mailRecord.id === 'number' && Number.isSafeInteger(mailRecord.id)
+ && (mailRecord.subject === null || typeof mailRecord.subject === 'string')
+ && typeof mailRecord.sender === 'string' && typeof mailRecord.snippet === 'string'
+ && (mailRecord.date === undefined || typeof mailRecord.date === 'string')
+ && (mailRecord.reply_count === undefined || mailRecord.reply_count === null
+ || (typeof mailRecord.reply_count === 'number' && Number.isSafeInteger(mailRecord.reply_count)))
+ && ['unread', 'is_read', 'has_draft', 'is_self_sent', 'requires_reply', 'schedule_conflict']
+ .every((fieldName) => mailRecord[fieldName] === undefined || typeof mailRecord[fieldName] === 'boolean');
+}
diff --git a/frontend/tests/e2e/dashboard-branding.spec.ts b/frontend/tests/e2e/dashboard-branding.spec.ts
index f89f5149e..cb4418740 100644
--- a/frontend/tests/e2e/dashboard-branding.spec.ts
+++ b/frontend/tests/e2e/dashboard-branding.spec.ts
@@ -164,6 +164,50 @@ test('renders Today dashboard pending reply lane with signed API headers', async
await page.screenshot({ path: testInfo.outputPath('today-pending-replies-mobile-scroll.png'), fullPage: false });
});
+for (const failureResponse of [
+ { name: 'source request fails', status: 503, body: '{"error_code":"source_unavailable"}' },
+ { name: 'source returns malformed members', status: 200, body: '{"emails":[null]}' },
+]) {
+test(`recovers the Today dashboard after a ${failureResponse.name}`, async ({ page }, testInfo) => {
+ const pageErrors: string[] = [];
+ page.on('pageerror', (pageError) => pageErrors.push(pageError.message));
+ const sessionToken = e2eSessionToken({ sub: 'alice', org: 'org-acme', workspace: 'workspace-org-acme' });
+ await page.addInitScript((token) => {
+ document.cookie = `naruon_session=${token}; Path=/; SameSite=Lax`;
+ }, sessionToken);
+ let inboxAttempts = 0;
+ let inboxAvailable = false;
+ await mockDashboardApi(page);
+ await page.route(/\/api\/emails(?:\?.*)?$/, async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ if (request.method() === 'GET' && url.pathname === '/api/emails') {
+ inboxAttempts += 1;
+ }
+ if (request.method() === 'GET' && url.pathname === '/api/emails' && !inboxAvailable) {
+ await route.fulfill({ status: failureResponse.status, contentType: 'application/json', body: failureResponse.body });
+ return;
+ }
+ await route.fallback();
+ });
+
+ await page.goto('/');
+ const dashboard = page.locator('section[aria-label="홈 개요"]:visible').first();
+ const recoveryAlert = dashboard.getByRole('alert');
+ await expect(recoveryAlert).toContainText('업무 현황을 모두 불러오지 못했습니다.');
+ await expect(dashboard.getByRole('article', { name: '받은 메일' })).toContainText('오류');
+ await page.screenshot({ path: testInfo.outputPath('dashboard-source-unavailable.png') });
+
+ inboxAvailable = true;
+ await recoveryAlert.getByRole('button', { name: '다시 시도' }).click();
+
+ await expect(recoveryAlert).toHaveCount(0);
+ await expect(dashboard.getByRole('article', { name: '받은 메일' })).toContainText('1');
+ expect(inboxAttempts).toBeGreaterThanOrEqual(2);
+ expect(pageErrors).toEqual([]);
+});
+}
+
test('keeps the short mobile AI quick action menu inside the viewport with scrollable actions', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 640 });
await mockDashboardApi(page);