@aiquants/daily-report 0.12.4 → 0.13.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 +32 -0
- package/dist/client.d.mts +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- 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 +175 -21
- package/dist/server.d.ts +175 -21
- package/dist/server.js +12 -9
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +12 -9
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-YZh7Pz7i.d.ts → sse-schema-D1oZnFg6.d.ts} +18 -0
- package/dist/{sse-schema-BiUnufrI.d.mts → sse-schema-lMo8yW4e.d.mts} +18 -0
- package/package.json +5 -5
- package/src/server/handlers.ts +181 -35
- package/src/server/handlers.visibility.spec.ts +458 -0
- package/src/server/ports.ts +48 -0
- package/src/server/service.spec.ts +335 -14
- package/src/server/service.ts +222 -37
- package/src/server/test-helpers/handlers-config.ts +93 -9
- package/src/server/visibility.spec.ts +142 -0
- package/src/server/visibility.ts +189 -0
- package/src/server.spec.ts +72 -0
- package/src/server.ts +20 -1
- package/src/shared/sse-schema.ts +15 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler-level tests for the source-type visibility port wiring and SSE filtering.
|
|
3
|
+
* 可視 sourceType ポートの結線と SSE フィルタに関するハンドラー層のテスト。
|
|
4
|
+
*
|
|
5
|
+
* ここで固定するのは「ポートを 1 回だけ呼ぶ」「例外は全拒否へ倒れる」「全経路へ集合が渡る」
|
|
6
|
+
* 「SSE が区分外イベントを落とす」の 4 点。いずれかが欠けると認可が素通りする。
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, vi } from "vitest"
|
|
9
|
+
import { createDailyReportHandlers } from "./handlers"
|
|
10
|
+
import { makeApiArgs, makeAttachmentArgs, makeHandlersFixture, TEST_INTERNAL_USER_ID } from "./test-helpers/handlers-config"
|
|
11
|
+
|
|
12
|
+
/** 区分制限付きのリゾルバ (NI のみ許可)。 */
|
|
13
|
+
const onlyNi = async () => ["NI"]
|
|
14
|
+
|
|
15
|
+
/** POST アクション用の引数を組み立てる。 */
|
|
16
|
+
const actionArgs = (fields: Record<string, string>) => {
|
|
17
|
+
const body = new FormData()
|
|
18
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
19
|
+
body.set(key, value)
|
|
20
|
+
}
|
|
21
|
+
return { request: new Request("http://localhost/daily_report/api/action", { method: "POST", body }), params: { endpoint: "action" } as Record<string, string | undefined> }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** SSE ルート用の引数を組み立てる。 */
|
|
25
|
+
const sseArgs = () => ({ request: new Request("http://localhost/sse/daily_report/updates"), params: { endpoint: "updates" } as Record<string, string | undefined> })
|
|
26
|
+
|
|
27
|
+
/** SSE レスポンスを購読し、受信したメッセージ本文を集める。 */
|
|
28
|
+
const collectSse = async (response: Response, onReady: () => Promise<void> | void): Promise<Record<string, unknown>[]> => {
|
|
29
|
+
const received: Record<string, unknown>[] = []
|
|
30
|
+
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
|
|
31
|
+
const decoder = new TextDecoder()
|
|
32
|
+
let buffer = ""
|
|
33
|
+
|
|
34
|
+
// ❗ 未完の行はバッファへ残す。読み取りチャンクは行境界で区切られる保証が無く、
|
|
35
|
+
// 毎回バッファを捨てると分割されたフレームを取りこぼす
|
|
36
|
+
const pump = (async () => {
|
|
37
|
+
while (true) {
|
|
38
|
+
const { done, value } = await reader.read()
|
|
39
|
+
if (done) break
|
|
40
|
+
buffer += decoder.decode(value, { stream: true })
|
|
41
|
+
const lines = buffer.split("\n")
|
|
42
|
+
buffer = lines.pop() ?? ""
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
const trimmed = line.trim()
|
|
45
|
+
if (trimmed.startsWith("data: ")) {
|
|
46
|
+
received.push(JSON.parse(trimmed.slice(6)) as Record<string, unknown>)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
})()
|
|
51
|
+
|
|
52
|
+
await onReady()
|
|
53
|
+
// ❗ マクロタスクを 1 つ挟んで pump に全フレームを読ませてから閉じる。
|
|
54
|
+
// cancel() はキュー済みのフレームを捨てるため、ここを飛ばすと「区分外が落ちた」ではなく
|
|
55
|
+
// 「そもそも読んでいない」を測ることになり、否定側の表明が原理的に落ちなくなる
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
57
|
+
await reader.cancel()
|
|
58
|
+
await pump
|
|
59
|
+
return received
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("可視 sourceType ポートの結線", () => {
|
|
63
|
+
it("読み取りリクエストごとにポートを 1 回だけ呼ぶ", async () => {
|
|
64
|
+
const port = vi.fn(onlyNi)
|
|
65
|
+
const { config, calls } = makeHandlersFixture({ resolveVisibleSourceTypes: port })
|
|
66
|
+
const handlers = createDailyReportHandlers(config)
|
|
67
|
+
|
|
68
|
+
await handlers.api.loader(makeApiArgs("business-date", { query: "?businessDate=2099-01-05" }))
|
|
69
|
+
|
|
70
|
+
expect(calls.resolveVisibleSourceTypes).toBe(1)
|
|
71
|
+
expect(port).toHaveBeenCalledTimes(1)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("3 つの読み取り経路すべてへ正規化済み集合が渡る", async () => {
|
|
75
|
+
const { config, calls } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi })
|
|
76
|
+
const handlers = createDailyReportHandlers(config)
|
|
77
|
+
|
|
78
|
+
await handlers.api.loader(makeApiArgs("business-date", { query: "?businessDate=2099-01-05" }))
|
|
79
|
+
await handlers.api.loader(makeApiArgs("report", { query: "?reportHubId=1" }))
|
|
80
|
+
await handlers.api.loader(makeApiArgs("ids-stream"))
|
|
81
|
+
|
|
82
|
+
const methods = calls.visibleArgs.map((entry) => entry.method)
|
|
83
|
+
expect(methods).toContain("getDailyReportsByBusinessDateByExternalId")
|
|
84
|
+
expect(methods).toContain("getDailyReportDetailByIdByExternalId")
|
|
85
|
+
expect(methods).toContain("getDailyReportIdsByExternalId")
|
|
86
|
+
// 全件配信になる null が紛れ込んでいないこと (渡し忘れの検出)
|
|
87
|
+
for (const entry of calls.visibleArgs) {
|
|
88
|
+
expect(entry.visible).not.toBeNull()
|
|
89
|
+
expect([...(entry.visible ?? [])]).toEqual(["ni"])
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("ポート未注入なら無制限 (null) が渡る (現行互換)", async () => {
|
|
94
|
+
const { config, calls } = makeHandlersFixture()
|
|
95
|
+
const handlers = createDailyReportHandlers(config)
|
|
96
|
+
|
|
97
|
+
await handlers.api.loader(makeApiArgs("business-date", { query: "?businessDate=2099-01-05" }))
|
|
98
|
+
|
|
99
|
+
expect(calls.visibleArgs.at(-1)?.visible).toBeNull()
|
|
100
|
+
expect(calls.resolveVisibleSourceTypes).toBe(0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("ポートが例外を投げたら空集合 (全拒否) へ倒れる", async () => {
|
|
104
|
+
const { config, calls } = makeHandlersFixture({
|
|
105
|
+
resolveVisibleSourceTypes: async () => {
|
|
106
|
+
throw new Error("authz store down")
|
|
107
|
+
},
|
|
108
|
+
// ログ出力でテスト出力を汚さない
|
|
109
|
+
...{ logger: { info: () => undefined, warn: () => undefined, error: () => undefined } },
|
|
110
|
+
})
|
|
111
|
+
const handlers = createDailyReportHandlers(config)
|
|
112
|
+
|
|
113
|
+
await handlers.api.loader(makeApiArgs("business-date", { query: "?businessDate=2099-01-05" }))
|
|
114
|
+
|
|
115
|
+
const visible = calls.visibleArgs.at(-1)?.visible
|
|
116
|
+
// ❗ null (無制限) へ倒れると認可ストア障害が全件配信になる
|
|
117
|
+
expect(visible).not.toBeNull()
|
|
118
|
+
expect(visible?.size).toBe(0)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("添付エンドポイントにも可視集合が渡る", async () => {
|
|
122
|
+
const { config, calls } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi, attachment: null })
|
|
123
|
+
const handlers = createDailyReportHandlers(config)
|
|
124
|
+
|
|
125
|
+
await handlers.attachment.loader(makeAttachmentArgs("A1"))
|
|
126
|
+
|
|
127
|
+
const entry = calls.visibleArgs.find((item) => item.method === "getAttachmentForUser")
|
|
128
|
+
expect(entry).toBeDefined()
|
|
129
|
+
expect([...(entry?.visible ?? [])]).toEqual(["ni"])
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("ミューテーションへの可視集合の伝播", () => {
|
|
134
|
+
it("他者の日報に作用する 4 種へ集合が渡る", async () => {
|
|
135
|
+
const { config, calls } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi })
|
|
136
|
+
const handlers = createDailyReportHandlers(config)
|
|
137
|
+
|
|
138
|
+
await handlers.api.action(actionArgs({ intent: "toggleStar", reportHubId: "1", isStarred: "true", clientTempId: "t1" }))
|
|
139
|
+
await handlers.api.action(actionArgs({ intent: "toggleRead", reportHubId: "1", isRead: "true", clientTempId: "t2" }))
|
|
140
|
+
await handlers.api.action(actionArgs({ intent: "addComment", reportHubId: "1", content: "c", clientTempId: "t3" }))
|
|
141
|
+
await handlers.api.action(actionArgs({ intent: "deleteComment", reportHubId: "1", commentId: "5", clientTempId: "t4" }))
|
|
142
|
+
|
|
143
|
+
expect(calls.mutations.map((entry) => entry.method)).toEqual(["setStarStatus", "setReadStatus", "addComment", "deleteComment"])
|
|
144
|
+
for (const entry of calls.mutations) {
|
|
145
|
+
expect([...(entry.visible ?? [])]).toEqual(["ni"])
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("toggleStar のゲート拒否は 500 ではなく 404 になる", async () => {
|
|
150
|
+
const { config } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi, mutationError: new Error("Not Found") })
|
|
151
|
+
const handlers = createDailyReportHandlers(config)
|
|
152
|
+
|
|
153
|
+
const response = await handlers.api.action(actionArgs({ intent: "toggleStar", reportHubId: "1", isStarred: "true", clientTempId: "t1" }))
|
|
154
|
+
|
|
155
|
+
expect(response.status).toBe(404)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("toggleRead のゲート拒否も 404 になる", async () => {
|
|
159
|
+
const { config } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi, mutationError: new Error("Not Found") })
|
|
160
|
+
const handlers = createDailyReportHandlers(config)
|
|
161
|
+
|
|
162
|
+
const response = await handlers.api.action(actionArgs({ intent: "toggleRead", reportHubId: "1", isRead: "true", clientTempId: "t2" }))
|
|
163
|
+
|
|
164
|
+
expect(response.status).toBe(404)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe("SSE の区分フィルタ", () => {
|
|
169
|
+
/** Redis Stream エントリを組み立てる。 */
|
|
170
|
+
const entry = (id: string, message: Record<string, unknown>) => ({ id, message: { data: JSON.stringify(message) } })
|
|
171
|
+
|
|
172
|
+
/** 各テストで使う report-* の最小ペイロード。 */
|
|
173
|
+
const reportPayload = (sourceType: string) => ({
|
|
174
|
+
reportHubId: 1,
|
|
175
|
+
date: "2099-01-05",
|
|
176
|
+
author: "a",
|
|
177
|
+
userId: "ENC8",
|
|
178
|
+
sourceType,
|
|
179
|
+
employeeName: null,
|
|
180
|
+
updatedBy: null,
|
|
181
|
+
updatedAt: null,
|
|
182
|
+
createdAt: null,
|
|
183
|
+
category: null,
|
|
184
|
+
creationCategory: null,
|
|
185
|
+
visitTimeFrom: null,
|
|
186
|
+
visitTimeTo: null,
|
|
187
|
+
customerName: null,
|
|
188
|
+
interviewers: [],
|
|
189
|
+
subject: "s",
|
|
190
|
+
content: "c",
|
|
191
|
+
comments: [],
|
|
192
|
+
isRead: false,
|
|
193
|
+
isStarred: false,
|
|
194
|
+
labels: [],
|
|
195
|
+
commentItems: [],
|
|
196
|
+
attachments: [],
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it("可視区分の report-update は届き、区分外は落ちる", async () => {
|
|
200
|
+
const { config, pushSseEntry } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi })
|
|
201
|
+
const handlers = createDailyReportHandlers(config)
|
|
202
|
+
const response = await handlers.sse.loader(sseArgs())
|
|
203
|
+
|
|
204
|
+
const received = await collectSse(response as Response, async () => {
|
|
205
|
+
pushSseEntry(entry("1-1", { type: "report-update", reportHubId: 1, report: reportPayload("NI"), clientTempId: "t1" }))
|
|
206
|
+
pushSseEntry(entry("1-2", { type: "report-update", reportHubId: 2, report: reportPayload("kintone"), clientTempId: "t2" }))
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const updates = received.filter((message) => message.type === "report-update")
|
|
210
|
+
expect(updates).toHaveLength(1)
|
|
211
|
+
expect(updates[0]?.reportHubId).toBe(1)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("sourceType を持たない旧 comment-add は Internal 扱いになる (可視なら届く)", async () => {
|
|
215
|
+
const { config, pushSseEntry } = makeHandlersFixture({ resolveVisibleSourceTypes: async () => ["Internal"] })
|
|
216
|
+
const handlers = createDailyReportHandlers(config)
|
|
217
|
+
const response = await handlers.sse.loader(sseArgs())
|
|
218
|
+
|
|
219
|
+
const received = await collectSse(response as Response, async () => {
|
|
220
|
+
pushSseEntry(entry("2-1", { type: "comment-add", reportHubId: 1, comment: { id: 1, userId: "ENC8", userName: "u", content: "c", createdAt: "", isMine: false }, clientTempId: "t1" }))
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
expect(received.some((message) => message.type === "comment-add")).toBe(true)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it("Internal 不可視の閲覧者には旧 comment-add が届かない (両方向の検証)", async () => {
|
|
227
|
+
const { config, pushSseEntry } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi })
|
|
228
|
+
const handlers = createDailyReportHandlers(config)
|
|
229
|
+
const response = await handlers.sse.loader(sseArgs())
|
|
230
|
+
|
|
231
|
+
const received = await collectSse(response as Response, async () => {
|
|
232
|
+
pushSseEntry(entry("3-1", { type: "comment-add", reportHubId: 1, comment: { id: 1, userId: "ENC8", userName: "u", content: "c", createdAt: "", isMine: false }, clientTempId: "t1" }))
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
expect(received.some((message) => message.type === "comment-add")).toBe(false)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("sourceType 無しの旧 report-delete は制限閲覧者に対して落ちる (fail-close)", async () => {
|
|
239
|
+
const { config, pushSseEntry } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi })
|
|
240
|
+
const handlers = createDailyReportHandlers(config)
|
|
241
|
+
const response = await handlers.sse.loader(sseArgs())
|
|
242
|
+
|
|
243
|
+
const received = await collectSse(response as Response, async () => {
|
|
244
|
+
pushSseEntry(entry("4-1", { type: "report-delete", reportHubId: 1, clientTempId: "t1" }))
|
|
245
|
+
pushSseEntry(entry("4-2", { type: "report-delete", reportHubId: 2, clientTempId: "t2", sourceType: "NI" }))
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const deletes = received.filter((message) => message.type === "report-delete")
|
|
249
|
+
expect(deletes).toHaveLength(1)
|
|
250
|
+
expect(deletes[0]?.reportHubId).toBe(2)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it("status-update は区分に関係なく本人へ届く (本文を運ばないため)", async () => {
|
|
254
|
+
const { config, pushSseEntry } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi })
|
|
255
|
+
const handlers = createDailyReportHandlers(config)
|
|
256
|
+
const response = await handlers.sse.loader(sseArgs())
|
|
257
|
+
|
|
258
|
+
const received = await collectSse(response as Response, async () => {
|
|
259
|
+
pushSseEntry(entry("5-1", { type: "status-update", reportHubId: 1, statusType: "star", value: true, clientTempId: "t1", recipientRawUserId: TEST_INTERNAL_USER_ID }))
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
expect(received.some((message) => message.type === "status-update")).toBe(true)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("無制限 (ポート未注入) では全区分が届く (現行互換)", async () => {
|
|
266
|
+
const { config, pushSseEntry } = makeHandlersFixture()
|
|
267
|
+
const handlers = createDailyReportHandlers(config)
|
|
268
|
+
const response = await handlers.sse.loader(sseArgs())
|
|
269
|
+
|
|
270
|
+
const received = await collectSse(response as Response, async () => {
|
|
271
|
+
pushSseEntry(entry("6-1", { type: "report-update", reportHubId: 1, report: reportPayload("kintone"), clientTempId: "t1" }))
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
expect(received.some((message) => message.type === "report-update")).toBe(true)
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
describe("SSE の可視集合 再解決 (失効の有界化)", () => {
|
|
279
|
+
/** Redis Stream エントリを組み立てる。 */
|
|
280
|
+
const entry = (id: string, message: Record<string, unknown>) => ({ id, message: { data: JSON.stringify(message) } })
|
|
281
|
+
|
|
282
|
+
/** 最小の report-update ペイロード。 */
|
|
283
|
+
const reportUpdate = (reportHubId: number, sourceType: string) => ({
|
|
284
|
+
type: "report-update",
|
|
285
|
+
reportHubId,
|
|
286
|
+
clientTempId: `t${reportHubId}`,
|
|
287
|
+
report: {
|
|
288
|
+
reportHubId,
|
|
289
|
+
date: "2099-01-05",
|
|
290
|
+
author: "a",
|
|
291
|
+
userId: "ENC8",
|
|
292
|
+
sourceType,
|
|
293
|
+
employeeName: null,
|
|
294
|
+
updatedBy: null,
|
|
295
|
+
updatedAt: null,
|
|
296
|
+
createdAt: null,
|
|
297
|
+
category: null,
|
|
298
|
+
creationCategory: null,
|
|
299
|
+
visitTimeFrom: null,
|
|
300
|
+
visitTimeTo: null,
|
|
301
|
+
customerName: null,
|
|
302
|
+
interviewers: [],
|
|
303
|
+
subject: "s",
|
|
304
|
+
content: "c",
|
|
305
|
+
comments: [],
|
|
306
|
+
isRead: false,
|
|
307
|
+
isStarred: false,
|
|
308
|
+
labels: [],
|
|
309
|
+
commentItems: [],
|
|
310
|
+
attachments: [],
|
|
311
|
+
},
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it("keep-alive 12 tick ごとに再解決し、新しい可視集合が以降のフィルタへ効く", async () => {
|
|
315
|
+
vi.useFakeTimers()
|
|
316
|
+
try {
|
|
317
|
+
// 1 回目は NI のみ、2 回目以降は kintone のみを許可する (権限の付け替えを模擬)
|
|
318
|
+
let call = 0
|
|
319
|
+
const { config, pushSseEntry, calls } = makeHandlersFixture({
|
|
320
|
+
resolveVisibleSourceTypes: async () => {
|
|
321
|
+
call += 1
|
|
322
|
+
return call === 1 ? ["NI"] : ["kintone"]
|
|
323
|
+
},
|
|
324
|
+
})
|
|
325
|
+
const handlers = createDailyReportHandlers(config)
|
|
326
|
+
const response = (await handlers.sse.loader(sseArgs())) as Response
|
|
327
|
+
|
|
328
|
+
const received: Record<string, unknown>[] = []
|
|
329
|
+
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
|
|
330
|
+
const decoder = new TextDecoder()
|
|
331
|
+
let buffer = ""
|
|
332
|
+
const pump = (async () => {
|
|
333
|
+
while (true) {
|
|
334
|
+
const { done, value } = await reader.read()
|
|
335
|
+
if (done) break
|
|
336
|
+
buffer += decoder.decode(value, { stream: true })
|
|
337
|
+
const lines = buffer.split("\n")
|
|
338
|
+
buffer = lines.pop() ?? ""
|
|
339
|
+
for (const line of lines) {
|
|
340
|
+
const trimmed = line.trim()
|
|
341
|
+
if (trimmed.startsWith("data: ")) received.push(JSON.parse(trimmed.slice(6)) as Record<string, unknown>)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
})()
|
|
345
|
+
|
|
346
|
+
// 再解決前: NI が可視、kintone は落ちる
|
|
347
|
+
pushSseEntry(entry("1-1", reportUpdate(1, "NI")))
|
|
348
|
+
pushSseEntry(entry("1-2", reportUpdate(2, "kintone")))
|
|
349
|
+
|
|
350
|
+
// keep-alive を 12 tick 進めて再解決を発火させる
|
|
351
|
+
await vi.advanceTimersByTimeAsync(5000 * 12)
|
|
352
|
+
// ❗ 再解決は fire-and-forget。マイクロタスクを 1 回流して `visible` の差し替えを確定させる。
|
|
353
|
+
// これを飛ばすと差し替え前の集合で判定され、テストが「間違った理由で」通る
|
|
354
|
+
await Promise.resolve()
|
|
355
|
+
await Promise.resolve()
|
|
356
|
+
|
|
357
|
+
// 再解決後: 可視集合が入れ替わり、kintone が届き NI が落ちる
|
|
358
|
+
pushSseEntry(entry("2-1", reportUpdate(3, "NI")))
|
|
359
|
+
pushSseEntry(entry("2-2", reportUpdate(4, "kintone")))
|
|
360
|
+
|
|
361
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
362
|
+
await reader.cancel()
|
|
363
|
+
await pump
|
|
364
|
+
|
|
365
|
+
expect(calls.resolveVisibleSourceTypes).toBeGreaterThanOrEqual(2)
|
|
366
|
+
const ids = received.filter((m) => m.type === "report-update").map((m) => m.reportHubId)
|
|
367
|
+
// 1 (NI・再解決前) と 4 (kintone・再解決後) だけが通る
|
|
368
|
+
expect(ids).toContain(1)
|
|
369
|
+
expect(ids).toContain(4)
|
|
370
|
+
expect(ids).not.toContain(2)
|
|
371
|
+
expect(ids).not.toContain(3)
|
|
372
|
+
} finally {
|
|
373
|
+
vi.useRealTimers()
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
describe("SSE の区分フィルタ: 残る分岐の網羅", () => {
|
|
379
|
+
const entry = (id: string, message: Record<string, unknown>) => ({ id, message: { data: JSON.stringify(message) } })
|
|
380
|
+
|
|
381
|
+
it("comment-delete も区分で落とされる (comment-add だけ直す退行を防ぐ)", async () => {
|
|
382
|
+
const { config, pushSseEntry } = makeHandlersFixture({ resolveVisibleSourceTypes: async () => ["kintone"] })
|
|
383
|
+
const handlers = createDailyReportHandlers(config)
|
|
384
|
+
const response = (await handlers.sse.loader(sseArgs())) as Response
|
|
385
|
+
|
|
386
|
+
const received = await collectSse(response, async () => {
|
|
387
|
+
// Internal 相当 (旧エントリ) と、明示的に可視な kintone の 2 本
|
|
388
|
+
pushSseEntry(entry("1-1", { type: "comment-delete", reportHubId: 1, commentId: 9, clientTempId: "t1" }))
|
|
389
|
+
pushSseEntry(entry("1-2", { type: "comment-delete", reportHubId: 2, commentId: 10, clientTempId: "t2", sourceType: "kintone" }))
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
const deletes = received.filter((m) => m.type === "comment-delete")
|
|
393
|
+
expect(deletes).toHaveLength(1)
|
|
394
|
+
expect(deletes[0]?.reportHubId).toBe(2)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it("connected は常に届く (接続確立の合図を区分で落とさない)", async () => {
|
|
398
|
+
const { config } = makeHandlersFixture({ resolveVisibleSourceTypes: async () => [] })
|
|
399
|
+
const handlers = createDailyReportHandlers(config)
|
|
400
|
+
const response = (await handlers.sse.loader(sseArgs())) as Response
|
|
401
|
+
|
|
402
|
+
const received = await collectSse(response, async () => undefined)
|
|
403
|
+
|
|
404
|
+
expect(received.some((m) => m.type === "connected")).toBe(true)
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
describe("落としたエントリの再開アンカー伝播", () => {
|
|
409
|
+
const entry = (id: string, message: Record<string, unknown>) => ({ id, message: { data: JSON.stringify(message) } })
|
|
410
|
+
|
|
411
|
+
it("区分で落としたエントリも内容なしフレームでアンカーだけ前進させる", async () => {
|
|
412
|
+
const { config, pushSseEntry } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi })
|
|
413
|
+
const handlers = createDailyReportHandlers(config)
|
|
414
|
+
const response = (await handlers.sse.loader(sseArgs())) as Response
|
|
415
|
+
|
|
416
|
+
const received = await collectSse(response, async () => {
|
|
417
|
+
// Internal のコメント (NI のみ可視な閲覧者には不可視)
|
|
418
|
+
pushSseEntry(entry("9-1", { type: "comment-add", reportHubId: 1, comment: { id: 1, userId: "ENC8", userName: "u", content: "secret", createdAt: "", isMine: false }, clientTempId: "t1" }))
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
// 内容 (comment-add) は届かない
|
|
422
|
+
expect(received.some((m) => m.type === "comment-add")).toBe(false)
|
|
423
|
+
// 接続確立の 1 本に加えて、アンカー前進用の connected がもう 1 本届く。
|
|
424
|
+
// これが無いとクライアントの lastEventId が固定され、再接続のたびに同じ範囲を読み直す
|
|
425
|
+
expect(received.filter((m) => m.type === "connected").length).toBeGreaterThanOrEqual(2)
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
describe("リゾルバへ渡す解決理由", () => {
|
|
430
|
+
it("接続時は initial、定期再解決は refresh を渡す (リクエストスコープ記憶化の迂回点)", async () => {
|
|
431
|
+
vi.useFakeTimers()
|
|
432
|
+
try {
|
|
433
|
+
const { config, calls } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi })
|
|
434
|
+
const handlers = createDailyReportHandlers(config)
|
|
435
|
+
const response = (await handlers.sse.loader(sseArgs())) as Response
|
|
436
|
+
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
|
|
437
|
+
|
|
438
|
+
expect(calls.visibilityResolveReasons).toEqual(["initial"])
|
|
439
|
+
|
|
440
|
+
await vi.advanceTimersByTimeAsync(5000 * 12)
|
|
441
|
+
await Promise.resolve()
|
|
442
|
+
|
|
443
|
+
expect(calls.visibilityResolveReasons).toContain("refresh")
|
|
444
|
+
await reader.cancel()
|
|
445
|
+
} finally {
|
|
446
|
+
vi.useRealTimers()
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it("読み取りリクエストは initial を渡す", async () => {
|
|
451
|
+
const { config, calls } = makeHandlersFixture({ resolveVisibleSourceTypes: onlyNi })
|
|
452
|
+
const handlers = createDailyReportHandlers(config)
|
|
453
|
+
|
|
454
|
+
await handlers.api.loader(makeApiArgs("business-date", { query: "?businessDate=2099-01-05" }))
|
|
455
|
+
|
|
456
|
+
expect(calls.visibilityResolveReasons).toEqual(["initial"])
|
|
457
|
+
})
|
|
458
|
+
})
|
package/src/server/ports.ts
CHANGED
|
@@ -55,6 +55,54 @@ export type DailyReportAuthResult = {
|
|
|
55
55
|
*/
|
|
56
56
|
export type DailyReportAuthenticate = (request: Request, options: { failureRedirect: string } | { failureRedirect: null }) => Promise<DailyReportAuthResult>
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Raw return of the visible-source-type resolver.
|
|
60
|
+
* 可視 sourceType リゾルバの生戻り値。
|
|
61
|
+
*
|
|
62
|
+
* - `undefined` / `null`: 無制限 (全件)。ポート未注入時と同じ既定の意味。
|
|
63
|
+
* - `string[]`: その集合に限定。要素は `DailyReportHub.sourceType` の値であり、**空配列は 0 件**。
|
|
64
|
+
*
|
|
65
|
+
* 中立契約: いかなる認可パッケージの型 (`PermissionView` 等) にも依存しない素の文字列配列。
|
|
66
|
+
*/
|
|
67
|
+
export type DailyReportVisibleSourceTypes = readonly string[] | null | undefined
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Why the visible-source-type resolver is being called.
|
|
71
|
+
* 可視 sourceType リゾルバが呼ばれた理由。
|
|
72
|
+
*
|
|
73
|
+
* - `initial`: リクエスト処理の開始時 (読み取り / ミューテーション / 添付 / SSE 接続確立)。
|
|
74
|
+
* - `refresh`: 長寿命の SSE 接続が権限失効を検出するための定期再解決。
|
|
75
|
+
*
|
|
76
|
+
* ❗ `refresh` は接続時と**同一の `Request` インスタンス**で呼ばれる (SSE 接続の生存期間中、
|
|
77
|
+
* 他に握れるハンドルが無いため)。したがってリクエストスコープで記憶化する実装は、
|
|
78
|
+
* `refresh` のときだけキャッシュを迂回しなければ再解決が常に古い値を返し、失効が反映されない。
|
|
79
|
+
*/
|
|
80
|
+
export type DailyReportVisibilityResolveReason = "initial" | "refresh"
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Context handed to the visible-source-type resolver.
|
|
84
|
+
* 可視 sourceType リゾルバへ渡される文脈。
|
|
85
|
+
*/
|
|
86
|
+
export type DailyReportVisibilityResolveContext = {
|
|
87
|
+
/** 呼び出し理由。`refresh` のときリクエストスコープのキャッシュを迂回すること。 */
|
|
88
|
+
reason: DailyReportVisibilityResolveReason
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolves which report source types a request may view.
|
|
93
|
+
* リクエストが閲覧してよい日報 sourceType を解決するポート。
|
|
94
|
+
*
|
|
95
|
+
* サーバー側の全データ経路 (一覧 / 詳細 / コメント / 添付 / SSE) に一貫して効く。
|
|
96
|
+
*
|
|
97
|
+
* ❗ 例外を投げた場合、ハンドラは**空集合 (全拒否) にフォールバック**する。認可ストアの
|
|
98
|
+
* 障害時に無制限へ倒れないための fail-close であり、実装側で握り潰さないこと。
|
|
99
|
+
* ❗ 全読み取りリクエストのクリティカルパス上で 1 回呼ばれる (キャッシュキーが戻り値に依存する)。
|
|
100
|
+
* 高速に保つこと。記憶化する場合はセッション / ユーザー単位の短時間 TTL (60 秒以下) とし、
|
|
101
|
+
* `context.reason === "refresh"` では必ずキャッシュを迂回すること
|
|
102
|
+
* (迂回しないと SSE の定期再解決が失効を観測できず、権限剥奪後も配信が続く)。
|
|
103
|
+
*/
|
|
104
|
+
export type DailyReportResolveVisibleSourceTypes = (request: Request, context: DailyReportVisibilityResolveContext) => DailyReportVisibleSourceTypes | Promise<DailyReportVisibleSourceTypes>
|
|
105
|
+
|
|
58
106
|
/**
|
|
59
107
|
* Resolves an external user id (e.g. Google sub) to the internal numeric user id.
|
|
60
108
|
* 外部ユーザー ID (例: Google sub) を内部数値ユーザー ID へ解決するポート。
|