Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
df4f669
fix(home): expose retryable backend unavailable state
seonghobae Sep 4, 2026
be7b6d4
fix(home): preserve partial source evidence
seonghobae Sep 4, 2026
cc9c7ef
test(home): pin retry stale-response ordering
seonghobae Sep 4, 2026
32a1e0b
test(home): cover dashboard retry recovery
seonghobae Sep 4, 2026
6e02f83
test(home): pin dashboard recovery succession contracts
seonghobae Sep 5, 2026
387787d
fix(home): settle dashboard sources independently
seonghobae Sep 5, 2026
a37b9c5
fix(home): preserve startup-view reset after recovery refactor
seonghobae Sep 5, 2026
1c08090
fix(home): unify source authentication recovery
seonghobae Sep 5, 2026
a48e8ba
test(home): preserve malformed dashboard fail-closed contract
seonghobae Sep 5, 2026
b88161f
fix: cancel obsolete dashboard read requests
seonghobae Sep 6, 2026
c298e4d
fix: validate mail and dashboard response members
seonghobae Sep 6, 2026
91a91c2
chore(governance): restore canonical AGENTS ownership
seonghobae Sep 6, 2026
d510f5a
test(home): reproduce missing AbortSignal combinators
seonghobae Sep 6, 2026
2e937e4
fix(home): fall back when AbortSignal combinators are unavailable
seonghobae Sep 6, 2026
82c0913
docs(home): record AbortSignal compatibility repair
seonghobae Sep 6, 2026
a6e6ac5
docs(home): anchor abort fallback to DOM standard
seonghobae Sep 6, 2026
187f332
docs(ci): record dashboard gate recovery evidence
seonghobae Sep 8, 2026
aeeda1e
fix(home): restore canonical governance ownership
seonghobae Sep 9, 2026
9ff6a2a
fix(home): adopt canonical frontend security prerequisite
seonghobae Sep 9, 2026
e03f67d
test(home): reject malformed project-folder evidence
seonghobae Sep 9, 2026
b211a45
fix(home): validate project-folder response members
seonghobae Sep 9, 2026
66ba284
test(home): cover project-folder contract branches
seonghobae Sep 9, 2026
8c4c53f
chore(stack): adopt security repair and restore release-note ownership
seonghobae Sep 9, 2026
eadb882
chore(stack): adopt current frontend security owner
seonghobae Sep 10, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- 홈 업무 API가 실패하면 0건으로 오인하지 않고 메일·답변 대기·작업별 오류와 재시도 동작을 표시합니다. 이전 요청의 늦은 응답은 새 결과를 덮지 못하며, 현재 viewport의 홈만 mount해 중복 API 요청을 막습니다.
- 긴 이메일·첨부 본문을 의미 단위 청크로 임베딩한 뒤 기존 email/attachment 벡터 계약으로 평균화하고, 청크 요청·벡터 누적을 제한된 창으로 처리합니다. OpenAI `text-embedding-3-*`에는 저장 차원(`1536`)을 직접 요청하도록 보강했습니다. 합성 메일 fixture 5건(70청크)과 provider 요청 계약으로 1,536차원 벡터 경로를 검증했으며, 실행 시 선택한 임베딩 제공자에 본문·파싱된 첨부 텍스트를 전송할 수 있습니다. 회사 기밀 데이터는 fixture·commit·PR·log에 포함하지 않습니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.

Expand Down
11 changes: 6 additions & 5 deletions frontend/src/app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ describe("Home workspace action bridge", () => {
expect(fetch).toHaveBeenCalledWith("/api/search", expect.objectContaining({ method: "POST" }));
});

