@things-factory/board-import 10.0.0-beta.71 → 10.0.0-beta.73

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.
Files changed (83) hide show
  1. package/dist-server/index.d.ts +1 -1
  2. package/dist-server/index.js +1 -1
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/service/adapters/dxf-adapter.d.ts +1 -1
  5. package/dist-server/service/adapters/dxf-adapter.js +18 -4
  6. package/dist-server/service/adapters/dxf-adapter.js.map +1 -1
  7. package/dist-server/service/adapters/image-adapter.d.ts +32 -0
  8. package/dist-server/service/adapters/image-adapter.js +420 -0
  9. package/dist-server/service/adapters/image-adapter.js.map +1 -0
  10. package/dist-server/service/ai/types.d.ts +96 -4
  11. package/dist-server/service/ai/types.js +221 -12
  12. package/dist-server/service/ai/types.js.map +1 -1
  13. package/dist-server/service/converters/generic-to.d.ts +17 -8
  14. package/dist-server/service/converters/generic-to.js +44 -44
  15. package/dist-server/service/converters/generic-to.js.map +1 -1
  16. package/dist-server/service/import-session/import-actions.d.ts +79 -0
  17. package/dist-server/service/import-session/import-actions.js +98 -0
  18. package/dist-server/service/import-session/import-actions.js.map +1 -0
  19. package/dist-server/service/import-session/import-session-resolver.d.ts +22 -1
  20. package/dist-server/service/import-session/import-session-resolver.js +166 -38
  21. package/dist-server/service/import-session/import-session-resolver.js.map +1 -1
  22. package/dist-server/service/import-session/import-worker.js +55 -13
  23. package/dist-server/service/import-session/import-worker.js.map +1 -1
  24. package/dist-server/service/import-session/index.d.ts +5 -3
  25. package/dist-server/service/import-session/index.js +16 -9
  26. package/dist-server/service/import-session/index.js.map +1 -1
  27. package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
  28. package/dist-server/service/import-session/materialize-from-session.js +104 -0
  29. package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
  30. package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
  31. package/dist-server/service/import-session/suggest-board-name.js +195 -0
  32. package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
  33. package/dist-server/service/import-tools.d.ts +10 -0
  34. package/dist-server/service/import-tools.js +227 -0
  35. package/dist-server/service/import-tools.js.map +1 -0
  36. package/dist-server/service/index.d.ts +22 -19
  37. package/dist-server/service/index.js +46 -36
  38. package/dist-server/service/index.js.map +1 -1
  39. package/dist-server/service/pipeline/index.d.ts +42 -9
  40. package/dist-server/service/pipeline/index.js +92 -23
  41. package/dist-server/service/pipeline/index.js.map +1 -1
  42. package/dist-server/service/pipeline/stage2-mapping.d.ts +20 -5
  43. package/dist-server/service/pipeline/stage2-mapping.js +84 -26
  44. package/dist-server/service/pipeline/stage2-mapping.js.map +1 -1
  45. package/dist-server/service/pipeline/stage3-board.d.ts +2 -2
  46. package/dist-server/service/pipeline/stage3-board.js +65 -10
  47. package/dist-server/service/pipeline/stage3-board.js.map +1 -1
  48. package/dist-server/service/pipeline/stage4-binding.d.ts +1 -1
  49. package/dist-server/service/pipeline/stage4-binding.js +2 -2
  50. package/dist-server/service/pipeline/stage4-binding.js.map +1 -1
  51. package/dist-server/service/registry/index.d.ts +1 -1
  52. package/dist-server/service/registry/index.js.map +1 -1
  53. package/dist-server/service/types/index.d.ts +35 -0
  54. package/dist-server/service/types/index.js.map +1 -1
  55. package/dist-server/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +7 -6
  57. package/server/index.ts +1 -1
  58. package/server/service/adapters/dxf-adapter.ts +17 -4
  59. package/server/service/adapters/image-adapter.test.ts +545 -0
  60. package/server/service/adapters/image-adapter.ts +464 -0
  61. package/server/service/ai/types.ts +354 -19
  62. package/server/service/converters/generic-to.test.ts +91 -0
  63. package/server/service/converters/generic-to.ts +66 -49
  64. package/server/service/import-session/import-actions.test.ts +185 -0
  65. package/server/service/import-session/import-actions.ts +164 -0
  66. package/server/service/import-session/import-session-resolver.ts +171 -39
  67. package/server/service/import-session/import-worker.ts +56 -12
  68. package/server/service/import-session/index.ts +19 -3
  69. package/server/service/import-session/materialize-from-session.test.ts +274 -0
  70. package/server/service/import-session/materialize-from-session.ts +158 -0
  71. package/server/service/import-session/suggest-board-name.test.ts +271 -0
  72. package/server/service/import-session/suggest-board-name.ts +279 -0
  73. package/server/service/import-tools.test.ts +137 -0
  74. package/server/service/import-tools.ts +255 -0
  75. package/server/service/index.ts +35 -18
  76. package/server/service/pipeline/index.ts +118 -23
  77. package/server/service/pipeline/stage2-mapping.test.ts +204 -0
  78. package/server/service/pipeline/stage2-mapping.ts +102 -27
  79. package/server/service/pipeline/stage3-board.test.ts +133 -0
  80. package/server/service/pipeline/stage3-board.ts +76 -11
  81. package/server/service/pipeline/stage4-binding.ts +2 -2
  82. package/server/service/registry/index.ts +1 -1
  83. package/server/service/types/index.ts +36 -0
