@aiquants/daily-report 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/dist/client.d.mts +363 -21
- package/dist/client.d.ts +363 -21
- package/dist/client.js +5 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +76 -2
- package/dist/index.d.ts +76 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +2 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +7 -5
- package/dist/server.mjs.map +1 -1
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
- package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +56 -68
- package/src/client/components/daily-report-resolved-content.tsx +31 -7
- package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +149 -52
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/route-helpers.spec.ts +100 -0
- package/src/client/route-helpers.ts +20 -41
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
- package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
- package/src/client.ts +4 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +245 -5
- package/src/server/service.ts +40 -0
- package/src/server/test-helpers/handlers-config.ts +40 -1
- package/src/shared/ids-stream.ts +92 -0
|
@@ -1,54 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Top-level daily-report page content (chrome-agnostic).
|
|
3
|
-
*
|
|
2
|
+
* Top-level daily-report page content (chrome-agnostic, stream-driven).
|
|
3
|
+
* アプリのレイアウトに依存しない日報ページ本体コンポーネント (ストリーム駆動)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* ID 一覧はモジュール常駐の NDJSON ストリームセッションから購読し、最初の
|
|
6
|
+
* チャンク到着と同時に一覧を描画する (全件解決を待つ Await ゲートは廃止)。
|
|
7
|
+
* 再訪時はセッションに残る受信済みデータで即座に復元し、鮮度はバックグラウンド
|
|
8
|
+
* 再検証 (SWR) と SSE が担う。アプリ側のルートは本コンポーネントを自前の
|
|
9
|
+
* レイアウトで包み、`config` でヘッダー描画・パス・ラベル名を注入する。
|
|
7
10
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import type { DailyReportItem, DailyReportUser } from "../../shared/types"
|
|
11
|
+
import { useEffect } from "react"
|
|
12
|
+
import type { DailyReportUser } from "../../shared/types"
|
|
11
13
|
import { type DailyReportClientConfigInput, DailyReportConfigProvider, useDailyReportConfig } from "../config-context"
|
|
12
14
|
import { DailyReportActionProvider } from "../contexts/daily-report-action-context"
|
|
15
|
+
import { ensureDailyReportIdsStreamSession, reloadDocument, retryDailyReportIdsStream, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
13
16
|
import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
|
|
17
|
+
import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
|
|
14
18
|
import { DailyReportResolvedContent } from "./daily-report-resolved-content"
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* Error display when the ids stream terminally failed before any data arrived.
|
|
22
|
+
* データ未受信のまま ID ストリームが力尽きたときのエラー表示コンポーネント。
|
|
19
23
|
*
|
|
20
|
-
*
|
|
21
|
-
*/
|
|
22
|
-
const DailyReportResolvedSync = ({ dailyReportItems, userId, onResolved }: { dailyReportItems: DailyReportItem[]; userId?: string | null; onResolved: (items: DailyReportItem[]) => void }) => {
|
|
23
|
-
/**
|
|
24
|
-
* Notifies parent when resolved items change.
|
|
25
|
-
* Dependencies:
|
|
26
|
-
* - dailyReportItems: latest resolved list from Await.
|
|
27
|
-
* - onResolved: parent synchronization callback.
|
|
28
|
-
* Cleanup:
|
|
29
|
-
* - None (pure synchronization side effect).
|
|
30
|
-
*
|
|
31
|
-
* 解決済み items が変化したときに親へ通知します。
|
|
32
|
-
* 依存配列:
|
|
33
|
-
* - dailyReportItems: Await で解決された最新リスト
|
|
34
|
-
* - onResolved: 親への同期コールバック
|
|
35
|
-
* クリーンアップ:
|
|
36
|
-
* - なし(同期のみの副作用)
|
|
37
|
-
*/
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
onResolved(dailyReportItems)
|
|
40
|
-
}, [dailyReportItems, onResolved])
|
|
41
|
-
|
|
42
|
-
return <DailyReportResolvedContent dailyReportItems={dailyReportItems} userId={userId} />
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Error display component for daily report data fetch failures.
|
|
47
|
-
* 日報データの取得失敗時に表示するエラーコンポーネント。
|
|
24
|
+
* 再試行はストリームセッションの retry へ直結する (受信済みカーソルから再開)。
|
|
48
25
|
*/
|
|
49
26
|
export const DailyReportLoadError = () => {
|
|
50
27
|
const { title, renderHeader } = useDailyReportConfig()
|
|
51
|
-
const { revalidate } = useRevalidator()
|
|
52
28
|
return (
|
|
53
29
|
<>
|
|
54
30
|
{renderHeader({ title, annotation: <span className="font-bold text-red-500">Error</span> })}
|
|
@@ -56,7 +32,11 @@ export const DailyReportLoadError = () => {
|
|
|
56
32
|
<div className="mx-auto flex h-full w-full max-w-6xl flex-1 flex-col gap-6">
|
|
57
33
|
<div className="flex h-full flex-1 flex-col items-center justify-center gap-4 rounded-lg border border-red-200 bg-white p-4 text-slate-500 shadow-sm dark:border-red-900 dark:bg-slate-900/70 dark:text-slate-400">
|
|
58
34
|
<p className="font-medium text-red-600">日報データの読み込みに失敗しました。</p>
|
|
59
|
-
<button
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
data-testid="daily-report-load-error-retry"
|
|
38
|
+
onClick={() => retryDailyReportIdsStream()}
|
|
39
|
+
className="rounded bg-slate-100 px-4 py-2 text-slate-700 text-sm hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">
|
|
60
40
|
再読み込み
|
|
61
41
|
</button>
|
|
62
42
|
</div>
|
|
@@ -67,8 +47,8 @@ export const DailyReportLoadError = () => {
|
|
|
67
47
|
}
|
|
68
48
|
|
|
69
49
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
50
|
+
* Loading screen shown only until the first stream chunk arrives.
|
|
51
|
+
* 最初のストリームチャンクが届くまでの間だけ表示するローディング画面。
|
|
72
52
|
*/
|
|
73
53
|
export const DailyReportLoading = () => {
|
|
74
54
|
const { title, renderHeader } = useDailyReportConfig()
|
|
@@ -77,7 +57,8 @@ export const DailyReportLoading = () => {
|
|
|
77
57
|
{renderHeader({
|
|
78
58
|
title,
|
|
79
59
|
annotation: (
|
|
80
|
-
<div className="flex flex-wrap gap-2 leading-none">
|
|
60
|
+
<div className="flex flex-wrap items-center gap-2 leading-none">
|
|
61
|
+
<DailyReportIdsStreamStatus />
|
|
81
62
|
<span>総件数: 読み込み中...</span>
|
|
82
63
|
<span>デバウンス: {DAILY_REPORT_FETCH_DEBOUNCE_MS} ms</span>
|
|
83
64
|
</div>
|
|
@@ -97,41 +78,48 @@ export type DailyReportPageProps = {
|
|
|
97
78
|
user?: DailyReportUser
|
|
98
79
|
/** 難読化済みの内部ユーザー ID (未登録ユーザーは null)。 */
|
|
99
80
|
userId?: string | null
|
|
100
|
-
/** clientLoader で defer した日報 ID 一覧 (Promise) または解決済み配列。 */
|
|
101
|
-
dailyReportIds?: Promise<DailyReportItem[]> | DailyReportItem[] | null
|
|
102
81
|
/** クライアント設定の部分上書き (ヘッダー描画・パス・ラベル名・フィールドラベルなど)。 */
|
|
103
82
|
config?: DailyReportClientConfigInput
|
|
104
83
|
}
|
|
105
84
|
|
|
106
85
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
86
|
+
* Stream-driven page body: session bootstrap, phase gating, and provider wiring.
|
|
87
|
+
* ストリーム駆動のページ本体。セッション確立・局面ゲーティング・プロバイダー結線を担う。
|
|
109
88
|
*/
|
|
110
|
-
const DailyReportPageInner = ({ user, userId
|
|
111
|
-
const
|
|
89
|
+
const DailyReportPageInner = ({ user, userId }: Omit<DailyReportPageProps, "config">) => {
|
|
90
|
+
const { apiBasePath } = useDailyReportConfig()
|
|
91
|
+
const stream = useDailyReportIdsStream()
|
|
112
92
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
93
|
+
/**
|
|
94
|
+
* ストリームセッション確立の保険。
|
|
95
|
+
* 通常は clientLoader が先に確立するが、clientLoader を経ないマウントや
|
|
96
|
+
* ユーザー切替 (userKey 不一致でセッション再生成) をここで拾う。
|
|
97
|
+
* 依存配列: apiBasePath / userId (いずれかの変化で再確認)。
|
|
98
|
+
* クリーンアップ: なし (セッションはモジュール常駐で、アンマウント後も生存させる)。
|
|
99
|
+
*/
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
ensureDailyReportIdsStreamSession({ apiBasePath, userKey: userId ?? null })
|
|
102
|
+
}, [apiBasePath, userId])
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 認証切れ (ストリームが 401/403) はドキュメントのフルリロードへ倒し、
|
|
106
|
+
* サーバー loader のログインリダイレクトに委譲する。
|
|
107
|
+
* 依存配列: stream.phase。クリーンアップ: なし。
|
|
108
|
+
*/
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (stream.phase === "auth-required") {
|
|
111
|
+
reloadDocument()
|
|
112
|
+
}
|
|
113
|
+
}, [stream.phase])
|
|
116
114
|
|
|
115
|
+
// 1 件でも届いたら (または空リストで完走したら) 本体を描画する
|
|
116
|
+
const showContent = stream.items.length > 0 || stream.phase === "complete"
|
|
117
117
|
return (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
{(resolvedItems) => {
|
|
124
|
-
if (!resolvedItems) {
|
|
125
|
-
throw new Error("Daily report ids resolve must not be null")
|
|
126
|
-
}
|
|
127
|
-
// 非 null を保証して以降の処理で利用
|
|
128
|
-
return <DailyReportResolvedSync dailyReportItems={resolvedItems as DailyReportItem[]} userId={userId} onResolved={handleResolvedItems} />
|
|
129
|
-
}}
|
|
130
|
-
</Await>
|
|
131
|
-
) : (
|
|
132
|
-
<DailyReportLoading />
|
|
133
|
-
)}
|
|
134
|
-
</Suspense>
|
|
118
|
+
// key でユーザーごとにプロバイダーをリマウントする。リロード無しのユーザー切替
|
|
119
|
+
// (再ログイン・成り代わり) で items / removedIds / 保留キュー等の状態が旧ユーザーの
|
|
120
|
+
// まま残ると、render-phase マージが旧ユーザーの行を新ユーザーの一覧へ持ち越す
|
|
121
|
+
<DailyReportActionProvider key={userId ?? "anonymous"} user={user} initialItems={stream.items} userId={userId}>
|
|
122
|
+
{showContent ? <DailyReportResolvedContent userId={userId} /> : stream.phase === "failed" ? <DailyReportLoadError /> : <DailyReportLoading />}
|
|
135
123
|
</DailyReportActionProvider>
|
|
136
124
|
)
|
|
137
125
|
}
|
|
@@ -2,35 +2,40 @@ import { Plus, RefreshCw, RotateCw } from "lucide-react"
|
|
|
2
2
|
import type { ReactNode } from "react"
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
4
4
|
import { useSearchParams } from "react-router"
|
|
5
|
-
import type { DailyReportItem } from "../../shared/types"
|
|
6
5
|
import { useDailyReportConfig } from "../config-context"
|
|
7
6
|
import { useDailyReportActionContext } from "../contexts/daily-report-action-context"
|
|
7
|
+
import { useAnimatedNumber } from "../hooks/use-animated-number"
|
|
8
8
|
import { Button } from "../ui/button"
|
|
9
9
|
import { Label } from "../ui/label"
|
|
10
10
|
import { Switch } from "../ui/switch"
|
|
11
11
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
|
12
12
|
import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
|
|
13
13
|
import { DailyReportDetailList } from "./daily-report-detail-list"
|
|
14
|
+
import { DailyReportIdsStreamStatus } from "./daily-report-ids-stream-status"
|
|
14
15
|
import { DailyReportList } from "./daily-report-list"
|
|
15
16
|
|
|
16
17
|
const tabTriggerClass = "min-w-24 p-2 font-medium text-slate-600 text-xs transition-colors data-[state=active]:bg-white data-[state=active]:text-slate-900 sm:min-w-36 sm:text-sm dark:text-slate-300 dark:data-[state=active]:bg-slate-800 dark:data-[state=active]:text-slate-100"
|
|
17
18
|
|
|
18
19
|
type DailyReportResolvedContentProps = {
|
|
19
|
-
dailyReportItems: DailyReportItem[]
|
|
20
20
|
userId?: string | null
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Displays
|
|
25
|
-
*
|
|
24
|
+
* Displays streamed daily reports with layout and tabbed virtual scroll views.
|
|
25
|
+
* レイアウトとタブ付き仮想スクロールでストリーム受信済み日報を表示するコンポーネント。
|
|
26
|
+
*
|
|
27
|
+
* アイテムは Action Context (ストリームチャンクをマージ済み) から読む。
|
|
26
28
|
*/
|
|
27
|
-
export const DailyReportResolvedContent = ({
|
|
29
|
+
export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedContentProps) => {
|
|
28
30
|
// 自動既読設定
|
|
29
31
|
const [autoMarkRead, setAutoMarkRead] = useState(false)
|
|
30
32
|
const { title, renderHeader, showDevControls } = useDailyReportConfig()
|
|
31
33
|
const { isSseEnabled, toggleSse, createReport, items: dailyReportItems, refetchData, clearCacheAndRefetch } = useDailyReportActionContext()
|
|
32
34
|
const [searchParams] = useSearchParams()
|
|
33
35
|
|
|
36
|
+
// 総件数の表示値 (チャンク到着ごとの跳びをイーズアウトで滑らかにする)
|
|
37
|
+
const animatedItemCount = useAnimatedNumber(dailyReportItems.length)
|
|
38
|
+
|
|
34
39
|
// 各タブのスクロール位置をアンマウント後も保持する Ref
|
|
35
40
|
const listScrollOffsetRef = useRef<number>(0)
|
|
36
41
|
const detailListScrollOffsetRef = useRef<number>(0)
|
|
@@ -49,6 +54,24 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
49
54
|
return dailyReportItems[0].reportHubId
|
|
50
55
|
})
|
|
51
56
|
|
|
57
|
+
// 初回の自動選択を一度きりにするフラグ (ユーザーの明示的な選択解除を上書きしない)
|
|
58
|
+
const hasAutoSelectedRef = useRef(false)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 最初のアイテム到着時に先頭日報を一度だけ自動選択する副作用。
|
|
62
|
+
* useState 初期化子はマウント時点で context の items がまだ空のため実質機能せず、
|
|
63
|
+
* ストリーミングでは最初のチャンク到着後に選択を確定させる必要がある。
|
|
64
|
+
* モバイルのオーバーレイは isMobileDetailOpen (明示タップ) でのみ開くため、
|
|
65
|
+
* プログラム選択でオーバーレイが開くことはない。
|
|
66
|
+
* 依存配列: dailyReportItems (チャンク到着ごと)。クリーンアップ: なし。
|
|
67
|
+
*/
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!hasAutoSelectedRef.current && dailyReportItems.length > 0) {
|
|
70
|
+
hasAutoSelectedRef.current = true
|
|
71
|
+
setSelectedItemId((prev) => prev ?? dailyReportItems[0].reportHubId)
|
|
72
|
+
}
|
|
73
|
+
}, [dailyReportItems])
|
|
74
|
+
|
|
52
75
|
const handleReload = async () => {
|
|
53
76
|
await refetchData()
|
|
54
77
|
}
|
|
@@ -123,8 +146,9 @@ export const DailyReportResolvedContent = ({ dailyReportItems: _initialItems, us
|
|
|
123
146
|
{renderHeader({
|
|
124
147
|
title,
|
|
125
148
|
annotation: (
|
|
126
|
-
<div className="flex flex-wrap gap-2 leading-1">
|
|
127
|
-
<
|
|
149
|
+
<div className="flex flex-wrap items-center gap-2 leading-1">
|
|
150
|
+
<DailyReportIdsStreamStatus />
|
|
151
|
+
<span className="tabular-nums">総件数: {animatedItemCount.toLocaleString()} 件</span>
|
|
128
152
|
<span>デバウンス: {DAILY_REPORT_FETCH_DEBOUNCE_MS} ms</span>
|
|
129
153
|
</div>
|
|
130
154
|
),
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-continuation gating tests for the action provider's deferred mutations.
|
|
3
|
+
* アクションプロバイダーの遅延ミューテーション継続に対する門番テスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件:
|
|
6
|
+
*
|
|
7
|
+
* - **POST /action の待機中にリロード無しのユーザー切替が起きた場合、解決した継続は
|
|
8
|
+
* モジュール常駐状態 (常駐ストリームセッション・DailyReportCache) へ書き込まない。**
|
|
9
|
+
* 門番を欠くと、旧ユーザーの日報行が新ユーザーの一覧へ注入され (観測集合に刻まれて
|
|
10
|
+
* 完走剪定も免れる)、切替時に破棄した詳細キャッシュへ旧ユーザーの下書き本文が
|
|
11
|
+
* 再シードされる (第 6 ラウンド敵対的レビューで実測再現された漏洩)。
|
|
12
|
+
* - **同一ユーザーのまま解決した継続は従来どおり反映される** (門番が正常経路を壊さない)。
|
|
13
|
+
*
|
|
14
|
+
* セッション・キャッシュはモジュール常駐シングルトンのため、テスト間で明示的に
|
|
15
|
+
* リセットする (このファイル内で直列実行)。
|
|
16
|
+
*/
|
|
17
|
+
import { act, render, renderHook, waitFor } from "@testing-library/react"
|
|
18
|
+
import { useEffect } from "react"
|
|
19
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
20
|
+
import type { DailyReportActionContextType } from "./daily-report-action-context"
|
|
21
|
+
|
|
22
|
+
// SSE 接続は EventSource を jsdom に持ち込まず、onMessage を横取りしてテストから直接叩けるようにする
|
|
23
|
+
const capturedOnMessage: { current: ((payload: unknown) => void) | null } = { current: null }
|
|
24
|
+
vi.mock("../hooks/use-daily-report-sse-connection", () => ({
|
|
25
|
+
useDailyReportSseConnection: ({ onMessage }: { onMessage: (payload: unknown) => void }) => {
|
|
26
|
+
capturedOnMessage.current = onMessage
|
|
27
|
+
},
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
import { clearDailyReportCache, getCachedReport } from "../hooks/use-daily-report"
|
|
31
|
+
import { ensureDailyReportIdsStreamSession, resetDailyReportIdsStreamSessionForTesting, useDailyReportIdsStream } from "../streaming/daily-report-ids-stream-session"
|
|
32
|
+
import { DailyReportActionProvider, useDailyReportActionContext } from "./daily-report-action-context"
|
|
33
|
+
|
|
34
|
+
/** NDJSON 1 行の ids-stream 応答を組み立てる。 */
|
|
35
|
+
const idsStreamResponse = (reportHubIds: number[]): Response => {
|
|
36
|
+
const line = JSON.stringify({
|
|
37
|
+
items: reportHubIds.map((reportHubId) => ({ reportHubId, businessDate: "2026-08-01", sourceType: "Internal" })),
|
|
38
|
+
nextCursor: null,
|
|
39
|
+
isLast: true,
|
|
40
|
+
total: reportHubIds.length,
|
|
41
|
+
})
|
|
42
|
+
return new Response(`${line}\n`, { status: 200, headers: { "Content-Type": "application/x-ndjson" } })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** /action 応答 (create 成功) を組み立てる。clientTempId は要求からエコーする。 */
|
|
46
|
+
const createActionResponse = (clientTempId: string, newId: number): Response =>
|
|
47
|
+
new Response(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
status: "OK",
|
|
50
|
+
reportHubId: newId,
|
|
51
|
+
clientTempId,
|
|
52
|
+
report: {
|
|
53
|
+
reportHubId: newId,
|
|
54
|
+
date: "2026-08-05",
|
|
55
|
+
createdAt: "2026-08-05 10:00:00",
|
|
56
|
+
author: "User A",
|
|
57
|
+
userId: "ENC_A",
|
|
58
|
+
sourceType: "Internal",
|
|
59
|
+
employeeName: null,
|
|
60
|
+
updatedBy: "User A",
|
|
61
|
+
updatedAt: "2026-08-05 10:00:00",
|
|
62
|
+
category: null,
|
|
63
|
+
creationCategory: null,
|
|
64
|
+
visitTimeFrom: null,
|
|
65
|
+
visitTimeTo: null,
|
|
66
|
+
customerName: null,
|
|
67
|
+
interviewers: [],
|
|
68
|
+
subject: "USER_A confidential draft subject",
|
|
69
|
+
content: "secret",
|
|
70
|
+
comments: [],
|
|
71
|
+
isRead: true,
|
|
72
|
+
isStarred: false,
|
|
73
|
+
labels: [],
|
|
74
|
+
commentItems: [],
|
|
75
|
+
attachments: [],
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
/** コンテキストを外へ取り出すだけの消費者。 */
|
|
82
|
+
const contextRef: { current: DailyReportActionContextType | null } = { current: null }
|
|
83
|
+
const ContextProbe = () => {
|
|
84
|
+
const ctx = useDailyReportActionContext()
|
|
85
|
+
/**
|
|
86
|
+
* コンテキスト参照の同期副作用。
|
|
87
|
+
* 依存配列: ctx (プロバイダー再レンダーごと)。クリーンアップ: なし。
|
|
88
|
+
*/
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
contextRef.current = ctx
|
|
91
|
+
}, [ctx])
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* fetch スタブ: ids-stream は即時応答、/action は解放関数で遅延解決する。
|
|
97
|
+
* 解放時の clientTempId エコーのため要求 FormData を記録する。
|
|
98
|
+
*/
|
|
99
|
+
const installFetchStub = () => {
|
|
100
|
+
let releaseAction: ((newId: number) => void) | null = null
|
|
101
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
102
|
+
const url = String(input)
|
|
103
|
+
if (url.includes("/ids-stream")) {
|
|
104
|
+
// A のセッションは [101]、B のセッションも [101] を正とする (999 は誰の全量にも無い)
|
|
105
|
+
return idsStreamResponse([101])
|
|
106
|
+
}
|
|
107
|
+
if (url.includes("/action")) {
|
|
108
|
+
const clientTempId = String((init?.body as FormData).get("clientTempId"))
|
|
109
|
+
return new Promise<Response>((resolve) => {
|
|
110
|
+
releaseAction = (newId: number) => resolve(createActionResponse(clientTempId, newId))
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
throw new Error(`unexpected fetch: ${url}`)
|
|
114
|
+
})
|
|
115
|
+
vi.stubGlobal("fetch", fetchMock)
|
|
116
|
+
return { release: (newId: number) => releaseAction?.(newId) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
resetDailyReportIdsStreamSessionForTesting()
|
|
121
|
+
clearDailyReportCache()
|
|
122
|
+
contextRef.current = null
|
|
123
|
+
capturedOnMessage.current = null
|
|
124
|
+
vi.unstubAllGlobals()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe("遅延ミューテーション継続の門番 (ユーザー切替汚染防止)", () => {
|
|
128
|
+
it("切替後に解決した createReport はセッションにもキャッシュにも書き込まない", async () => {
|
|
129
|
+
const stub = installFetchStub()
|
|
130
|
+
|
|
131
|
+
// ユーザー A のセッション確立 + プロバイダー描画
|
|
132
|
+
act(() => {
|
|
133
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_A" })
|
|
134
|
+
})
|
|
135
|
+
const { unmount } = render(
|
|
136
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A" initialItems={[]}>
|
|
137
|
+
<ContextProbe />
|
|
138
|
+
</DailyReportActionProvider>,
|
|
139
|
+
)
|
|
140
|
+
await waitFor(() => expect(contextRef.current).not.toBeNull())
|
|
141
|
+
|
|
142
|
+
// A が作成を発行 (POST は保留)
|
|
143
|
+
let createPromise: Promise<number> | undefined
|
|
144
|
+
act(() => {
|
|
145
|
+
createPromise = contextRef.current?.createReport("2026-08-05")
|
|
146
|
+
})
|
|
147
|
+
createPromise?.catch(() => {})
|
|
148
|
+
|
|
149
|
+
// リロード無しのユーザー切替: プロバイダー破棄 + セッション B へ
|
|
150
|
+
unmount()
|
|
151
|
+
act(() => {
|
|
152
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_B" })
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// B のストリーム完走を待ってから A の POST を解決させる
|
|
156
|
+
const { result: streamResult } = renderHook(() => useDailyReportIdsStream())
|
|
157
|
+
await waitFor(() => expect(streamResult.current.phase).toBe("complete"))
|
|
158
|
+
act(() => {
|
|
159
|
+
stub.release(999)
|
|
160
|
+
})
|
|
161
|
+
await expect(createPromise).rejects.toThrow()
|
|
162
|
+
|
|
163
|
+
// B のセッションに A の新規日報 999 が注入されていない
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
expect(streamResult.current.items.map((item) => item.reportHubId)).toEqual([101])
|
|
166
|
+
})
|
|
167
|
+
// 切替時に破棄した詳細キャッシュへ A の下書きが再シードされていない
|
|
168
|
+
expect(getCachedReport(999, { ignoreExpiry: true })).toBeFalsy()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("切替〜アンマウント間の隙間で届いた旧ユーザーの SSE report-create はセッションにもキャッシュにも書き込まない", async () => {
|
|
172
|
+
installFetchStub()
|
|
173
|
+
|
|
174
|
+
// ユーザー A のセッション + プロバイダー (SSE onMessage を横取り)
|
|
175
|
+
act(() => {
|
|
176
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_A" })
|
|
177
|
+
})
|
|
178
|
+
render(
|
|
179
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A" initialItems={[]}>
|
|
180
|
+
<ContextProbe />
|
|
181
|
+
</DailyReportActionProvider>,
|
|
182
|
+
)
|
|
183
|
+
await waitFor(() => expect(capturedOnMessage.current).not.toBeNull())
|
|
184
|
+
|
|
185
|
+
// 隙間状態の再現: A のプロバイダー (EventSource) はマウントされたまま、
|
|
186
|
+
// モジュールセッションだけが B へ差し替わる
|
|
187
|
+
act(() => {
|
|
188
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_B" })
|
|
189
|
+
})
|
|
190
|
+
const { result: streamResult } = renderHook(() => useDailyReportIdsStream())
|
|
191
|
+
await waitFor(() => expect(streamResult.current.phase).toBe("complete"))
|
|
192
|
+
|
|
193
|
+
// A 宛の下書き report-create が旧接続へ届く
|
|
194
|
+
act(() => {
|
|
195
|
+
capturedOnMessage.current?.({
|
|
196
|
+
type: "report-create",
|
|
197
|
+
clientTempId: "other-tab-op",
|
|
198
|
+
report: {
|
|
199
|
+
reportHubId: 999,
|
|
200
|
+
date: "2026-08-05",
|
|
201
|
+
createdAt: "2026-08-05 10:00:00",
|
|
202
|
+
author: "User A",
|
|
203
|
+
userId: "ENC_A",
|
|
204
|
+
sourceType: "Internal",
|
|
205
|
+
employeeName: null,
|
|
206
|
+
updatedBy: "User A",
|
|
207
|
+
updatedAt: "2026-08-05 10:00:00",
|
|
208
|
+
category: null,
|
|
209
|
+
creationCategory: null,
|
|
210
|
+
visitTimeFrom: null,
|
|
211
|
+
visitTimeTo: null,
|
|
212
|
+
customerName: null,
|
|
213
|
+
interviewers: [],
|
|
214
|
+
subject: "USER_A private draft via SSE",
|
|
215
|
+
content: "secret",
|
|
216
|
+
comments: [],
|
|
217
|
+
isRead: false,
|
|
218
|
+
isStarred: false,
|
|
219
|
+
labels: [],
|
|
220
|
+
commentItems: [],
|
|
221
|
+
attachments: [],
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// B のセッションに 999 が注入されず、破棄済みキャッシュにも再シードされない
|
|
227
|
+
expect(streamResult.current.items.map((item) => item.reportHubId)).toEqual([101])
|
|
228
|
+
expect(getCachedReport(999, { ignoreExpiry: true })).toBeFalsy()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("同一ユーザーのまま解決した createReport は従来どおりセッションとキャッシュへ反映される", async () => {
|
|
232
|
+
const stub = installFetchStub()
|
|
233
|
+
|
|
234
|
+
act(() => {
|
|
235
|
+
ensureDailyReportIdsStreamSession({ userKey: "ENC_A" })
|
|
236
|
+
})
|
|
237
|
+
render(
|
|
238
|
+
<DailyReportActionProvider user={{ id: "a", displayName: "User A" }} userId="ENC_A" initialItems={[]}>
|
|
239
|
+
<ContextProbe />
|
|
240
|
+
</DailyReportActionProvider>,
|
|
241
|
+
)
|
|
242
|
+
await waitFor(() => expect(contextRef.current).not.toBeNull())
|
|
243
|
+
|
|
244
|
+
const { result: streamResult } = renderHook(() => useDailyReportIdsStream())
|
|
245
|
+
await waitFor(() => expect(streamResult.current.phase).toBe("complete"))
|
|
246
|
+
|
|
247
|
+
let createPromise: Promise<number> | undefined
|
|
248
|
+
act(() => {
|
|
249
|
+
createPromise = contextRef.current?.createReport("2026-08-05")
|
|
250
|
+
})
|
|
251
|
+
act(() => {
|
|
252
|
+
stub.release(999)
|
|
253
|
+
})
|
|
254
|
+
await expect(createPromise).resolves.toBe(999)
|
|
255
|
+
|
|
256
|
+
// 門番が正常経路を壊していない: 常駐セッションへ 999 が中継され、キャッシュも温まる
|
|
257
|
+
await waitFor(() => {
|
|
258
|
+
expect(streamResult.current.items.map((item) => item.reportHubId)).toContain(999)
|
|
259
|
+
})
|
|
260
|
+
expect(getCachedReport(999, { ignoreExpiry: true })?.subject).toBe("USER_A confidential draft subject")
|
|
261
|
+
})
|
|
262
|
+
})
|