@aiquants/daily-report 0.7.0 → 0.9.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 +438 -26
- package/dist/client.d.ts +438 -26
- 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 +115 -3
- package/dist/index.d.ts +115 -3
- 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 +15 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +9 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +9 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-Df49KA7B.d.mts → sse-schema-BiUnufrI.d.mts} +12 -0
- package/dist/{sse-schema-RHckD7TS.d.ts → sse-schema-YZh7Pz7i.d.ts} +12 -0
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-comment-item.tsx +6 -1
- package/src/client/components/daily-report-detail-list.tsx +65 -34
- package/src/client/components/daily-report-edit-form.tsx +6 -0
- 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-list.tsx +88 -55
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +62 -69
- package/src/client/components/daily-report-resolved-content.tsx +36 -10
- package/src/client/components/report-views.spec.tsx +37 -2
- package/src/client/config-context.tsx +18 -0
- package/src/client/contexts/daily-report-action-context.spec.tsx +278 -0
- package/src/client/contexts/daily-report-action-context.tsx +203 -65
- package/src/client/contexts/daily-report-error-context.spec.tsx +66 -0
- package/src/client/contexts/daily-report-error-context.tsx +98 -0
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/hooks/use-daily-report.spec.ts +50 -2
- package/src/client/hooks/use-daily-report.ts +30 -1
- 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 +704 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +677 -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 +5 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.action.spec.ts +44 -0
- package/src/server/handlers.ids-stream.spec.ts +414 -0
- package/src/server/handlers.ts +324 -8
- package/src/server/service.spec.ts +83 -7
- package/src/server/service.ts +100 -3
- package/src/server/test-helpers/handlers-config.ts +48 -1
- package/src/server.spec.ts +49 -0
- package/src/server.ts +7 -0
- package/src/shared/business-date.spec.ts +22 -1
- package/src/shared/business-date.ts +20 -0
- package/src/shared/comment-adapter.spec.ts +38 -0
- package/src/shared/comment-adapter.ts +9 -7
- package/src/shared/ids-stream.spec.ts +65 -0
- package/src/shared/ids-stream.ts +139 -0
- package/src/shared/sse-schema.ts +5 -0
- package/src/shared/text-utils.spec.ts +42 -0
- package/src/shared/text-utils.ts +8 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resilience tests for the ids NDJSON stream client.
|
|
3
|
+
* ids NDJSON ストリームクライアントの耐障害テスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件のうち、特に重要なもの:
|
|
6
|
+
*
|
|
7
|
+
* - **受信済みデータはどの失敗経路でも失われない** (retryable / failed / auth-required)。
|
|
8
|
+
* - **カーソル再開 URL の契約**: `cursor_report_hub_id` は常に付与、`cursor_business_date` は
|
|
9
|
+
* NULL 営業日領域では省略する。
|
|
10
|
+
* - **前進があった時点で連続失敗カウンタが 0 に戻る** (断続的不調で failed へ落とさない)。
|
|
11
|
+
* - **バックオフは 1s → 2s → 4s ... 上限 30s、6 連続失敗で failed** (手動 retry 待ち)。
|
|
12
|
+
* - **complete 後の refresh は phase を complete のまま保つ** (SWR: UI を後退させない)。
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it, vi } from "vitest"
|
|
15
|
+
import { DailyReportIdsStreamClient, type DailyReportIdsStreamState } from "./daily-report-ids-stream-client"
|
|
16
|
+
|
|
17
|
+
/** NDJSON 行群を 1 本のストリーム Response に組み立てる。chunks 単位で enqueue が割れる。 */
|
|
18
|
+
const streamResponse = (chunks: string[], init: { status?: number } = {}): Response => {
|
|
19
|
+
const encoder = new TextEncoder()
|
|
20
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
21
|
+
start(controller) {
|
|
22
|
+
for (const chunk of chunks) {
|
|
23
|
+
controller.enqueue(encoder.encode(chunk))
|
|
24
|
+
}
|
|
25
|
+
controller.close()
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
return new Response(stream, { status: init.status ?? 200, headers: { "Content-Type": "application/x-ndjson" } })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** JSON 行の配列を改行区切りテキストへ変換する。 */
|
|
32
|
+
const ndjson = (lines: unknown[]): string => lines.map((line) => `${JSON.stringify(line)}\n`).join("")
|
|
33
|
+
|
|
34
|
+
/** アイテムの略記生成。 */
|
|
35
|
+
const item = (reportHubId: number, businessDate: string | null = "2026-08-01") => ({ reportHubId, businessDate, sourceType: "Internal" })
|
|
36
|
+
|
|
37
|
+
/** テストハーネス: 応答キュー + 手動タイマー + クライアント組み立て。 */
|
|
38
|
+
const makeHarness = (factories: Array<(init: { signal: AbortSignal }) => Response | Promise<Response>>, options: { publishCoalesceMs?: number; readStallTimeoutMs?: number } = {}) => {
|
|
39
|
+
const fetchUrls: string[] = []
|
|
40
|
+
const fetchFn = vi.fn(async (input: string, init: { signal: AbortSignal }) => {
|
|
41
|
+
fetchUrls.push(input)
|
|
42
|
+
const factory = factories[Math.min(fetchUrls.length - 1, factories.length - 1)]
|
|
43
|
+
return factory(init)
|
|
44
|
+
})
|
|
45
|
+
/** 記録された待機 (ms)。waitForState が随時発火させる。 */
|
|
46
|
+
const recordedDelays: number[] = []
|
|
47
|
+
const pendingTimers: Array<() => void> = []
|
|
48
|
+
const client = new DailyReportIdsStreamClient({
|
|
49
|
+
apiBasePath: "/daily_report/api",
|
|
50
|
+
fetchFn,
|
|
51
|
+
setTimeoutFn: (handler, ms) => {
|
|
52
|
+
recordedDelays.push(ms)
|
|
53
|
+
pendingTimers.push(handler)
|
|
54
|
+
return pendingTimers.length as unknown as ReturnType<typeof setTimeout>
|
|
55
|
+
},
|
|
56
|
+
clearTimeoutFn: () => {},
|
|
57
|
+
warn: () => {},
|
|
58
|
+
// 既存テストは publish タイミングの決定性を優先して合体を無効化する
|
|
59
|
+
// (合体挙動そのものは専用テストで publishCoalesceMs を指定して検証する)
|
|
60
|
+
publishCoalesceMs: options.publishCoalesceMs ?? 0,
|
|
61
|
+
// stall ウォッチドッグも既定で無効化する (recordedDelays に混ざらないよう)。
|
|
62
|
+
// stall 検知そのものは専用テストで readStallTimeoutMs を指定して検証する
|
|
63
|
+
readStallTimeoutMs: options.readStallTimeoutMs ?? 0,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
/** 状態が条件を満たすまでマイクロタスクを回し、保留タイマーを逐次発火する。 */
|
|
67
|
+
const waitForState = async (predicate: (state: DailyReportIdsStreamState) => boolean): Promise<void> => {
|
|
68
|
+
for (let i = 0; i < 200; i++) {
|
|
69
|
+
if (predicate(client.getState())) return
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
71
|
+
const timer = pendingTimers.shift()
|
|
72
|
+
timer?.()
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`state not reached: ${JSON.stringify(client.getState())}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { client, fetchFn, fetchUrls, recordedDelays, pendingTimers, waitForState }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("DailyReportIdsStreamClient / 正常系", () => {
|
|
81
|
+
it("複数チャンクを蓄積して complete へ到達し、total と件数を反映する", async () => {
|
|
82
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(3), item(2)], nextCursor: { businessDate: "2026-08-01", reportHubId: 2 }, isLast: false, total: 3 }]), ndjson([{ items: [item(1)], nextCursor: null, isLast: true }])])])
|
|
83
|
+
harness.client.start()
|
|
84
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
85
|
+
|
|
86
|
+
const state = harness.client.getState()
|
|
87
|
+
expect(state.items.map((i) => i.reportHubId)).toEqual([3, 2, 1])
|
|
88
|
+
expect(state.loadedCount).toBe(3)
|
|
89
|
+
expect(state.totalCount).toBe(3)
|
|
90
|
+
expect(state.hasCompletedOnce).toBe(true)
|
|
91
|
+
expect(state.error).toBeNull()
|
|
92
|
+
expect(harness.fetchUrls).toEqual(["/daily_report/api/ids-stream"])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("行境界と無関係に割れたネットワークチャンクを正しく結合する", async () => {
|
|
96
|
+
const full = ndjson([{ items: [item(10), item(9)], nextCursor: null, isLast: true, total: 2 }])
|
|
97
|
+
const mid = Math.floor(full.length / 2)
|
|
98
|
+
const harness = makeHarness([() => streamResponse([full.slice(0, mid), full.slice(mid)])])
|
|
99
|
+
harness.client.start()
|
|
100
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
101
|
+
expect(harness.client.getState().items.map((i) => i.reportHubId)).toEqual([10, 9])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("start は冪等 (二重呼び出しで接続は 1 本)", async () => {
|
|
105
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])])])
|
|
106
|
+
harness.client.start()
|
|
107
|
+
harness.client.start()
|
|
108
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
109
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(1)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("重複アイテムは reportHubId で一意化され、内容変化は上書きされる", async () => {
|
|
113
|
+
const harness = makeHarness([
|
|
114
|
+
() =>
|
|
115
|
+
streamResponse([
|
|
116
|
+
ndjson([
|
|
117
|
+
{ items: [item(5, "2026-08-01"), item(4)], nextCursor: { businessDate: "2026-08-01", reportHubId: 4 }, isLast: false, total: 3 },
|
|
118
|
+
// 再開重複 (5 の内容変化) + 新規 3
|
|
119
|
+
{ items: [item(5, "2026-08-02"), item(3)], nextCursor: null, isLast: true },
|
|
120
|
+
]),
|
|
121
|
+
]),
|
|
122
|
+
])
|
|
123
|
+
harness.client.start()
|
|
124
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
125
|
+
const state = harness.client.getState()
|
|
126
|
+
expect(state.loadedCount).toBe(3)
|
|
127
|
+
expect(state.items.find((i) => i.reportHubId === 5)?.businessDate).toBe("2026-08-02")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("壊れた 1 行はスキップし、残りで完走する", async () => {
|
|
131
|
+
const harness = makeHarness([() => streamResponse([`{"broken":\n${ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])}`])])
|
|
132
|
+
harness.client.start()
|
|
133
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
134
|
+
expect(harness.client.getState().loadedCount).toBe(1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("getState のスナップショット参照は publish 間で安定している", async () => {
|
|
138
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])])])
|
|
139
|
+
const before = harness.client.getState()
|
|
140
|
+
expect(harness.client.getState()).toBe(before)
|
|
141
|
+
harness.client.start()
|
|
142
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
143
|
+
const after = harness.client.getState()
|
|
144
|
+
expect(after).not.toBe(before)
|
|
145
|
+
expect(harness.client.getState()).toBe(after)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe("DailyReportIdsStreamClient / 再開と復旧", () => {
|
|
150
|
+
it("エラー行を受けた接続は受信済みカーソルから再開する", async () => {
|
|
151
|
+
const harness = makeHarness([
|
|
152
|
+
() => streamResponse([ndjson([{ items: [item(9), item(8)], nextCursor: { businessDate: "2026-08-01", reportHubId: 8 }, isLast: false, total: 3 }, { error: "stream interrupted by server error" }])]),
|
|
153
|
+
() => streamResponse([ndjson([{ items: [item(7)], nextCursor: null, isLast: true, total: 3 }])]),
|
|
154
|
+
])
|
|
155
|
+
harness.client.start()
|
|
156
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
157
|
+
expect(harness.fetchUrls[1]).toBe("/daily_report/api/ids-stream?cursor_report_hub_id=8&cursor_business_date=2026-08-01")
|
|
158
|
+
expect(harness.client.getState().items.map((i) => i.reportHubId)).toEqual([9, 8, 7])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("isLast 無しの終端 (サーバー再起動等) はカーソル再開する", async () => {
|
|
162
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(9)], nextCursor: { businessDate: "2026-08-01", reportHubId: 9 }, isLast: false, total: 2 }])]), () => streamResponse([ndjson([{ items: [item(8)], nextCursor: null, isLast: true, total: 2 }])])])
|
|
163
|
+
harness.client.start()
|
|
164
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
165
|
+
expect(harness.fetchUrls[1]).toContain("cursor_report_hub_id=9")
|
|
166
|
+
expect(harness.client.getState().loadedCount).toBe(2)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("NULL 営業日領域の再開は cursor_business_date を省略する", async () => {
|
|
170
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(9, null)], nextCursor: { businessDate: null, reportHubId: 9 }, isLast: false, total: 2 }])]), () => streamResponse([ndjson([{ items: [item(8, null)], nextCursor: null, isLast: true, total: 2 }])])])
|
|
171
|
+
harness.client.start()
|
|
172
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
173
|
+
expect(harness.fetchUrls[1]).toBe("/daily_report/api/ids-stream?cursor_report_hub_id=9")
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it("HTTP 失敗は指数バックオフし、6 連続失敗で failed へ落ちる (データは保持)", async () => {
|
|
177
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(9)], nextCursor: { businessDate: "2026-08-01", reportHubId: 9 }, isLast: false, total: 5 }])]), () => new Response("err", { status: 500 })])
|
|
178
|
+
harness.client.start()
|
|
179
|
+
await harness.waitForState((s) => s.phase === "failed")
|
|
180
|
+
|
|
181
|
+
// 初回接続は前進ありだが isLast 無し終端なので失敗 1 回目として数え、その後 500 が 5 連続で計 6
|
|
182
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(6)
|
|
183
|
+
expect(harness.recordedDelays).toEqual([1000, 2000, 4000, 8000, 16000])
|
|
184
|
+
const state = harness.client.getState()
|
|
185
|
+
expect(state.items.map((i) => i.reportHubId)).toEqual([9])
|
|
186
|
+
expect(state.error?.message).toContain("HTTP 500")
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("前進があると連続失敗カウンタが 0 へ戻る", async () => {
|
|
190
|
+
const harness = makeHarness([
|
|
191
|
+
() => new Response("err", { status: 500 }),
|
|
192
|
+
() => new Response("err", { status: 500 }),
|
|
193
|
+
// 前進あり (ただし未完) → カウンタリセット
|
|
194
|
+
() => streamResponse([ndjson([{ items: [item(9)], nextCursor: { businessDate: "2026-08-01", reportHubId: 9 }, isLast: false, total: 2 }])]),
|
|
195
|
+
() => streamResponse([ndjson([{ items: [item(8)], nextCursor: null, isLast: true, total: 2 }])]),
|
|
196
|
+
])
|
|
197
|
+
harness.client.start()
|
|
198
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
199
|
+
// 1000 (失敗1) → 2000 (失敗2) → 前進でリセット → 1000 (失敗1 相当)
|
|
200
|
+
expect(harness.recordedDelays).toEqual([1000, 2000, 1000])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("401 は auth-required で停止し、リトライしない", async () => {
|
|
204
|
+
const harness = makeHarness([() => new Response("{}", { status: 401 })])
|
|
205
|
+
harness.client.start()
|
|
206
|
+
await harness.waitForState((s) => s.phase === "auth-required")
|
|
207
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(1)
|
|
208
|
+
expect(harness.recordedDelays).toEqual([])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it("retry は failed からのみ再開し、それ以外では何もしない", async () => {
|
|
212
|
+
const harness = makeHarness([
|
|
213
|
+
() => new Response("err", { status: 500 }),
|
|
214
|
+
() => new Response("err", { status: 500 }),
|
|
215
|
+
() => new Response("err", { status: 500 }),
|
|
216
|
+
() => new Response("err", { status: 500 }),
|
|
217
|
+
() => new Response("err", { status: 500 }),
|
|
218
|
+
() => new Response("err", { status: 500 }),
|
|
219
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]),
|
|
220
|
+
])
|
|
221
|
+
harness.client.start()
|
|
222
|
+
await harness.waitForState((s) => s.phase === "failed")
|
|
223
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(6)
|
|
224
|
+
|
|
225
|
+
harness.client.retry()
|
|
226
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
227
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(7)
|
|
228
|
+
|
|
229
|
+
// complete 中の retry は無視される
|
|
230
|
+
harness.client.retry()
|
|
231
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
232
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(7)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe("DailyReportIdsStreamClient / SWR 再検証", () => {
|
|
237
|
+
it("refresh は complete を保ったまま先頭から再読込し、差分を上書きする", async () => {
|
|
238
|
+
const phases: Array<{ phase: string; isRevalidating: boolean }> = []
|
|
239
|
+
const harness = makeHarness([
|
|
240
|
+
() => streamResponse([ndjson([{ items: [item(2, "2026-08-01"), item(1)], nextCursor: null, isLast: true, total: 2 }])]),
|
|
241
|
+
() => streamResponse([ndjson([{ items: [item(3), item(2, "2026-08-02"), item(1)], nextCursor: null, isLast: true, total: 3 }])]),
|
|
242
|
+
])
|
|
243
|
+
harness.client.subscribe(() => {
|
|
244
|
+
const s = harness.client.getState()
|
|
245
|
+
phases.push({ phase: s.phase, isRevalidating: s.isRevalidating })
|
|
246
|
+
})
|
|
247
|
+
harness.client.start()
|
|
248
|
+
await harness.waitForState((s) => s.phase === "complete" && s.loadedCount === 2)
|
|
249
|
+
|
|
250
|
+
harness.client.refresh()
|
|
251
|
+
await harness.waitForState((s) => s.loadedCount === 3 && !s.isRevalidating)
|
|
252
|
+
|
|
253
|
+
const state = harness.client.getState()
|
|
254
|
+
expect(state.phase).toBe("complete")
|
|
255
|
+
expect(state.totalCount).toBe(3)
|
|
256
|
+
expect(state.items.find((i) => i.reportHubId === 2)?.businessDate).toBe("2026-08-02")
|
|
257
|
+
// 再検証 URL はカーソル無しの先頭から
|
|
258
|
+
expect(harness.fetchUrls[1]).toBe("/daily_report/api/ids-stream")
|
|
259
|
+
// 再検証中に streaming/retrying へ後退した publish が存在しない
|
|
260
|
+
expect(phases.filter((p) => p.isRevalidating).every((p) => p.phase === "complete")).toBe(true)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it("refresh({forceRefresh}) はサーバーキャッシュバイパスのクエリを付ける", async () => {
|
|
264
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]), () => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])])])
|
|
265
|
+
harness.client.start()
|
|
266
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
267
|
+
harness.client.refresh({ forceRefresh: true })
|
|
268
|
+
await harness.waitForState((s) => !s.isRevalidating)
|
|
269
|
+
expect(harness.fetchUrls[1]).toBe("/daily_report/api/ids-stream?forceRefresh=true")
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it("streaming 中の refresh は無視される", async () => {
|
|
273
|
+
let release: (() => void) | null = null
|
|
274
|
+
const harness = makeHarness([
|
|
275
|
+
(init) =>
|
|
276
|
+
new Promise<Response>((resolve, reject) => {
|
|
277
|
+
init.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")))
|
|
278
|
+
release = () => resolve(streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]))
|
|
279
|
+
}),
|
|
280
|
+
])
|
|
281
|
+
harness.client.start()
|
|
282
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
283
|
+
harness.client.refresh()
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
285
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(1)
|
|
286
|
+
release?.()
|
|
287
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe("DailyReportIdsStreamClient / レビュー回帰 (確定所見の再発防止)", () => {
|
|
292
|
+
it("total 単独の変化でも購読者へ通知される (無通知のスナップショット置換をしない)", async () => {
|
|
293
|
+
const harness = makeHarness([
|
|
294
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]),
|
|
295
|
+
// 同一アイテム (内容変化なし) だが total だけ変わる再検証応答
|
|
296
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 5 }])]),
|
|
297
|
+
])
|
|
298
|
+
harness.client.start()
|
|
299
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
300
|
+
let notified = 0
|
|
301
|
+
harness.client.subscribe(() => {
|
|
302
|
+
notified += 1
|
|
303
|
+
})
|
|
304
|
+
harness.client.refresh()
|
|
305
|
+
await harness.waitForState((s) => s.totalCount === 5 && !s.isRevalidating)
|
|
306
|
+
expect(notified).toBeGreaterThan(0)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it("再検証の完走はサーバーから消えた行を剪定する (削除日報の亡霊を残さない)", async () => {
|
|
310
|
+
const harness = makeHarness([
|
|
311
|
+
() => streamResponse([ndjson([{ items: [item(3), item(2), item(1)], nextCursor: null, isLast: true, total: 3 }])]),
|
|
312
|
+
// 2 が削除された世界の再検証応答
|
|
313
|
+
() => streamResponse([ndjson([{ items: [item(3), item(1)], nextCursor: null, isLast: true, total: 2 }])]),
|
|
314
|
+
])
|
|
315
|
+
harness.client.start()
|
|
316
|
+
await harness.waitForState((s) => s.loadedCount === 3)
|
|
317
|
+
harness.client.refresh()
|
|
318
|
+
await harness.waitForState((s) => s.loadedCount === 2 && !s.isRevalidating)
|
|
319
|
+
const state = harness.client.getState()
|
|
320
|
+
expect(state.items.map((i) => i.reportHubId)).toEqual([3, 1])
|
|
321
|
+
expect(state.totalCount).toBe(2)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("forceRefresh はループ内の全リトライで維持される (サーバー未到達の失敗で脱落しない)", async () => {
|
|
325
|
+
const harness = makeHarness([
|
|
326
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]),
|
|
327
|
+
// 強制再検証の初回試行はネットワーク断で失敗
|
|
328
|
+
() => Promise.reject(new Error("network down")),
|
|
329
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]),
|
|
330
|
+
])
|
|
331
|
+
harness.client.start()
|
|
332
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
333
|
+
harness.client.refresh({ forceRefresh: true })
|
|
334
|
+
await harness.waitForState((s) => !s.isRevalidating)
|
|
335
|
+
expect(harness.fetchUrls[1]).toContain("forceRefresh=true")
|
|
336
|
+
expect(harness.fetchUrls[2]).toContain("forceRefresh=true")
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it("failed からの refresh({forceRefresh}) はフラグを失わない", async () => {
|
|
340
|
+
const harness = makeHarness([
|
|
341
|
+
() => new Response("err", { status: 500 }),
|
|
342
|
+
() => new Response("err", { status: 500 }),
|
|
343
|
+
() => new Response("err", { status: 500 }),
|
|
344
|
+
() => new Response("err", { status: 500 }),
|
|
345
|
+
() => new Response("err", { status: 500 }),
|
|
346
|
+
() => new Response("err", { status: 500 }),
|
|
347
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]),
|
|
348
|
+
])
|
|
349
|
+
harness.client.start()
|
|
350
|
+
await harness.waitForState((s) => s.phase === "failed")
|
|
351
|
+
harness.client.refresh({ forceRefresh: true })
|
|
352
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
353
|
+
expect(harness.fetchUrls[6]).toContain("forceRefresh=true")
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it("バックオフ待機中に新しい refresh が始まっても後続ループのタイマーは消されない (追い越し競合)", async () => {
|
|
357
|
+
const harness = makeHarness([
|
|
358
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]),
|
|
359
|
+
() => new Response("err", { status: 500 }),
|
|
360
|
+
() => new Response("err", { status: 500 }),
|
|
361
|
+
() => streamResponse([ndjson([{ items: [item(2), item(1)], nextCursor: null, isLast: true, total: 2 }])]),
|
|
362
|
+
])
|
|
363
|
+
harness.client.start()
|
|
364
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
365
|
+
|
|
366
|
+
// 再検証 #1 → 500 → バックオフ待機に入る
|
|
367
|
+
harness.client.refresh()
|
|
368
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
369
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
370
|
+
expect(harness.recordedDelays.length).toBeGreaterThan(0)
|
|
371
|
+
|
|
372
|
+
// 待機中に再検証 #2 を重ねる (旧ループは即時解放され、新ループが独立に完走できること)
|
|
373
|
+
harness.client.refresh()
|
|
374
|
+
await harness.waitForState((s) => s.loadedCount === 2 && !s.isRevalidating)
|
|
375
|
+
expect(harness.client.getState().phase).toBe("complete")
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it("契約外の営業日を持つ nextCursor は採用せず、直前の正当な位置から再開する", async () => {
|
|
379
|
+
const harness = makeHarness([
|
|
380
|
+
// 年 3 桁の混入データ: このカーソルを id 単独で送ると NULL 領域へスキップして欠落する
|
|
381
|
+
() => streamResponse([ndjson([{ items: [item(9, "999-01-01")], nextCursor: { businessDate: "999-01-01", reportHubId: 9 }, isLast: false, total: 2 }])]),
|
|
382
|
+
() => streamResponse([ndjson([{ items: [item(8, "999-01-01")], nextCursor: null, isLast: true, total: 2 }])]),
|
|
383
|
+
])
|
|
384
|
+
harness.client.start()
|
|
385
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
386
|
+
// 再開 URL にカーソルが載らない (先頭から読み直し、Map の重複排除が吸収する)
|
|
387
|
+
expect(harness.fetchUrls[1]).toBe("/daily_report/api/ids-stream")
|
|
388
|
+
expect(harness.client.getState().loadedCount).toBe(2)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it("完走の publish が completedAt を刻み、再検証の諦めでは据え置く", async () => {
|
|
392
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]), () => new Response("err", { status: 500 })])
|
|
393
|
+
harness.client.start()
|
|
394
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
395
|
+
const completedAt = harness.client.getState().completedAt
|
|
396
|
+
expect(completedAt).not.toBeNull()
|
|
397
|
+
|
|
398
|
+
// 再検証は 500 連発で力尽きる — completedAt は完走時のまま
|
|
399
|
+
harness.client.refresh()
|
|
400
|
+
await harness.waitForState((s) => !s.isRevalidating && s.error !== null)
|
|
401
|
+
expect(harness.client.getState().phase).toBe("complete")
|
|
402
|
+
expect(harness.client.getState().completedAt).toBe(completedAt)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
describe("DailyReportIdsStreamClient / 再検証多重起動防止と外部ミューテーション", () => {
|
|
407
|
+
it("走行中の再検証がある間の refresh は無視される (中断・再開始しない)", async () => {
|
|
408
|
+
let release: (() => void) | null = null
|
|
409
|
+
const harness = makeHarness([
|
|
410
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]),
|
|
411
|
+
(init) =>
|
|
412
|
+
new Promise<Response>((resolve, reject) => {
|
|
413
|
+
init.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")))
|
|
414
|
+
release = () => resolve(streamResponse([ndjson([{ items: [item(2), item(1)], nextCursor: null, isLast: true, total: 2 }])]))
|
|
415
|
+
}),
|
|
416
|
+
])
|
|
417
|
+
harness.client.start()
|
|
418
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
419
|
+
|
|
420
|
+
harness.client.refresh()
|
|
421
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
422
|
+
expect(harness.client.getState().isRevalidating).toBe(true)
|
|
423
|
+
|
|
424
|
+
// 再訪の多重 ensure 相当: 走行中の再検証を中断してはならない
|
|
425
|
+
harness.client.refresh()
|
|
426
|
+
harness.client.refresh()
|
|
427
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
428
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(2)
|
|
429
|
+
|
|
430
|
+
release?.()
|
|
431
|
+
await harness.waitForState((s) => s.loadedCount === 2 && !s.isRevalidating)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it("applyExternalUpsert は常駐データへ反映され、走査中の観測集合にも刻まれて剪定を免れる", async () => {
|
|
435
|
+
let release: (() => void) | null = null
|
|
436
|
+
const harness = makeHarness([
|
|
437
|
+
(init) =>
|
|
438
|
+
new Promise<Response>((resolve, reject) => {
|
|
439
|
+
init.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")))
|
|
440
|
+
// サーバー一覧には 3,2 のみ (99 は走査中に SSE で作成された想定)
|
|
441
|
+
release = () => resolve(streamResponse([ndjson([{ items: [item(3), item(2)], nextCursor: null, isLast: true, total: 2 }])]))
|
|
442
|
+
}),
|
|
443
|
+
])
|
|
444
|
+
harness.client.start()
|
|
445
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
446
|
+
|
|
447
|
+
// 走査中の外部 upsert (作成直後の SSE 相当)
|
|
448
|
+
harness.client.applyExternalUpsert({ reportHubId: 99, businessDate: "2026-08-04" })
|
|
449
|
+
expect(harness.client.getState().items.some((i) => i.reportHubId === 99)).toBe(true)
|
|
450
|
+
|
|
451
|
+
release?.()
|
|
452
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
453
|
+
// 完走剪定でも 99 は「生きていると分かっている」ため残る
|
|
454
|
+
expect(harness.client.getState().items.some((i) => i.reportHubId === 99)).toBe(true)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it("走査外の外部 upsert は次の再検証完走で (サーバーに無ければ) 剪定される", async () => {
|
|
458
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]), () => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])])])
|
|
459
|
+
harness.client.start()
|
|
460
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
461
|
+
|
|
462
|
+
harness.client.applyExternalUpsert({ reportHubId: 99, businessDate: "2026-08-04" })
|
|
463
|
+
expect(harness.client.getState().loadedCount).toBe(2)
|
|
464
|
+
|
|
465
|
+
harness.client.refresh()
|
|
466
|
+
await harness.waitForState((s) => s.loadedCount === 1 && !s.isRevalidating)
|
|
467
|
+
expect(harness.client.getState().items.map((i) => i.reportHubId)).toEqual([1])
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it("applyExternalRemoval は常駐データから即時除去し、購読者へ通知する", async () => {
|
|
471
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(2), item(1)], nextCursor: null, isLast: true, total: 2 }])])])
|
|
472
|
+
harness.client.start()
|
|
473
|
+
await harness.waitForState((s) => s.loadedCount === 2)
|
|
474
|
+
|
|
475
|
+
let notified = 0
|
|
476
|
+
harness.client.subscribe(() => {
|
|
477
|
+
notified += 1
|
|
478
|
+
})
|
|
479
|
+
harness.client.applyExternalRemoval(2)
|
|
480
|
+
expect(harness.client.getState().items.map((i) => i.reportHubId)).toEqual([1])
|
|
481
|
+
expect(notified).toBeGreaterThan(0)
|
|
482
|
+
|
|
483
|
+
// 既に無い ID の除去は通知しない
|
|
484
|
+
const before = notified
|
|
485
|
+
harness.client.applyExternalRemoval(2)
|
|
486
|
+
expect(notified).toBe(before)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it("外部 upsert は sourceType 未指定のとき既存値を保持し、同一内容の再適用では通知しない", async () => {
|
|
490
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])])])
|
|
491
|
+
harness.client.start()
|
|
492
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
493
|
+
|
|
494
|
+
let notified = 0
|
|
495
|
+
harness.client.subscribe(() => {
|
|
496
|
+
notified += 1
|
|
497
|
+
})
|
|
498
|
+
// 同一 businessDate + sourceType 未指定: 既存 sourceType (Internal) を保持し、変化なし
|
|
499
|
+
harness.client.applyExternalUpsert({ reportHubId: 1, businessDate: "2026-08-01" })
|
|
500
|
+
expect(notified).toBe(0)
|
|
501
|
+
expect(harness.client.getState().items[0].sourceType).toBe("Internal")
|
|
502
|
+
|
|
503
|
+
// businessDate 変化は反映 + 通知
|
|
504
|
+
harness.client.applyExternalUpsert({ reportHubId: 1, businessDate: "2026-08-02" })
|
|
505
|
+
expect(notified).toBeGreaterThan(0)
|
|
506
|
+
expect(harness.client.getState().items[0]).toEqual({ reportHubId: 1, businessDate: "2026-08-02", sourceType: "Internal" })
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
describe("DailyReportIdsStreamClient / 第 3 ラウンド回帰 (トゥームストーンと forceRefresh 持ち越し)", () => {
|
|
511
|
+
it("走査中の外部削除は、同一スナップショット後続チャンクの再配送でも蘇らない", async () => {
|
|
512
|
+
let release: (() => void) | null = null
|
|
513
|
+
const harness = makeHarness([
|
|
514
|
+
(init) =>
|
|
515
|
+
new Promise<Response>((resolve, reject) => {
|
|
516
|
+
init.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")))
|
|
517
|
+
// チャンク 1: [5,4] / チャンク 2 (削除前スナップショットの再配送): [3] + 5 を再配送
|
|
518
|
+
release = () => resolve(streamResponse([ndjson([{ items: [item(5), item(4)], nextCursor: { businessDate: "2026-08-01", reportHubId: 4 }, isLast: false, total: 4 }]), ndjson([{ items: [item(5), item(3)], nextCursor: null, isLast: true }])]))
|
|
519
|
+
}),
|
|
520
|
+
])
|
|
521
|
+
harness.client.start()
|
|
522
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
523
|
+
|
|
524
|
+
// 走査開始直後に 5 が外部削除される
|
|
525
|
+
harness.client.applyExternalRemoval(5)
|
|
526
|
+
release?.()
|
|
527
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
528
|
+
// 再配送された 5 は受け入れられず、完走後も存在しない
|
|
529
|
+
expect(
|
|
530
|
+
harness.client
|
|
531
|
+
.getState()
|
|
532
|
+
.items.map((i) => i.reportHubId)
|
|
533
|
+
.sort(),
|
|
534
|
+
).toEqual([3, 4])
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it("トゥームストーンは新しい走査の開始でクリアされる (削除コミット後のスナップショットが権威)", async () => {
|
|
538
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(2), item(1)], nextCursor: null, isLast: true, total: 2 }])]), () => streamResponse([ndjson([{ items: [item(2), item(1)], nextCursor: null, isLast: true, total: 2 }])])])
|
|
539
|
+
harness.client.start()
|
|
540
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
541
|
+
|
|
542
|
+
harness.client.applyExternalRemoval(2)
|
|
543
|
+
expect(harness.client.getState().loadedCount).toBe(1)
|
|
544
|
+
|
|
545
|
+
// 新しい走査 (サーバーの新スナップショット) が 2 を含むなら、それが権威として復元される
|
|
546
|
+
harness.client.refresh()
|
|
547
|
+
await harness.waitForState((s) => s.loadedCount === 2 && !s.isRevalidating)
|
|
548
|
+
expect(
|
|
549
|
+
harness.client
|
|
550
|
+
.getState()
|
|
551
|
+
.items.map((i) => i.reportHubId)
|
|
552
|
+
.sort(),
|
|
553
|
+
).toEqual([1, 2])
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it("走行中の再検証中に届いた forceRefresh 要求は捨てられず、完走後に連結実行される", async () => {
|
|
557
|
+
let release: (() => void) | null = null
|
|
558
|
+
const harness = makeHarness([
|
|
559
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]),
|
|
560
|
+
(init) =>
|
|
561
|
+
new Promise<Response>((resolve, reject) => {
|
|
562
|
+
init.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")))
|
|
563
|
+
release = () => resolve(streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])]))
|
|
564
|
+
}),
|
|
565
|
+
() => streamResponse([ndjson([{ items: [item(2), item(1)], nextCursor: null, isLast: true, total: 2 }])]),
|
|
566
|
+
])
|
|
567
|
+
harness.client.start()
|
|
568
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
569
|
+
|
|
570
|
+
// 再検証 #1 (通常) が走行中に、キャッシュクリア (forceRefresh) 要求が届く
|
|
571
|
+
harness.client.refresh()
|
|
572
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
573
|
+
harness.client.refresh({ forceRefresh: true })
|
|
574
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(2)
|
|
575
|
+
|
|
576
|
+
// #1 完走後、持ち越された forceRefresh 付き再検証が自動連結される
|
|
577
|
+
release?.()
|
|
578
|
+
await harness.waitForState((s) => s.loadedCount === 2 && !s.isRevalidating)
|
|
579
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(3)
|
|
580
|
+
expect(harness.fetchUrls[2]).toContain("forceRefresh=true")
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
describe("DailyReportIdsStreamClient / publish 合体スロットル", () => {
|
|
585
|
+
it("先頭チャンクは即時 publish され、窓内に密集した後続チャンクは 1 回へ合体する", async () => {
|
|
586
|
+
const harness = makeHarness(
|
|
587
|
+
[
|
|
588
|
+
() =>
|
|
589
|
+
streamResponse([
|
|
590
|
+
// 3 つのネットワーク read に分けて届く 3 チャンク (密集到着の再現)
|
|
591
|
+
ndjson([{ items: [item(9)], nextCursor: { businessDate: "2026-08-01", reportHubId: 9 }, isLast: false, total: 3 }]),
|
|
592
|
+
ndjson([{ items: [item(8)], nextCursor: { businessDate: "2026-08-01", reportHubId: 8 }, isLast: false }]),
|
|
593
|
+
ndjson([{ items: [item(7)], nextCursor: null, isLast: true }]),
|
|
594
|
+
]),
|
|
595
|
+
],
|
|
596
|
+
{ publishCoalesceMs: 50 },
|
|
597
|
+
)
|
|
598
|
+
const itemCountsPerPublish: number[] = []
|
|
599
|
+
harness.client.subscribe(() => {
|
|
600
|
+
itemCountsPerPublish.push(harness.client.getState().loadedCount)
|
|
601
|
+
})
|
|
602
|
+
harness.client.start()
|
|
603
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
604
|
+
|
|
605
|
+
// 先頭チャンク (1 件) は即時、密集した残りは合体または完走 publish で届く。
|
|
606
|
+
// 「チャンクごとに 3 回のアイテム publish」にはならない
|
|
607
|
+
expect(itemCountsPerPublish[0]).toBeLessThanOrEqual(2)
|
|
608
|
+
expect(harness.client.getState().loadedCount).toBe(3)
|
|
609
|
+
// 合体窓のトレーリングタイマー (50ms 以下) が記録されるか、完走 publish が吸収している
|
|
610
|
+
expect(harness.recordedDelays.every((ms) => ms <= 50)).toBe(true)
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it("完走 publish は保留中のトレーリング publish を吸収して全量を運ぶ", async () => {
|
|
614
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(2)], nextCursor: { businessDate: "2026-08-01", reportHubId: 2 }, isLast: false, total: 2 }]), ndjson([{ items: [item(1)], nextCursor: null, isLast: true }])])], { publishCoalesceMs: 5_000 })
|
|
615
|
+
harness.client.start()
|
|
616
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
617
|
+
// 巨大な合体窓でも完走時には全量が state に載っている (トレーリング待ちで欠けない)
|
|
618
|
+
expect(harness.client.getState().loadedCount).toBe(2)
|
|
619
|
+
expect(harness.client.getState().items.map((i) => i.reportHubId)).toEqual([2, 1])
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
describe("DailyReportIdsStreamClient / 破棄", () => {
|
|
624
|
+
it("dispose は進行中の接続を中断し、以後の活動を止める", async () => {
|
|
625
|
+
const harness = makeHarness([
|
|
626
|
+
(init) =>
|
|
627
|
+
new Promise<Response>((_resolve, reject) => {
|
|
628
|
+
init.signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")))
|
|
629
|
+
}),
|
|
630
|
+
])
|
|
631
|
+
harness.client.start()
|
|
632
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
633
|
+
harness.client.dispose()
|
|
634
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
635
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
636
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(1)
|
|
637
|
+
// dispose 後は start/retry/refresh のいずれも接続を作らない
|
|
638
|
+
harness.client.start()
|
|
639
|
+
harness.client.retry()
|
|
640
|
+
harness.client.refresh()
|
|
641
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
642
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(1)
|
|
643
|
+
})
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
describe("DailyReportIdsStreamClient / stall ウォッチドッグ", () => {
|
|
647
|
+
/** 初期チャンクを流した後、close せず read を永久保留にする (half-open 接続の模擬)。 */
|
|
648
|
+
const hangingResponse = (chunks: string[]): Response => {
|
|
649
|
+
const encoder = new TextEncoder()
|
|
650
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
651
|
+
start(controller) {
|
|
652
|
+
for (const chunk of chunks) {
|
|
653
|
+
controller.enqueue(encoder.encode(chunk))
|
|
654
|
+
}
|
|
655
|
+
// 意図的に close しない → 後続 read は解決も reject もしない
|
|
656
|
+
},
|
|
657
|
+
})
|
|
658
|
+
return new Response(stream, { status: 200, headers: { "Content-Type": "application/x-ndjson" } })
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
it("無音が続く half-open 接続を検知し、カーソル再開で自己回復する", async () => {
|
|
662
|
+
const harness = makeHarness(
|
|
663
|
+
[
|
|
664
|
+
// 1 本目: 先頭ページを届けた後ハング (isLast 無し・close 無し)
|
|
665
|
+
() => hangingResponse([ndjson([{ items: [item(2)], nextCursor: { businessDate: "2026-08-01", reportHubId: 2 }, isLast: false, total: 2 }])]),
|
|
666
|
+
// 2 本目 (再開): 残りを届けて complete
|
|
667
|
+
() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true }])]),
|
|
668
|
+
],
|
|
669
|
+
{ readStallTimeoutMs: 30_000 },
|
|
670
|
+
)
|
|
671
|
+
harness.client.start()
|
|
672
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
673
|
+
|
|
674
|
+
const state = harness.client.getState()
|
|
675
|
+
// 受信済みデータは保持され、再開で全量に到達する
|
|
676
|
+
expect(state.items.map((i) => i.reportHubId)).toEqual([2, 1])
|
|
677
|
+
expect(state.error).toBeNull()
|
|
678
|
+
// 2 本目はカーソル再開 (先頭ページの nextCursor を採用)
|
|
679
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(2)
|
|
680
|
+
expect(harness.fetchUrls[1]).toBe("/daily_report/api/ids-stream?cursor_report_hub_id=2&cursor_business_date=2026-08-01")
|
|
681
|
+
// stall タイマー (30s) が実際に登録されている
|
|
682
|
+
expect(harness.recordedDelays).toContain(30_000)
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it("readStallTimeoutMs=0 では stall タイマーを張らない (正常系は素の read)", async () => {
|
|
686
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(1)], nextCursor: null, isLast: true, total: 1 }])])], { readStallTimeoutMs: 0 })
|
|
687
|
+
harness.client.start()
|
|
688
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
689
|
+
expect(harness.client.getState().loadedCount).toBe(1)
|
|
690
|
+
// 無効化時は read 監視タイマーを一切登録しない
|
|
691
|
+
expect(harness.recordedDelays).not.toContain(30_000)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it("データが流れている間は誤検知しない (read 勝利でタイマーは解除される)", async () => {
|
|
695
|
+
const harness = makeHarness([() => streamResponse([ndjson([{ items: [item(3), item(2)], nextCursor: { businessDate: "2026-08-01", reportHubId: 2 }, isLast: false, total: 3 }]), ndjson([{ items: [item(1)], nextCursor: null, isLast: true }])])], {
|
|
696
|
+
readStallTimeoutMs: 30_000,
|
|
697
|
+
})
|
|
698
|
+
harness.client.start()
|
|
699
|
+
await harness.waitForState((s) => s.phase === "complete")
|
|
700
|
+
// 正常完走: 再接続は発生しない (stall 誤検知なし)
|
|
701
|
+
expect(harness.fetchFn).toHaveBeenCalledTimes(1)
|
|
702
|
+
expect(harness.client.getState().items.map((i) => i.reportHubId)).toEqual([3, 2, 1])
|
|
703
|
+
})
|
|
704
|
+
})
|