@@ -9,14 +9,95 @@
9
9
  *
10
10
  * 비동기 worker 는 단일 프로세스 내 setImmediate 기반. 추후 큐(Redis/BullMQ) 로 이전 가능.
11
11
  */
12
- import { Resolver, Query, Mutation, Arg, Ctx, Directive, Field, InputType } from 'type-graphql'
12
+ import {
13
+ Resolver,
14
+ Query,
15
+ Mutation,
16
+ Arg,
17
+ Ctx,
18
+ Directive,
19
+ Field,
20
+ ID,
21
+ InputType,
22
+ ObjectType
23
+ } from 'type-graphql'
13
24
  import { GraphQLJSON } from 'graphql-scalars'
25
+ import { Like } from 'typeorm'
14
26
  import '@things-factory/auth-base'
15
27
 
16
28
  import { getRepository } from '@things-factory/shell'
29
+ import { Board, Group } from '@things-factory/board-service'
30
+ import { Attachment } from '@things-factory/attachment-base'
31
+
32
+ import { ImportSession } from './import-session'
33
+ import { materializeFromSession } from './materialize-from-session'
34
+ import {
35
+ suggestBoardName,
36
+ suggestBoardMeta,
37
+ type SuggestBoardMetaResult,
38
+ type SuggestBoardRepoLike
39
+ } from './suggest-board-name'
40
+ import {
41
+ startImportJobAsync,
42
+ fetchImportSession,
43
+ listImportSessionsByChatSession
44
+ } from './import-actions'
17
45
 
18
- import { ImportSession } from './import-session.js'
19
- import { runImportJob } from './import-worker.js'
46
+ @InputType()
47
+ class MaterializeImportSessionInput {
48
+ @Field(() => ID, { description: 'ImportSession id (must be in completed state).' })
49
+ sessionId!: string
50
+
51
+ @Field({ description: 'New Board name.' })
52
+ name!: string
53
+
54
+ @Field({ nullable: true, description: 'Optional Board description.' })
55
+ description?: string
56
+
57
+ @Field(() => ID, { nullable: true, description: 'Optional Group id to attach the Board to.' })
58
+ groupId?: string
59
+
60
+ @Field({ nullable: true, description: "Board type — 'main' | 'sub' | 'popup'. Default 'main'." })
61
+ type?: 'main' | 'sub' | 'popup'
62
+
63
+ @Field({ nullable: true, description: 'Base64 thumbnail. If omitted, board-service default empty thumbnail is used.' })
64
+ thumbnail?: string
65
+
66
+ @Field({
67
+ nullable: true,
68
+ description:
69
+ "ImportSession 의 결과 시안 중 하나의 id. 'as-is' / 'scene' / 'auto-fit'. " +
70
+ '미지정 시 default variant (보통 \'scene\') 사용.'
71
+ })
72
+ variantId?: string
73
+ }
74
+
75
+ @ObjectType()
76
+ class BoardMetaSuggestion {
77
+ @Field({ description: 'Suggested short board name (collision-free with current domain).' })
78
+ name!: string
79
+
80
+ @Field({
81
+ description:
82
+ 'Suggested detailed description — combines user prompt, AI importStrategy narrative, and category distribution stats.'
83
+ })
84
+ description!: string
85
+ }
86
+
87
+ @InputType()
88
+ class SuggestBoardNameInput {
89
+ @Field(() => ID, {
90
+ nullable: true,
91
+ description: 'ImportSession id — 있으면 거기서 userPrompt / VLM reasoning / attachment.name 추출.'
92
+ })
93
+ sessionId?: string
94
+
95
+ @Field({
96
+ nullable: true,
97
+ description: '명시 hint. session 정보보다 우선. 사용자가 직접 적은 메모를 기반으로 추천받고 싶을 때.'
98
+ })
99
+ hint?: string
100
+ }
20
101
 