it("shows startup dashboard empty states and ignores malformed API payloads", async () => {
it("shows an unavailable dashboard state for malformed API payloads", async () => {
localStorage.setItem("naruon_startup_view", "dashboard");
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({
ok: true,
Expand All @@ -486,11 +486,12 @@ describe("Home workspace action bridge", () => {
await act(async () => {
root?.render(<Home />);
});
await waitForCondition(() => container?.textContent?.includes("수신된 메일이 없습니다.") ?? false);
await waitForCondition(() => Boolean(container?.querySelector('[role="alert"][aria-label="대시보드 데이터 상태"]')));

expect(container.textContent).toContain("수신된 메일이 없습니다.");
expect(container.textContent).toContain("답변 대기 중인 보낸 메일이 없습니다.");
expect(container.textContent).toContain("대기 작업이 없습니다.");
expect(container.textContent).toContain("업무 현황을 모두 불러오지 못했습니다.");
expect(container.textContent).not.toContain("수신된 메일이 없습니다.");
expect(container.textContent).not.toContain("답변 대기 중인 보낸 메일이 없습니다.");
expect(container.textContent).not.toContain("대기 작업이 없습니다.");
});

it("shows desktop calendar empty and error states from the search API", async () => {
Expand Down
108 changes: 108 additions & 0 deletions frontend/src/components/WorkspaceHome.dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,114 @@ describe("WorkspaceHome Today dashboard", () => {
expect(headers["X-Dev-Auth-Token"]).toBeUndefined();
});

it("shows a retryable unavailable state without presenting failed data as empty", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
let primaryDataAvailable = false;
const primaryCalls: string[] = [];
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (
url.endsWith("/api/emails")
|| url.endsWith("/api/emails/pending-replies?limit=3")
|| url.endsWith("/api/tasks")
) {
primaryCalls.push(url);
if (!primaryDataAvailable) return Promise.reject(new Error("backend unavailable"));
if (url.endsWith("/api/emails")) {
return Promise.resolve({
ok: true,
json: async () => ({ emails: [{ id: 101, subject: "복구된 고객 메일", sender: "customer@example.com", date: "2026-05-17T09:00:00Z", snippet: "계약 확인" }] }),
});
}
return Promise.resolve({ ok: true, json: async () => url.endsWith("/api/tasks") ? [] : ({ emails: [] }) });
}
const sourceEvidenceResponse = emptySourceEvidenceResponse(url);
if (sourceEvidenceResponse) return sourceEvidenceResponse;
const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url);
if (calendarCandidateResponse) return calendarCandidateResponse;
throw new Error(`Unexpected fetch: ${url}`);
}));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<WorkspaceHome forcedStartupView="dashboard" />);
});
await waitForCondition(() => container?.textContent?.includes("업무 현황을 모두 불러오지 못했습니다.") ?? false);

expect(container.querySelector('[role="alert"]')?.textContent).toContain("다시 시도");
expect(container.textContent).toContain("최근 메일을 확인하지 못했습니다.");
expect(container.textContent).not.toContain("수신된 메일이 없습니다.");

const callsBeforeRetry = primaryCalls.length;
primaryDataAvailable = true;
await act(async () => {
container?.querySelector<HTMLButtonElement>('[role="alert"] button')?.click();
});
await waitForCondition(() => primaryCalls.length === callsBeforeRetry + 3);
await waitForCondition(() => container?.querySelector('[role="alert"]') === null);

expect(container.textContent).toContain("복구된 고객 메일");
expect(container.querySelector('[role="alert"]')).toBeNull();
});

it("preserves project evidence when only calendar sources fail", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/calendar/writeback-sources")) {
return Promise.reject(new Error("calendar unavailable"));
}
if (url.endsWith("/api/webdav/folders")) {
return Promise.resolve({
ok: true,
json: async () => ([{
folder_uid: "project-folder-1",
project_name: "계약 검토",
webdav_path: "/contracts",
}]),
});
}
if (url.endsWith("/api/emails")) {
return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) });
}
if (url.endsWith("/api/emails/pending-replies?limit=3")) {
return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) });
}
if (url.endsWith("/api/tasks")) {
return Promise.resolve({ ok: true, json: async () => ([]) });
}
const calendarCandidateResponse = emptyCalendarCandidateSearchResponse(url);
if (calendarCandidateResponse) return calendarCandidateResponse;
throw new Error(`Unexpected fetch: ${url}`);
}));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<WorkspaceHome forcedStartupView="dashboard" />);
});
await waitForCondition(() => container?.textContent?.includes("일정 원본 목록 응답을 확인할 수 없습니다.") ?? false);

const metrics = container.querySelector<HTMLElement>('[aria-label="홈 지표"]');
expect(metrics?.textContent).toContain("일정 원본오류");
expect(metrics?.textContent).toContain("프로젝트 원본1");
expect(metrics?.textContent).not.toContain("프로젝트 원본오류");
expect(container.querySelector('[role="alert"]')?.textContent).toContain("다시 시도");
});

