@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
|
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"
|
|
|
4
4
|
import { createEpochStore, SqlResultCache } from "./cache"
|
|
5
5
|
import { defineDailyReportSchema } from "./schema"
|
|
6
6
|
import { createDailyReportService, type DailyReportDb, type DailyReportHubRow, formatDateValue } from "./service"
|
|
7
|
+
import { buildVisibleSourceTypeCondition, normalizeVisibleSourceTypes } from "./visibility"
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Security regression tests for audit-actor masking in mapHubRecord.
|
|
@@ -100,6 +101,15 @@ const makeDb = (rows: unknown[], options: { selectQueue?: unknown[][] } = {}): {
|
|
|
100
101
|
updates.push(recorded)
|
|
101
102
|
return Promise.resolve({ rowsAffected: [1] })
|
|
102
103
|
},
|
|
104
|
+
// `.output().where()` 形 (setStarStatus / setReadStatus の更新経路) も受ける。
|
|
105
|
+
// 更新後の行が必要なため、set された値をそのままエコーして返す
|
|
106
|
+
output: () => ({
|
|
107
|
+
where: (expr: SQL | undefined) => {
|
|
108
|
+
recorded.whereParams = expr ? dialect.sqlToQuery(expr).params : []
|
|
109
|
+
updates.push(recorded)
|
|
110
|
+
return Promise.resolve([{ hubId: 1, userId: 8, isRead: false, isStarred: false, ...recorded.set }])
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
103
113
|
}
|
|
104
114
|
return chain
|
|
105
115
|
}
|
|
@@ -113,7 +123,10 @@ const makeDb = (rows: unknown[], options: { selectQueue?: unknown[][] } = {}): {
|
|
|
113
123
|
}
|
|
114
124
|
// DELETE: 記録のみ (deleteComment 用)
|
|
115
125
|
const del = () => ({ where: () => Promise.resolve({ rowsAffected: [1] }) })
|
|
116
|
-
|
|
126
|
+
// TRANSACTION: 同じモックをそのまま tx として渡す (createDailyReport 等の検証用)。
|
|
127
|
+
// ロールバックは模擬しない (本モックの目的は発行クエリとキャッシュ挙動の観測)
|
|
128
|
+
const db: DailyReportDb = { select, update, insert, delete: del, transaction: (fn: (tx: DailyReportDb) => Promise<unknown>) => fn(db) } as unknown as DailyReportDb
|
|
129
|
+
return { db, queries, updates }
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
const baseHub = (over: Partial<DailyReportHubRow>): DailyReportHubRow => ({
|
|
@@ -276,14 +289,14 @@ describe("添付ファイルの認可付き取得", () => {
|
|
|
276
289
|
epochs,
|
|
277
290
|
draftLabelName: "draft",
|
|
278
291
|
})
|
|
279
|
-
expect(await svc.getAttachmentForUser(1, 8)).toBeNull()
|
|
292
|
+
expect(await svc.getAttachmentForUser(1, 8, null)).toBeNull()
|
|
280
293
|
})
|
|
281
294
|
|
|
282
295
|
it("認可述語が閲覧者の所有と下書き非該当を OR で結び、論理削除も除外する", async () => {
|
|
283
296
|
// これがこの機能の唯一の認可境界。述語そのものを SQL 文字列として固定する
|
|
284
297
|
// (モックは行を絞れないので、生成される WHERE 句を直接表明するしかない)
|
|
285
298
|
const svc = makeService([{ fileName: "a.png", filePath: "gcs://b/a.png", fileType: "image/png", fileSize: 1 }])
|
|
286
|
-
await svc.getAttachmentForUser(11, 8)
|
|
299
|
+
await svc.getAttachmentForUser(11, 8, null)
|
|
287
300
|
|
|
288
301
|
// 最後のクエリが添付の認可判定 (直前は下書きラベルの解決)
|
|
289
302
|
const recorded = svc.__queries.at(-1)
|
|
@@ -300,7 +313,7 @@ describe("添付ファイルの認可付き取得", () => {
|
|
|
300
313
|
|
|
301
314
|
it("認可判定の束縛値が入れ替わっていないこと (添付 ID と閲覧者 ID)", async () => {
|
|
302
315
|
const svc = makeService([])
|
|
303
|
-
await svc.getAttachmentForUser(777, 42)
|
|
316
|
+
await svc.getAttachmentForUser(777, 42, null)
|
|
304
317
|
expect(svc.__queries.at(-1)?.whereParams).toEqual([777, 42])
|
|
305
318
|
})
|
|
306
319
|
|
|
@@ -310,15 +323,15 @@ describe("添付ファイルの認可付き取得", () => {
|
|
|
310
323
|
const draftPredicate = (sql: string): boolean => /\[user_id\] = @par\d+\) or \(\(\[draft_label\]\.\[hub_id\] is null\)/.test(sql)
|
|
311
324
|
|
|
312
325
|
const byDate = makeService([])
|
|
313
|
-
await byDate.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", { forceRefresh: true })
|
|
326
|
+
await byDate.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", { forceRefresh: true }, null)
|
|
314
327
|
expect(byDate.__queries.some((q) => q.whereSql !== null && draftPredicate(q.whereSql))).toBe(true)
|
|
315
328
|
|
|
316
329
|
const byId = makeService([])
|
|
317
|
-
await byId.getDailyReportDetailById(1, 8, { forceRefresh: true })
|
|
330
|
+
await byId.getDailyReportDetailById(1, 8, { forceRefresh: true }, null)
|
|
318
331
|
expect(byId.__queries.some((q) => q.whereSql !== null && draftPredicate(q.whereSql))).toBe(true)
|
|
319
332
|
|
|
320
333
|
const byAttachment = makeService([])
|
|
321
|
-
await byAttachment.getAttachmentForUser(1, 8)
|
|
334
|
+
await byAttachment.getAttachmentForUser(1, 8, null)
|
|
322
335
|
expect(byAttachment.__queries.some((q) => q.whereSql !== null && draftPredicate(q.whereSql))).toBe(true)
|
|
323
336
|
})
|
|
324
337
|
|
|
@@ -327,7 +340,7 @@ describe("添付ファイルの認可付き取得", () => {
|
|
|
327
340
|
// 意図的な緩和であり、返すのは ID・営業日・ソース種別だけで本文も添付も含まない。
|
|
328
341
|
// 「3 経路と同一」ではないことを明示的に固定し、無自覚な差分と区別する
|
|
329
342
|
const byIds = makeService([])
|
|
330
|
-
await byIds.getDailyReportIdsByExternalId("ext", { forceRefresh: true })
|
|
343
|
+
await byIds.getDailyReportIdsByExternalId("ext", { forceRefresh: true }, null)
|
|
331
344
|
const idsWhere = byIds.__queries.map((q) => q.whereSql ?? "").find((sql) => sql.includes("[draft_label].[hub_id] is null"))
|
|
332
345
|
expect(idsWhere).toBeDefined()
|
|
333
346
|
expect(idsWhere).toContain("[user_id] is null")
|
|
@@ -337,7 +350,7 @@ describe("添付ファイルの認可付き取得", () => {
|
|
|
337
350
|
// `draftLabelNames` には取込元ごとの下書き名を複数注入できる。1 個の ID でしか結合しないと
|
|
338
351
|
// 2 つ目の下書きラベルが付いた日報が誰にでも可視になる
|
|
339
352
|
const svc = makeService([{ id: 11 }, { id: 22 }])
|
|
340
|
-
await svc.getAttachmentForUser(1, 8)
|
|
353
|
+
await svc.getAttachmentForUser(1, 8, null)
|
|
341
354
|
// 結合条件は where ではなく leftJoin の ON 側に載る
|
|
342
355
|
const joinParams = svc.__queries.flatMap((q) => q.joinParams)
|
|
343
356
|
expect(joinParams).toContain(11)
|
|
@@ -391,19 +404,19 @@ describe("コメント操作の整合性チェック", () => {
|
|
|
391
404
|
it("addComment は存在しない日報に対して Not Found を投げる (孤児コメントを作らない)", async () => {
|
|
392
405
|
// hub 取得が空 = 存在しない日報。以前は hub 未取得でもフォールスルーして INSERT していた
|
|
393
406
|
const svc = makeService([])
|
|
394
|
-
await expect(svc.addComment(8, 999, "本文", "2099-01-05", "tmp")).rejects.toThrow("Not Found")
|
|
407
|
+
await expect(svc.addComment(8, 999, "本文", "2099-01-05", "tmp", null)).rejects.toThrow("Not Found")
|
|
395
408
|
})
|
|
396
409
|
|
|
397
410
|
it("addComment は論理削除済みの日報を拒否する", async () => {
|
|
398
411
|
const svc = makeService([{ sourceType: "Internal", deletedAt: "2099-01-06 00:00:00", userId: 8 }])
|
|
399
|
-
await expect(svc.addComment(8, 1, "本文", "2099-01-05", "tmp")).rejects.toThrow("Not Found")
|
|
412
|
+
await expect(svc.addComment(8, 1, "本文", "2099-01-05", "tmp", null)).rejects.toThrow("Not Found")
|
|
400
413
|
})
|
|
401
414
|
|
|
402
415
|
it("deleteComment は reportHubId がコメントの実所属と一致しないと Not Found を投げる", async () => {
|
|
403
416
|
// コメントは hubId=100 に属するのに、呼び出しは reportHubId=999 を渡している。
|
|
404
417
|
// 不一致のまま進むと無関係な日報のキャッシュ無効化と SSE 配信を起こすため拒否する
|
|
405
418
|
const svc = makeService([{ id: 5, hubId: 100, userId: 8, body: "x", createdAt: "2099-01-05 00:00:00", createdBy: "8", updatedAt: "2099-01-05 00:00:00", updatedBy: "8" }])
|
|
406
|
-
await expect(svc.deleteComment(8, 999, 5, "2099-01-05", "tmp")).rejects.toThrow("Not Found")
|
|
419
|
+
await expect(svc.deleteComment(8, 999, 5, "2099-01-05", "tmp", null)).rejects.toThrow("Not Found")
|
|
407
420
|
})
|
|
408
421
|
|
|
409
422
|
it("addComment: 公開済み日報のコメントは全員配信 (recipientRawUserId 無し)", async () => {
|
|
@@ -415,7 +428,7 @@ describe("コメント操作の整合性チェック", () => {
|
|
|
415
428
|
[{ displayName: "山田" }], // コメント投稿者名
|
|
416
429
|
],
|
|
417
430
|
})
|
|
418
|
-
await svc.addComment(8, 1, "本文", "2099-01-05", "tmp")
|
|
431
|
+
await svc.addComment(8, 1, "本文", "2099-01-05", "tmp", null)
|
|
419
432
|
const sse = (svc as unknown as { __sse: Record<string, unknown>[] }).__sse
|
|
420
433
|
const add = sse.find((m) => m.type === "comment-add")
|
|
421
434
|
expect(add).toBeDefined()
|
|
@@ -431,7 +444,7 @@ describe("コメント操作の整合性チェック", () => {
|
|
|
431
444
|
[{ displayName: "山田" }], // 投稿者名
|
|
432
445
|
],
|
|
433
446
|
})
|
|
434
|
-
await svc.addComment(8, 1, "秘密の下書きコメント", "2099-01-05", "tmp")
|
|
447
|
+
await svc.addComment(8, 1, "秘密の下書きコメント", "2099-01-05", "tmp", null)
|
|
435
448
|
const sse = (svc as unknown as { __sse: Record<string, unknown>[] }).__sse
|
|
436
449
|
const add = sse.find((m) => m.type === "comment-add")
|
|
437
450
|
expect(add?.recipientRawUserId).toBe(8)
|
|
@@ -539,3 +552,311 @@ describe("formatDateValue (Intl-free rewrite equivalence)", () => {
|
|
|
539
552
|
expect(performance.now() - startMs).toBeLessThan(2000)
|
|
540
553
|
})
|
|
541
554
|
})
|
|
555
|
+
|
|
556
|
+
describe("区分 (sourceType) 可視性フィルタ", () => {
|
|
557
|
+
/** テスト用の可視集合 (NI と kintone のみ許可)。 */
|
|
558
|
+
const restricted = normalizeVisibleSourceTypes(["NI", "kintone"])
|
|
559
|
+
/** 全拒否の可視集合 (権限なし・リゾルバ例外時と同じ状態)。 */
|
|
560
|
+
const denyAll = normalizeVisibleSourceTypes([])
|
|
561
|
+
|
|
562
|
+
/** 区分述語を含む最後のクエリを取り出す。 */
|
|
563
|
+
const lastQueryWithSourceType = (svc: { __queries: { whereSql: string | null; whereParams: unknown[] }[] }) => svc.__queries.filter((q) => (q.whereSql ?? "").includes("[source_type]")).at(-1)
|
|
564
|
+
|
|
565
|
+
describe("読み取り経路への述語注入", () => {
|
|
566
|
+
it("ID 一覧 (全量) の WHERE に畳み込み済みトークンの IN 述語が載る", async () => {
|
|
567
|
+
const svc = makeService([])
|
|
568
|
+
await svc.getDailyReportIdsByExternalId("ext", { forceRefresh: true }, restricted)
|
|
569
|
+
|
|
570
|
+
const recorded = lastQueryWithSourceType(svc)
|
|
571
|
+
// レンダリングは大文字 LOWER( なので大小文字を厳密に一致させる
|
|
572
|
+
expect(recorded?.whereSql).toContain("LOWER(")
|
|
573
|
+
expect(recorded?.whereSql).toContain("[source_type]")
|
|
574
|
+
expect(recorded?.whereSql).toContain(" in ")
|
|
575
|
+
// 閲覧者 ID など他の束縛値と同居するため部分集合で検査する
|
|
576
|
+
expect(recorded?.whereParams).toEqual(expect.arrayContaining(["kintone", "ni"]))
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it("ID 一覧 (先頭ページ) にも同一の述語が載る", async () => {
|
|
580
|
+
const svc = makeService([])
|
|
581
|
+
await svc.getDailyReportIdsFirstPageByExternalId("ext", 200, restricted)
|
|
582
|
+
|
|
583
|
+
const recorded = lastQueryWithSourceType(svc)
|
|
584
|
+
expect(recorded?.whereSql).toContain("LOWER(")
|
|
585
|
+
expect(recorded?.whereParams).toEqual(expect.arrayContaining(["kintone", "ni"]))
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
it("営業日別一覧の WHERE に述語が載る", async () => {
|
|
589
|
+
const svc = makeService([])
|
|
590
|
+
await svc.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", { forceRefresh: true }, restricted)
|
|
591
|
+
|
|
592
|
+
const recorded = lastQueryWithSourceType(svc)
|
|
593
|
+
expect(recorded?.whereSql).toContain("LOWER(")
|
|
594
|
+
expect(recorded?.whereParams).toEqual(expect.arrayContaining(["kintone", "ni"]))
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it("詳細取得の WHERE に述語が載る", async () => {
|
|
598
|
+
const svc = makeService([])
|
|
599
|
+
await svc.getDailyReportDetailById(1, 8, { forceRefresh: true }, restricted)
|
|
600
|
+
|
|
601
|
+
const recorded = lastQueryWithSourceType(svc)
|
|
602
|
+
expect(recorded?.whereSql).toContain("LOWER(")
|
|
603
|
+
expect(recorded?.whereParams).toEqual(expect.arrayContaining(["kintone", "ni"]))
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
it("添付の認可述語にも載る (一覧だけ絞って添付から抜ける穴を塞ぐ)", async () => {
|
|
607
|
+
const svc = makeService([{ fileName: "a.png", filePath: "gcs://b/a.png", fileType: null, fileSize: 1 }])
|
|
608
|
+
await svc.getAttachmentForUser(11, 8, restricted)
|
|
609
|
+
|
|
610
|
+
const recorded = lastQueryWithSourceType(svc)
|
|
611
|
+
expect(recorded?.whereSql).toContain("LOWER(")
|
|
612
|
+
expect(recorded?.whereParams).toEqual(expect.arrayContaining(["kintone", "ni"]))
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it("外部 ID 経由の詳細取得は可視集合をそのまま透過する (ここで null へ潰さない)", async () => {
|
|
616
|
+
const svc = makeService([])
|
|
617
|
+
await svc.getDailyReportDetailByIdByExternalId(1, "ext", { forceRefresh: true }, restricted)
|
|
618
|
+
|
|
619
|
+
const recorded = lastQueryWithSourceType(svc)
|
|
620
|
+
expect(recorded?.whereParams).toEqual(expect.arrayContaining(["kintone", "ni"]))
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it("無制限 (null) では述語が一切現れない (現行互換の回帰ガード)", async () => {
|
|
624
|
+
const svc = makeService([])
|
|
625
|
+
await svc.getDailyReportIdsByExternalId("ext", { forceRefresh: true }, null)
|
|
626
|
+
await svc.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", { forceRefresh: true }, null)
|
|
627
|
+
await svc.getDailyReportDetailById(1, 8, { forceRefresh: true }, null)
|
|
628
|
+
|
|
629
|
+
expect(svc.__queries.every((q) => !(q.whereSql ?? "").includes("[source_type]"))).toBe(true)
|
|
630
|
+
})
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
describe("全拒否 (空集合) は DB を引かずに 0 件へ短絡する", () => {
|
|
634
|
+
it("読み取り 5 経路すべてが問い合わせ無しで空を返す", async () => {
|
|
635
|
+
const svc = makeService([{ fileName: "a.png", filePath: "gcs://b/a.png", fileType: null, fileSize: 1 }], true)
|
|
636
|
+
|
|
637
|
+
expect(await svc.getDailyReportIdsByExternalId("ext", { forceRefresh: true }, denyAll)).toEqual([])
|
|
638
|
+
expect(await svc.getDailyReportIdsFirstPageByExternalId("ext", 200, denyAll)).toEqual([])
|
|
639
|
+
expect(await svc.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", { forceRefresh: true }, denyAll)).toEqual([])
|
|
640
|
+
expect(await svc.getDailyReportDetailById(1, 8, { forceRefresh: true }, denyAll)).toBeNull()
|
|
641
|
+
expect(await svc.getAttachmentForUser(11, 8, denyAll)).toBeNull()
|
|
642
|
+
|
|
643
|
+
// 短絡しているので DB は一度も引かれない
|
|
644
|
+
expect(svc.__queries).toHaveLength(0)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it("条件生成器自体も fail-close する (呼び出し側の短絡が漏れても認可は反転しない)", () => {
|
|
648
|
+
// 空集合を「制限なし」と解釈すると添付が全区分ダウンロード可能になる。
|
|
649
|
+
// 短絡を迂回しても 1 = 0 が残ることを、生成 SQL で直接固定する
|
|
650
|
+
const condition = buildVisibleSourceTypeCondition(tables.hub.sourceType, denyAll)
|
|
651
|
+
expect(condition).toBeDefined()
|
|
652
|
+
expect(dialect.sqlToQuery(condition as SQL).sql).toContain("1 = 0")
|
|
653
|
+
})
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
describe("キャッシュの可視集合ダイジェスト隔離", () => {
|
|
657
|
+
it("正規化後に等価な可視集合はキャッシュを再利用する (性能無退行)", async () => {
|
|
658
|
+
const svc = makeService([])
|
|
659
|
+
await svc.getDailyReportIdsByExternalId("ext", {}, normalizeVisibleSourceTypes(["NI"]))
|
|
660
|
+
const afterFirst = svc.__queries.length
|
|
661
|
+
await svc.getDailyReportIdsByExternalId("ext", {}, normalizeVisibleSourceTypes([" ni "]))
|
|
662
|
+
|
|
663
|
+
expect(svc.__queries.length).toBe(afterFirst)
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it("異なる可視集合は別バケットになる (別権限へ他人の結果を配らない)", async () => {
|
|
667
|
+
const svc = makeService([])
|
|
668
|
+
await svc.getDailyReportIdsByExternalId("ext", {}, normalizeVisibleSourceTypes(["ni"]))
|
|
669
|
+
const afterFirst = svc.__queries.length
|
|
670
|
+
await svc.getDailyReportIdsByExternalId("ext", {}, normalizeVisibleSourceTypes(["kintone"]))
|
|
671
|
+
|
|
672
|
+
expect(svc.__queries.length).toBeGreaterThan(afterFirst)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it("営業日別と詳細でも可視集合ごとに隔離される", async () => {
|
|
676
|
+
const svc = makeService([])
|
|
677
|
+
await svc.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", {}, normalizeVisibleSourceTypes(["ni"]))
|
|
678
|
+
const afterDateFirst = svc.__queries.length
|
|
679
|
+
await svc.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", {}, normalizeVisibleSourceTypes(["kintone"]))
|
|
680
|
+
expect(svc.__queries.length).toBeGreaterThan(afterDateFirst)
|
|
681
|
+
|
|
682
|
+
await svc.getDailyReportDetailById(1, 8, {}, normalizeVisibleSourceTypes(["ni"]))
|
|
683
|
+
const afterDetailFirst = svc.__queries.length
|
|
684
|
+
await svc.getDailyReportDetailById(1, 8, {}, normalizeVisibleSourceTypes(["kintone"]))
|
|
685
|
+
expect(svc.__queries.length).toBeGreaterThan(afterDetailFirst)
|
|
686
|
+
})
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
describe("キャッシュ無効化はダイジェスト付きキーを取りこぼさない", () => {
|
|
690
|
+
it("setStarStatus の後は同じ可視集合の詳細が再取得される", async () => {
|
|
691
|
+
const svc = makeService([
|
|
692
|
+
{
|
|
693
|
+
hub: {
|
|
694
|
+
id: 1,
|
|
695
|
+
sourceType: "Internal",
|
|
696
|
+
deletedAt: null,
|
|
697
|
+
userId: 8,
|
|
698
|
+
businessDate: "2099-01-05",
|
|
699
|
+
title: "t",
|
|
700
|
+
summary: null,
|
|
701
|
+
sourceId: "1",
|
|
702
|
+
sourceIdNum: 1,
|
|
703
|
+
createdAt: "2099-01-05 00:00:00",
|
|
704
|
+
createdBy: "8",
|
|
705
|
+
updatedAt: "2099-01-05 00:00:00",
|
|
706
|
+
updatedBy: "8",
|
|
707
|
+
deletedBy: null,
|
|
708
|
+
},
|
|
709
|
+
internal: null,
|
|
710
|
+
isStarred: false,
|
|
711
|
+
isRead: false,
|
|
712
|
+
},
|
|
713
|
+
])
|
|
714
|
+
await svc.getDailyReportDetailById(1, 8, {}, restricted)
|
|
715
|
+
|
|
716
|
+
await svc.setStarStatus(8, 1, "2099-01-05", true, "tmp", null)
|
|
717
|
+
// ❗ 基準はミューテーション「後」に取る。前に取ると setStarStatus 自身が発行する
|
|
718
|
+
// 2 本の select だけで閾値を満たしてしまい、無効化が壊れていても通ってしまう
|
|
719
|
+
const afterMutation = svc.__queries.length
|
|
720
|
+
await svc.getDailyReportDetailById(1, 8, {}, restricted)
|
|
721
|
+
|
|
722
|
+
// 接頭辞無効化が効いていなければキャッシュヒットで再クエリが 1 本も出ない
|
|
723
|
+
expect(svc.__queries.length).toBeGreaterThan(afterMutation)
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it("setReadStatus の後も同様に再取得される (setStar だけ直す退行を防ぐ)", async () => {
|
|
727
|
+
const svc = makeService([
|
|
728
|
+
{
|
|
729
|
+
hub: {
|
|
730
|
+
id: 1,
|
|
731
|
+
sourceType: "Internal",
|
|
732
|
+
deletedAt: null,
|
|
733
|
+
userId: 8,
|
|
734
|
+
businessDate: "2099-01-05",
|
|
735
|
+
title: "t",
|
|
736
|
+
summary: null,
|
|
737
|
+
sourceId: "1",
|
|
738
|
+
sourceIdNum: 1,
|
|
739
|
+
createdAt: "2099-01-05 00:00:00",
|
|
740
|
+
createdBy: "8",
|
|
741
|
+
updatedAt: "2099-01-05 00:00:00",
|
|
742
|
+
updatedBy: "8",
|
|
743
|
+
deletedBy: null,
|
|
744
|
+
},
|
|
745
|
+
internal: null,
|
|
746
|
+
isStarred: false,
|
|
747
|
+
isRead: false,
|
|
748
|
+
},
|
|
749
|
+
])
|
|
750
|
+
await svc.getDailyReportDetailById(1, 8, {}, restricted)
|
|
751
|
+
|
|
752
|
+
await svc.setReadStatus(8, 1, "2099-01-05", true, "tmp", null)
|
|
753
|
+
// 基準はミューテーション後 (理由は setStarStatus のテストと同じ)
|
|
754
|
+
const afterMutation = svc.__queries.length
|
|
755
|
+
await svc.getDailyReportDetailById(1, 8, {}, restricted)
|
|
756
|
+
|
|
757
|
+
expect(svc.__queries.length).toBeGreaterThan(afterMutation)
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it("setStarStatus の後は同じ可視集合の営業日別一覧も再取得される (date 側の接頭辞変換を個別に gate)", async () => {
|
|
761
|
+
// detail 側だけ接頭辞化しても date 側が完全一致のままなら、営業日別一覧が
|
|
762
|
+
// 古いスター状態を配り続ける。両者を別々の表明で固定する
|
|
763
|
+
const svc = makeService([{ hub: baseHub({ id: 1 }), internal: null, isStarred: false, isRead: false, creatorName: "山田" }])
|
|
764
|
+
await svc.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", {}, restricted)
|
|
765
|
+
|
|
766
|
+
await svc.setStarStatus(8, 1, "2099-01-05", true, "tmp", null)
|
|
767
|
+
const afterMutation = svc.__queries.length
|
|
768
|
+
await svc.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", {}, restricted)
|
|
769
|
+
|
|
770
|
+
expect(svc.__queries.length).toBeGreaterThan(afterMutation)
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
it("createDailyReport は作成者の ID 一覧キャッシュを落とす (redis 無し配備でも消える)", async () => {
|
|
774
|
+
// createDailyReport は ids の広域 prefix 無効化を持たないため、ユーザー単位の
|
|
775
|
+
// 接頭辞無効化だけが頼り。完全一致キーのままにすると、作成した日報が自分の一覧から
|
|
776
|
+
// TTL 分 (既定 180 秒) 消える
|
|
777
|
+
// 行を返さないモックでも create は完走する (詳細が null なら SSE を出さないだけ)。
|
|
778
|
+
// 検証対象は「無効化が走ったか」なので、これで十分かつ流れの変化に強い
|
|
779
|
+
const svc = makeService([])
|
|
780
|
+
await svc.getDailyReportIdsByExternalId("ext", {}, restricted)
|
|
781
|
+
|
|
782
|
+
await svc.createDailyReport(8, "2099-01-05", "tmp")
|
|
783
|
+
const afterCreate = svc.__queries.length
|
|
784
|
+
await svc.getDailyReportIdsByExternalId("ext", {}, restricted)
|
|
785
|
+
|
|
786
|
+
// 無効化が効いていなければキャッシュヒットで再クエリは 0 本になる
|
|
787
|
+
expect(svc.__queries.length).toBeGreaterThan(afterCreate)
|
|
788
|
+
})
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
describe("ミューテーションの区分ゲート", () => {
|
|
792
|
+
it("addComment は不可視区分の日報を Not Found で拒否する", async () => {
|
|
793
|
+
const svc = makeService([{ sourceType: "Internal", deletedAt: null, userId: 8 }])
|
|
794
|
+
await expect(svc.addComment(8, 1, "本文", "2099-01-05", "tmp", restricted)).rejects.toThrow("Not Found")
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it("setStarStatus は不可視区分を Not Found で拒否する", async () => {
|
|
798
|
+
const svc = makeService([{ sourceType: "Internal", deletedAt: null }])
|
|
799
|
+
await expect(svc.setStarStatus(8, 1, "2099-01-05", true, "tmp", restricted)).rejects.toThrow("Not Found")
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
it("setReadStatus は不可視区分を Not Found で拒否する", async () => {
|
|
803
|
+
const svc = makeService([{ sourceType: "Internal", deletedAt: null }])
|
|
804
|
+
await expect(svc.setReadStatus(8, 1, "2099-01-05", true, "tmp", restricted)).rejects.toThrow("Not Found")
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
it("setStarStatus は存在しない hubId を Not Found で拒否する (存在オラクルの是正)", async () => {
|
|
808
|
+
const svc = makeService([])
|
|
809
|
+
await expect(svc.setStarStatus(8, 999, "2099-01-05", true, "tmp", null)).rejects.toThrow("Not Found")
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
it("setReadStatus は論理削除済みの日報を Not Found で拒否する", async () => {
|
|
813
|
+
const svc = makeService([{ sourceType: "Internal", deletedAt: "2099-01-06 00:00:00" }])
|
|
814
|
+
await expect(svc.setReadStatus(8, 1, "2099-01-05", true, "tmp", null)).rejects.toThrow("Not Found")
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it("deleteComment は所有者チェックより先に区分ゲートを適用する (403/404 の存在オラクル回避)", async () => {
|
|
818
|
+
// 他人 (userId=99) のコメント かつ 不可視区分。所有者チェックが先だと Unauthorized(403) になり、
|
|
819
|
+
// 「隠したはずの日報が存在する」ことを推定できてしまう
|
|
820
|
+
const svc = makeService([], false, {
|
|
821
|
+
selectQueue: [[{ id: 5, hubId: 1, userId: 99, body: "x", createdAt: "2099-01-05 00:00:00", createdBy: "99", updatedAt: "2099-01-05 00:00:00", updatedBy: "99" }], [{ userId: 99, sourceType: "Internal" }]],
|
|
822
|
+
})
|
|
823
|
+
await expect(svc.deleteComment(8, 1, 5, "2099-01-05", "tmp", restricted)).rejects.toThrow("Not Found")
|
|
824
|
+
})
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
describe("SSE ペイロードの区分付与", () => {
|
|
828
|
+
it("comment-add は親日報の sourceType を載せる", async () => {
|
|
829
|
+
const svc = makeService([], false, {
|
|
830
|
+
selectQueue: [[{ sourceType: "Internal", deletedAt: null, userId: 8 }], [{ id: 10 }], [], [{ displayName: "山田" }]],
|
|
831
|
+
})
|
|
832
|
+
await svc.addComment(8, 1, "本文", "2099-01-05", "tmp", null)
|
|
833
|
+
|
|
834
|
+
const add = svc.__sse.find((m) => m.type === "comment-add")
|
|
835
|
+
expect(add?.sourceType).toBe("Internal")
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
it("report-delete は削除対象の sourceType を載せる", async () => {
|
|
839
|
+
const svc = makeService([{ id: 1, sourceType: "NI", businessDate: "2099-01-05", userId: 8, title: "t", createdAt: "2099-01-05 00:00:00", createdBy: "8", updatedAt: "2099-01-05 00:00:00", updatedBy: "8", deletedAt: null }])
|
|
840
|
+
await svc.deleteDailyReport(1, 8, "tmp")
|
|
841
|
+
|
|
842
|
+
const del = svc.__sse.find((m) => m.type === "report-delete")
|
|
843
|
+
expect(del?.sourceType).toBe("NI")
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
describe("所有者経路の内部取得は無制限で行う", () => {
|
|
848
|
+
it("createDailyReport の内部詳細取得に区分述語が載らない (自報の取得と配信を壊さない)", async () => {
|
|
849
|
+
// 作成者の可視集合に "Internal" が無くても、自分で作った日報の取得と SSE 配信は
|
|
850
|
+
// 成立しなければならない。区分の出し分けは受信側 SSE フィルタと次回読み取りが担う
|
|
851
|
+
const svc = makeService([])
|
|
852
|
+
|
|
853
|
+
const created = await svc.createDailyReport(8, "2099-01-05", "tmp")
|
|
854
|
+
|
|
855
|
+
// 作成自体は可視集合に関係なく成立する
|
|
856
|
+
expect(created.reportHubId).toBe(501)
|
|
857
|
+
// 内部取得は明示 null を渡すため、発行された全クエリに区分述語が一切現れない
|
|
858
|
+
expect(svc.__queries.length).toBeGreaterThan(0)
|
|
859
|
+
expect(svc.__queries.every((q) => !(q.whereSql ?? "").includes("[source_type]"))).toBe(true)
|
|
860
|
+
})
|
|
861
|
+
})
|
|
862
|
+
})
|