@aiquants/daily-report 0.6.2 → 0.7.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/dist/client.d.mts +81 -3
- package/dist/client.d.ts +81 -3
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +4 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- 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 +434 -6
- package/dist/server.d.ts +434 -6
- package/dist/server.js +6 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +6 -6
- package/dist/server.mjs.map +1 -1
- package/dist/{sse-schema-rbG114od.d.mts → sse-schema-Df49KA7B.d.mts} +643 -249
- package/dist/{sse-schema-eXcMG-Ej.d.ts → sse-schema-RHckD7TS.d.ts} +643 -249
- package/dist/styles/daily-report.standalone.css +1 -1
- package/dist/{types-D1PKubyo.d.mts → types-Ct1ggzy-.d.mts} +30 -1
- package/dist/{types-D1PKubyo.d.ts → types-Ct1ggzy-.d.ts} +30 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +51 -0
- package/src/client/components/daily-report-attachment-indicator.tsx +45 -0
- package/src/client/components/daily-report-attachment-list.spec.tsx +94 -0
- package/src/client/components/daily-report-attachment-list.tsx +112 -0
- package/src/client/components/daily-report-detail-list.tsx +14 -7
- package/src/client/components/daily-report-list.tsx +19 -11
- package/src/client/components/report-views.spec.tsx +340 -0
- package/src/client/config-context.tsx +8 -0
- package/src/client/contexts/daily-report-action-context.tsx +2 -0
- package/src/client/hooks/use-responsive-layout.spec.ts +74 -0
- package/src/client/hooks/use-responsive-layout.ts +56 -0
- package/src/client/utils/constants.spec.ts +96 -0
- package/src/client/utils/constants.ts +47 -2
- package/src/client.ts +2 -0
- package/src/server/handlers.attachment.spec.ts +470 -0
- package/src/server/handlers.ts +426 -1
- package/src/server/ports.ts +74 -0
- package/src/server/schema.ts +80 -5
- package/src/server/service.spec.ts +280 -14
- package/src/server/service.ts +308 -25
- package/src/server/test-helpers/handlers-config.ts +142 -0
- package/src/server.ts +16 -1
- package/src/shared/sse-schema.spec.ts +50 -0
- package/src/shared/sse-schema.ts +27 -0
- package/src/shared/types.ts +31 -0
package/src/server/service.ts
CHANGED
|
@@ -8,10 +8,10 @@ import { aliasedTable, and, asc, desc, eq, getColumns, inArray, isNull, or, sql
|
|
|
8
8
|
import { normalizeBusinessDateKey } from "../shared/business-date"
|
|
9
9
|
import { createLogger, type DailyReportLogger, LogLevel } from "../shared/logger"
|
|
10
10
|
import { commentAddMessageSchema, commentDeleteMessageSchema, reportCreateMessageSchema, reportDeleteMessageSchema, reportPublishMessageSchema, reportUpdateMessageSchema, statusUpdateMessageSchema } from "../shared/sse-schema"
|
|
11
|
-
import type { DailyReportComment, DailyReportCommentItem, DailyReportDetail, DailyReportInterviewer, DailyReportItem, DailyReportLabelDef } from "../shared/types"
|
|
11
|
+
import type { DailyReportAttachmentSummary, DailyReportComment, DailyReportCommentItem, DailyReportDetail, DailyReportInterviewer, DailyReportItem, DailyReportLabelDef } from "../shared/types"
|
|
12
12
|
import type { EpochStore, SqlResultCache, SqlResultCacheQueryOptions } from "./cache"
|
|
13
13
|
import type { DailyReportExternalSource } from "./external-source"
|
|
14
|
-
import type { DailyReportEncodeUserId, DailyReportRedisProvider, DailyReportResolveUserId } from "./ports"
|
|
14
|
+
import type { DailyReportEncodeUserId, DailyReportIdCodec, DailyReportRedisProvider, DailyReportResolveUserId } from "./ports"
|
|
15
15
|
import type { DailyReportTables, DailyReportUserTable } from "./schema"
|
|
16
16
|
|
|
17
17
|
export type { SqlResultCacheQueryOptions }
|
|
@@ -113,7 +113,7 @@ const cols = (t: unknown) => getColumns(t as Parameters<typeof getColumns>[0])
|
|
|
113
113
|
export type DailyReportServiceConfig = {
|
|
114
114
|
/** drizzle mssql データベースハンドル。 */
|
|
115
115
|
db: DailyReportDb
|
|
116
|
-
/** 日報
|
|
116
|
+
/** 日報 7 テーブル (アプリ既存モデルまたは defineDailyReportSchema 生成物)。`attachment` のみ任意。 */
|
|
117
117
|
tables: DailyReportTables
|
|
118
118
|
/** 表示名解決に使う外部ユーザーテーブル ({ id, displayName })。 */
|
|
119
119
|
userTable: DailyReportUserTable
|
|
@@ -145,6 +145,15 @@ export type DailyReportServiceConfig = {
|
|
|
145
145
|
businessDateTtlMs?: number
|
|
146
146
|
/** 外部 ID → 内部 ID のプロセス内キャッシュを無効化 (テスト用)。 */
|
|
147
147
|
disableUserIdCache?: boolean
|
|
148
|
+
/**
|
|
149
|
+
* 添付 ID の難読化コーデック。
|
|
150
|
+
*
|
|
151
|
+
* 任意にしているのは、添付テーブルを注入しない消費アプリと既存テストのフィクスチャを
|
|
152
|
+
* 壊さないため。未注入なら添付一覧は常に空になる (縮退動作)。
|
|
153
|
+
* ユーザー ID 用のコーデックとは **別インスタンス**を渡すこと。同一だと
|
|
154
|
+
* レスポンス中の `userId` トークンがそのまま添付トークンとして解釈できてしまう。
|
|
155
|
+
*/
|
|
156
|
+
attachmentIdCodec?: DailyReportIdCodec
|
|
148
157
|
/** ロガー (既定は console ベース)。 */
|
|
149
158
|
logger?: DailyReportLogger
|
|
150
159
|
}
|
|
@@ -264,6 +273,12 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
264
273
|
type HubRecord = HubQueryRow & {
|
|
265
274
|
labels?: RawJsonLabel[]
|
|
266
275
|
comments?: RawJsonComment[]
|
|
276
|
+
/**
|
|
277
|
+
* 添付ファイル一覧。**任意にしない。**
|
|
278
|
+
* `?` を付けると `mapHubRecord` 側で `?? []` が必要になり、注入を忘れた読み取り経路が
|
|
279
|
+
* 黙って空配列を返す。必須にしておけば注入漏れは全呼び出し箇所でコンパイルエラーになる。
|
|
280
|
+
*/
|
|
281
|
+
attachments: DailyReportAttachmentSummary[]
|
|
267
282
|
}
|
|
268
283
|
|
|
269
284
|
/** 外部ソースアダプタの select 追加フィールドを構築する。 */
|
|
@@ -329,9 +344,11 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
329
344
|
|
|
330
345
|
// 作成区分がアプリ設定の下書き候補に合致する場合、ラベル一覧に下書きラベルが含まれるよう補完
|
|
331
346
|
const isCreationCategoryDraft = creationCategory ? draftLabelNames.includes(creationCategory) : false
|
|
332
|
-
|
|
347
|
+
// 同期関数なので DB へは問い合わせず、既に解決済みのキャッシュがある場合だけ補完する
|
|
348
|
+
const cachedPrimaryDraftLabelId = cachedDraftLabelIds?.[0] ?? null
|
|
349
|
+
if (isCreationCategoryDraft && cachedPrimaryDraftLabelId && !labelsRaw.some((l) => l.id === cachedPrimaryDraftLabelId)) {
|
|
333
350
|
labelsRaw.push({
|
|
334
|
-
id:
|
|
351
|
+
id: cachedPrimaryDraftLabelId,
|
|
335
352
|
name: draftLabelName,
|
|
336
353
|
color: null,
|
|
337
354
|
})
|
|
@@ -364,16 +381,26 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
364
381
|
userId: encodeUserId(c.userId),
|
|
365
382
|
isMine: currentUserId ? c.userId === currentUserId : false,
|
|
366
383
|
})),
|
|
384
|
+
// `?? []` を書かない。HubRecord 側で必須にしてあるため、注入漏れはここではなく
|
|
385
|
+
// 呼び出し箇所でコンパイルエラーになる (それが検知したい事象)。
|
|
386
|
+
attachments: row.attachments,
|
|
367
387
|
}
|
|
368
388
|
}
|
|
369
389
|
|
|
370
390
|
/**
|
|
371
391
|
* Fetches daily report IDs alongside normalized business dates.
|
|
372
392
|
* 日報 ID と正規化済み営業日を取得する内部処理。
|
|
393
|
+
*
|
|
394
|
+
* ❗ 可視性述語は他の 3 経路 (営業日一覧 / 詳細取得 / 添付取得) と**意図的に 1 つ緩い**。
|
|
395
|
+
* ここだけ `isNull(DailyReportHub.userId)` を追加で許しており、投稿者を持たない
|
|
396
|
+
* 取込元の日報を一覧から消さないための緩和である。返すのは ID・営業日・ソース種別だけで
|
|
397
|
+
* 本文も添付も含まないため、緩めても内容は漏れない。
|
|
398
|
+
* この差分は無自覚なものではなく、`service.spec.ts` が明示的に表明して固定している。
|
|
399
|
+
* 他の 3 経路をこの形に合わせてはならない (合わせると内容が漏れる)。
|
|
373
400
|
*/
|
|
374
401
|
const fetchDailyReportIdsByUserId = async (userId: number): Promise<DailyReportItem[]> => {
|
|
375
402
|
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
376
|
-
const
|
|
403
|
+
const draftLabelJoinIds = await getDraftLabelJoinIds()
|
|
377
404
|
|
|
378
405
|
const result = await db
|
|
379
406
|
.select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
|
|
@@ -382,7 +409,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
382
409
|
sourceType: DailyReportHub.sourceType,
|
|
383
410
|
})
|
|
384
411
|
.from(DailyReportHub)
|
|
385
|
-
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id),
|
|
412
|
+
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
386
413
|
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
|
|
387
414
|
.orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
|
|
388
415
|
|
|
@@ -412,7 +439,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
412
439
|
.where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt)))
|
|
413
440
|
.orderBy(desc(DailyReportHub.id))
|
|
414
441
|
|
|
415
|
-
|
|
442
|
+
const attachmentsMap = await getAttachmentsByHubIds(rows.map((r) => r.hub.id))
|
|
443
|
+
return rows.map((row) => mapHubRecord({ ...row, attachments: attachmentsMap.get(row.hub.id) ?? [] }))
|
|
416
444
|
}
|
|
417
445
|
|
|
418
446
|
/**
|
|
@@ -491,19 +519,212 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
491
519
|
}
|
|
492
520
|
|
|
493
521
|
/**
|
|
494
|
-
* Retrieves
|
|
495
|
-
*
|
|
522
|
+
* Retrieves every label id that counts as a draft.
|
|
523
|
+
* 下書きとみなすラベル ID を**すべて**取得する処理 (キャッシュ付き)。
|
|
524
|
+
*
|
|
525
|
+
* ❗ 可視性述語は必ずこちら (複数形) を使うこと。`draftLabelNames` には複数のラベル名を
|
|
526
|
+
* 注入できる (取込元ごとに下書きの呼び名が違うため) のに、1 個の ID だけで結合すると
|
|
527
|
+
* **2 つ目以降の下書きラベルが付いた日報が誰にでも可視になる**。
|
|
528
|
+
* 名前が 1 つも解決できない場合は空配列を返し、呼び出し側は結合対象を
|
|
529
|
+
* 「存在しない ID」にして「下書きは存在しない」と解釈する。
|
|
530
|
+
*
|
|
531
|
+
* @returns Draft label ids, ascending. 昇順の下書きラベル ID 群。
|
|
532
|
+
*/
|
|
533
|
+
let cachedDraftLabelIds: number[] | null = null
|
|
534
|
+
const getDraftLabelIds = async (): Promise<number[]> => {
|
|
535
|
+
if (cachedDraftLabelIds !== null) return cachedDraftLabelIds
|
|
536
|
+
const rows = await db.select<{ id: number }>({ id: DailyReportLabel.id }).from(DailyReportLabel).where(inArray(DailyReportLabel.name, draftLabelNames)).orderBy(DailyReportLabel.id)
|
|
537
|
+
cachedDraftLabelIds = rows.map((row) => row.id)
|
|
538
|
+
return cachedDraftLabelIds
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Join targets for the draft-label relation, safe when no draft label exists.
|
|
543
|
+
* 下書きラベル関連の結合対象。下書きラベルが 1 件も無い場合でも安全に働く値。
|
|
544
|
+
*
|
|
545
|
+
* `inArray` に空配列を渡すと方言によっては構文エラーや常に真になりうるため、
|
|
546
|
+
* 実在しない ID (`-1`) を 1 個入れて「どの行にも一致しない」ことを明示する。
|
|
547
|
+
*
|
|
548
|
+
* @returns Ids to join on. 結合に使う ID 群。
|
|
549
|
+
*/
|
|
550
|
+
const getDraftLabelJoinIds = async (): Promise<number[]> => {
|
|
551
|
+
const ids = await getDraftLabelIds()
|
|
552
|
+
return ids.length > 0 ? ids : [-1]
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Retrieves the canonical draft label id used when creating a draft.
|
|
557
|
+
* 下書きを作成するときに付与する代表の下書きラベル ID を取得する処理。
|
|
558
|
+
*
|
|
559
|
+
* 付与は 1 個に決めなければならないため `draftLabelNames` の先頭に対応する ID を返す。
|
|
560
|
+
* **可視性判定には使わないこと** (`getDraftLabelIds` を使う)。
|
|
561
|
+
*
|
|
562
|
+
* @returns The canonical draft label id, or null when it does not exist. 代表の下書きラベル ID (存在しなければ null)。
|
|
496
563
|
*/
|
|
497
|
-
let cachedDraftLabelId: number | null = null
|
|
498
564
|
const getDraftLabelId = async (): Promise<number | null> => {
|
|
499
|
-
|
|
500
|
-
|
|
565
|
+
const ids = await getDraftLabelIds()
|
|
566
|
+
if (ids.length === 0) return null
|
|
567
|
+
// 先頭の名前に対応する ID を選ぶ。ID 順ではなく注入順を正とする (代表名は draftLabelName)
|
|
568
|
+
const [primary] = await db.select<{ id: number }>({ id: DailyReportLabel.id }).top(1).from(DailyReportLabel).where(eq(DailyReportLabel.name, draftLabelName))
|
|
569
|
+
return primary?.id ?? ids[0]
|
|
570
|
+
}
|
|
501
571
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
572
|
+
/**
|
|
573
|
+
* Derives the tri-state verification status of an attachment's backing object.
|
|
574
|
+
* 添付ファイル実体の検証状態 (三値) を導出する処理。
|
|
575
|
+
*
|
|
576
|
+
* 判定不能 (`unknown`) を `absent` へ丸めない。存在確認そのものが失敗した場合を
|
|
577
|
+
* 偽の真偽で埋めると、一時的な権限・通信障害が「消失」として表示される。
|
|
578
|
+
*/
|
|
579
|
+
const deriveAttachmentState = (checkedAt: Date | string | null, missingAt: Date | string | null): DailyReportAttachmentSummary["state"] => {
|
|
580
|
+
if (missingAt != null) return "absent"
|
|
581
|
+
if (checkedAt != null) return "present"
|
|
582
|
+
return "unknown"
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Fetches attachments for multiple hubs in one query, keyed by hub id.
|
|
587
|
+
* 複数 hub の添付ファイルを 1 クエリで取得し hub ID をキーにまとめる処理。
|
|
588
|
+
*
|
|
589
|
+
* 一覧描画は N 件の日報を同時に返すため、日報ごとに問い合わせると N+1 になる。
|
|
590
|
+
* ラベル・コメントと同じ一括取得の流儀に揃える。
|
|
591
|
+
*
|
|
592
|
+
* @param hubIds Hub ids to fetch attachments for. 添付を取得する hub ID 群。
|
|
593
|
+
* @returns Attachments grouped by hub id. hub ID ごとにまとめた添付一覧。
|
|
594
|
+
*/
|
|
595
|
+
const getAttachmentsByHubIds = async (hubIds: number[]): Promise<Map<number, DailyReportAttachmentSummary[]>> => {
|
|
596
|
+
const map = new Map<number, DailyReportAttachmentSummary[]>()
|
|
597
|
+
const attachment = tables.attachment
|
|
598
|
+
// codec は config 直参照。createDailyReportService の分割代入は 8 キーのみで、
|
|
599
|
+
// 裸の識別子として書くと TS2304 になる (任意設定は config.x で読むのがこのファイルの様式)
|
|
600
|
+
const codec = config.attachmentIdCodec
|
|
601
|
+
if (hubIds.length === 0 || !attachment || !codec) {
|
|
602
|
+
return map
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const rows = await db
|
|
606
|
+
.select<{ id: number; hubId: number; fileName: string; fileType: string | null; fileSize: number | null; createdAt: Date | string | null; objectCheckedAt: Date | string | null; objectMissingAt: Date | string | null }>({
|
|
607
|
+
id: attachment.id,
|
|
608
|
+
hubId: attachment.hubId,
|
|
609
|
+
fileName: attachment.fileName,
|
|
610
|
+
fileType: attachment.fileType,
|
|
611
|
+
fileSize: attachment.fileSize,
|
|
612
|
+
createdAt: attachment.createdAt,
|
|
613
|
+
objectCheckedAt: attachment.objectCheckedAt,
|
|
614
|
+
objectMissingAt: attachment.objectMissingAt,
|
|
615
|
+
})
|
|
616
|
+
.from(attachment)
|
|
617
|
+
.where(inArray(attachment.hubId, hubIds))
|
|
618
|
+
.orderBy(asc(attachment.createdAt), asc(attachment.id))
|
|
619
|
+
|
|
620
|
+
for (const row of rows) {
|
|
621
|
+
// 内部数値 ID はここで難読化トークンへ変換する。以降クライアントへ生 ID は出ない。
|
|
622
|
+
// `uploaded_by` / `created_by` / `updated_by` / `file_path` は選択もしない
|
|
623
|
+
const item: DailyReportAttachmentSummary = {
|
|
624
|
+
id: codec.encode(Number(row.id)),
|
|
625
|
+
fileName: String(row.fileName),
|
|
626
|
+
fileType: row.fileType ? String(row.fileType) : null,
|
|
627
|
+
fileSize: row.fileSize != null ? Number(row.fileSize) : null,
|
|
628
|
+
createdAt: formatDateValue(row.createdAt, "YYYY-MM-DD HH:mm:ss"),
|
|
629
|
+
state: deriveAttachmentState(row.objectCheckedAt, row.objectMissingAt),
|
|
630
|
+
}
|
|
631
|
+
const list = map.get(Number(row.hubId))
|
|
632
|
+
if (list) {
|
|
633
|
+
list.push(item)
|
|
634
|
+
} else {
|
|
635
|
+
map.set(Number(row.hubId), [item])
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return map
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Fetches one attachment together with its visibility check for a given user.
|
|
644
|
+
* 指定ユーザーの可視性判定込みで添付ファイル 1 件を取得する処理。
|
|
645
|
+
*
|
|
646
|
+
* 認可は SQL 側で完結させる。アプリ層のフィルタリングに依存しない。
|
|
647
|
+
* 認可されない / 存在しない / 親日報が論理削除済みのいずれでも `null` を返すため、
|
|
648
|
+
* 呼び出し側は 403 と 404 を区別せず 404 とすること (存在オラクル回避)。
|
|
649
|
+
*
|
|
650
|
+
* 本関数は意図的に `SqlResultCache.getOrFetch` を通さない。これは読み取りキャッシュではなく
|
|
651
|
+
* リクエストごとの**認可判定**であり、結果をキャッシュするとユーザーを跨いで再利用されうる。
|
|
652
|
+
*
|
|
653
|
+
* @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
|
|
654
|
+
* @param userId Internal numeric user id of the viewer. 閲覧者の内部数値ユーザー ID。
|
|
655
|
+
* @returns Attachment row when visible, otherwise null. 可視なら添付行、それ以外は null。
|
|
656
|
+
*/
|
|
657
|
+
const getAttachmentForUser = async (attachmentId: number, userId: number): Promise<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null } | null> => {
|
|
658
|
+
const attachment = tables.attachment
|
|
659
|
+
if (!attachment) {
|
|
660
|
+
return null
|
|
661
|
+
}
|
|
662
|
+
const draftLabelJoinIds = await getDraftLabelJoinIds()
|
|
663
|
+
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
664
|
+
|
|
665
|
+
// `.top(1)` は `.select()` の直後でなければならない。drizzle-mssql は top() を
|
|
666
|
+
// db.select() が返す MsSqlSelectBuilder にしか定義しておらず、.from() 以降のクラスには
|
|
667
|
+
// 存在しない。誤った順序は型 (構造型 SelectChain が全段階に top を宣言) でも
|
|
668
|
+
// 単体テスト (モックが全段階で top を返す) でも検出されず、本番で TypeError になる。
|
|
669
|
+
const rows = await db
|
|
670
|
+
.select<{ fileName: string; filePath: string; fileType: string | null; fileSize: number | null }>({
|
|
671
|
+
fileName: attachment.fileName,
|
|
672
|
+
filePath: attachment.filePath,
|
|
673
|
+
fileType: attachment.fileType,
|
|
674
|
+
fileSize: attachment.fileSize,
|
|
675
|
+
})
|
|
676
|
+
.top(1)
|
|
677
|
+
.from(attachment)
|
|
678
|
+
.innerJoin(DailyReportHub, eq(DailyReportHub.id, attachment.hubId))
|
|
679
|
+
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
680
|
+
.where(and(eq(attachment.id, attachmentId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
|
|
681
|
+
|
|
682
|
+
return rows[0] ?? null
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Records that an attachment's backing object was confirmed missing.
|
|
687
|
+
* 添付ファイルの実体が消失していたことを記録する処理。
|
|
688
|
+
*
|
|
689
|
+
* 実配信で `not_found` を得たときに呼ぶ。次回描画で三値が `absent` になる。
|
|
690
|
+
* 記録の失敗は配信結果に影響させない (呼び出し側は await しない)。
|
|
691
|
+
*
|
|
692
|
+
* @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
|
|
693
|
+
*/
|
|
694
|
+
const markAttachmentMissing = async (attachmentId: number): Promise<void> => {
|
|
695
|
+
const attachment = tables.attachment
|
|
696
|
+
if (!attachment) return
|
|
697
|
+
const now = new Date()
|
|
698
|
+
try {
|
|
699
|
+
await db.update(attachment).set({ objectCheckedAt: now, objectMissingAt: now, updatedAt: now }).where(eq(attachment.id, attachmentId))
|
|
700
|
+
} catch (error) {
|
|
701
|
+
logger.error("markAttachmentMissing failed", error)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Records that an attachment's backing object was confirmed present.
|
|
707
|
+
* 添付ファイルの実体が存在していたことを記録する処理。
|
|
708
|
+
*
|
|
709
|
+
* `markAttachmentMissing` と対にすること。消失記録が一方向だと、一時的な障害で
|
|
710
|
+
* 一度 `absent` が立った添付は UI がリンクを描画しなくなり (押しても 404 なので当然)、
|
|
711
|
+
* **利用者が再取得を発火させる手段そのものが消える**。実体が戻っても手動の
|
|
712
|
+
* 一括検証を回すまで復帰しない。配信が 200 で成功した時点で解除するのが唯一の
|
|
713
|
+
* 自動回復経路である。
|
|
714
|
+
*
|
|
715
|
+
* 記録の失敗は配信結果に影響させない (呼び出し側は await しない)。
|
|
716
|
+
*
|
|
717
|
+
* @param attachmentId Internal numeric attachment id. 内部数値の添付 ID。
|
|
718
|
+
*/
|
|
719
|
+
const markAttachmentPresent = async (attachmentId: number): Promise<void> => {
|
|
720
|
+
const attachment = tables.attachment
|
|
721
|
+
if (!attachment) return
|
|
722
|
+
const now = new Date()
|
|
723
|
+
try {
|
|
724
|
+
await db.update(attachment).set({ objectCheckedAt: now, objectMissingAt: null, updatedAt: now }).where(eq(attachment.id, attachmentId))
|
|
725
|
+
} catch (error) {
|
|
726
|
+
logger.error("markAttachmentPresent failed", error)
|
|
505
727
|
}
|
|
506
|
-
return null
|
|
507
728
|
}
|
|
508
729
|
|
|
509
730
|
/**
|
|
@@ -526,7 +747,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
526
747
|
return sqlResultCache.getOrFetch<DailyReportDetail>({
|
|
527
748
|
cacheKey,
|
|
528
749
|
fetcher: async () => {
|
|
529
|
-
const
|
|
750
|
+
const draftLabelJoinIds = await getDraftLabelJoinIds()
|
|
530
751
|
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
531
752
|
|
|
532
753
|
const rows = await applyExternalJoins(
|
|
@@ -543,7 +764,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
543
764
|
)
|
|
544
765
|
.leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
|
|
545
766
|
.leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
|
|
546
|
-
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id),
|
|
767
|
+
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
547
768
|
.leftJoin(users, eq(DailyReportHub.userId, users.id))
|
|
548
769
|
.where(and(eq(DailyReportHub.businessDate, sql`${normalizedBusinessDate}`), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
|
|
549
770
|
.orderBy(desc(DailyReportHub.id))
|
|
@@ -580,6 +801,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
580
801
|
.where(inArray(DailyReportCommentModel.hubId, hubIds))
|
|
581
802
|
.orderBy(asc(DailyReportCommentModel.createdAt))
|
|
582
803
|
|
|
804
|
+
// 添付も同じ流儀で一括取得する (日報ごとに引くと N+1 になる)
|
|
805
|
+
const attachmentsMap = await getAttachmentsByHubIds(hubIds)
|
|
806
|
+
|
|
583
807
|
const labelsMap = new Map<number, RawJsonLabel[]>()
|
|
584
808
|
const commentsMap = new Map<number, RawJsonComment[]>()
|
|
585
809
|
|
|
@@ -616,6 +840,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
616
840
|
return mapHubRecord(
|
|
617
841
|
{
|
|
618
842
|
...row,
|
|
843
|
+
attachments: attachmentsMap.get(hubId) ?? [],
|
|
619
844
|
labels: labelsMap.get(hubId) || [],
|
|
620
845
|
comments: commentsMap.get(hubId) || [],
|
|
621
846
|
},
|
|
@@ -633,6 +858,13 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
633
858
|
/**
|
|
634
859
|
* Retrieves a single daily report detail by ID with user-specific status and relations.
|
|
635
860
|
* 指定した日報詳細を、ユーザー状態と関連データを含めて取得する。
|
|
861
|
+
*
|
|
862
|
+
* 可視性の述語は営業日一覧 (`getDailyReportsByBusinessDateByExternalId`) および
|
|
863
|
+
* `getAttachmentForUser` と同一に保つこと。3 経路のどれか 1 つだけ緩いと、
|
|
864
|
+
* 緩い経路がメタデータ (件名・本文・添付のファイル名やトークン) を渡してしまい、
|
|
865
|
+
* 「SQL 述語が唯一の認可境界」という不変条件が崩れる。
|
|
866
|
+
* 4 番目の経路である `fetchDailyReportIdsByUserId` だけは意図的に 1 つ緩い
|
|
867
|
+
* (返すのが ID・営業日・ソース種別のみのため)。詳細は同関数の docstring を参照。
|
|
636
868
|
*/
|
|
637
869
|
const getDailyReportDetailById = async (reportHubId: number, userId: number, { forceRefresh = false, snapshot = false, ttlMsOverride }: SqlResultCacheQueryOptions = {}): Promise<DailyReportDetail | null> => {
|
|
638
870
|
const cacheKey = `daily-report:detail:${reportHubId}:user:${userId}`
|
|
@@ -640,6 +872,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
640
872
|
const results = (await sqlResultCache.getOrFetch<DailyReportDetail>({
|
|
641
873
|
cacheKey,
|
|
642
874
|
fetcher: async () => {
|
|
875
|
+
const draftLabelJoinIds = await getDraftLabelJoinIds()
|
|
876
|
+
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
877
|
+
|
|
643
878
|
const rows = await applyExternalJoins(
|
|
644
879
|
db
|
|
645
880
|
.select<HubQueryRow>({
|
|
@@ -654,8 +889,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
654
889
|
)
|
|
655
890
|
.leftJoin(DailyReportInternal, eq(DailyReportHub.id, DailyReportInternal.hubId))
|
|
656
891
|
.leftJoin(DailyReportUserStatus, and(eq(DailyReportHub.id, DailyReportUserStatus.hubId), eq(DailyReportUserStatus.userId, userId)))
|
|
892
|
+
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
657
893
|
.leftJoin(users, eq(DailyReportHub.userId, users.id))
|
|
658
|
-
.where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt)))
|
|
894
|
+
.where(and(eq(DailyReportHub.id, reportHubId), isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DraftLabelRelation.hubId))))
|
|
659
895
|
|
|
660
896
|
if (rows.length === 0) {
|
|
661
897
|
return []
|
|
@@ -687,6 +923,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
687
923
|
.where(eq(DailyReportCommentModel.hubId, hubId))
|
|
688
924
|
.orderBy(asc(DailyReportCommentModel.createdAt))
|
|
689
925
|
|
|
926
|
+
const attachmentsMap = await getAttachmentsByHubIds([hubId])
|
|
927
|
+
|
|
690
928
|
const labelsMapped = labels.map((l) => ({
|
|
691
929
|
id: l.id,
|
|
692
930
|
name: l.name,
|
|
@@ -705,6 +943,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
705
943
|
mapHubRecord(
|
|
706
944
|
{
|
|
707
945
|
...rows[0],
|
|
946
|
+
attachments: attachmentsMap.get(hubId) ?? [],
|
|
708
947
|
labels: labelsMapped,
|
|
709
948
|
comments: commentsMapped,
|
|
710
949
|
},
|
|
@@ -1072,6 +1311,8 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1072
1311
|
isStarred: false,
|
|
1073
1312
|
labels: labels,
|
|
1074
1313
|
commentItems: [],
|
|
1314
|
+
// 新規作成直後の日報に添付は存在しない
|
|
1315
|
+
attachments: [],
|
|
1075
1316
|
}
|
|
1076
1317
|
})
|
|
1077
1318
|
|
|
@@ -1092,8 +1333,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1092
1333
|
|
|
1093
1334
|
if (fullDetail) {
|
|
1094
1335
|
// 下書き判定
|
|
1095
|
-
|
|
1096
|
-
const
|
|
1336
|
+
// 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
|
|
1337
|
+
const draftLabelIds = await getDraftLabelIds()
|
|
1338
|
+
const isDraft = fullDetail.labels.some((l) => draftLabelIds.includes(l.id))
|
|
1097
1339
|
await publishToSseStream(
|
|
1098
1340
|
reportCreateMessageSchema.parse({
|
|
1099
1341
|
type: "report-create",
|
|
@@ -1230,8 +1472,9 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1230
1472
|
const fullDetail = await getDailyReportDetailById(reportHubId, userId, { forceRefresh: true })
|
|
1231
1473
|
if (fullDetail) {
|
|
1232
1474
|
// 下書き判定
|
|
1233
|
-
|
|
1234
|
-
const
|
|
1475
|
+
// 判定は代表 ID ではなく下書きラベル**全体**で行う (2 つ目の下書き名を見落とさない)
|
|
1476
|
+
const draftLabelIds = await getDraftLabelIds()
|
|
1477
|
+
const isDraft = fullDetail.labels.some((l) => draftLabelIds.includes(l.id))
|
|
1235
1478
|
await publishToSseStream(
|
|
1236
1479
|
reportUpdateMessageSchema.parse({
|
|
1237
1480
|
type: "report-update",
|
|
@@ -1298,14 +1541,48 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1298
1541
|
return fullDetail ?? null
|
|
1299
1542
|
}
|
|
1300
1543
|
|
|
1544
|
+
/**
|
|
1545
|
+
* Invalidates the read caches that carry hub-scoped data across every process.
|
|
1546
|
+
* hub 単位のデータを載せている読み取りキャッシュを、全プロセス横断で無効化する処理。
|
|
1547
|
+
*
|
|
1548
|
+
* 用途: 通常の更新経路 (create / update / publish) を通らずに DB を書き換えた場合、
|
|
1549
|
+
* 例えば外部システムからの一括取込。プロセス内 Map を消すだけでは稼働中の
|
|
1550
|
+
* 別プロセス (別ワーカー・別コンテナ) のキャッシュが TTL まで stale のままになるため、
|
|
1551
|
+
* Redis の epoch を進めて全プロセスのキャッシュ行を一斉に無効化する。
|
|
1552
|
+
*
|
|
1553
|
+
* `clearCache()` との違い: `clearCache()` が進める epoch は一覧 ID のものだけで、
|
|
1554
|
+
* 詳細キャッシュ (`detail-epoch:<hubId>`) と営業日キャッシュ (`date-epoch:<date>`) は
|
|
1555
|
+
* 進まない。日報本体に紐づくデータ (添付など) を書き換えたときはこちらを使うこと。
|
|
1556
|
+
*
|
|
1557
|
+
* @param hubIds Hub ids whose detail cache must be dropped. 詳細キャッシュを捨てる hub ID 群。
|
|
1558
|
+
* @param businessDates Business dates (YYYY-MM-DD) whose list cache must be dropped. 一覧キャッシュを捨てる営業日群。
|
|
1559
|
+
* @returns Nothing. なし。
|
|
1560
|
+
*/
|
|
1561
|
+
const invalidateHubReadCaches = async (hubIds: readonly number[], businessDates: readonly string[]): Promise<void> => {
|
|
1562
|
+
sqlResultCache.invalidatePrefix(DAILY_REPORT_BUSINESS_DATE_CACHE_PREFIX)
|
|
1563
|
+
for (const hubId of new Set(hubIds)) {
|
|
1564
|
+
sqlResultCache.invalidatePrefix(`daily-report:detail:${hubId}:user:`)
|
|
1565
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DETAIL_EPOCH_PREFIX}${hubId}`)
|
|
1566
|
+
}
|
|
1567
|
+
for (const businessDate of new Set(businessDates)) {
|
|
1568
|
+
const normalized = normalizeBusinessDateKey(businessDate)
|
|
1569
|
+
// 正規化できない値で epoch キーを作ると、誰も参照しないキーが増えるだけになる
|
|
1570
|
+
if (!normalized) continue
|
|
1571
|
+
await incrementRedisEpoch(`${DAILY_REPORT_DATE_EPOCH_PREFIX}${normalized}`)
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1301
1575
|
/**
|
|
1302
1576
|
* Clears all server-side SQL result and user ID caches.
|
|
1303
1577
|
* サーバー側のすべての SQL 結果キャッシュおよびユーザー ID キャッシュを全消去する。
|
|
1578
|
+
*
|
|
1579
|
+
* Redis で進めるのは一覧 ID の epoch のみ。詳細/営業日キャッシュも捨てたい場合は
|
|
1580
|
+
* `invalidateHubReadCaches` を使うこと (このメソッドでは進まない)。
|
|
1304
1581
|
*/
|
|
1305
1582
|
const clearCache = async (): Promise<void> => {
|
|
1306
1583
|
sqlResultCache.clearAll()
|
|
1307
1584
|
userIdCache.clear()
|
|
1308
|
-
|
|
1585
|
+
cachedDraftLabelIds = null
|
|
1309
1586
|
if (epochs) {
|
|
1310
1587
|
await incrementRedisEpoch(DAILY_REPORT_IDS_EPOCH_KEY)
|
|
1311
1588
|
}
|
|
@@ -1318,6 +1595,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1318
1595
|
streamMaxLen: DAILY_REPORT_SSE_STREAM_MAXLEN,
|
|
1319
1596
|
// キャッシュクリア
|
|
1320
1597
|
clearCache,
|
|
1598
|
+
invalidateHubReadCaches,
|
|
1321
1599
|
// ユーザー解決
|
|
1322
1600
|
getUserIdByExternalId,
|
|
1323
1601
|
// 参照系
|
|
@@ -1327,6 +1605,11 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1327
1605
|
getDailyReportDetailById,
|
|
1328
1606
|
getDailyReportDetailByIdByExternalId,
|
|
1329
1607
|
getDraftLabelId,
|
|
1608
|
+
// 添付 (ハンドラーが認可判定と実体消失記録に使う)
|
|
1609
|
+
getAttachmentsByHubIds,
|
|
1610
|
+
getAttachmentForUser,
|
|
1611
|
+
markAttachmentMissing,
|
|
1612
|
+
markAttachmentPresent,
|
|
1330
1613
|
// 更新系
|
|
1331
1614
|
setStarStatus,
|
|
1332
1615
|
setReadStatus,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test fixture that assembles a minimal `DailyReportHandlersConfig`.
|
|
3
|
+
* 最小限の `DailyReportHandlersConfig` を組み立てるテスト用フィクスチャ。
|
|
4
|
+
*
|
|
5
|
+
* `handlers.ts` を対象にした spec がこれまで存在しなかったため、認証・サービス・
|
|
6
|
+
* 難読化・SSE リーダーのモック一式をここに集約する。各テストは必要な部分だけを上書きする。
|
|
7
|
+
*
|
|
8
|
+
* 本番コードからは import されない (テスト専用)。
|
|
9
|
+
*/
|
|
10
|
+
import type { DailyReportHandlersConfig } from "../handlers"
|
|
11
|
+
import type { DailyReportAttachmentBytes, DailyReportAttachmentError, DailyReportIdCodec } from "../ports"
|
|
12
|
+
import type { DailyReportService } from "../service"
|
|
13
|
+
import type { DailyReportSseReader } from "../sse-reader"
|
|
14
|
+
|
|
15
|
+
/** 既定の外部ユーザー ID (認証済みユーザーを表す)。 */
|
|
16
|
+
export const TEST_EXTERNAL_USER_ID = "external-user"
|
|
17
|
+
|
|
18
|
+
/** 既定の内部ユーザー ID。 */
|
|
19
|
+
export const TEST_INTERNAL_USER_ID = 42
|
|
20
|
+
|
|
21
|
+
/** テスト用の添付 ID コーデック。`A<id>` 形式で、ユーザー ID 用とは別空間であることを表す。 */
|
|
22
|
+
export const testAttachmentCodec: DailyReportIdCodec = {
|
|
23
|
+
encode: (id) => `A${id}`,
|
|
24
|
+
decode: (token) => {
|
|
25
|
+
if (!token.startsWith("A")) return null
|
|
26
|
+
const value = Number(token.slice(1))
|
|
27
|
+
return Number.isInteger(value) ? value : null
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `service.getAttachmentForUser` が返す行の形。 */
|
|
32
|
+
export type TestAttachmentRow = { fileName: string; filePath: string; fileType: string | null; fileSize: number | null }
|
|
33
|
+
|
|
34
|
+
/** フィクスチャの調整点。 */
|
|
35
|
+
export type HandlersFixtureOptions = {
|
|
36
|
+
/** 認証結果。null を渡すと未認証を表す。 */
|
|
37
|
+
user?: { id: string } | null
|
|
38
|
+
/** 認証ポートが返す `Set-Cookie`。 */
|
|
39
|
+
cookie?: string
|
|
40
|
+
/** 外部 ID → 内部 ID の解決結果。 */
|
|
41
|
+
internalUserId?: number | null
|
|
42
|
+
/** 認可判定の結果。null なら不可視 (404 相当)。 */
|
|
43
|
+
attachment?: TestAttachmentRow | null
|
|
44
|
+
/** 読み取りポートの応答。 */
|
|
45
|
+
readResult?: DailyReportAttachmentBytes | DailyReportAttachmentError
|
|
46
|
+
/** ポートを未注入にする (縮退動作の検証用)。 */
|
|
47
|
+
omitPorts?: boolean
|
|
48
|
+
/** 添付の上限バイト数。 */
|
|
49
|
+
attachmentMaxBytes?: number
|
|
50
|
+
/** レート上限 (1 分あたり)。 */
|
|
51
|
+
attachmentRateLimitPerMinute?: number
|
|
52
|
+
/** 同時実行上限。 */
|
|
53
|
+
attachmentConcurrency?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 呼び出し記録。テストが副作用を検証するために使う。 */
|
|
57
|
+
export type HandlersFixtureCalls = {
|
|
58
|
+
/** `service.getAttachmentForUser` が受け取った `(attachmentId, viewerUserId)`。 */
|
|
59
|
+
getAttachmentForUser: Array<[number, number]>
|
|
60
|
+
readAttachment: Array<{ filePath: string; maxBytes: number; head: boolean; principal?: string }>
|
|
61
|
+
markAttachmentMissing: number[]
|
|
62
|
+
markAttachmentPresent: number[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Builds a handlers config plus the call log the assertions inspect.
|
|
67
|
+
* ハンドラー設定と、表明で参照する呼び出し記録を組み立てる処理。
|
|
68
|
+
*
|
|
69
|
+
* @param options Fixture adjustments. フィクスチャの調整点。
|
|
70
|
+
* @returns The config and its call log. 設定と呼び出し記録。
|
|
71
|
+
*/
|
|
72
|
+
export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { config: DailyReportHandlersConfig; calls: HandlersFixtureCalls } => {
|
|
73
|
+
const calls: HandlersFixtureCalls = { getAttachmentForUser: [], readAttachment: [], markAttachmentMissing: [], markAttachmentPresent: [] }
|
|
74
|
+
|
|
75
|
+
const user = options.user === undefined ? { id: TEST_EXTERNAL_USER_ID } : options.user
|
|
76
|
+
const internalUserId = options.internalUserId === undefined ? TEST_INTERNAL_USER_ID : options.internalUserId
|
|
77
|
+
const attachment = options.attachment === undefined ? null : options.attachment
|
|
78
|
+
const readResult: DailyReportAttachmentBytes | DailyReportAttachmentError = options.readResult ?? { ok: true, bytes: new Uint8Array(new ArrayBuffer(3)), contentType: null }
|
|
79
|
+
|
|
80
|
+
const service = {
|
|
81
|
+
getUserIdByExternalId: async () => internalUserId,
|
|
82
|
+
getAttachmentForUser: async (attachmentId: number, viewerUserId: number) => {
|
|
83
|
+
// 引数を記録する。記録しないと「閲覧者 ID を渡し忘れた」欠陥がテストに映らない
|
|
84
|
+
calls.getAttachmentForUser.push([attachmentId, viewerUserId])
|
|
85
|
+
return attachment
|
|
86
|
+
},
|
|
87
|
+
markAttachmentMissing: async (id: number) => {
|
|
88
|
+
calls.markAttachmentMissing.push(id)
|
|
89
|
+
},
|
|
90
|
+
markAttachmentPresent: async (id: number) => {
|
|
91
|
+
calls.markAttachmentPresent.push(id)
|
|
92
|
+
},
|
|
93
|
+
} as unknown as DailyReportService
|
|
94
|
+
|
|
95
|
+
const base: DailyReportHandlersConfig = {
|
|
96
|
+
authenticate: async () => ({ user: user ?? undefined, cookie: options.cookie }),
|
|
97
|
+
service,
|
|
98
|
+
encodeUserId: (id) => `ENC${id}`,
|
|
99
|
+
sseReader: { subscribe: () => () => undefined } as unknown as DailyReportSseReader,
|
|
100
|
+
streamKey: "test-stream",
|
|
101
|
+
attachmentMaxBytes: options.attachmentMaxBytes,
|
|
102
|
+
attachmentRateLimitPerMinute: options.attachmentRateLimitPerMinute,
|
|
103
|
+
attachmentConcurrency: options.attachmentConcurrency,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (options.omitPorts) {
|
|
107
|
+
return { config: base, calls }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
config: {
|
|
112
|
+
...base,
|
|
113
|
+
attachmentIdCodec: testAttachmentCodec,
|
|
114
|
+
readAttachment: async (filePath, opts) => {
|
|
115
|
+
calls.readAttachment.push({ filePath, ...opts })
|
|
116
|
+
// 本物の実装は head:true のとき本体を読まず長さ 0 のビューを返し、実サイズは `size` で申告する。
|
|
117
|
+
// フィクスチャが常に本体長を返すと、HEAD の Content-Length が 0 になる欠陥が観測できない
|
|
118
|
+
if (opts.head && readResult.ok) {
|
|
119
|
+
return { ...readResult, bytes: new Uint8Array(new ArrayBuffer(0)), size: readResult.size ?? readResult.bytes.byteLength }
|
|
120
|
+
}
|
|
121
|
+
return readResult
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
calls,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Builds loader args for the attachment endpoint.
|
|
130
|
+
* 添付エンドポイント用の loader 引数を組み立てる処理。
|
|
131
|
+
*
|
|
132
|
+
* @param token Attachment token in the URL. URL 中の添付トークン。
|
|
133
|
+
* @param init Optional method / query / headers. メソッド・クエリ・ヘッダーの任意指定。
|
|
134
|
+
* @returns Loader args. loader 引数。
|
|
135
|
+
*/
|
|
136
|
+
export const makeAttachmentArgs = (token: string, init: { method?: string; query?: string; headers?: Record<string, string> } = {}) => ({
|
|
137
|
+
request: new Request(`http://localhost/daily_report/api/attachment/${encodeURIComponent(token)}${init.query ?? ""}`, {
|
|
138
|
+
method: init.method ?? "GET",
|
|
139
|
+
headers: init.headers,
|
|
140
|
+
}),
|
|
141
|
+
params: { token } as Record<string, string | undefined>,
|
|
142
|
+
})
|