Skip to content

Commit dbafc03

Browse files
authored
fix(mission): 추천 생성 복귀 흐름 안정화 (#251) (#252)
* fix(mission): 추천 생성 복귀 흐름을 안정화한다 * fix(mission): 보류 작업 정리를 안전하게 처리한다
1 parent 5f6f685 commit dbafc03

14 files changed

Lines changed: 295 additions & 45 deletions

apps/native/src/bridge.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ const handlers = {
3737
clearGuestTokens: () => guestAuth.clearGuestTokens(),
3838
savePendingMissionGeneration: (job) => pendingMissionGeneration.savePendingMissionGeneration(job),
3939
getPendingMissionGeneration: () => pendingMissionGeneration.getPendingMissionGeneration(),
40-
clearPendingMissionGeneration: () => pendingMissionGeneration.clearPendingMissionGeneration(),
40+
clearPendingMissionGeneration: (jobId) =>
41+
pendingMissionGeneration.clearPendingMissionGeneration(jobId),
4142
hasStartedMissionCreation: () => missionCreationHistory.hasStartedMissionCreation(),
4243
getMissionCreationStartDate: () => missionCreationHistory.getMissionCreationStartDate(),
4344
markMissionCreationStarted: () => missionCreationHistory.markMissionCreationStarted(),
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const storage = vi.hoisted(() => new Map<string, string>());
4+
5+
vi.mock("expo-secure-store", () => ({
6+
deleteItemAsync: vi.fn(async (key: string) => {
7+
storage.delete(key);
8+
}),
9+
getItemAsync: vi.fn(async (key: string) => storage.get(key) ?? null),
10+
setItemAsync: vi.fn(async (key: string, value: string) => {
11+
storage.set(key, value);
12+
}),
13+
}));
14+
15+
import * as SecureStore from "expo-secure-store";
16+
import {
17+
clearPendingMissionGeneration,
18+
getPendingMissionGeneration,
19+
savePendingMissionGeneration,
20+
} from "./pending-job";
21+
22+
describe("pending mission generation", () => {
23+
beforeEach(() => {
24+
storage.clear();
25+
vi.clearAllMocks();
26+
});
27+
28+
it("대상 job이 현재 기록과 같을 때만 삭제한다", async () => {
29+
await savePendingMissionGeneration({ createdAt: 1, expiresAt: null, jobId: "job-new" });
30+
31+
await clearPendingMissionGeneration("job-old");
32+
33+
await expect(getPendingMissionGeneration()).resolves.toEqual({
34+
createdAt: 1,
35+
expiresAt: null,
36+
jobId: "job-new",
37+
});
38+
expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled();
39+
});
40+
41+
it("빈 문자열 job도 현재 기록과 일치하지 않으면 삭제하지 않는다", async () => {
42+
await savePendingMissionGeneration({ createdAt: 1, expiresAt: null, jobId: "job-1" });
43+
44+
await clearPendingMissionGeneration("");
45+
46+
await expect(getPendingMissionGeneration()).resolves.toEqual({
47+
createdAt: 1,
48+
expiresAt: null,
49+
jobId: "job-1",
50+
});
51+
});
52+
53+
it("대상 job이 현재 기록과 같으면 삭제한다", async () => {
54+
await savePendingMissionGeneration({ createdAt: 1, expiresAt: null, jobId: "job-1" });
55+
56+
await clearPendingMissionGeneration("job-1");
57+
58+
await expect(getPendingMissionGeneration()).resolves.toBeNull();
59+
});
60+
61+
it("새 job 저장과 이전 job 삭제가 겹쳐도 새 기록을 유지한다", async () => {
62+
await savePendingMissionGeneration({ createdAt: 1, expiresAt: null, jobId: "job-old" });
63+
64+
await Promise.all([
65+
savePendingMissionGeneration({ createdAt: 2, expiresAt: null, jobId: "job-new" }),
66+
clearPendingMissionGeneration("job-old"),
67+
]);
68+
69+
await expect(getPendingMissionGeneration()).resolves.toEqual({
70+
createdAt: 2,
71+
expiresAt: null,
72+
jobId: "job-new",
73+
});
74+
});
75+
});

apps/native/src/mission-generation/pending-job.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ import type { PendingMissionGeneration } from "@repo/bridge/types";
22
import * as SecureStore from "expo-secure-store";
33

44
const PENDING_MISSION_GENERATION_KEY = "pending_mission_generation";
5+
let pendingOperation = Promise.resolve();
56

6-
export async function savePendingMissionGeneration(job: PendingMissionGeneration): Promise<void> {
7-
await SecureStore.setItemAsync(PENDING_MISSION_GENERATION_KEY, JSON.stringify(job));
7+
function serializePendingOperation<T>(operation: () => Promise<T>): Promise<T> {
8+
const result = pendingOperation.then(operation, operation);
9+
pendingOperation = result.then(
10+
() => undefined,
11+
() => undefined,
12+
);
13+
return result;
814
}
915

10-
export async function getPendingMissionGeneration(): Promise<PendingMissionGeneration | null> {
16+
async function readPendingMissionGeneration(): Promise<PendingMissionGeneration | null> {
1117
const stored = await SecureStore.getItemAsync(PENDING_MISSION_GENERATION_KEY);
1218
if (!stored) return null;
1319
try {
@@ -25,6 +31,22 @@ export async function getPendingMissionGeneration(): Promise<PendingMissionGener
2531
}
2632
}
2733

28-
export function clearPendingMissionGeneration(): Promise<void> {
29-
return SecureStore.deleteItemAsync(PENDING_MISSION_GENERATION_KEY);
34+
export function savePendingMissionGeneration(job: PendingMissionGeneration): Promise<void> {
35+
return serializePendingOperation(() =>
36+
SecureStore.setItemAsync(PENDING_MISSION_GENERATION_KEY, JSON.stringify(job)),
37+
);
38+
}
39+
40+
export function getPendingMissionGeneration(): Promise<PendingMissionGeneration | null> {
41+
return serializePendingOperation(readPendingMissionGeneration);
42+
}
43+
44+
export function clearPendingMissionGeneration(jobId?: string): Promise<void> {
45+
return serializePendingOperation(async () => {
46+
if (jobId !== undefined) {
47+
const pendingJob = await readPendingMissionGeneration();
48+
if (pendingJob?.jobId !== jobId) return;
49+
}
50+
await SecureStore.deleteItemAsync(PENDING_MISSION_GENERATION_KEY);
51+
});
3052
}
Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,53 @@
1-
import { describe, expect, it, vi } from "vitest";
2-
import { render, screen } from "@/lib/test/react";
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { fireEvent, render, screen, waitFor } from "@/lib/test/react";
3+
4+
const mocks = vi.hoisted(() => ({
5+
getPendingMissionGeneration: vi.fn(),
6+
isNativeApp: vi.fn(),
7+
push: vi.fn(),
8+
}));
9+
10+
vi.mock("@repo/bridge", () => ({
11+
bridge: {
12+
clearPendingMissionGeneration: vi.fn(),
13+
getPendingMissionGeneration: mocks.getPendingMissionGeneration,
14+
},
15+
isNativeApp: mocks.isNativeApp,
16+
}));
17+
18+
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mocks.push }) }));
19+
320
import { MissionAddMenu } from "./mission-add-menu";
421