21
102
  @InputType()
22
103
  class ImportBoardAsyncInput {
@@ -43,6 +124,14 @@ class ImportBoardAsyncInput {
43
124
 
44
125
  @Field(() => GraphQLJSON, { nullable: true, description: 'Adapter parse options (excludeLayers, maxEntities, ...).' })
45
126
  parseOptions?: any
127
+
128
+ @Field({
129
+ nullable: true,
130
+ description:
131
+ 'User-provided context / hints about the drawing — passed to VLM as additional guidance. ' +
132
+ 'e.g. "fab lithography zone, central rectangles are stockers, thin lines are OHT rails."'
133
+ })
134
+ userPrompt?: string
46
135
  }
47
136
 
48
137
  @Resolver()
@@ -54,7 +143,7 @@ export class ImportSessionResolver {
54
143
  @Ctx() context: ResolverContext
55
144
  ): Promise<ImportSession | null> {
56
145
  const { domain } = context.state
57
- return (await getRepository(ImportSession).findOneBy({ id, domain: { id: domain.id } })) ?? null
146
+ return await fetchImportSession(id, { domain, user: context.state.user }, getRepository(ImportSession) as any)
58
147
  }
59
148
 
60
149
  @Query(() => [ImportSession], { description: 'List recent import sessions for a chat session.' })
@@ -63,12 +152,8 @@ export class ImportSessionResolver {
63
152
  @Arg('chatSessionId') chatSessionId: string,
64
153
  @Ctx() context: ResolverContext
65
154
  ): Promise<ImportSession[]> {
66
- const { domain } = context.state
67
- return await getRepository(ImportSession).find({
68
- where: { chatSessionId, domain: { id: domain.id } },
69
- order: { createdAt: 'DESC' },
70
- take: 50
71
- })
155
+ const { domain, user } = context.state
156
+ return await listImportSessionsByChatSession(chatSessionId, { domain, user }, getRepository(ImportSession) as any)
72
157
  }
73
158
 
74
159
  @Mutation(() => ImportSession, {
@@ -80,34 +165,81 @@ export class ImportSessionResolver {
80
165
  @Ctx() context: ResolverContext
81
166
  ): Promise<ImportSession> {
82
167
  const { domain, user } = context.state
83
- const repo = getRepository(ImportSession)
84
-
85
- const session = await repo.save({
86
- domain,
87
- attachmentId: input.attachmentId,
88
- chatSessionId: input.chatSessionId,
89
- status: 'queued',
90
- progress: 0,
91
- message: 'Queued',
92
- optionsJson: JSON.stringify({
93
- scopes: input.scopes,
94
- normalizeOrigin: input.normalizeOrigin,
95
- flipY: input.flipY,
96
- scale: input.scale,
97
- applyBinding: input.applyBinding,
98
- parseOptions: input.parseOptions
99
- }),
100
- creator: user
101
- } as any)
102
-
103
- // 비동기 worker 시작 — 응답은 이미 즉시 반환됨
104
- setImmediate(() => {
105
- runImportJob(session.id!).catch(err => {
106
- // worker 내부에서 처리되지만 안전망
107
- console.error('[board-import] runImportJob unhandled:', err)
108
- })
109
- })
110
-
111
- return session
168
+ return await startImportJobAsync(input, { domain, user }, getRepository(ImportSession) as any)
169
+ }
170
+
171
+ @Query(() => String, {
172
+ description:
173
+ '@deprecated suggestBoardMeta 사용 권장. 단순 string name 만 반환하는 구버전 query.'
174
+ })
175
+ @Directive('@privilege(category: "board-import", privilege: "query")')
176
+ async suggestBoardName(
177
+ @Arg('input') input: SuggestBoardNameInput,
178
+ @Ctx() context: ResolverContext
179
+ ): Promise<string> {
180
+ const { domain } = context.state
181
+ const deps = this._buildSuggestDeps()
182
+ return await suggestBoardName(input, { domain }, deps)
183
+ }
184
+
185
+ @Query(() => BoardMetaSuggestion, {
186
+ description:
187
+ '도메인 안에서 충돌하지 않는 Board name + 상세 description 제안. ' +
188
+ 'name 은 짧고 심플 (30자 이내, 충돌 회피 (n) suffix), description 은 자세히 ' +
189
+ '(사용자 prompt + AI importStrategy + 카테고리 분포 통계 합성).'
190
+ })
191
+ @Directive('@privilege(category: "board-import", privilege: "query")')
192
+ async suggestBoardMeta(
193
+ @Arg('input') input: SuggestBoardNameInput,
194
+ @Ctx() context: ResolverContext
195
+ ): Promise<SuggestBoardMetaResult> {
196
+ const { domain } = context.state
197
+ const deps = this._buildSuggestDeps()
198
+ return await suggestBoardMeta(input, { domain }, deps)
199
+ }
200
+
201
+ /** 두 query 가 공유하는 deps wiring — typeorm find idiomatic 패턴 (distinctAlias 회귀 회피). */
202
+ private _buildSuggestDeps() {
203
+ const sessionRepo = getRepository(ImportSession) as any
204
+ const attachmentRepo = getRepository(Attachment) as any
205
+ const boardRepo: SuggestBoardRepoLike = {
206
+ findExistingNames: async ({ domainId, basePrefix }) => {
207
+ // 도메인 격리 + 'basePrefix' 로 시작하는 name 후보 한 번에 조회.
208
+ // 단순 find({ where, select }) — take 없음 → DISTINCT 서브쿼리 미발생.
209
+ const escaped = basePrefix.replace(/[%_\\]/g, ch => '\\' + ch)
210
+ const items = await getRepository(Board).find({
211
+ select: ['name'],
212
+ where: { domain: { id: domainId }, name: Like(`${escaped}%`) }
213
+ })
214
+ return items.map(b => b.name as string)
215
+ }
216
+ }
217
+ return { sessionRepo, attachmentRepo, boardRepo }
218
+ }
219
+
220
+ @Mutation(() => Board, {
221
+ description:
222
+ '완료된 ImportSession 의 결과 boardModel 을 새 Board entity 로 영속화한다. ' +
223
+ '검수 단계 (사용자/AI 가 import 결과를 확인 후 명시적으로 호출) 에서 사용. ' +
224
+ 'Board.state="draft" 로 생성되므로 release mutation 으로 별도 발행 필요.'
225
+ })
226
+ @Directive('@privilege(category: "board-import", privilege: "mutation")')
227
+ async materializeImportSession(
228
+ @Arg('input') input: MaterializeImportSessionInput,
229
+ @Ctx() context: ResolverContext
230
+ ): Promise<Board> {
231
+ const { domain, user } = context.state
232
+ const sessionRepo = getRepository(ImportSession)
233
+ const boardRepo = getRepository(Board)
234
+ const groupRepo = getRepository(Group)
235
+
236
+ const session = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } })
237
+
238
+ return (await materializeFromSession(
239
+ session as any,
240
+ input,
241
+ { domain, user },
242
+ { boardRepo: boardRepo as any, groupRepo: groupRepo as any }
243
+ )) as Board
112
244
  }
