@aiquants/daily-report 0.19.0 → 0.20.1
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 +8 -0
- package/dist/client.d.mts +11 -706
- package/dist/client.d.ts +11 -706
- package/dist/client.js +0 -1
- package/dist/client.mjs +0 -1
- package/dist/index.d.mts +5 -197
- package/dist/index.d.ts +5 -197
- package/dist/index.js +0 -1
- package/dist/index.mjs +0 -1
- package/dist/logger-D3krZrNK.d.mts +0 -14
- package/dist/logger-D3krZrNK.d.ts +0 -14
- package/dist/server.d.mts +115 -728
- package/dist/server.d.ts +115 -728
- package/dist/server.js +10 -11
- package/dist/server.mjs +10 -11
- package/dist/sse-schema-BdUGThiz.d.ts +1025 -1009
- package/dist/sse-schema-ChSFm-4q.d.mts +1025 -1009
- package/dist/types-CqgYQgbH.d.mts +0 -34
- package/dist/types-CqgYQgbH.d.ts +0 -34
- package/package.json +11 -11
- package/dist/client.js.map +0 -1
- package/dist/client.mjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/server.mjs.map +0 -1
- package/src/client/components/business-day-thumb-overlay.tsx +0 -19
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +0 -51
- package/src/client/components/daily-report-attachment-indicator.tsx +0 -45
- package/src/client/components/daily-report-attachment-list.spec.tsx +0 -94
- package/src/client/components/daily-report-attachment-list.tsx +0 -112
- package/src/client/components/daily-report-comment-item.tsx +0 -89
- package/src/client/components/daily-report-comment-section.tsx +0 -166
- package/src/client/components/daily-report-detail-list.tsx +0 -842
- package/src/client/components/daily-report-edit-form.tsx +0 -87
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +0 -93
- package/src/client/components/daily-report-ids-stream-status.tsx +0 -67
- package/src/client/components/daily-report-list.tsx +0 -1232
- package/src/client/components/daily-report-page.spec.tsx +0 -119
- package/src/client/components/daily-report-page.tsx +0 -140
- package/src/client/components/daily-report-resize-panels.spec.tsx +0 -307
- package/src/client/components/daily-report-resolved-content.tsx +0 -207
- package/src/client/components/daily-report-scroll-restoration.spec.tsx +0 -422
- package/src/client/components/report-views.spec.tsx +0 -375
- package/src/client/components/unread-indicator.tsx +0 -13
- package/src/client/config-context.tsx +0 -184
- package/src/client/contexts/daily-report-action-context.spec.tsx +0 -433
- package/src/client/contexts/daily-report-action-context.tsx +0 -1147
- package/src/client/contexts/daily-report-error-context.spec.tsx +0 -66
- package/src/client/contexts/daily-report-error-context.tsx +0 -98
- package/src/client/hooks/use-animated-number.spec.ts +0 -103
- package/src/client/hooks/use-animated-number.ts +0 -69
- package/src/client/hooks/use-daily-report-comments.ts +0 -73
- package/src/client/hooks/use-daily-report-sse-connection.ts +0 -86
- package/src/client/hooks/use-daily-report.spec.ts +0 -546
- package/src/client/hooks/use-daily-report.ts +0 -549
- package/src/client/hooks/use-dynamic-viewport-height.ts +0 -127
- package/src/client/hooks/use-responsive-layout.spec.ts +0 -74
- package/src/client/hooks/use-responsive-layout.ts +0 -63
- package/src/client/route-helpers.spec.ts +0 -100
- package/src/client/route-helpers.ts +0 -61
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +0 -910
- package/src/client/streaming/daily-report-ids-stream-client.ts +0 -730
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +0 -401
- package/src/client/streaming/daily-report-ids-stream-session.ts +0 -319
- package/src/client/ui/button.tsx +0 -42
- package/src/client/ui/cn.ts +0 -14
- package/src/client/ui/input.tsx +0 -21
- package/src/client/ui/label.tsx +0 -16
- package/src/client/ui/switch.tsx +0 -19
- package/src/client/ui/tabs.tsx +0 -40
- package/src/client/ui/textarea.tsx +0 -19
- package/src/client/utils/constants.spec.ts +0 -96
- package/src/client/utils/constants.ts +0 -84
- package/src/client.ts +0 -28
- package/src/index.ts +0 -11
- package/src/lucide-icons.d.ts +0 -6
- package/src/server/authz.spec.ts +0 -57
- package/src/server/authz.ts +0 -99
- package/src/server/cache.spec.ts +0 -556
- package/src/server/cache.ts +0 -303
- package/src/server/etag.ts +0 -18
- package/src/server/external-source.ts +0 -60
- package/src/server/handlers.action.spec.ts +0 -44
- package/src/server/handlers.attachment.spec.ts +0 -470
- package/src/server/handlers.ids-stream.spec.ts +0 -618
- package/src/server/handlers.ts +0 -1638
- package/src/server/handlers.unauthorized-cookie.spec.ts +0 -187
- package/src/server/handlers.visibility.spec.ts +0 -774
- package/src/server/ports.ts +0 -222
- package/src/server/response.ts +0 -37
- package/src/server/schema.spec.ts +0 -30
- package/src/server/schema.ts +0 -344
- package/src/server/service.spec.ts +0 -1130
- package/src/server/service.ts +0 -2024
- package/src/server/sse-reader.spec.ts +0 -302
- package/src/server/sse-reader.ts +0 -267
- package/src/server/test-helpers/handlers-config.ts +0 -321
- package/src/server/visibility.spec.ts +0 -403
- package/src/server/visibility.ts +0 -373
- package/src/server.spec.ts +0 -160
- package/src/server.ts +0 -131
- package/src/shared/business-date.spec.ts +0 -82
- package/src/shared/business-date.ts +0 -104
- package/src/shared/comment-adapter.spec.ts +0 -38
- package/src/shared/comment-adapter.ts +0 -80
- package/src/shared/ids-stream.spec.ts +0 -154
- package/src/shared/ids-stream.ts +0 -279
- package/src/shared/logger.ts +0 -47
- package/src/shared/sse-schema.spec.ts +0 -50
- package/src/shared/sse-schema.ts +0 -198
- package/src/shared/text-utils.spec.ts +0 -42
- package/src/shared/text-utils.ts +0 -65
- package/src/shared/types.ts +0 -109
- package/src/styles/standalone.entry.css +0 -51
|
@@ -1,618 +0,0 @@
|
|
|
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 { computeIdsStreamSnapshotToken, type DailyReportIdsStreamChunkLine, type 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({ kind: "error", 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
|
-
// 同ファイル後半と同じ書き方。null 初期化だと制御フロー解析が null へ絞り込み、
|
|
113
|
-
// 同期的に代入されることを追跡できず呼び出しが never 型になる
|
|
114
|
-
let releaseFull: () => void = () => {}
|
|
115
|
-
const fullGate = new Promise<void>((resolve) => {
|
|
116
|
-
releaseFull = resolve
|
|
117
|
-
})
|
|
118
|
-
const { config, calls } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: fullGate })
|
|
119
|
-
const { api } = createDailyReportHandlers(config)
|
|
120
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
121
|
-
const reader = response.body?.getReader()
|
|
122
|
-
if (!reader) throw new Error("body must be a stream")
|
|
123
|
-
const decoder = new TextDecoder()
|
|
124
|
-
|
|
125
|
-
// 1 行目: 全量を待たずに先頭ページ (30 件・total 無し) が**暫定**として届く
|
|
126
|
-
const first = await reader.read()
|
|
127
|
-
const firstLine = JSON.parse(decoder.decode(first.value)) as DailyReportIdsStreamChunkLine
|
|
128
|
-
expect(firstLine.items).toEqual(items.slice(0, 30))
|
|
129
|
-
expect(firstLine.total).toBeUndefined()
|
|
130
|
-
expect(firstLine.isLast).toBe(false)
|
|
131
|
-
expect(firstLine.provisional).toBe(true)
|
|
132
|
-
// ❗ 暫定行にカーソルを載せてはならない。未確認領域を指すカーソルで再開すると、
|
|
133
|
-
// 継ぎ目が三つ目のスナップショットへ移るだけになる
|
|
134
|
-
expect(firstLine.nextCursor).toBeNull()
|
|
135
|
-
expect(calls.getDailyReportIdsFirstPage).toEqual([[TEST_EXTERNAL_USER_ID, 30]])
|
|
136
|
-
|
|
137
|
-
// 全量を解放すると、続きの行 (先頭ページの続きから) に total が載る
|
|
138
|
-
releaseFull()
|
|
139
|
-
let rest = ""
|
|
140
|
-
while (true) {
|
|
141
|
-
const { done, value } = await reader.read()
|
|
142
|
-
if (done) break
|
|
143
|
-
rest += decoder.decode(value, { stream: true })
|
|
144
|
-
}
|
|
145
|
-
const restLines = rest
|
|
146
|
-
.split("\n")
|
|
147
|
-
.filter((line) => line.trim() !== "")
|
|
148
|
-
.map((line) => JSON.parse(line) as DailyReportIdsStreamChunkLine)
|
|
149
|
-
expect(restLines[0].total).toBe(1100)
|
|
150
|
-
expect(restLines.slice(1).every((line) => line.total === undefined)).toBe(true)
|
|
151
|
-
// ❗ 権威パスは**先頭から**流し直す。ページのキーを再開カーソルにすると、ページ送出後に
|
|
152
|
-
// 先頭領域へ入った行が丸ごと飛ばされる (欠損の構造的原因)
|
|
153
|
-
expect(restLines.flatMap((line) => line.items)).toEqual(items)
|
|
154
|
-
expect(restLines.every((line) => line.provisional === undefined)).toBe(true)
|
|
155
|
-
expect(restLines[restLines.length - 1].isLast).toBe(true)
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
it("先頭ページが上限未満なら全量クエリを待たずに完結する (小規模データセットの最速経路)", async () => {
|
|
159
|
-
const items = makeItems(5)
|
|
160
|
-
// 全量は永遠に解決しない (待っていないことの証明)
|
|
161
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: new Promise<void>(() => {}) })
|
|
162
|
-
const { api } = createDailyReportHandlers(config)
|
|
163
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
164
|
-
expect(lines).toHaveLength(1)
|
|
165
|
-
expect(lines[0]).toEqual({ kind: "chunk", items, nextCursor: null, isLast: true, total: 5 })
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
it("先頭ページ送出後に全量クエリが失敗したら、定型エラー行 + 正常クローズ (受信済みの続きから再開できる)", async () => {
|
|
169
|
-
const items = makeItems(1100)
|
|
170
|
-
// 全量クエリだけを失敗させる (先頭ページは成功して 200 件が届いている)。
|
|
171
|
-
// 生成直後に catch を付けて未処理拒否警告を防ぐ (await 側では正しく throw される)
|
|
172
|
-
const failedGate = Promise.reject(new Error("full query down: SanwaPython secret"))
|
|
173
|
-
failedGate.catch(() => {})
|
|
174
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: failedGate })
|
|
175
|
-
const { api } = createDailyReportHandlers(config)
|
|
176
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
177
|
-
expect(response.status).toBe(200)
|
|
178
|
-
const body = await response.text()
|
|
179
|
-
expect(body).not.toContain("SanwaPython")
|
|
180
|
-
const lines = body
|
|
181
|
-
.split("\n")
|
|
182
|
-
.filter((line) => line.trim() !== "")
|
|
183
|
-
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
184
|
-
// 1 行目: 先頭ページ (30 件・total 無し・isLast 無し) が暫定として届く。
|
|
185
|
-
// 完走しない走査では剪定が走らないため、暫定行はそのまま残って画面を埋める
|
|
186
|
-
expect((lines[0] as DailyReportIdsStreamChunkLine).items).toHaveLength(30)
|
|
187
|
-
expect((lines[0] as DailyReportIdsStreamChunkLine).isLast).toBe(false)
|
|
188
|
-
expect((lines[0] as DailyReportIdsStreamChunkLine).total).toBeUndefined()
|
|
189
|
-
expect((lines[0] as DailyReportIdsStreamChunkLine).provisional).toBe(true)
|
|
190
|
-
expect((lines[0] as DailyReportIdsStreamChunkLine).nextCursor).toBeNull()
|
|
191
|
-
// 2 行目: 内部情報を含まない定型エラー行 (カーソルが無いのでクライアントは先頭からやり直す)
|
|
192
|
-
expect(lines[1]).toEqual({ kind: "error", error: "stream interrupted by server error" })
|
|
193
|
-
expect(lines).toHaveLength(2)
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
it("キャッシュヒット相当 (全量が即時) では従来どおり先頭行に total が載る", async () => {
|
|
197
|
-
const items = makeItems(5)
|
|
198
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
199
|
-
const { api } = createDailyReportHandlers(config)
|
|
200
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
201
|
-
expect(lines[0].total).toBe(5)
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
it("再開接続は先頭ページクエリを発行しない (受信済みの続きだけを読む)", async () => {
|
|
205
|
-
const items = makeItems(5)
|
|
206
|
-
const { config, calls } = makeHandlersFixture({ idsStreamItems: items })
|
|
207
|
-
const { api } = createDailyReportHandlers(config)
|
|
208
|
-
await (await api.loader(makeApiArgs("ids-stream", { query: `?cursor_report_hub_id=${items[1].reportHubId}&cursor_business_date=${items[1].businessDate}` }))).text()
|
|
209
|
-
expect(calls.getDailyReportIdsFirstPage).toHaveLength(0)
|
|
210
|
-
})
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
describe("ids-stream / チャンク分割と total", () => {
|
|
214
|
-
it("空データセットは total 0 の単一終端行になる", async () => {
|
|
215
|
-
const { config } = makeHandlersFixture({ idsStreamItems: [] })
|
|
216
|
-
const { api } = createDailyReportHandlers(config)
|
|
217
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
218
|
-
expect(lines).toHaveLength(1)
|
|
219
|
-
expect(lines[0]).toEqual({ kind: "chunk", items: [], nextCursor: null, isLast: true, total: 0, snapshot: computeIdsStreamSnapshotToken([]) })
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
it("小規模データは 1 行で全件 + total + isLast", async () => {
|
|
223
|
-
const items = makeItems(5)
|
|
224
|
-
const { config, calls } = makeHandlersFixture({ idsStreamItems: items })
|
|
225
|
-
const { api } = createDailyReportHandlers(config)
|
|
226
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
227
|
-
expect(lines).toHaveLength(1)
|
|
228
|
-
expect(lines[0].items).toEqual(items)
|
|
229
|
-
expect(lines[0].total).toBe(5)
|
|
230
|
-
expect(lines[0].isLast).toBe(true)
|
|
231
|
-
expect(lines[0].nextCursor).toBeNull()
|
|
232
|
-
expect(calls.getDailyReportIds).toEqual([[TEST_EXTERNAL_USER_ID, false]])
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
it("9000 件は 30/4000/4000/970 の 4 チャンクに割れ、total は先頭行のみ、カーソルが連鎖する", async () => {
|
|
236
|
-
const items = makeItems(9000)
|
|
237
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
238
|
-
const { api } = createDailyReportHandlers(config)
|
|
239
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
240
|
-
|
|
241
|
-
expect(lines.map((line) => line.items.length)).toEqual([30, 4000, 4000, 970])
|
|
242
|
-
expect(lines[0].total).toBe(9000)
|
|
243
|
-
expect(lines.slice(1).every((line) => line.total === undefined)).toBe(true)
|
|
244
|
-
|
|
245
|
-
// カーソルは各チャンク末尾アイテムのキーと一致する
|
|
246
|
-
expect(lines[0].nextCursor).toEqual({ businessDate: items[29].businessDate, reportHubId: items[29].reportHubId })
|
|
247
|
-
expect(lines[1].nextCursor).toEqual({ businessDate: items[4029].businessDate, reportHubId: items[4029].reportHubId })
|
|
248
|
-
expect(lines[2].nextCursor).toEqual({ businessDate: items[8029].businessDate, reportHubId: items[8029].reportHubId })
|
|
249
|
-
expect(lines[3].nextCursor).toBeNull()
|
|
250
|
-
expect(lines.map((line) => line.isLast)).toEqual([false, false, false, true])
|
|
251
|
-
|
|
252
|
-
// 全チャンクの連結が元配列と厳密一致する (欠落・重複なし)
|
|
253
|
-
expect(lines.flatMap((line) => line.items)).toEqual(items)
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
it("forceRefresh=true はサービスへ透過する", async () => {
|
|
257
|
-
const { config, calls } = makeHandlersFixture({ idsStreamItems: makeItems(1) })
|
|
258
|
-
const { api } = createDailyReportHandlers(config)
|
|
259
|
-
await (await api.loader(makeApiArgs("ids-stream", { query: "?forceRefresh=true" }))).text()
|
|
260
|
-
expect(calls.getDailyReportIds).toEqual([[TEST_EXTERNAL_USER_ID, true]])
|
|
261
|
-
})
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
describe("ids-stream / カーソル再開", () => {
|
|
265
|
-
it("中間カーソルからの再開は「厳密に後」の行から欠落・重複なく続く", async () => {
|
|
266
|
-
const items = makeItems(1100)
|
|
267
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
268
|
-
const { api } = createDailyReportHandlers(config)
|
|
269
|
-
|
|
270
|
-
const cursorItem = items[199]
|
|
271
|
-
// カーソルとトークンは対で送る (トークン無しは安全側 = 先頭からの再送になる)
|
|
272
|
-
const query = `?cursor_report_hub_id=${cursorItem.reportHubId}&cursor_business_date=${cursorItem.businessDate}&cursor_snapshot=${computeIdsStreamSnapshotToken(items)}`
|
|
273
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
274
|
-
|
|
275
|
-
const resumed = lines.flatMap((line) => line.items)
|
|
276
|
-
expect(resumed).toEqual(items.slice(200))
|
|
277
|
-
// 再開接続でも先頭行に最新の total が載り直す
|
|
278
|
-
expect(lines[0].total).toBe(1100)
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
it("NULL 営業日領域は cursor_report_hub_id 単独で再開できる", async () => {
|
|
282
|
-
const dated = makeItems(3)
|
|
283
|
-
const nullRegion: DailyReportItem[] = [
|
|
284
|
-
{ reportHubId: 900, businessDate: null, sourceType: "NI" },
|
|
285
|
-
{ reportHubId: 800, businessDate: null, sourceType: "NI" },
|
|
286
|
-
{ reportHubId: 700, businessDate: null, sourceType: "NI" },
|
|
287
|
-
]
|
|
288
|
-
const { config } = makeHandlersFixture({ idsStreamItems: [...dated, ...nullRegion] })
|
|
289
|
-
const { api } = createDailyReportHandlers(config)
|
|
290
|
-
|
|
291
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query: `?cursor_report_hub_id=900&cursor_snapshot=${computeIdsStreamSnapshotToken([...dated, ...nullRegion])}` }))))
|
|
292
|
-
expect(lines.flatMap((line) => line.items)).toEqual([
|
|
293
|
-
{ reportHubId: 800, businessDate: null, sourceType: "NI" },
|
|
294
|
-
{ reportHubId: 700, businessDate: null, sourceType: "NI" },
|
|
295
|
-
])
|
|
296
|
-
})
|
|
297
|
-
|
|
298
|
-
it("末尾以降を指すカーソルは空チャンク + isLast で正常終端する", async () => {
|
|
299
|
-
const items = makeItems(5)
|
|
300
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
301
|
-
const { api } = createDailyReportHandlers(config)
|
|
302
|
-
const last = items[items.length - 1]
|
|
303
|
-
const query = `?cursor_report_hub_id=${last.reportHubId}&cursor_business_date=${last.businessDate}&cursor_snapshot=${computeIdsStreamSnapshotToken(items)}`
|
|
304
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
305
|
-
expect(lines).toHaveLength(1)
|
|
306
|
-
expect(lines[0]).toEqual({ kind: "chunk", items: [], nextCursor: null, isLast: true, total: 5, snapshot: computeIdsStreamSnapshotToken(items) })
|
|
307
|
-
})
|
|
308
|
-
|
|
309
|
-
it.each([
|
|
310
|
-
["営業日のみ (位置を決められない)", "?cursor_business_date=2026-07-31"],
|
|
311
|
-
["日付形式違反 (スラッシュ)", "?cursor_report_hub_id=10&cursor_business_date=2026/07/31"],
|
|
312
|
-
["日付形式違反 (桁欠け)", "?cursor_report_hub_id=10&cursor_business_date=2026-7-31"],
|
|
313
|
-
["非整数 ID", "?cursor_report_hub_id=abc"],
|
|
314
|
-
["小数 ID", "?cursor_report_hub_id=12.5"],
|
|
315
|
-
["ゼロ ID", "?cursor_report_hub_id=0"],
|
|
316
|
-
["負数 ID", "?cursor_report_hub_id=-1"],
|
|
317
|
-
["先頭ゼロ ID (正規形でない)", "?cursor_report_hub_id=007"],
|
|
318
|
-
])("不正カーソル (%s) は 400 で、サービスを呼ばない", async (_label, query) => {
|
|
319
|
-
const { config, calls } = makeHandlersFixture({ idsStreamItems: makeItems(3) })
|
|
320
|
-
const { api } = createDailyReportHandlers(config)
|
|
321
|
-
const response = await api.loader(makeApiArgs("ids-stream", { query }))
|
|
322
|
-
expect(response.status).toBe(400)
|
|
323
|
-
expect(calls.getDailyReportIds).toHaveLength(0)
|
|
324
|
-
})
|
|
325
|
-
})
|
|
326
|
-
|
|
327
|
-
describe("ids-stream / 逐次配信", () => {
|
|
328
|
-
it("チャンクは 1 つの巨大な書き込みではなく複数回の enqueue で届く (pull 単位のバックプレッシャー)", async () => {
|
|
329
|
-
const items = makeItems(9000)
|
|
330
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
331
|
-
const { api } = createDailyReportHandlers(config)
|
|
332
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
333
|
-
const body = response.body
|
|
334
|
-
if (!body) throw new Error("body must be a stream")
|
|
335
|
-
|
|
336
|
-
// reader で読み、read() の回数 (= enqueue の粒度) を数える
|
|
337
|
-
const reader = body.getReader()
|
|
338
|
-
const decoder = new TextDecoder()
|
|
339
|
-
let readCount = 0
|
|
340
|
-
let text = ""
|
|
341
|
-
while (true) {
|
|
342
|
-
const { done, value } = await reader.read()
|
|
343
|
-
if (done) break
|
|
344
|
-
readCount += 1
|
|
345
|
-
text += decoder.decode(value, { stream: true })
|
|
346
|
-
}
|
|
347
|
-
// 200/4000/4000/800 の 4 チャンク = 4 回以上の read で届く (全体一括 1 回ではない)
|
|
348
|
-
expect(readCount).toBeGreaterThanOrEqual(4)
|
|
349
|
-
expect(text.split("\n").filter((line) => line.trim() !== "")).toHaveLength(4)
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
it("reader.cancel() は例外なく受理される (切断安全)", async () => {
|
|
353
|
-
const { config } = makeHandlersFixture({ idsStreamItems: makeItems(1100) })
|
|
354
|
-
const { api } = createDailyReportHandlers(config)
|
|
355
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
356
|
-
const reader = response.body?.getReader()
|
|
357
|
-
if (!reader) throw new Error("body must be a stream")
|
|
358
|
-
await reader.read()
|
|
359
|
-
await expect(reader.cancel()).resolves.toBeUndefined()
|
|
360
|
-
})
|
|
361
|
-
})
|
|
362
|
-
|
|
363
|
-
describe("ids-stream / 中断", () => {
|
|
364
|
-
it("送出前に中断済みのリクエストは 1 行も流さずクローズする", async () => {
|
|
365
|
-
const { config } = makeHandlersFixture({ idsStreamItems: makeItems(50) })
|
|
366
|
-
const { api } = createDailyReportHandlers(config)
|
|
367
|
-
const abortController = new AbortController()
|
|
368
|
-
abortController.abort()
|
|
369
|
-
const response = await api.loader(makeApiArgs("ids-stream", { signal: abortController.signal }))
|
|
370
|
-
expect(response.status).toBe(200)
|
|
371
|
-
expect(await response.text()).toBe("")
|
|
372
|
-
})
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
describe("ids-stream / ハートビート", () => {
|
|
376
|
-
it("次チャンク待ち (全量クエリ確定待ち) が長引く間は空行ハートビートを送る", async () => {
|
|
377
|
-
vi.useFakeTimers()
|
|
378
|
-
try {
|
|
379
|
-
// 全量クエリを保留させ、先頭ページ (200 件) 描画後に無音区間を作る
|
|
380
|
-
let releaseFull: () => void = () => {}
|
|
381
|
-
const fullGate = new Promise<void>((resolve) => {
|
|
382
|
-
releaseFull = resolve
|
|
383
|
-
})
|
|
384
|
-
const items = makeItems(400)
|
|
385
|
-
const { config } = makeHandlersFixture({
|
|
386
|
-
idsStreamItems: items,
|
|
387
|
-
idsStreamFirstPageItems: items.slice(0, 200),
|
|
388
|
-
idsStreamFullGate: fullGate,
|
|
389
|
-
})
|
|
390
|
-
const { api } = createDailyReportHandlers(config)
|
|
391
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
392
|
-
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
|
|
393
|
-
const decoder = new TextDecoder()
|
|
394
|
-
|
|
395
|
-
// 先頭ページの最初のチャンク (200 件、isLast 無し)
|
|
396
|
-
const first = await reader.read()
|
|
397
|
-
const firstLine = JSON.parse(decoder.decode(first.value).trim()) as DailyReportIdsStreamChunkLine
|
|
398
|
-
expect(firstLine.items).toHaveLength(200)
|
|
399
|
-
expect(firstLine.isLast).toBe(false)
|
|
400
|
-
|
|
401
|
-
// 全量ゲート未解放のまま 15s 経過 → 接続の生存を示す空行
|
|
402
|
-
const heartbeatRead = reader.read()
|
|
403
|
-
await vi.advanceTimersByTimeAsync(15_000)
|
|
404
|
-
const heartbeat = await heartbeatRead
|
|
405
|
-
expect(decoder.decode(heartbeat.value)).toBe("\n")
|
|
406
|
-
|
|
407
|
-
// 全量を解放すると残りが流れて isLast で閉じる (ハートビートが本体を壊していない)
|
|
408
|
-
releaseFull()
|
|
409
|
-
let sawLast = false
|
|
410
|
-
for (let i = 0; i < 50; i++) {
|
|
411
|
-
const { done, value } = await reader.read()
|
|
412
|
-
if (done) break
|
|
413
|
-
const text = decoder.decode(value).trim()
|
|
414
|
-
if (text === "") continue
|
|
415
|
-
for (const raw of text.split("\n")) {
|
|
416
|
-
const line = JSON.parse(raw) as DailyReportIdsStreamChunkLine
|
|
417
|
-
if (line.isLast) sawLast = true
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
expect(sawLast).toBe(true)
|
|
421
|
-
} finally {
|
|
422
|
-
vi.useRealTimers()
|
|
423
|
-
}
|
|
424
|
-
})
|
|
425
|
-
})
|
|
426
|
-
|
|
427
|
-
describe("ids-stream / 継ぎ目の閉じ込め (幽霊行と欠損行の構造的排除)", () => {
|
|
428
|
-
it("全量がキャッシュ由来のときは先頭ページを起動しない (鮮度逆転で作成直後の行を消さない)", async () => {
|
|
429
|
-
// ❗ バケットヒット・進行中フェッチへの相乗り・refresh-ahead が返す全量配列は
|
|
430
|
-
// 本リクエストより古くなり得る。そこへ新鮮な先頭ページを重ねると
|
|
431
|
-
// 「ページに在って全量に無い行 = 削除済み」の判定が反転し、作成直後の日報を消す。
|
|
432
|
-
// 副次効果として、従来ヒット時に走って捨てられていた TOP-N クエリも消える
|
|
433
|
-
const items = makeItems(500)
|
|
434
|
-
const { config, calls } = makeHandlersFixture({ idsStreamItems: items, idsStreamCacheHit: true })
|
|
435
|
-
const { api } = createDailyReportHandlers(config)
|
|
436
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
437
|
-
|
|
438
|
-
expect(calls.getDailyReportIdsFirstPage).toHaveLength(0)
|
|
439
|
-
expect(lines.every((line) => line.provisional === undefined)).toBe(true)
|
|
440
|
-
expect(lines[0].total).toBe(500)
|
|
441
|
-
expect(lines.flatMap((line) => line.items)).toEqual(items)
|
|
442
|
-
})
|
|
443
|
-
|
|
444
|
-
it("先頭ページ後に先頭領域へ入った行を取りこぼさない (欠損の構造的排除)", async () => {
|
|
445
|
-
// 先頭ページ (古いスナップショット) の後、全量 (新しいスナップショット) の先頭に
|
|
446
|
-
// 新しい行が入っている状況。ページのキーをカーソルにして継続すると、この行は
|
|
447
|
-
// 先頭領域に居るため丸ごと飛ばされ、その走査では二度と届かない
|
|
448
|
-
const base = makeItems(100)
|
|
449
|
-
const inserted = { reportHubId: 99999, businessDate: base[0].businessDate, sourceType: "Internal" }
|
|
450
|
-
const full = [inserted, ...base]
|
|
451
|
-
let releaseFull: () => void = () => {}
|
|
452
|
-
const fullGate = new Promise<void>((resolve) => {
|
|
453
|
-
releaseFull = resolve
|
|
454
|
-
})
|
|
455
|
-
const { config } = makeHandlersFixture({ idsStreamItems: full, idsStreamFirstPageItems: base.slice(0, 30), idsStreamFullGate: fullGate })
|
|
456
|
-
const { api } = createDailyReportHandlers(config)
|
|
457
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
458
|
-
releaseFull()
|
|
459
|
-
const lines = asChunks(await readNdjsonLines(response))
|
|
460
|
-
|
|
461
|
-
const authoritative = lines.filter((line) => line.provisional !== true)
|
|
462
|
-
expect(authoritative.flatMap((line) => line.items)).toEqual(full)
|
|
463
|
-
expect(authoritative.flatMap((line) => line.items).some((item) => item.reportHubId === 99999)).toBe(true)
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
it("先頭ページ後に消えた行は権威パスに現れない (幽霊行の消去材料を渡す)", async () => {
|
|
467
|
-
// ページには居たが全量には居ない行。暫定行は観測集合へ刻まれないため、
|
|
468
|
-
// クライアントの完走剪定がこの行を正確に落とせる
|
|
469
|
-
const full = makeItems(100)
|
|
470
|
-
const ghost = { reportHubId: 88888, businessDate: full[0].businessDate, sourceType: "Internal" }
|
|
471
|
-
let releaseFull: () => void = () => {}
|
|
472
|
-
const fullGate = new Promise<void>((resolve) => {
|
|
473
|
-
releaseFull = resolve
|
|
474
|
-
})
|
|
475
|
-
const { config } = makeHandlersFixture({ idsStreamItems: full, idsStreamFirstPageItems: [ghost, ...full.slice(0, 29)], idsStreamFullGate: fullGate })
|
|
476
|
-
const { api } = createDailyReportHandlers(config)
|
|
477
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
478
|
-
releaseFull()
|
|
479
|
-
const lines = asChunks(await readNdjsonLines(response))
|
|
480
|
-
|
|
481
|
-
const provisional = lines.filter((line) => line.provisional === true)
|
|
482
|
-
const authoritative = lines.filter((line) => line.provisional !== true)
|
|
483
|
-
expect(provisional.flatMap((line) => line.items).some((item) => item.reportHubId === 88888)).toBe(true)
|
|
484
|
-
expect(authoritative.flatMap((line) => line.items).some((item) => item.reportHubId === 88888)).toBe(false)
|
|
485
|
-
expect(authoritative.flatMap((line) => line.items)).toEqual(full)
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
it("先頭ページが上限未満なら権威として完結する (小規模データセットを剪定で全消ししない)", async () => {
|
|
489
|
-
// ❗ この分岐を provisional にすると、観測集合が空のまま完走し、剪定が全行を落とす
|
|
490
|
-
const items = makeItems(5)
|
|
491
|
-
let releaseFull: () => void = () => {}
|
|
492
|
-
const fullGate = new Promise<void>((resolve) => {
|
|
493
|
-
releaseFull = resolve
|
|
494
|
-
})
|
|
495
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: fullGate })
|
|
496
|
-
const { api } = createDailyReportHandlers(config)
|
|
497
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
498
|
-
const lines = asChunks(await readNdjsonLines(response))
|
|
499
|
-
releaseFull()
|
|
500
|
-
|
|
501
|
-
expect(lines).toHaveLength(1)
|
|
502
|
-
expect(lines[0].provisional).toBeUndefined()
|
|
503
|
-
expect(lines[0].isLast).toBe(true)
|
|
504
|
-
expect(lines[0].total).toBe(5)
|
|
505
|
-
})
|
|
506
|
-
|
|
507
|
-
it("暫定ページを出した経路でも権威パスの初回チャンクは大きいまま (行数を無駄に増やさない)", async () => {
|
|
508
|
-
const items = makeItems(9000)
|
|
509
|
-
let releaseFull: () => void = () => {}
|
|
510
|
-
const fullGate = new Promise<void>((resolve) => {
|
|
511
|
-
releaseFull = resolve
|
|
512
|
-
})
|
|
513
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: fullGate })
|
|
514
|
-
const { api } = createDailyReportHandlers(config)
|
|
515
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
516
|
-
releaseFull()
|
|
517
|
-
const lines = asChunks(await readNdjsonLines(response))
|
|
518
|
-
|
|
519
|
-
// 30 (暫定) + 4000/4000/1000 (権威・先頭から)
|
|
520
|
-
expect(lines.map((line) => line.items.length)).toEqual([30, 4000, 4000, 1000])
|
|
521
|
-
})
|
|
522
|
-
})
|
|
523
|
-
|
|
524
|
-
describe("ids-stream / スナップショットトークン (resume 継ぎ目の閉じ込め)", () => {
|
|
525
|
-
it("トークン不一致の再開はカーソルを捨てて先頭から流し直し、先頭行に restarted を立てる", async () => {
|
|
526
|
-
// ❗ カーソルを作った配列と今の配列が別物なら、カーソルより前に入った行が丸ごと飛ぶ
|
|
527
|
-
// (再接続のたびの欠損)。不一致は先頭からの再送で確実に回復する
|
|
528
|
-
const items = makeItems(100)
|
|
529
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
530
|
-
const { api } = createDailyReportHandlers(config)
|
|
531
|
-
const cursorItem = items[10]
|
|
532
|
-
const staleToken = computeIdsStreamSnapshotToken(items.slice(1)) // 別の配列のトークン
|
|
533
|
-
const query = `?cursor_report_hub_id=${cursorItem.reportHubId}&cursor_business_date=${cursorItem.businessDate}&cursor_snapshot=${staleToken}`
|
|
534
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
535
|
-
|
|
536
|
-
expect(lines[0].restarted).toBe(true)
|
|
537
|
-
expect(lines.slice(1).every((line) => line.restarted === undefined)).toBe(true)
|
|
538
|
-
expect(lines.flatMap((line) => line.items)).toEqual(items)
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
it("トークン無しの再開 (旧クライアント) も先頭から流し直す", async () => {
|
|
542
|
-
const items = makeItems(50)
|
|
543
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
544
|
-
const { api } = createDailyReportHandlers(config)
|
|
545
|
-
const cursorItem = items[10]
|
|
546
|
-
const query = `?cursor_report_hub_id=${cursorItem.reportHubId}&cursor_business_date=${cursorItem.businessDate}`
|
|
547
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
548
|
-
|
|
549
|
-
expect(lines[0].restarted).toBe(true)
|
|
550
|
-
expect(lines.flatMap((line) => line.items)).toEqual(items)
|
|
551
|
-
})
|
|
552
|
-
|
|
553
|
-
it("不正な形式のトークンは 400 にせず「トークン無し」として扱う", async () => {
|
|
554
|
-
// 再開の失敗は先頭からの再送で回復できる。接続ごと落とすより常に安全側
|
|
555
|
-
const items = makeItems(10)
|
|
556
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
557
|
-
const { api } = createDailyReportHandlers(config)
|
|
558
|
-
const cursorItem = items[2]
|
|
559
|
-
const query = `?cursor_report_hub_id=${cursorItem.reportHubId}&cursor_business_date=${cursorItem.businessDate}&cursor_snapshot=NOT-HEX!`
|
|
560
|
-
const response = await api.loader(makeApiArgs("ids-stream", { query }))
|
|
561
|
-
expect(response.status).toBe(200)
|
|
562
|
-
const lines = asChunks(await readNdjsonLines(response))
|
|
563
|
-
expect(lines[0].restarted).toBe(true)
|
|
564
|
-
})
|
|
565
|
-
|
|
566
|
-
it("トークン一致の再開はカーソル位置から続き、restarted を立てない", async () => {
|
|
567
|
-
const items = makeItems(50)
|
|
568
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
569
|
-
const { api } = createDailyReportHandlers(config)
|
|
570
|
-
const cursorItem = items[9]
|
|
571
|
-
const query = `?cursor_report_hub_id=${cursorItem.reportHubId}&cursor_business_date=${cursorItem.businessDate}&cursor_snapshot=${computeIdsStreamSnapshotToken(items)}`
|
|
572
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
573
|
-
|
|
574
|
-
expect(lines.every((line) => line.restarted === undefined)).toBe(true)
|
|
575
|
-
expect(lines.flatMap((line) => line.items)).toEqual(items.slice(10))
|
|
576
|
-
})
|
|
577
|
-
|
|
578
|
-
it("resume 経路の行にもトークンが載る (再武装 — 2 度目の切断もカーソル再開できる材料)", async () => {
|
|
579
|
-
// ❗ ここを欠くと最初の再開成功でクライアントのトークンが失われ、
|
|
580
|
-
// 2 度目以降の切断が全て全量再送 (~22 万行) へ退化する
|
|
581
|
-
const items = makeItems(50)
|
|
582
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items })
|
|
583
|
-
const { api } = createDailyReportHandlers(config)
|
|
584
|
-
const cursorItem = items[9]
|
|
585
|
-
const query = `?cursor_report_hub_id=${cursorItem.reportHubId}&cursor_business_date=${cursorItem.businessDate}&cursor_snapshot=${computeIdsStreamSnapshotToken(items)}`
|
|
586
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream", { query }))))
|
|
587
|
-
|
|
588
|
-
expect(lines.every((line) => line.snapshot === computeIdsStreamSnapshotToken(items))).toBe(true)
|
|
589
|
-
})
|
|
590
|
-
|
|
591
|
-
it("暫定行にはトークンを載せない (未確認スナップショットを再開起点にしない)", async () => {
|
|
592
|
-
const items = makeItems(100)
|
|
593
|
-
let releaseFull: () => void = () => {}
|
|
594
|
-
const fullGate = new Promise<void>((resolve) => {
|
|
595
|
-
releaseFull = resolve
|
|
596
|
-
})
|
|
597
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: fullGate })
|
|
598
|
-
const { api } = createDailyReportHandlers(config)
|
|
599
|
-
const response = await api.loader(makeApiArgs("ids-stream"))
|
|
600
|
-
releaseFull()
|
|
601
|
-
const lines = asChunks(await readNdjsonLines(response))
|
|
602
|
-
|
|
603
|
-
const provisional = lines.filter((line) => line.provisional === true)
|
|
604
|
-
expect(provisional.length).toBeGreaterThan(0)
|
|
605
|
-
expect(provisional.every((line) => line.snapshot === undefined)).toBe(true)
|
|
606
|
-
const authoritative = lines.filter((line) => line.provisional !== true)
|
|
607
|
-
expect(authoritative.every((line) => line.snapshot === computeIdsStreamSnapshotToken(items))).toBe(true)
|
|
608
|
-
})
|
|
609
|
-
|
|
610
|
-
it("短絡行 (ページ上限未満で完結) にもトークンを載せない (全量配列を代表しない)", async () => {
|
|
611
|
-
const items = makeItems(5)
|
|
612
|
-
const { config } = makeHandlersFixture({ idsStreamItems: items, idsStreamFullGate: new Promise<void>(() => {}) })
|
|
613
|
-
const { api } = createDailyReportHandlers(config)
|
|
614
|
-
const lines = asChunks(await readNdjsonLines(await api.loader(makeApiArgs("ids-stream"))))
|
|
615
|
-
expect(lines).toHaveLength(1)
|
|
616
|
-
expect(lines[0].snapshot).toBeUndefined()
|
|
617
|
-
})
|
|
618
|
-
})
|