522
describe("MissionAddMenu", () => {
6-
it("플로팅 버튼 영역 밖의 미션 카드를 가로채지 않는다", () => {
7-
render(<MissionAddMenu isOpen={false} onToggle={vi.fn()} />);
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
mocks.isNativeApp.mockReturnValue(true);
26+
});
827

9-
const addButton = screen.getByRole("button", { name: "미션 추가 메뉴 열기" });
10-
expect(addButton.parentElement).toHaveClass("pointer-events-none");
11-
expect(addButton).toHaveClass("pointer-events-auto");
12-
expect(document.getElementById("mission-add-menu")).toHaveClass("pointer-events-none");
28+
it("진행 중인 추천 job이 있으면 채팅 화면을 거치지 않고 로딩으로 이동한다", async () => {
29+
mocks.getPendingMissionGeneration.mockResolvedValue({
30+
createdAt: Date.now(),
31+
expiresAt: null,
32+
jobId: "job-1",
33+
});
34+
render(<MissionAddMenu isOpen onToggle={vi.fn()} />);
35+
36+
fireEvent.click(screen.getByRole("link", { name: "추천받기" }));
37+
38+
await waitFor(() =>
39+
expect(mocks.push).toHaveBeenCalledWith("/mission/new/loading?jobId=job-1"),
40+
);
1341
});
1442

15-
it("열린 메뉴의 선택지는 클릭을 받는다", () => {
43+
it("구 버전 네이티브 브릿지에서는 기존 채팅 화면으로 이동한다", async () => {
44+
mocks.getPendingMissionGeneration.mockImplementation(() => {
45+
throw new Error("Method is not defined");
46+
});
1647
render(<MissionAddMenu isOpen onToggle={vi.fn()} />);
1748

18-
expect(document.getElementById("mission-add-menu")).toHaveClass("pointer-events-auto");
49+
fireEvent.click(screen.getByRole("link", { name: "추천받기" }));
50+
51+
await waitFor(() => expect(mocks.push).toHaveBeenCalledWith("/mission/new"));
1952
});
2053
});

apps/web/app/(tabs)/mission/_components/mission-add-menu.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { bridge, isNativeApp } from "@repo/bridge";
12
import { cn } from "@repo/ui";
23
import { Plus } from "lucide-react";
34
import Link from "next/link";
5+
import { useRouter } from "next/navigation";
6+
import { buildMissionLoadingHref } from "@/app/mission/constants/mission-creation";
47

58
interface MissionAddMenuProps {
69
isOpen: boolean;
@@ -10,6 +13,26 @@ interface MissionAddMenuProps {
1013
const MENU_ID = "mission-add-menu";
1114

1215
export function MissionAddMenu({ isOpen, onToggle }: MissionAddMenuProps) {
16+
const router = useRouter();
17+
18+
function handleRecommendationClick(event: React.MouseEvent<HTMLAnchorElement>) {
19+
if (!isNativeApp()) return;
20+
21+
event.preventDefault();
22+
void Promise.resolve()
23+
.then(() => bridge.getPendingMissionGeneration())
24+
.then((job) => {
25+
if (job && (!job.expiresAt || Date.parse(job.expiresAt) > Date.now())) {
26+
router.push(buildMissionLoadingHref(job.jobId));
27+
return;
28+
}
29+
if (job) void bridge.clearPendingMissionGeneration(job.jobId).catch(() => {});
30+
router.push("/mission/new");
31+
})
32+
// 구 버전 네이티브 앱은 이 브릿지 메서드가 없다. 이 경우에는 기존 생성 화면으로 간다.
33+
.catch(() => router.push("/mission/new"));
34+
}
35+
1336
return (
1437
<div className="pointer-events-none fixed inset-x-0 bottom-24 z-10 mx-auto flex w-full max-w-md flex-col items-end gap-3 px-5">
1538
<div
@@ -26,6 +49,7 @@ export function MissionAddMenu({ isOpen, onToggle }: MissionAddMenuProps) {
2649
<Link
2750
className="w-full px-4 py-1 text-left text-body-b1-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
2851
href="/mission/new"
52+
onClick={handleRecommendationClick}
2953
>
3054
추천받기
3155
</Link>

apps/web/app/(tabs)/mission/page.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ vi.mock("@/api/mission", () => ({
4646
fetchMissions: vi.fn(),
4747
}));
4848

49+
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
50+
4951
vi.mock("@/app/(tabs)/_components/pigbox-progress-gauge", () => ({
5052
PigboxProgressGauge: ({ playRequest, progress }: { playRequest?: number; progress: number }) => (
5153
<div data-pigbox-play-request={playRequest} data-pigbox-progress={Math.round(progress)} />

apps/web/app/_components/pending-mission-generation-recovery.test.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,16 @@ describe("PendingMissionGenerationRecovery", () => {
107107
const { client, rerender } = renderRecovery();
108108

109109
await vi.waitFor(() => expect(getPendingMissionGeneration).toHaveBeenCalled());
110+
const requestsBeforeReturningToMission = getPendingMissionGeneration.mock.calls.length;
110111
fetchGenerationJobStatus.mockResolvedValue(SUCCEEDED_JOB);
111112
pathname.mockReturnValue("/mission");
112113
rerender(<RecoveryWithClient client={client} />);
113114

114-
await vi.waitFor(() => expect(getPendingMissionGeneration).toHaveBeenCalledTimes(2));
115+
await vi.waitFor(() =>
116+
expect(getPendingMissionGeneration.mock.calls.length).toBeGreaterThan(
117+
requestsBeforeReturningToMission,
118+
),
119+
);
115120
expect(screen.queryByText("미션이 생성됐어요.")).toBeNull();
116121
});
117122

@@ -145,4 +150,14 @@ describe("PendingMissionGenerationRecovery", () => {
145150
await vi.waitFor(() => expect(clearPendingMissionGeneration).toHaveBeenCalled());
146151
expect(replace).not.toHaveBeenCalled();
147152
});
153+
154+
it("구 버전 네이티브 브릿지에 복구 메서드가 없어도 전역 오류를 내지 않는다", async () => {
155+
getPendingMissionGeneration.mockImplementation(() => {
156+
throw new Error("Method is not defined");
157+
});
158+
renderRecovery();
159+
160+
await vi.waitFor(() => expect(getPendingMissionGeneration).toHaveBeenCalled());
161+
expect(replace).not.toHaveBeenCalled();
162+
});
148163
});

apps/web/app/_components/pending-mission-generation-recovery.tsx

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -53,26 +53,32 @@ export function PendingMissionGenerationRecovery() {
5353

5454
let cancelled = false;
5555
const recoverPendingJob = () => {
56-
void bridge.getPendingMissionGeneration().then((job) => {
57-
if (cancelled) return;
58-
if (!job) {
59-
setPendingJob(undefined);
60-
return;
61-
}
62-
if (job.expiresAt && Date.parse(job.expiresAt) <= Date.now()) {
63-
setPendingJob(undefined);
64-
void bridge.clearPendingMissionGeneration();
65-
return;
66-
}
67-
if (completedJobId.current === job.jobId) {
68-
setPendingJob(undefined);
69-
return;
70-
}
71-
setPendingJob(job);
72-
if (pathname === "/mission/new") {
73-
router.replace(buildMissionLoadingHref(job.jobId));
74-
}
75-
});
56+
void Promise.resolve()
57+
.then(() => bridge.getPendingMissionGeneration())
58+
.then((job) => {
59+
if (cancelled) return;
60+
if (!job) {
61+
setPendingJob(undefined);
62+
return;
63+
}
64+
if (job.expiresAt && Date.parse(job.expiresAt) <= Date.now()) {
65+
setPendingJob(undefined);
66+
void bridge.clearPendingMissionGeneration();
67+
return;
68+
}
69+
if (completedJobId.current === job.jobId) {
70+
setPendingJob(undefined);
71+
return;
72+
}
73+
setPendingJob(job);
74+
if (pathname === "/mission/new") {
75+
router.replace(buildMissionLoadingHref(job.jobId));
76+
}
77+
})
78+
// 웹 배포가 먼저 나간 경우, 구 버전 네이티브에는 해당 브릿지 메서드가 없다.
79+
.catch(() => {
80+
if (!cancelled) setPendingJob(undefined);
81+
});
7682
};
7783

7884
recoverPendingJob();

apps/web/app/mission/_components/mission-creation-result.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function MissionCreationResult({ jobId }: MissionCreationResultProps) {
5656
const [selectedDraftIds, setSelectedDraftIds] = useState<string[]>([]);
5757

5858
useEffect(() => {
59-
void clearPendingMissionGeneration();
59+
void clearPendingMissionGeneration(jobId);
6060
}, [jobId]);
6161

6262
function toggleDraft(id: string, pressed: boolean) {

apps/web/app/mission/new/_components/mission-creation-chat.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,11 @@ export function MissionCreationChat() {
159159
setSubmitError(undefined);
160160
requestJob.mutate(request, {
161161
onError: () => setSubmitError("미션 생성을 시작하지 못했어요. 잠시 후 다시 시도해 주세요."),
162-
onSuccess: async (job) => {
162+
onSuccess: (job) => {
163163
// 생성 이력은 홈 CTA만 바꾸는 부가 상태다. 구 버전 네이티브 브릿지의 응답을
164164
// 기다리다가 실제 생성 job의 로딩 화면 진입까지 늦추면 안 된다.
165165
void markMissionCreationStarted();
166-
await savePendingMissionGeneration({
166+
void savePendingMissionGeneration({
167167
createdAt: Date.now(),
168168
expiresAt: job.expiresAt,
169169
jobId: job.jobId,

0 commit comments

Comments
 (0)