@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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SQL } from "drizzle-orm"
|
|
2
|
+
import { bigint, MsSqlDialect, mssqlSchema, nvarchar, primaryKey } from "drizzle-orm/mssql-core"
|
|
2
3
|
import { describe, expect, it } from "vitest"
|
|
3
4
|
import { createEpochStore, SqlResultCache } from "./cache"
|
|
4
5
|
import { defineDailyReportSchema } from "./schema"
|
|
@@ -13,17 +14,92 @@ import { createDailyReportService, type DailyReportDb, type DailyReportHubRow }
|
|
|
13
14
|
const testUsers = mssqlSchema("test").table("Users", { id: bigint("id", { mode: "number" }).notNull(), displayName: nvarchar("display_name", { length: 100 }) }, (t) => [primaryKey({ columns: [t.id] })])
|
|
14
15
|
const tables = defineDailyReportSchema("test", { userTable: testUsers })
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
/** 本物の方言。`where` に渡された式を実際の SQL 文字列へ落として表明するために使う。 */
|
|
18
|
+
const dialect = new MsSqlDialect()
|
|
19
|
+
|
|
20
|
+
/** 1 本のクエリについてモックが記録した内容。 */
|
|
21
|
+
type RecordedQuery = {
|
|
22
|
+
/** `where` に渡された式を実 SQL へ落としたもの。未指定なら null。 */
|
|
23
|
+
whereSql: string | null
|
|
24
|
+
/**
|
|
25
|
+
* `where` の束縛値。
|
|
26
|
+
*
|
|
27
|
+
* SQL 文字列だけでは**認可の中身を検証できない**。drizzle は値をすべて `@parN` へ落とすため、
|
|
28
|
+
* 文字列は「どのカラムをどう結んだか」しか含まない。閲覧者 ID と添付 ID の取り違えは
|
|
29
|
+
* ここを見なければ 1 件も検出できない。
|
|
30
|
+
*/
|
|
31
|
+
whereParams: unknown[]
|
|
32
|
+
/** `leftJoin` された回数。 */
|
|
33
|
+
leftJoins: number
|
|
34
|
+
/** `leftJoin` の ON 条件に載った束縛値 (下書きラベル ID はここに来る)。 */
|
|
35
|
+
joinParams: unknown[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `update(...).set(...).where(...)` について記録した内容。 */
|
|
39
|
+
type RecordedUpdate = {
|
|
40
|
+
/** `set` に渡された更新内容。 */
|
|
41
|
+
set: Record<string, unknown>
|
|
42
|
+
/** `where` の束縛値。 */
|
|
43
|
+
whereParams: unknown[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Minimal db mock that also records the SQL each query would emit.
|
|
48
|
+
* 各クエリが実際に生成する SQL を記録もする最小の db モック。
|
|
49
|
+
*
|
|
50
|
+
* チェーンは thenable にしてある。`.where()` で終わるクエリ (認可判定の
|
|
51
|
+
* `getAttachmentForUser` や `getDraftLabelId`) を `orderBy` 前提のモックで受けると
|
|
52
|
+
* チェーンオブジェクト自身が await の結果になり、分割代入が TypeError で落ちる。
|
|
53
|
+
* それを避けるためにモックを「どの段でも解決できる」形にしている。
|
|
54
|
+
*
|
|
55
|
+
* @param rows Rows every query resolves to. すべてのクエリが返す行。
|
|
56
|
+
* @returns The db mock and the per-query recordings. db モックとクエリごとの記録。
|
|
57
|
+
*/
|
|
58
|
+
const makeDb = (rows: unknown[]): { db: DailyReportDb; queries: RecordedQuery[]; updates: RecordedUpdate[] } => {
|
|
59
|
+
const queries: RecordedQuery[] = []
|
|
60
|
+
const updates: RecordedUpdate[] = []
|
|
61
|
+
const select = () => {
|
|
62
|
+
const recorded: RecordedQuery = { whereSql: null, whereParams: [], leftJoins: 0, joinParams: [] }
|
|
63
|
+
queries.push(recorded)
|
|
64
|
+
const chain: Record<string, unknown> = {
|
|
65
|
+
from: () => chain,
|
|
66
|
+
innerJoin: () => chain,
|
|
67
|
+
leftJoin: (_table: unknown, onExpr?: SQL) => {
|
|
68
|
+
recorded.leftJoins += 1
|
|
69
|
+
if (onExpr) recorded.joinParams.push(...dialect.sqlToQuery(onExpr).params)
|
|
70
|
+
return chain
|
|
71
|
+
},
|
|
72
|
+
where: (expr: SQL | undefined) => {
|
|
73
|
+
const query = expr ? dialect.sqlToQuery(expr) : null
|
|
74
|
+
recorded.whereSql = query?.sql ?? null
|
|
75
|
+
recorded.whereParams = query?.params ?? []
|
|
76
|
+
return chain
|
|
77
|
+
},
|
|
78
|
+
top: () => chain,
|
|
79
|
+
orderBy: () => Promise.resolve(rows),
|
|
80
|
+
// `.where()` / `.orderBy()` のどちらで終わっても await できるようにする。
|
|
81
|
+
// thenable であること自体が目的なので、この規則はここでは意図的に外す
|
|
82
|
+
// biome-ignore lint/suspicious/noThenProperty: 意図的な thenable (クエリビルダの終端を模擬する)
|
|
83
|
+
then: (resolve: (value: unknown) => unknown) => resolve(rows),
|
|
84
|
+
}
|
|
85
|
+
return chain
|
|
86
|
+
}
|
|
87
|
+
const update = () => {
|
|
88
|
+
const recorded: RecordedUpdate = { set: {}, whereParams: [] }
|
|
89
|
+
const chain: Record<string, unknown> = {
|
|
90
|
+
set: (values: Record<string, unknown>) => {
|
|
91
|
+
recorded.set = values
|
|
92
|
+
return chain
|
|
93
|
+
},
|
|
94
|
+
where: (expr: SQL | undefined) => {
|
|
95
|
+
recorded.whereParams = expr ? dialect.sqlToQuery(expr).params : []
|
|
96
|
+
updates.push(recorded)
|
|
97
|
+
return Promise.resolve({ rowsAffected: [1] })
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
return chain
|
|
25
101
|
}
|
|
26
|
-
return {
|
|
102
|
+
return { db: { select, update } as unknown as DailyReportDb, queries, updates }
|
|
27
103
|
}
|
|
28
104
|
|
|
29
105
|
const baseHub = (over: Partial<DailyReportHubRow>): DailyReportHubRow => ({
|
|
@@ -44,11 +120,12 @@ const baseHub = (over: Partial<DailyReportHubRow>): DailyReportHubRow => ({
|
|
|
44
120
|
...over,
|
|
45
121
|
})
|
|
46
122
|
|
|
47
|
-
const makeService = (rows: unknown[]) => {
|
|
123
|
+
const makeService = (rows: unknown[], withAttachmentCodec = false) => {
|
|
48
124
|
const epochs = createEpochStore(undefined)
|
|
49
125
|
const cache = new SqlResultCache({ defaultTtlMs: 60_000 }, epochs)
|
|
50
|
-
|
|
51
|
-
|
|
126
|
+
const { db, queries, updates } = makeDb(rows)
|
|
127
|
+
const service = createDailyReportService({
|
|
128
|
+
db,
|
|
52
129
|
tables,
|
|
53
130
|
userTable: testUsers,
|
|
54
131
|
resolveUserId: async () => 8,
|
|
@@ -56,7 +133,11 @@ const makeService = (rows: unknown[]) => {
|
|
|
56
133
|
cache,
|
|
57
134
|
epochs,
|
|
58
135
|
draftLabelName: "draft", // 必須注入 (ドラフト可視性フィルタ用のラベル名。テストは中立値で十分)
|
|
136
|
+
// 既定では未注入のまま。既存テストが縮退動作 (添付は常に空) で通ることも同時に確認する。
|
|
137
|
+
// ユーザー ID 用 (`ENC`) とは別の接頭辞にして ID 空間の分離を表現する。
|
|
138
|
+
...(withAttachmentCodec ? { attachmentIdCodec: { encode: (id: number) => `ATT${id}`, decode: (t: string) => (t.startsWith("ATT") ? Number(t.slice(3)) : null) } } : {}),
|
|
59
139
|
})
|
|
140
|
+
return Object.assign(service, { __queries: queries, __updates: updates })
|
|
60
141
|
}
|
|
61
142
|
|
|
62
143
|
describe("mapHubRecord audit-actor masking (security)", () => {
|
|
@@ -95,3 +176,188 @@ describe("mapHubRecord audit-actor masking (security)", () => {
|
|
|
95
176
|
expect(r.author).toBe("") // author は非 null 契約のため空文字
|
|
96
177
|
})
|
|
97
178
|
})
|
|
179
|
+
|
|
180
|
+
describe("添付ファイルの一括取得", () => {
|
|
181
|
+
it("コーデック未注入なら空マップを返す (既定の縮退動作)", async () => {
|
|
182
|
+
const svc = makeService([])
|
|
183
|
+
const map = await svc.getAttachmentsByHubIds([1, 2])
|
|
184
|
+
expect(map.size).toBe(0)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("hubIds が空なら DB を叩かずに空マップを返す", async () => {
|
|
188
|
+
const svc = makeService([], true)
|
|
189
|
+
const map = await svc.getAttachmentsByHubIds([])
|
|
190
|
+
expect(map.size).toBe(0)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("hub ID ごとにグルーピングし、内部数値 ID を難読化トークンへ変換する", async () => {
|
|
194
|
+
const rows = [
|
|
195
|
+
{ id: 11, hubId: 1, fileName: "a.png", fileType: "image/png", fileSize: 10, createdAt: "2099-01-05 00:00:00", objectCheckedAt: null, objectMissingAt: null },
|
|
196
|
+
{ id: 12, hubId: 1, fileName: "b.pdf", fileType: null, fileSize: null, createdAt: "2099-01-05 00:00:01", objectCheckedAt: "2099-01-06 00:00:00", objectMissingAt: null },
|
|
197
|
+
{ id: 13, hubId: 2, fileName: "c.txt", fileType: "text/plain", fileSize: 0, createdAt: "2099-01-05 00:00:02", objectCheckedAt: "2099-01-06 00:00:00", objectMissingAt: "2099-01-06 00:00:00" },
|
|
198
|
+
]
|
|
199
|
+
const svc = makeService(rows, true)
|
|
200
|
+
const map = await svc.getAttachmentsByHubIds([1, 2])
|
|
201
|
+
|
|
202
|
+
expect(map.get(1)).toHaveLength(2)
|
|
203
|
+
expect(map.get(2)).toHaveLength(1)
|
|
204
|
+
// 生の内部 ID が出ないこと。ユーザー ID 用の接頭辞とも異なること
|
|
205
|
+
expect(map.get(1)?.[0].id).toBe("ATT11")
|
|
206
|
+
expect(map.get(1)?.[0].id).not.toBe("11")
|
|
207
|
+
expect(map.get(1)?.[0].id.startsWith("ENC")).toBe(false)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("実体状態を三値へ導出する (未検証 / 実在 / 消失)", async () => {
|
|
211
|
+
const rows = [
|
|
212
|
+
{ id: 1, hubId: 1, fileName: "unknown.bin", fileType: null, fileSize: null, createdAt: null, objectCheckedAt: null, objectMissingAt: null },
|
|
213
|
+
{ id: 2, hubId: 1, fileName: "present.bin", fileType: null, fileSize: null, createdAt: null, objectCheckedAt: "2099-01-06 00:00:00", objectMissingAt: null },
|
|
214
|
+
{ id: 3, hubId: 1, fileName: "absent.bin", fileType: null, fileSize: null, createdAt: null, objectCheckedAt: "2099-01-06 00:00:00", objectMissingAt: "2099-01-06 00:00:00" },
|
|
215
|
+
]
|
|
216
|
+
const svc = makeService(rows, true)
|
|
217
|
+
const list = (await svc.getAttachmentsByHubIds([1])).get(1) ?? []
|
|
218
|
+
expect(list.map((a) => a.state)).toEqual(["unknown", "present", "absent"])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("サイズ 0 と サイズ不明 (null) を区別する", async () => {
|
|
222
|
+
const rows = [
|
|
223
|
+
{ id: 1, hubId: 1, fileName: "empty.bin", fileType: null, fileSize: 0, createdAt: null, objectCheckedAt: null, objectMissingAt: null },
|
|
224
|
+
{ id: 2, hubId: 1, fileName: "unsized.bin", fileType: null, fileSize: null, createdAt: null, objectCheckedAt: null, objectMissingAt: null },
|
|
225
|
+
]
|
|
226
|
+
const svc = makeService(rows, true)
|
|
227
|
+
const list = (await svc.getAttachmentsByHubIds([1])).get(1) ?? []
|
|
228
|
+
expect(list[0].fileSize).toBe(0)
|
|
229
|
+
expect(list[1].fileSize).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("空文字の fileType を null へ正規化する (取込元が欠損を空文字で表現する場合)", async () => {
|
|
233
|
+
const rows = [{ id: 1, hubId: 1, fileName: "x.bin", fileType: "", fileSize: null, createdAt: null, objectCheckedAt: null, objectMissingAt: null }]
|
|
234
|
+
const svc = makeService(rows, true)
|
|
235
|
+
expect((await svc.getAttachmentsByHubIds([1])).get(1)?.[0].fileType).toBeNull()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe("添付ファイルの認可付き取得", () => {
|
|
240
|
+
it("添付テーブル未注入なら null を返す", async () => {
|
|
241
|
+
const epochs = createEpochStore(undefined)
|
|
242
|
+
const cache = new SqlResultCache({ defaultTtlMs: 60_000 }, epochs)
|
|
243
|
+
const { attachment: _omitted, ...tablesWithoutAttachment } = tables
|
|
244
|
+
const svc = createDailyReportService({
|
|
245
|
+
db: makeDb([]).db,
|
|
246
|
+
tables: tablesWithoutAttachment,
|
|
247
|
+
userTable: testUsers,
|
|
248
|
+
resolveUserId: async () => 8,
|
|
249
|
+
encodeUserId: (id) => `ENC${id}`,
|
|
250
|
+
cache,
|
|
251
|
+
epochs,
|
|
252
|
+
draftLabelName: "draft",
|
|
253
|
+
})
|
|
254
|
+
expect(await svc.getAttachmentForUser(1, 8)).toBeNull()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it("認可述語が閲覧者の所有と下書き非該当を OR で結び、論理削除も除外する", async () => {
|
|
258
|
+
// これがこの機能の唯一の認可境界。述語そのものを SQL 文字列として固定する
|
|
259
|
+
// (モックは行を絞れないので、生成される WHERE 句を直接表明するしかない)
|
|
260
|
+
const svc = makeService([{ fileName: "a.png", filePath: "gcs://b/a.png", fileType: "image/png", fileSize: 1 }])
|
|
261
|
+
await svc.getAttachmentForUser(11, 8)
|
|
262
|
+
|
|
263
|
+
// 最後のクエリが添付の認可判定 (直前は下書きラベルの解決)
|
|
264
|
+
const recorded = svc.__queries.at(-1)
|
|
265
|
+
const where = recorded?.whereSql ?? ""
|
|
266
|
+
expect(where).toContain("[DailyReportAttachment].[id]")
|
|
267
|
+
expect(where).toContain("[deleted_at] is null")
|
|
268
|
+
expect(where).toContain("[user_id] = ")
|
|
269
|
+
expect(where).toContain(" or ")
|
|
270
|
+
expect(where).toContain("[draft_label].[hub_id] is null")
|
|
271
|
+
// ❗ SQL 文字列だけでは足りない。値はすべて @parN へ落ちるため、
|
|
272
|
+
// 「添付 ID の位置に閲覧者 ID を渡す」ような取り違えは束縛値を見ないと検出できない
|
|
273
|
+
expect(recorded?.whereParams).toEqual([11, 8])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it("認可判定の束縛値が入れ替わっていないこと (添付 ID と閲覧者 ID)", async () => {
|
|
277
|
+
const svc = makeService([])
|
|
278
|
+
await svc.getAttachmentForUser(777, 42)
|
|
279
|
+
expect(svc.__queries.at(-1)?.whereParams).toEqual([777, 42])
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it("営業日一覧・詳細取得・添付取得の 3 経路が同一の可視性述語を持つ", async () => {
|
|
283
|
+
// 1 経路だけ緩いと、緩い経路がメタデータ (件名・添付名・トークン) を渡してしまう。
|
|
284
|
+
// 「所有者本人」または「下書きラベルが付いていない」の OR を 3 経路すべてで固定する
|
|
285
|
+
const draftPredicate = (sql: string): boolean => /\[user_id\] = @par\d+\) or \(\(\[draft_label\]\.\[hub_id\] is null\)/.test(sql)
|
|
286
|
+
|
|
287
|
+
const byDate = makeService([])
|
|
288
|
+
await byDate.getDailyReportsByBusinessDateByExternalId("2099-01-05", "ext", { forceRefresh: true })
|
|
289
|
+
expect(byDate.__queries.some((q) => q.whereSql !== null && draftPredicate(q.whereSql))).toBe(true)
|
|
290
|
+
|
|
291
|
+
const byId = makeService([])
|
|
292
|
+
await byId.getDailyReportDetailById(1, 8, { forceRefresh: true })
|
|
293
|
+
expect(byId.__queries.some((q) => q.whereSql !== null && draftPredicate(q.whereSql))).toBe(true)
|
|
294
|
+
|
|
295
|
+
const byAttachment = makeService([])
|
|
296
|
+
await byAttachment.getAttachmentForUser(1, 8)
|
|
297
|
+
expect(byAttachment.__queries.some((q) => q.whereSql !== null && draftPredicate(q.whereSql))).toBe(true)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("ID 一覧経路だけは `user_id IS NULL` を追加で許す (意図的な差分)", async () => {
|
|
301
|
+
// 4 番目の経路。取込元が投稿者を持たない日報 (user_id IS NULL) を一覧から消さないための
|
|
302
|
+
// 意図的な緩和であり、返すのは ID・営業日・ソース種別だけで本文も添付も含まない。
|
|
303
|
+
// 「3 経路と同一」ではないことを明示的に固定し、無自覚な差分と区別する
|
|
304
|
+
const byIds = makeService([])
|
|
305
|
+
await byIds.getDailyReportIdsByExternalId("ext", { forceRefresh: true })
|
|
306
|
+
const idsWhere = byIds.__queries.map((q) => q.whereSql ?? "").find((sql) => sql.includes("[draft_label].[hub_id] is null"))
|
|
307
|
+
expect(idsWhere).toBeDefined()
|
|
308
|
+
expect(idsWhere).toContain("[user_id] is null")
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it("下書きラベルは複数解決し、可視性の結合が 2 つ目以降も対象にする", async () => {
|
|
312
|
+
// `draftLabelNames` には取込元ごとの下書き名を複数注入できる。1 個の ID でしか結合しないと
|
|
313
|
+
// 2 つ目の下書きラベルが付いた日報が誰にでも可視になる
|
|
314
|
+
const svc = makeService([{ id: 11 }, { id: 22 }])
|
|
315
|
+
await svc.getAttachmentForUser(1, 8)
|
|
316
|
+
// 結合条件は where ではなく leftJoin の ON 側に載る
|
|
317
|
+
const joinParams = svc.__queries.flatMap((q) => q.joinParams)
|
|
318
|
+
expect(joinParams).toContain(11)
|
|
319
|
+
expect(joinParams).toContain(22)
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe("実体存在の記録 (三値の書き込み側)", () => {
|
|
324
|
+
it("markAttachmentMissing は消失時刻と検証時刻の両方を立てる", async () => {
|
|
325
|
+
const svc = makeService([])
|
|
326
|
+
await svc.markAttachmentMissing(77)
|
|
327
|
+
const [update] = svc.__updates
|
|
328
|
+
expect(update?.whereParams).toEqual([77])
|
|
329
|
+
// objectMissingAt を落とすと三値が absent にならず、消失が永久に検知されない
|
|
330
|
+
expect(update?.set.objectMissingAt).toBeInstanceOf(Date)
|
|
331
|
+
expect(update?.set.objectCheckedAt).toBeInstanceOf(Date)
|
|
332
|
+
expect(update?.set.updatedAt).toBeInstanceOf(Date)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it("markAttachmentPresent は消失時刻を NULL へ戻す (自己回復)", async () => {
|
|
336
|
+
const svc = makeService([])
|
|
337
|
+
await svc.markAttachmentPresent(77)
|
|
338
|
+
const [update] = svc.__updates
|
|
339
|
+
expect(update?.whereParams).toEqual([77])
|
|
340
|
+
// ここが null でないと absent が解除されず、UI からリンクが戻らない
|
|
341
|
+
expect(update?.set.objectMissingAt).toBeNull()
|
|
342
|
+
expect(update?.set.objectCheckedAt).toBeInstanceOf(Date)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it("添付テーブル未注入なら書き込みを試みない", async () => {
|
|
346
|
+
const epochs = createEpochStore(undefined)
|
|
347
|
+
const { attachment: _omitted, ...tablesWithoutAttachment } = tables
|
|
348
|
+
const { db, updates } = makeDb([])
|
|
349
|
+
const svc = createDailyReportService({
|
|
350
|
+
db,
|
|
351
|
+
tables: tablesWithoutAttachment,
|
|
352
|
+
userTable: testUsers,
|
|
353
|
+
resolveUserId: async () => 8,
|
|
354
|
+
encodeUserId: (id) => `ENC${id}`,
|
|
355
|
+
cache: new SqlResultCache({ defaultTtlMs: 60_000 }, epochs),
|
|
356
|
+
epochs,
|
|
357
|
+
draftLabelName: "draft",
|
|
358
|
+
})
|
|
359
|
+
await svc.markAttachmentMissing(1)
|
|
360
|
+
await svc.markAttachmentPresent(1)
|
|
361
|
+
expect(updates).toHaveLength(0)
|
|
362
|
+
})
|
|
363
|
+
})
|