@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.
@@ -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
- return { db: { select, update, insert, delete: del } as unknown as DailyReportDb, queries, updates }
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
+ })