113
245
  }
@@ -15,8 +15,8 @@ import { getRepository } from '@things-factory/shell'
15
15
  import { Attachment } from '@things-factory/attachment-base'
16
16
  import { STORAGE } from '@things-factory/attachment-base'
17
17
 
18
- import { importBoard } from '../pipeline/index.js'
19
- import { ImportSession } from './import-session.js'
18
+ import { importBoardVariants } from '../pipeline/index'
19
+ import { ImportSession } from './import-session'
20
20
 
21
21
  export async function runImportJob(sessionId: string): Promise<void> {
22
22
  const repo = getRepository(ImportSession)
@@ -34,8 +34,9 @@ export async function runImportJob(sessionId: string): Promise<void> {
34
34
  throw new Error(`Attachment ${session.attachmentId} not found`)
35
35
  }
36
36
 
37
- // attachment-base STORAGE.readFile 으로 본문 로드 (Buffer)
38
- const fileBuffer = STORAGE.readFile(attachment.path!, null) as Buffer
37
+ // attachment-base STORAGE.readFile 으로 본문 로드 (Buffer).
38
+ // file storage 는 sync, db/s3/azure 는 async — await 으로 양쪽 다 흡수.
39
+ const fileBuffer = (await STORAGE.readFile(attachment.path!, null)) as Buffer
39
40
 
40
41
  await update(repo, sessionId, { status: 'parsing', progress: 20, message: 'Parsing drawing' })
41
42
 
@@ -45,7 +46,15 @@ export async function runImportJob(sessionId: string): Promise<void> {
45
46
  // importBoard 는 내부에서 stage 1~4 모두 수행. 단계별 progress 는 우선 거시적 업데이트.
46
47
  await update(repo, sessionId, { status: 'mapping', progress: 50, message: 'Mapping entities' })
47
48
 
48
- const result = await importBoard({
49
+ // userPrompt 는 parseOptions 로 합류 — image-adapter 가 VLM 프롬프트에 사용.
50
+ // 다른 adapter (DXF) 에는 무해 (몰라도 무시).
51
+ const mergedParseOptions = {
52
+ ...(opts.parseOptions || {}),
53
+ ...(opts.userPrompt ? { userPrompt: opts.userPrompt } : {})
54
+ }
55
+ // AI 가 같은 도면을 다른 관점으로 해석한 multi-variant. parse 한 번, 시안별 mapping + assemble.
56
+ // DXF 등 single-variant adapter 는 1 boardModel 만.
57
+ const result = await importBoardVariants({
49
58
  file: fileBuffer,
50
59
  filename,
51
60
  scopes: opts.scopes,
@@ -53,22 +62,57 @@ export async function runImportJob(sessionId: string): Promise<void> {
53
62
  flipY: opts.flipY,
54
63
  scale: opts.scale,
55
64
  applyBinding: opts.applyBinding,
56
- parseOptions: opts.parseOptions
65
+ parseOptions: mergedParseOptions
66
+ })
67
+
68
+ await update(repo, sessionId, { status: 'assembling', progress: 80, message: 'Assembling boards' })
69
+
70
+ // 시안별 카테고리 분포 — 각 variant 의 entities 가 다르므로.
71
+ const variantsWithStats = result.variants.map((v, i) => {
72
+ const dist: Record<string, number> = {}
73
+ for (const c of v.boardModel.components ?? []) {
74
+ const k = c.category ?? 'unknown'
75
+ dist[k] = (dist[k] ?? 0) + 1
76
+ }
77
+ return { variant: v, mapping: result.mappings[i], categoryDistribution: dist }
57
78
  })
58
79
 
59
- await update(repo, sessionId, { status: 'assembling', progress: 80, message: 'Assembling board' })
80
+ // default variantId — 첫 variant.
81
+ const defaultVariantId = result.variants[0]?.id
60
82
 
83
+ // resultJson 구조:
84
+ // - variants: 풀 시안 데이터 (선택용 — boardModel + subject + importStrategy + stats + categoryDist)
85
+ // - boardModel: default variant 의 boardModel (back-compat)
86
+ // - defaultVariantId: 첫 variant 의 id
87
+ // - metadata: viewType / aiClientId / userPrompt 등 시안 무관 공통
88
+ const defaultVariant = variantsWithStats[0]
61
89
  const resultJson = JSON.stringify({
62
- boardModel: result.boardModel,
63
- stats: result.mapping.stats,
64
- warnings: result.mapping.warnings
90
+ variants: variantsWithStats.map(({ variant, mapping, categoryDistribution }) => ({
91
+ id: variant.id,
92
+ label: variant.label,
93
+ description: variant.description,
94
+ subjectName: variant.subjectName,
95
+ subjectDescription: variant.subjectDescription,
96
+ importStrategy: variant.importStrategy,
97
+ boardModel: variant.boardModel,
98
+ stats: mapping.stats,
99
+ warnings: mapping.warnings,
100
+ categoryDistribution
101
+ })),
102
+ defaultVariantId,
103
+ boardModel: defaultVariant?.variant.boardModel,
104
+ stats: defaultVariant?.mapping.stats,
105
+ warnings: defaultVariant?.mapping.warnings,
106
+ metadata: result.parse.metadata,
107
+ categoryDistribution: defaultVariant?.categoryDistribution
65
108
  })
66
109
 
110
+ const totalAcross = result.mappings.reduce((s, m) => s + (m.stats.total ?? 0), 0)
67
111
  await update(repo, sessionId, {
68
112
  status: 'completed',
69
113
  progress: 100,
70
- message: `Completed: ${result.mapping.stats.matched} matched, ${result.mapping.stats.generic} generic`,
71
- totalEntities: result.mapping.stats.total,
114
+ message: `Completed: ${result.variants.length} variants, ${totalAcross} entities total`,
115
+ totalEntities: defaultVariant?.mapping.stats.total ?? 0,
72
116
  resultJson,
73
117
  completedAt: new Date()
74
118
  })
@@ -1,8 +1,24 @@
1
- import { ImportSession } from './import-session.js'
2
- import { ImportSessionResolver } from './import-session-resolver.js'
1
+ import { ImportSession } from './import-session'
2
+ import { ImportSessionResolver } from './import-session-resolver'
3
3
 
4
4
  export { ImportSession, ImportSessionResolver }
5
- export { runImportJob } from './import-worker.js'
5
+ export { runImportJob } from './import-worker'
6
+ export {
7
+ startImportJobAsync,
8
+ fetchImportSession,
9
+ listImportSessionsByChatSession,
10
+ describeAdapter,
11
+ type StartImportJobInput,
12
+ type ImportActionsContext,
13
+ type SessionRepoLike,
14
+ type AdapterInfo
15
+ } from './import-actions'
16
+ export {
17
+ materializeFromSession,
18
+ type MaterializeRequest,
19
+ type MaterializeContext,
20
+ type MaterializeDeps
21
+ } from './materialize-from-session'
6
22
 
7
23
  export const entities = [ImportSession]
8
24
  export const resolverClasses = [ImportSessionResolver]
@@ -0,0 +1,274 @@
1
+ /**
2
+ * materializeFromSession — 단위 테스트.
3
+ *
4
+ * Resolver IO (typeorm getRepository, ResolverContext) 와 분리된 순수 비즈니스 로직 검증.
5
+ * mock repository 로 검증 시나리오 / Board persist 정책 / 예외 케이스 회귀 방지.
6
+ */
7
+ import {
8
+ materializeFromSession,
9
+ type BoardRepoLike,
10
+ type GroupRepoLike,
11
+ type MaterializeContext,
12
+ type SessionLike
13
+ } from './materialize-from-session'
14
+
15
+ // ── Test fixtures ────────────────────────────────────────────────────
16
+
17
+ const DOMAIN = { id: 'domain-1', name: 'test-domain' }
18
+ const USER = { id: 'user-1', name: 'tester' }
19
+ const CONTEXT: MaterializeContext = { domain: DOMAIN, user: USER }
20
+
21
+ const SESSION_BASE: SessionLike = {
22
+ id: 'session-1',
23
+ status: 'completed',
24
+ result: {
25
+ boardModel: {
26
+ width: 1920,
27
+ height: 1080,
28
+ components: [{ refid: 1, type: 'rect' }]
29
+ }
30
+ }
31
+ }
32
+
33
+ function makeBoardRepo(overrides: Partial<BoardRepoLike> = {}): BoardRepoLike & {
34
+ saveCalls: any[]
35
+ findOneByCalls: any[]
36
+ } {
37
+ const findOneByCalls: any[] = []
38
+ const saveCalls: any[] = []
39
+ const repo: any = {
40
+ findOneByCalls,
41
+ saveCalls,
42
+ findOneBy: async (criteria: any) => {
43
+ findOneByCalls.push(criteria)
44
+ return null
45
+ },
46
+ save: async (entity: any) => {
47
+ saveCalls.push(entity)
48
+ return { ...entity, id: 'new-board-1' }
49
+ },
50
+ ...overrides
51
+ }
52
+ return repo
53
+ }
54
+
55
+ function makeGroupRepo(overrides: Partial<GroupRepoLike> = {}): GroupRepoLike {
56
+ return {
57
+ findOneBy: async () => null,
58
+ ...overrides
59
+ }
60
+ }
61
+
62
+ const baseInput = {
63
+ sessionId: 'session-1',
64
+ name: 'New Imported Board'
65
+ }
66
+
67
+ // ── 검증 / 예외 ──────────────────────────────────────────────────────
68
+
69
+ describe('materializeFromSession — 검증', () => {
70
+ test('session 이 null 이면 not found 에러', async () => {
71
+ await expect(
72
+ materializeFromSession(null, baseInput, CONTEXT, {
73
+ boardRepo: makeBoardRepo(),
74
+ groupRepo: makeGroupRepo()
75
+ })
76
+ ).rejects.toThrow(/not found/)
77
+ })
78
+
79
+ test("status 가 'completed' 가 아니면 cannot materialize 에러", async () => {
80
+ const session: SessionLike = { ...SESSION_BASE, status: 'parsing' }
81
+ await expect(
82
+ materializeFromSession(session, baseInput, CONTEXT, {
83
+ boardRepo: makeBoardRepo(),
84
+ groupRepo: makeGroupRepo()
85
+ })
86
+ ).rejects.toThrow(/cannot materialize/)
87
+ })
88
+
89
+ test('result.boardModel 이 없으면 no boardModel 에러', async () => {
90
+ const session: SessionLike = { ...SESSION_BASE, result: { boardModel: undefined } }
91
+ await expect(
92
+ materializeFromSession(session, baseInput, CONTEXT, {
93
+ boardRepo: makeBoardRepo(),
94
+ groupRepo: makeGroupRepo()
95
+ })
96
+ ).rejects.toThrow(/no boardModel/)
97
+ })
98
+
99
+ test('이름이 같은 도메인에 이미 있으면 already taken 에러', async () => {
100
+ const boardRepo = makeBoardRepo({
101
+ findOneBy: async () => ({ id: 'existing-1', name: baseInput.name })
102
+ })
103
+ await expect(
104
+ materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
105
+ boardRepo: boardRepo as any,
106
+ groupRepo: makeGroupRepo()
107
+ })
108
+ ).rejects.toThrow(/already taken/)
109
+ })
110
+ })
111
+
112
+ // ── 정상 흐름 ────────────────────────────────────────────────────────
113
+
114
+ describe('materializeFromSession — Board 생성 정책', () => {
115
+ test("저장 시 state='draft' + sourceImportSessionId=session.id", async () => {
116
+ const boardRepo = makeBoardRepo()
117
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
118
+ boardRepo: boardRepo as any,
119
+ groupRepo: makeGroupRepo()
120
+ })
121
+ expect(boardRepo.saveCalls).toHaveLength(1)
122
+ const saved = boardRepo.saveCalls[0]
123
+ expect(saved.state).toBe('draft')
124
+ expect(saved.sourceImportSessionId).toBe('session-1')
125
+ })
126
+
127
+ test('boardModel 이 JSON 문자열로 직렬화되어 model 필드에 저장됨', async () => {
128
+ const boardRepo = makeBoardRepo()
129
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
130
+ boardRepo: boardRepo as any,
131
+ groupRepo: makeGroupRepo()
132
+ })
133
+ const saved = boardRepo.saveCalls[0]
134
+ expect(typeof saved.model).toBe('string')
135
+ expect(JSON.parse(saved.model)).toEqual(SESSION_BASE.result!.boardModel)
136
+ })
137
+
138
+ test("type 미지정 시 default 'main'", async () => {
139
+ const boardRepo = makeBoardRepo()
140
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
141
+ boardRepo: boardRepo as any,
142
+ groupRepo: makeGroupRepo()
143
+ })
144
+ expect(boardRepo.saveCalls[0].type).toBe('main')
145
+ })
146
+
147
+ test("type='popup' 입력 시 그대로 저장", async () => {
148
+ const boardRepo = makeBoardRepo()
149
+ await materializeFromSession(
150
+ SESSION_BASE,
151
+ { ...baseInput, type: 'popup' },
152
+ CONTEXT,
153
+ { boardRepo: boardRepo as any, groupRepo: makeGroupRepo() }
154
+ )
155
+ expect(boardRepo.saveCalls[0].type).toBe('popup')
156
+ })
157
+
158
+ test('thumbnail 미지정 시 기본 empty thumbnail 사용', async () => {
159
+ const boardRepo = makeBoardRepo()
160
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
161
+ boardRepo: boardRepo as any,
162
+ groupRepo: makeGroupRepo()
163
+ })
164
+ expect(boardRepo.saveCalls[0].thumbnail).toMatch(/^data:image\/gif;base64,/)
165
+ })
166
+
167
+ test('thumbnail 지정 시 그대로 저장', async () => {
168
+ const boardRepo = makeBoardRepo()
169
+ const customThumbnail = 'data:image/png;base64,custom'
170
+ await materializeFromSession(
171
+ SESSION_BASE,
172
+ { ...baseInput, thumbnail: customThumbnail },
173
+ CONTEXT,
174
+ { boardRepo: boardRepo as any, groupRepo: makeGroupRepo() }
175
+ )
176
+ expect(boardRepo.saveCalls[0].thumbnail).toBe(customThumbnail)
177
+ })
178
+
179
+ test('groupId 지정 시 groupRepo 조회 결과를 group 으로 attach', async () => {
180
+ const boardRepo = makeBoardRepo()
181
+ const group = { id: 'group-1', name: 'test-group' }
182
+ const groupRepo = makeGroupRepo({ findOneBy: async () => group })
183
+ await materializeFromSession(
184
+ SESSION_BASE,
185
+ { ...baseInput, groupId: 'group-1' },
186
+ CONTEXT,
187
+ { boardRepo: boardRepo as any, groupRepo }
188
+ )
189
+ expect(boardRepo.saveCalls[0].group).toEqual(group)
190
+ })
191
+
192
+ test('groupId 미지정 시 group=undefined 로 저장', async () => {
193
+ const boardRepo = makeBoardRepo()
194
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
195
+ boardRepo: boardRepo as any,
196
+ groupRepo: makeGroupRepo()
197
+ })
198
+ expect(boardRepo.saveCalls[0].group).toBeUndefined()
199
+ })
200
+
201
+ test('createQueryBuilder 미지원 환경에서 sortOrder=0 fallback', async () => {
202
+ const boardRepo = makeBoardRepo()
203
+ expect(boardRepo.createQueryBuilder).toBeUndefined()
204
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
205
+ boardRepo: boardRepo as any,
206
+ groupRepo: makeGroupRepo()
207
+ })
208
+ expect(boardRepo.saveCalls[0].sortOrder).toBe(0)
209
+ })
210
+
211
+ test('createQueryBuilder 지원 환경에서 sortOrder = MIN(sortOrder) - 1', async () => {
212
+ const boardRepo = makeBoardRepo({
213
+ createQueryBuilder: () =>
214
+ ({
215
+ select: function (this: any) {
216
+ return this
217
+ },
218
+ where: function (this: any) {
219
+ return this
220
+ },
221
+ getRawOne: async () => ({ minSortOrder: 5 })
222
+ }) as any
223
+ })
224
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
225
+ boardRepo: boardRepo as any,
226
+ groupRepo: makeGroupRepo()
227
+ })
228
+ expect(boardRepo.saveCalls[0].sortOrder).toBe(4)
229
+ })
230
+
231
+ test('빈 보드 (minSortOrder=null) 환경에서 sortOrder=0', async () => {
232
+ const boardRepo = makeBoardRepo({
233
+ createQueryBuilder: () =>
234
+ ({
235
+ select: function (this: any) {
236
+ return this
237
+ },
238
+ where: function (this: any) {
239
+ return this
240
+ },
241
+ getRawOne: async () => ({ minSortOrder: null })
242
+ }) as any
243
+ })
244
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
245
+ boardRepo: boardRepo as any,
246
+ groupRepo: makeGroupRepo()
247
+ })
248
+ expect(boardRepo.saveCalls[0].sortOrder).toBe(0)
249
+ })
250
+
251
+ test('domain 과 creator/updater 가 context 에서 그대로 전파', async () => {
252
+ const boardRepo = makeBoardRepo()
253
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
254
+ boardRepo: boardRepo as any,
255
+ groupRepo: makeGroupRepo()
256
+ })
257
+ const saved = boardRepo.saveCalls[0]
258
+ expect(saved.domain).toBe(DOMAIN)
259
+ expect(saved.creator).toBe(USER)
260
+ expect(saved.updater).toBe(USER)
261
+ })
262
+
263
+ test('이름 충돌 검사가 도메인 id 기반', async () => {
264
+ const boardRepo = makeBoardRepo()
265
+ await materializeFromSession(SESSION_BASE, baseInput, CONTEXT, {
266
+ boardRepo: boardRepo as any,
267
+ groupRepo: makeGroupRepo()
268
+ })
269
+ expect(boardRepo.findOneByCalls[0]).toEqual({
270
+ name: baseInput.name,
271
+ domain: { id: DOMAIN.id }
272
+ })
273
+ })
274
+ })