it("creates overdue reply follow-up tasks from the Today dashboard with signed headers", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
Expand Down
171 changes: 171 additions & 0 deletions frontend/src/components/WorkspaceHome.retry-race.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/* @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("@/components/EmailList", () => ({
EmailList: () => <section aria-label="mock email list">mock email list</section>,
}));

vi.mock("@/components/EmailDetail", () => ({
EmailDetail: () => <section aria-label="mock email detail">mock email detail</section>,
}));

vi.mock("@/components/ui/resizable", () => ({
ResizablePanelGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ResizablePanel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ResizableHandle: () => <div />,
}));

vi.mock("@/components/mobile-workspace-panels", () => ({
MobileCalendarPanel: () => <section>mock calendar</section>,
MobileSearchPanel: () => <section>mock search</section>,
}));

vi.mock("next/dynamic", () => ({
default: () => function MockDynamic() {
return <div>mock graph</div>;
},
}));

vi.mock("lucide-react", () => ({
CalendarDays: () => <svg aria-hidden="true" />,
CheckCircle2: () => <svg aria-hidden="true" />,
Inbox: () => <svg aria-hidden="true" />,
Network: () => <svg aria-hidden="true" />,
Send: () => <svg aria-hidden="true" />,
Settings: () => <svg aria-hidden="true" />,
Sparkles: () => <svg aria-hidden="true" />,
}));

import { WorkspaceHome } from "./WorkspaceHome";

type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
};

function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}

async function flushAsyncWork() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}

async function waitForCondition(condition: () => boolean) {
for (let index = 0; index < 30; index += 1) {
if (condition()) return;
await flushAsyncWork();
}
throw new Error("waitForCondition timed out after 30 attempts");
}

describe("WorkspaceHome dashboard retry ordering", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;

afterEach(() => {
const mountedRoot = root;
if (mountedRoot) {
act(() => mountedRoot.unmount());
}
root = null;
container?.remove();
container = null;
localStorage.clear();
vi.unstubAllGlobals();
});

it("keeps a recovered retry result when an older request resolves later", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})));

const firstEmails = deferred<{ ok: boolean; json: () => Promise<unknown> }>();
let emailCallCount = 0;
let calendarCallCount = 0;

vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/emails")) {
emailCallCount += 1;
if (emailCallCount === 1) return firstEmails.promise;
return Promise.resolve({
ok: true,
json: async () => ({
emails: [{
id: 202,
subject: "재시도 후 최신 메일",
sender: "customer@example.com",
date: "2026-09-05T08:00:00Z",
snippet: "최신 결과",
}],
}),
});
}
if (url.endsWith("/api/emails/pending-replies?limit=3")) {
return Promise.resolve({ ok: true, json: async () => ({ emails: [] }) });
}
if (url.endsWith("/api/tasks")) {
return Promise.resolve({ ok: true, json: async () => [] });
}
if (url.endsWith("/api/calendar/writeback-sources")) {
calendarCallCount += 1;
if (calendarCallCount === 1) return Promise.reject(new Error("calendar unavailable"));
return Promise.resolve({ ok: true, json: async () => [] });
}
if (url.endsWith("/api/webdav/folders")) {
return Promise.resolve({ ok: true, json: async () => [] });
}
if (url.endsWith("/api/search")) {
return Promise.resolve({ ok: true, json: async () => ({ results: [] }) });
}
throw new Error(`Unexpected fetch: ${url}`);
}));

container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<WorkspaceHome forcedStartupView="dashboard" />);
});
await waitForCondition(() => container?.querySelector('[role="alert"]') !== null);

await act(async () => {
container?.querySelector<HTMLButtonElement>('[role="alert"] button')?.click();
});
await waitForCondition(() => container?.textContent?.includes("재시도 후 최신 메일") ?? false);

firstEmails.resolve({
ok: true,
json: async () => ({
emails: [{
id: 101,
subject: "늦게 도착한 이전 메일",
sender: "stale@example.com",
date: "2026-09-05T07:00:00Z",
snippet: "이전 결과",
}],
}),
});
await flushAsyncWork();
await flushAsyncWork();

expect(container.textContent).toContain("재시도 후 최신 메일");
expect(container.textContent).not.toContain("늦게 도착한 이전 메일");
expect(emailCallCount).toBe(2);
expect(calendarCallCount).toBe(2);
});
});
Loading
Loading