@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,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action-endpoint tests: the dev-only clearCache gate.
|
|
3
|
+
* action エンドポイントのテスト: 開発専用 clearCache の封鎖ゲート。
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from "vitest"
|
|
6
|
+
import { createDailyReportHandlers } from "./handlers"
|
|
7
|
+
import { makeHandlersFixture } from "./test-helpers/handlers-config"
|
|
8
|
+
|
|
9
|
+
/** POST /action の引数を組み立てる (FormData ボディ付き)。 */
|
|
10
|
+
const makeActionArgs = (intent: string) => {
|
|
11
|
+
const body = new FormData()
|
|
12
|
+
body.set("intent", intent)
|
|
13
|
+
return {
|
|
14
|
+
request: new Request("http://localhost/daily_report/api/action", { method: "POST", body }),
|
|
15
|
+
params: { endpoint: "action" } as Record<string, string | undefined>,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("action / clearCache の封鎖ゲート", () => {
|
|
20
|
+
it("既定 (未注入) では clearCache を 400 で拒否し、service.clearCache を呼ばない", async () => {
|
|
21
|
+
// 認証済みなら誰でも全ワーカーのキャッシュを無制限破棄できてしまうため既定封鎖
|
|
22
|
+
const { config, calls } = makeHandlersFixture()
|
|
23
|
+
const { api } = createDailyReportHandlers(config)
|
|
24
|
+
const res = await api.action(makeActionArgs("clearCache"))
|
|
25
|
+
expect(res.status).toBe(400)
|
|
26
|
+
expect(calls.clearCache).toBe(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("enableDevCacheClear:true のときだけ clearCache を実行する", async () => {
|
|
30
|
+
const { config, calls } = makeHandlersFixture({ enableDevCacheClear: true })
|
|
31
|
+
const { api } = createDailyReportHandlers(config)
|
|
32
|
+
const res = await api.action(makeActionArgs("clearCache"))
|
|
33
|
+
expect(res.status).toBe(200)
|
|
34
|
+
expect(calls.clearCache).toBe(1)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("未認証は clearCache 以前に 401 で弾く", async () => {
|
|
38
|
+
const { config, calls } = makeHandlersFixture({ user: null, enableDevCacheClear: true })
|
|
39
|
+
const { api } = createDailyReportHandlers(config)
|
|
40
|
+
const res = await api.action(makeActionArgs("clearCache"))
|
|
41
|
+
expect(res.status).toBe(401)
|
|
42
|
+
expect(calls.clearCache).toBe(0)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NDJSON streaming tests for the `ids-stream` endpoint.
|
|
3
|
+
* `ids-stream` エンドポイントの NDJSON 配信テスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定する不変条件のうち、特に重要なもの:
|
|
6
|
+
*
|
|
7
|
+
* - **旧 `ids` JSON エンドポイントは存在しない** (クリーンカットオーバーの回帰防止)。
|
|
8
|
+
* - **チャンク順序とカーソル連鎖が SQL の並び順 (営業日降順・ID 降順・NULL 営業日末尾) と
|
|
9
|
+
* 厳密に一致する。** ズレると再開時に行の欠落または重複が起きる。
|
|
10
|
+
* - **`total` は接続ごとの先頭行にのみ載る** (進捗分母の契約)。
|
|
11
|
+
* - **不正カーソルは fail-closed で 400** (DB アクセスなし)。
|
|
12
|
+
* - **`Cache-Control: no-transform`** — compression ミドルウェアのバッファリング防止が
|
|
13
|
+
* ストリーミングの成立条件そのもの。
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it, vi } from "vitest"
|
|
16
|
+
import type { DailyReportIdsStreamChunkLine, DailyReportIdsStreamLine } from "../shared/ids-stream"
|
|
17
|
+
import type { DailyReportItem } from "../shared/types"
|
|
18
|
+
import { createDailyReportHandlers } from "./handlers"
|
|
19
|
+
import { makeApiArgs, makeHandlersFixture, TEST_EXTERNAL_USER_ID } from "./test-helpers/handlers-config"
|
|
20
|
+
|
|
21
|
+
/** ストリーム順 (営業日降順・ID 降順) の合成アイテムを count 件生成する。 */
|
|
22
|
+
const makeItems = (count: number): DailyReportItem[] => {
|
|
23
|
+
// 10 件ごとに営業日を 1 日下げ、同日内は ID 降順になる (日付演算で常に正規形 YYYY-MM-DD を保つ)
|
|
24
|
+
const baseUtc = Date.UTC(2026, 6, 31)
|
|
25
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
26
|
+
reportHubId: count - index,
|
|
27
|
+
businessDate: new Date(baseUtc - Math.floor(index / 10) * 86_400_000).toISOString().slice(0, 10),
|
|
28
|
+
sourceType: "Internal",
|
|
29
|
+
}))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** レスポンス全体をドレインし、NDJSON 行の配列へパースする。 */
|
|
33
|
+
const readNdjsonLines = async (response: Response): Promise<DailyReportIdsStreamLine[]> => {
|
|
34
|
+
const text = await response.text()
|
|
35
|
+
return text
|
|
36
|
+
.split("\n")
|
|
37
|
+
.filter((line) => line.trim() !== "")
|
|
38
|
+
.map((line) => JSON.parse(line) as DailyReportIdsStreamLine)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** チャンク行だけを取り出す (エラー行が混じればテストを落とす)。 */
|
|
42
|
+
const asChunks = (lines: DailyReportIdsStreamLine[]): DailyReportIdsStreamChunkLine[] => {
|
|
43
|
+
return lines.map((line) => {
|
|
44
|
+
expect(line).toHaveProperty("items")
|
|
45
|
+
return line as DailyReportIdsStreamChunkLine
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("ids-stream / 入口の検証", () => {
|
|
50
|
+
it("未認証は 401 を返し、サービスを呼ばない", async () => {
|
|
51
|
+
const { config, calls } = makeHandlersFixture({ user: null })
|
|
52
|
+
const { api } = createDailyReportHandlers(config)
|
|
53
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
54
|
+
expect(response.status).toBe(401)
|
|
55
|
+
expect(calls.getDailyReportIds).toHaveLength(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("GET 以外は 405 を返す", async () => {
|
|
59
|
+
const { config } = makeHandlersFixture()
|
|
60
|
+
const { api } = createDailyReportHandlers(config)
|
|
61
|
+
const response = await api.loader(makeApiArgs("ids-stream", { method: "POST" }))
|
|
62
|
+
expect(response.status).toBe(405)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("旧 ids エンドポイントは存在せず 404 を返す (クリーンカットオーバーの固定)", async () => {
|
|
66
|
+
const { config } = makeHandlersFixture()
|
|
67
|
+
const { api } = createDailyReportHandlers(config)
|
|
68
|
+
const response = await api.loader(makeApiArgs("ids"))
|
|
69
|
+
expect(response.status).toBe(404)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("ストリーミング応答のヘッダー契約 (x-ndjson / no-transform / nosniff / Set-Cookie 伝搬)", async () => {
|
|
73
|
+
const { config } = makeHandlersFixture({ cookie: "session=abc", idsStreamItems: makeItems(1) })
|
|
74
|
+
const { api } = createDailyReportHandlers(config)
|
|
75
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
76
|
+
expect(response.status).toBe(200)
|
|
77
|
+
expect(response.headers.get("Content-Type")).toBe("application/x-ndjson; charset=utf-8")
|
|
78
|
+
expect(response.headers.get("Cache-Control")).toBe("private, no-cache, no-transform")
|
|
79
|
+
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff")
|
|
80
|
+
expect(response.headers.get("Set-Cookie")).toBe("session=abc")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("新規接続でサービス例外 (全量・先頭ページとも失敗) は定型エラー行 + 正常クローズ", async () => {
|
|
84
|
+
const { config } = makeHandlersFixture({ idsStreamError: new Error("db down: SanwaPython secret") })
|
|
85
|
+
const { api } = createDailyReportHandlers(config)
|
|
86
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
87
|
+
// ストリーミングは開始済みなので 200 のままエラー行で通知する (クライアントは再開する)
|
|
88
|
+
expect(response.status).toBe(200)
|
|
89
|
+
const body = await response.text()
|
|
90
|
+
expect(body).not.toContain("SanwaPython")
|
|
91
|
+
const lines = body
|
|
92
|
+
.split("\n")
|
|
93
|
+
.filter((line) => line.trim() !== "")
|
|
94
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
95
|
+
expect(lines).toHaveLength(1)
|
|
96
|
+
expect(lines[0]).toEqual({ error: "stream interrupted by server error" })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("再開接続でサービス例外はストリーム開始前に 500 JSON へ落ちる", async () => {
|
|
100
|
+
const { config } = makeHandlersFixture({ idsStreamError: new Error("db down: SanwaPython secret") })
|
|
101
|
+
const { api } = createDailyReportHandlers(config)
|
|
102
|
+
const response = await api.loader(makeApiArgs("ids-stream", { query: "?cursor_report_hub_id=10&cursor_business_date=2026-07-31" }))
|
|
103
|
+
expect(response.status).toBe(500)
|
|
104
|
+
expect(response.headers.get("Content-Type")).toContain("application/json")
|
|
105
|
+
expect(await response.text()).not.toContain("SanwaPython")
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe("ids-stream / 高速先頭ページ (キャッシュミス時の初回描画短縮)", () => {
|
|
110
|
+
it("全量クエリが遅い場合、先頭ページが先に届き、全量確定後の行に total が載る", async () => {
|
|
111
|
+
const items = makeItems(1100)
|
|
112
|
+
let releaseFull: (() => void) | null = null
|
|
113
|
+
const fullGate = new Promise<void>((resolve) => {
|
|
114
|
+
releaseFull = resolve
|
|
115
|
+
})
|
|
116
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: fullGate })
|
|
117
|
+
const { api } = createDailyReportHandlers(config)
|
|
118
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
119
|
+
const reader = response.body?.getReader()
|
|
120
|
+
if (!reader) throw new Error("body must be a stream")
|
|
121
|
+
const decoder = new TextDecoder()
|
|
122
|
+
|
|
123
|
+
// 1 行目: 全量を待たずに先頭ページ (200 件・total 無し) が届く
|
|
124
|
+
const first = await reader.read()
|
|
125
|
+
const firstLine = JSON.parse(decoder.decode(first.value)) as DailyReportIdsStreamChunkLine
|
|
126
|
+
expect(firstLine.items).toEqual(items.slice(0, 200))
|
|
127
|
+
expect(firstLine.total).toBeUndefined()
|
|
128
|
+
expect(firstLine.isLast).toBe(false)
|
|
129
|
+
expect(firstLine.nextCursor).toEqual({ businessDate: items[199].businessDate, reportHubId: items[199].reportHubId })
|
|
130
|
+
expect(calls.getDailyReportIdsFirstPage).toEqual([[TEST_EXTERNAL_USER_ID, 200]])
|
|
131
|
+
|
|
132
|
+
// 全量を解放すると、続きの行 (先頭ページの続きから) に total が載る
|
|
133
|
+
releaseFull?.()
|
|
134
|
+
let rest = ""
|
|
135
|
+
while (true) {
|
|
136
|
+
const { done, value } = await reader.read()
|
|
137
|
+
if (done) break
|
|
138
|
+
rest += decoder.decode(value, { stream: true })
|
|
139
|
+
}
|
|
140
|
+
const restLines = rest
|
|
141
|
+
.split("\n")
|
|
142
|
+
.filter((line) => line.trim() !== "")
|
|
143
|
+
.map((line) => JSON.parse(line) as DailyReportIdsStreamChunkLine)
|
|
144
|
+
expect(restLines[0].total).toBe(1100)
|
|
145
|
+
expect(restLines.slice(1).every((line) => line.total === undefined)).toBe(true)
|
|
146
|
+
// 先頭ページ + 続きの連結が全量と厳密一致 (欠落・重複なし)
|
|
147
|
+
expect([...firstLine.items, ...restLines.flatMap((line) => line.items)]).toEqual(items)
|
|
148
|
+
expect(restLines[restLines.length - 1].isLast).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("先頭ページが上限未満なら全量クエリを待たずに完結する (小規模データセットの最速経路)", async () => {
|
|
152
|
+
const items = makeItems(5)
|
|
153
|
+
// 全量は永遠に解決しない (待っていないことの証明)
|
|
154
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: new Promise<void>(() => {}) })
|
|
155
|
+
const { api } = createDailyReportHandlers(config)
|
|
156
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
157
|
+
expect(lines).toHaveLength(1)
|
|
158
|
+
expect(lines[0]).toEqual({ items, nextCursor: null, isLast: true, total: 5 })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("先頭ページ送出後に全量クエリが失敗したら、定型エラー行 + 正常クローズ (受信済みの続きから再開できる)", async () => {
|
|
162
|
+
const items = makeItems(1100)
|
|
163
|
+
// 全量クエリだけを失敗させる (先頭ページは成功して 200 件が届いている)。
|
|
164
|
+
// 生成直後に catch を付けて未処理拒否警告を防ぐ (await 側では正しく throw される)
|
|
165
|
+
const failedGate = Promise.reject(new Error("full query down: SanwaPython secret"))
|
|
166
|
+
failedGate.catch(() => {})
|
|
167
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: failedGate })
|
|
168
|
+
const { api } = createDailyReportHandlers(config)
|
|
169
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
170
|
+
expect(response.status).toBe(200)
|
|
171
|
+
const body = await response.text()
|
|
172
|
+
expect(body).not.toContain("SanwaPython")
|
|
173
|
+
const lines = body
|
|
174
|
+
.split("\n")
|
|
175
|
+
.filter((line) => line.trim() !== "")
|
|
176
|
+
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
177
|
+
// 1 行目: 先頭ページ (200 件・total 無し・isLast 無し)
|
|
178
|
+
expect((lines[0] as DailyReportIdsStreamChunkLine).items).toHaveLength(200)
|
|
179
|
+
expect((lines[0] as DailyReportIdsStreamChunkLine).isLast).toBe(false)
|
|
180
|
+
expect((lines[0] as DailyReportIdsStreamChunkLine).total).toBeUndefined()
|
|
181
|
+
// 2 行目: 内部情報を含まない定型エラー行 (クライアントはカーソル再開する)
|
|
182
|
+
expect(lines[1]).toEqual({ error: "stream interrupted by server error" })
|
|
183
|
+
expect(lines).toHaveLength(2)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("キャッシュヒット相当 (全量が即時) では従来どおり先頭行に total が載る", async () => {
|
|
187
|
+
const items = makeItems(5)
|
|
188
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
189
|
+
const { api } = createDailyReportHandlers(config)
|
|
190
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
191
|
+
expect(lines[0].total).toBe(5)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("再開接続は先頭ページクエリを発行しない (受信済みの続きだけを読む)", async () => {
|
|
195
|
+
const items = makeItems(5)
|
|
196
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: items })
|
|
197
|
+
const { api } = createDailyReportHandlers(config)
|
|
198
|
+
await (await api.loader(makeApiArgs("ids-stream", { query: `?cursor_report_hub_id=${items[1].reportHubId}&cursor_business_date=${items[1].businessDate}` }))).text()
|
|
199
|
+
expect(calls.getDailyReportIdsFirstPage).toHaveLength(0)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe("ids-stream / チャンク分割と total", () => {
|
|
204
|
+
it("空データセットは total 0 の単一終端行になる", async () => {
|
|
205
|
+
const { config } = makeHandlersFixture({ idsStreamItems: [] })
|
|
206
|
+
const { api } = createDailyReportHandlers(config)
|
|
207
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
208
|
+
expect(lines).toHaveLength(1)
|
|
209
|
+
expect(lines[0]).toEqual({ items: [], nextCursor: null, isLast: true, total: 0 })
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("小規模データは 1 行で全件 + total + isLast", async () => {
|
|
213
|
+
const items = makeItems(5)
|
|
214
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: items })
|
|
215
|
+
const { api } = createDailyReportHandlers(config)
|
|
216
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
217
|
+
expect(lines).toHaveLength(1)
|
|
218
|
+
expect(lines[0].items).toEqual(items)
|
|
219
|
+
expect(lines[0].total).toBe(5)
|
|
220
|
+
expect(lines[0].isLast).toBe(true)
|
|
221
|
+
expect(lines[0].nextCursor).toBeNull()
|
|
222
|
+
expect(calls.getDailyReportIds).toEqual([[TEST_EXTERNAL_USER_ID, false]])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("9000 件は 200/4000/4000/800 の 4 チャンクに割れ、total は先頭行のみ、カーソルが連鎖する", async () => {
|
|
226
|
+
const items = makeItems(9000)
|
|
227
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
228
|
+
const { api } = createDailyReportHandlers(config)
|
|
229
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
230
|
+
|
|
231
|
+
expect(lines.map((line) => line.items.length)).toEqual([200, 4000, 4000, 800])
|
|
232
|
+
expect(lines[0].total).toBe(9000)
|
|
233
|
+
expect(lines.slice(1).every((line) => line.total === undefined)).toBe(true)
|
|
234
|
+
|
|
235
|
+
// カーソルは各チャンク末尾アイテムのキーと一致する
|
|
236
|
+
expect(lines[0].nextCursor).toEqual({ businessDate: items[199].businessDate, reportHubId: items[199].reportHubId })
|
|
237
|
+
expect(lines[1].nextCursor).toEqual({ businessDate: items[4199].businessDate, reportHubId: items[4199].reportHubId })
|
|
238
|
+
expect(lines[2].nextCursor).toEqual({ businessDate: items[8199].businessDate, reportHubId: items[8199].reportHubId })
|
|
239
|
+
expect(lines[3].nextCursor).toBeNull()
|
|
240
|
+
expect(lines.map((line) => line.isLast)).toEqual([false, false, false, true])
|
|
241
|
+
|
|
242
|
+
// 全チャンクの連結が元配列と厳密一致する (欠落・重複なし)
|
|
243
|
+
expect(lines.flatMap((line) => line.items)).toEqual(items)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("forceRefresh=true はサービスへ透過する", async () => {
|
|
247
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: makeItems(1) })
|
|
248
|
+
const { api } = createDailyReportHandlers(config)
|
|
249
|
+
await (await api.loader(makeApiArgs("ids-stream", { query: "?forceRefresh=true" }))).text()
|
|
250
|
+
expect(calls.getDailyReportIds).toEqual([[TEST_EXTERNAL_USER_ID, true]])
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
describe("ids-stream / カーソル再開", () => {
|
|
255
|
+
it("中間カーソルからの再開は「厳密に後」の行から欠落・重複なく続く", async () => {
|
|
256
|
+
const items = makeItems(1100)
|
|
257
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
258
|
+
const { api } = createDailyReportHandlers(config)
|
|
259
|
+
|
|
260
|
+
const cursorItem = items[199]
|
|
261
|
+
const query = `?cursor_report_hub_id=${cursorItem.reportHubId}&cursor_business_date=${cursorItem.businessDate}`
|
|
262
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
263
|
+
|
|
264
|
+
const resumed = lines.flatMap((line) => line.items)
|
|
265
|
+
expect(resumed).toEqual(items.slice(200))
|
|
266
|
+
// 再開接続でも先頭行に最新の total が載り直す
|
|
267
|
+
expect(lines[0].total).toBe(1100)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("NULL 営業日領域は cursor_report_hub_id 単独で再開できる", async () => {
|
|
271
|
+
const dated = makeItems(3)
|
|
272
|
+
const nullRegion: DailyReportItem[] = [
|
|
273
|
+
{ reportHubId: 900, businessDate: null, sourceType: "NI" },
|
|
274
|
+
{ reportHubId: 800, businessDate: null, sourceType: "NI" },
|
|
275
|
+
{ reportHubId: 700, businessDate: null, sourceType: "NI" },
|
|
276
|
+
]
|
|
277
|
+
const { config } = makeHandlersFixture({ idsStreamItems: [...dated, ...nullRegion] })
|
|
278
|
+
const { api } = createDailyReportHandlers(config)
|
|
279
|
+
|
|
280
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query: "?cursor_report_hub_id=900" }))))
|
|
281
|
+
expect(lines.flatMap((line) => line.items)).toEqual([
|
|
282
|
+
{ reportHubId: 800, businessDate: null, sourceType: "NI" },
|
|
283
|
+
{ reportHubId: 700, businessDate: null, sourceType: "NI" },
|
|
284
|
+
])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("末尾以降を指すカーソルは空チャンク + isLast で正常終端する", async () => {
|
|
288
|
+
const items = makeItems(5)
|
|
289
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
290
|
+
const { api } = createDailyReportHandlers(config)
|
|
291
|
+
const last = items[items.length - 1]
|
|
292
|
+
const query = `?cursor_report_hub_id=${last.reportHubId}&cursor_business_date=${last.businessDate}`
|
|
293
|
+
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
294
|
+
expect(lines).toHaveLength(1)
|
|
295
|
+
expect(lines[0]).toEqual({ items: [], nextCursor: null, isLast: true, total: 5 })
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it.each([
|
|
299
|
+
["営業日のみ (位置を決められない)", "?cursor_business_date=2026-07-31"],
|
|
300
|
+
["日付形式違反 (スラッシュ)", "?cursor_report_hub_id=10&cursor_business_date=2026/07/31"],
|
|
301
|
+
["日付形式違反 (桁欠け)", "?cursor_report_hub_id=10&cursor_business_date=2026-7-31"],
|
|
302
|
+
["非整数 ID", "?cursor_report_hub_id=abc"],
|
|
303
|
+
["小数 ID", "?cursor_report_hub_id=12.5"],
|
|
304
|
+
["ゼロ ID", "?cursor_report_hub_id=0"],
|
|
305
|
+
["負数 ID", "?cursor_report_hub_id=-1"],
|
|
306
|
+
["先頭ゼロ ID (正規形でない)", "?cursor_report_hub_id=007"],
|
|
307
|
+
])("不正カーソル (%s) は 400 で、サービスを呼ばない", async (_label, query) => {
|
|
308
|
+
const { config, calls } = makeHandlersFixture({ idsStreamItems: makeItems(3) })
|
|
309
|
+
const { api } = createDailyReportHandlers(config)
|
|
310
|
+
const response = await api.loader(makeApiArgs("ids-stream", { query }))
|
|
311
|
+
expect(response.status).toBe(400)
|
|
312
|
+
expect(calls.getDailyReportIds).toHaveLength(0)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
describe("ids-stream / 逐次配信", () => {
|
|
317
|
+
it("チャンクは 1 つの巨大な書き込みではなく複数回の enqueue で届く (pull 単位のバックプレッシャー)", async () => {
|
|
318
|
+
const items = makeItems(9000)
|
|
319
|
+
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
320
|
+
const { api } = createDailyReportHandlers(config)
|
|
321
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
322
|
+
const body = response.body
|
|
323
|
+
if (!body) throw new Error("body must be a stream")
|
|
324
|
+
|
|
325
|
+
// reader で読み、read() の回数 (= enqueue の粒度) を数える
|
|
326
|
+
const reader = body.getReader()
|
|
327
|
+
const decoder = new TextDecoder()
|
|
328
|
+
let readCount = 0
|
|
329
|
+
let text = ""
|
|
330
|
+
while (true) {
|
|
331
|
+
const { done, value } = await reader.read()
|
|
332
|
+
if (done) break
|
|
333
|
+
readCount += 1
|
|
334
|
+
text += decoder.decode(value, { stream: true })
|
|
335
|
+
}
|
|
336
|
+
// 200/4000/4000/800 の 4 チャンク = 4 回以上の read で届く (全体一括 1 回ではない)
|
|
337
|
+
expect(readCount).toBeGreaterThanOrEqual(4)
|
|
338
|
+
expect(text.split("\n").filter((line) => line.trim() !== "")).toHaveLength(4)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it("reader.cancel() は例外なく受理される (切断安全)", async () => {
|
|
342
|
+
const { config } = makeHandlersFixture({ idsStreamItems: makeItems(1100) })
|
|
343
|
+
const { api } = createDailyReportHandlers(config)
|
|
344
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
345
|
+
const reader = response.body?.getReader()
|
|
346
|
+
if (!reader) throw new Error("body must be a stream")
|
|
347
|
+
await reader.read()
|
|
348
|
+
await expect(reader.cancel()).resolves.toBeUndefined()
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe("ids-stream / 中断", () => {
|
|
353
|
+
it("送出前に中断済みのリクエストは 1 行も流さずクローズする", async () => {
|
|
354
|
+
const { config } = makeHandlersFixture({ idsStreamItems: makeItems(50) })
|
|
355
|
+
const { api } = createDailyReportHandlers(config)
|
|
356
|
+
const abortController = new AbortController()
|
|
357
|
+
abortController.abort()
|
|
358
|
+
const response = await api.loader(makeApiArgs("ids-stream", { signal: abortController.signal }))
|
|
359
|
+
expect(response.status).toBe(200)
|
|
360
|
+
expect(await response.text()).toBe("")
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe("ids-stream / ハートビート", () => {
|
|
365
|
+
it("次チャンク待ち (全量クエリ確定待ち) が長引く間は空行ハートビートを送る", async () => {
|
|
366
|
+
vi.useFakeTimers()
|
|
367
|
+
try {
|
|
368
|
+
// 全量クエリを保留させ、先頭ページ (200 件) 描画後に無音区間を作る
|
|
369
|
+
let releaseFull: () => void = () => {}
|
|
370
|
+
const fullGate = new Promise<void>((resolve) => {
|
|
371
|
+
releaseFull = resolve
|
|
372
|
+
})
|
|
373
|
+
const items = makeItems(400)
|
|
374
|
+
const { config } = makeHandlersFixture({
|
|
375
|
+
idsStreamItems: items,
|
|
376
|
+
idsStreamFirstPageItems: items.slice(0, 200),
|
|
377
|
+
idsStreamFullGate: fullGate,
|
|
378
|
+
})
|
|
379
|
+
const { api } = createDailyReportHandlers(config)
|
|
380
|
+
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
381
|
+
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
|
|
382
|
+
const decoder = new TextDecoder()
|
|
383
|
+
|
|
384
|
+
// 先頭ページの最初のチャンク (200 件、isLast 無し)
|
|
385
|
+
const first = await reader.read()
|
|
386
|
+
const firstLine = JSON.parse(decoder.decode(first.value).trim()) as DailyReportIdsStreamChunkLine
|
|
387
|
+
expect(firstLine.items).toHaveLength(200)
|
|
388
|
+
expect(firstLine.isLast).toBe(false)
|
|
389
|
+
|
|
390
|
+
// 全量ゲート未解放のまま 15s 経過 → 接続の生存を示す空行
|
|
391
|
+
const heartbeatRead = reader.read()
|
|
392
|
+
await vi.advanceTimersByTimeAsync(15_000)
|
|
393
|
+
const heartbeat = await heartbeatRead
|
|
394
|
+
expect(decoder.decode(heartbeat.value)).toBe("\n")
|
|
395
|
+
|
|
396
|
+
// 全量を解放すると残りが流れて isLast で閉じる (ハートビートが本体を壊していない)
|
|
397
|
+
releaseFull()
|
|
398
|
+
let sawLast = false
|
|
399
|
+
for (let i = 0; i < 50; i++) {
|
|
400
|
+
const { done, value } = await reader.read()
|
|
401
|
+
if (done) break
|
|
402
|
+
const text = decoder.decode(value).trim()
|
|
403
|
+
if (text === "") continue
|
|
404
|
+
for (const raw of text.split("\n")) {
|
|
405
|
+
const line = JSON.parse(raw) as DailyReportIdsStreamChunkLine
|
|
406
|
+
if (line.isLast) sawLast = true
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
expect(sawLast).toBe(true)
|
|
410
|
+
} finally {
|
|
411
|
+
vi.useRealTimers()
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
})
|