@things-factory/board-import 10.0.0-beta.108
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/config/config.development.js +44 -0
- package/config/config.production.js +18 -0
- package/dist-server/index.d.ts +7 -0
- package/dist-server/index.js +11 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/adapters/dxf-adapter.d.ts +8 -0
- package/dist-server/service/adapters/dxf-adapter.js +220 -0
- package/dist-server/service/adapters/dxf-adapter.js.map +1 -0
- package/dist-server/service/adapters/image-adapter.d.ts +32 -0
- package/dist-server/service/adapters/image-adapter.js +420 -0
- package/dist-server/service/adapters/image-adapter.js.map +1 -0
- package/dist-server/service/ai/types.d.ts +185 -0
- package/dist-server/service/ai/types.js +378 -0
- package/dist-server/service/ai/types.js.map +1 -0
- package/dist-server/service/converters/generic-to.d.ts +31 -0
- package/dist-server/service/converters/generic-to.js +84 -0
- package/dist-server/service/converters/generic-to.js.map +1 -0
- package/dist-server/service/extraction/contracts.d.ts +112 -0
- package/dist-server/service/extraction/contracts.js +3 -0
- package/dist-server/service/extraction/contracts.js.map +1 -0
- package/dist-server/service/extraction/index.d.ts +17 -0
- package/dist-server/service/extraction/index.js +62 -0
- package/dist-server/service/extraction/index.js.map +1 -0
- package/dist-server/service/extraction/label-scale-calibrator.d.ts +5 -0
- package/dist-server/service/extraction/label-scale-calibrator.js +111 -0
- package/dist-server/service/extraction/label-scale-calibrator.js.map +1 -0
- package/dist-server/service/extraction/layout-refiner.d.ts +16 -0
- package/dist-server/service/extraction/layout-refiner.js +88 -0
- package/dist-server/service/extraction/layout-refiner.js.map +1 -0
- package/dist-server/service/extraction/pipeline.d.ts +11 -0
- package/dist-server/service/extraction/pipeline.js +124 -0
- package/dist-server/service/extraction/pipeline.js.map +1 -0
- package/dist-server/service/extraction/source-router.d.ts +17 -0
- package/dist-server/service/extraction/source-router.js +40 -0
- package/dist-server/service/extraction/source-router.js.map +1 -0
- package/dist-server/service/extraction/stubs.d.ts +18 -0
- package/dist-server/service/extraction/stubs.js +38 -0
- package/dist-server/service/extraction/stubs.js.map +1 -0
- package/dist-server/service/extraction/vector-geometry.d.ts +7 -0
- package/dist-server/service/extraction/vector-geometry.js +21 -0
- package/dist-server/service/extraction/vector-geometry.js.map +1 -0
- package/dist-server/service/extraction/vlm-recognizer.d.ts +18 -0
- package/dist-server/service/extraction/vlm-recognizer.js +78 -0
- package/dist-server/service/extraction/vlm-recognizer.js.map +1 -0
- package/dist-server/service/import-session/import-actions.d.ts +79 -0
- package/dist-server/service/import-session/import-actions.js +98 -0
- package/dist-server/service/import-session/import-actions.js.map +1 -0
- package/dist-server/service/import-session/import-session-resolver.d.ts +39 -0
- package/dist-server/service/import-session/import-session-resolver.js +263 -0
- package/dist-server/service/import-session/import-session-resolver.js.map +1 -0
- package/dist-server/service/import-session/import-session.d.ts +28 -0
- package/dist-server/service/import-session/import-session.js +152 -0
- package/dist-server/service/import-session/import-session.js.map +1 -0
- package/dist-server/service/import-session/import-worker.d.ts +1 -0
- package/dist-server/service/import-session/import-worker.js +121 -0
- package/dist-server/service/import-session/import-worker.js.map +1 -0
- package/dist-server/service/import-session/index.d.ts +8 -0
- package/dist-server/service/import-session/index.js +19 -0
- package/dist-server/service/import-session/index.js.map +1 -0
- package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
- package/dist-server/service/import-session/materialize-from-session.js +104 -0
- package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
- package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
- package/dist-server/service/import-session/suggest-board-name.js +195 -0
- package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
- package/dist-server/service/import-tools.d.ts +10 -0
- package/dist-server/service/import-tools.js +227 -0
- package/dist-server/service/import-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +26 -0
- package/dist-server/service/index.js +60 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/pipeline/index.d.ts +66 -0
- package/dist-server/service/pipeline/index.js +127 -0
- package/dist-server/service/pipeline/index.js.map +1 -0
- package/dist-server/service/pipeline/stage2-mapping.d.ts +29 -0
- package/dist-server/service/pipeline/stage2-mapping.js +231 -0
- package/dist-server/service/pipeline/stage2-mapping.js.map +1 -0
- package/dist-server/service/pipeline/stage3-board.d.ts +52 -0
- package/dist-server/service/pipeline/stage3-board.js +108 -0
- package/dist-server/service/pipeline/stage3-board.js.map +1 -0
- package/dist-server/service/pipeline/stage4-binding.d.ts +9 -0
- package/dist-server/service/pipeline/stage4-binding.js +28 -0
- package/dist-server/service/pipeline/stage4-binding.js.map +1 -0
- package/dist-server/service/registry/index.d.ts +27 -0
- package/dist-server/service/registry/index.js +108 -0
- package/dist-server/service/registry/index.js.map +1 -0
- package/dist-server/service/types/index.d.ts +152 -0
- package/dist-server/service/types/index.js +3 -0
- package/dist-server/service/types/index.js.map +1 -0
- package/dist-server/service/types/registry.d.ts +175 -0
- package/dist-server/service/types/registry.js +3 -0
- package/dist-server/service/types/registry.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +35 -0
- package/probe/gcp-vision-probe.mjs +114 -0
- package/probe/opencv-geometry-probe.py +98 -0
- package/server/index.ts +7 -0
- package/server/service/adapters/dxf-adapter.ts +230 -0
- package/server/service/adapters/image-adapter.test.ts +545 -0
- package/server/service/adapters/image-adapter.ts +464 -0
- package/server/service/ai/types.ts +619 -0
- package/server/service/converters/generic-to.test.ts +105 -0
- package/server/service/converters/generic-to.ts +121 -0
- package/server/service/extraction/contracts.ts +142 -0
- package/server/service/extraction/extraction.test.ts +374 -0
- package/server/service/extraction/index.ts +51 -0
- package/server/service/extraction/label-scale-calibrator.ts +124 -0
- package/server/service/extraction/layout-refiner.ts +104 -0
- package/server/service/extraction/pipeline.ts +163 -0
- package/server/service/extraction/source-router.ts +45 -0
- package/server/service/extraction/stubs.ts +46 -0
- package/server/service/extraction/vector-geometry.ts +26 -0
- package/server/service/extraction/vlm-recognizer.ts +87 -0
- package/server/service/import-session/import-actions.test.ts +185 -0
- package/server/service/import-session/import-actions.ts +164 -0
- package/server/service/import-session/import-session-resolver.ts +245 -0
- package/server/service/import-session/import-session.ts +141 -0
- package/server/service/import-session/import-worker.ts +132 -0
- package/server/service/import-session/index.ts +24 -0
- package/server/service/import-session/materialize-from-session.test.ts +274 -0
- package/server/service/import-session/materialize-from-session.ts +158 -0
- package/server/service/import-session/suggest-board-name.test.ts +271 -0
- package/server/service/import-session/suggest-board-name.ts +279 -0
- package/server/service/import-tools.test.ts +137 -0
- package/server/service/import-tools.ts +255 -0
- package/server/service/index.ts +117 -0
- package/server/service/pipeline/index.ts +184 -0
- package/server/service/pipeline/stage2-mapping.test.ts +204 -0
- package/server/service/pipeline/stage2-mapping.ts +270 -0
- package/server/service/pipeline/stage3-board.test.ts +133 -0
- package/server/service/pipeline/stage3-board.ts +179 -0
- package/server/service/pipeline/stage4-binding.ts +31 -0
- package/server/service/registry/index.ts +127 -0
- package/server/service/types/index.ts +169 -0
- package/server/service/types/registry.ts +186 -0
- package/things-factory.config.js +1 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ImportSession } from './import-session'
|
|
2
|
+
import { ImportSessionResolver } from './import-session-resolver'
|
|
3
|
+
|
|
4
|
+
export { ImportSession, ImportSessionResolver }
|
|
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'
|
|
22
|
+
|
|
23
|
+
export const entities = [ImportSession]
|
|
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
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* materializeFromSession — ImportSession.result.boardModel → 신규 Board 영속화의 **순수 비즈니스
|
|
3
|
+
* 로직**. typeorm `getRepository` / `ResolverContext` 같은 외부 의존성을 인자로 분리해서 단위
|
|
4
|
+
* 테스트 가능하도록 구성. resolver 측 mutation 은 IO (repo, ctx) 만 wiring 해서 이 함수를 호출.
|
|
5
|
+
*
|
|
6
|
+
* 호출 책임:
|
|
7
|
+
* - resolver: getRepository / context.state 에서 도메인/유저 추출
|
|
8
|
+
* - 이 함수: 검증 → 정책 적용 → save 호출 → 결과 반환
|
|
9
|
+
*
|
|
10
|
+
* 정책:
|
|
11
|
+
* - ImportSession.status 가 'completed' 가 아니면 거절
|
|
12
|
+
* - result.boardModel 이 없으면 거절
|
|
13
|
+
* - Board.name 이 같은 도메인에 이미 있으면 거절 (createBoard 와 동일 정책)
|
|
14
|
+
* - 새 보드는 sortOrder = (현재 최소 sortOrder - 1) 또는 0 (createBoard 와 동일 정책)
|
|
15
|
+
* - state='draft', sourceImportSessionId=session.id, model=JSON.stringify(boardModel)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const EMPTY_THUMBNAIL = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
|
|
19
|
+
|
|
20
|
+
export interface MaterializeRequest {
|
|
21
|
+
sessionId: string
|
|
22
|
+
name: string
|
|
23
|
+
description?: string
|
|
24
|
+
groupId?: string
|
|
25
|
+
type?: 'main' | 'sub' | 'popup'
|
|
26
|
+
thumbnail?: string
|
|
27
|
+
/** 사용자가 선택한 시안 id. 미지정 시 session.result.defaultVariantId 또는 첫 variant. */
|
|
28
|
+
variantId?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SessionLike {
|
|
32
|
+
id: string
|
|
33
|
+
status: string
|
|
34
|
+
result?: {
|
|
35
|
+
boardModel?: any
|
|
36
|
+
variants?: Array<{ id: string; boardModel: any }>
|
|
37
|
+
defaultVariantId?: string
|
|
38
|
+
} | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BoardRepoLike {
|
|
42
|
+
findOneBy(criteria: any): Promise<any | null>
|
|
43
|
+
save(entity: any): Promise<any>
|
|
44
|
+
/** sortOrder 결정용 — 미존재 시 0 으로 fallback. */
|
|
45
|
+
createQueryBuilder?(alias: string): {
|
|
46
|
+
select(expr: string, alias: string): any
|
|
47
|
+
where(cond: string, params: any): any
|
|
48
|
+
getRawOne(): Promise<any>
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface GroupRepoLike {
|
|
53
|
+
findOneBy(criteria: any): Promise<any | null>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MaterializeContext {
|
|
57
|
+
domain: { id: string; [k: string]: any }
|
|
58
|
+
user: any
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MaterializeDeps {
|
|
62
|
+
boardRepo: BoardRepoLike
|
|
63
|
+
groupRepo: GroupRepoLike
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ImportSession 결과를 새 Board 로 저장. 검증 실패 시 throw, 성공 시 저장된 Board 반환.
|
|
68
|
+
*/
|
|
69
|
+
export async function materializeFromSession(
|
|
70
|
+
session: SessionLike | null,
|
|
71
|
+
input: MaterializeRequest,
|
|
72
|
+
context: MaterializeContext,
|
|
73
|
+
deps: MaterializeDeps
|
|
74
|
+
): Promise<any> {
|
|
75
|
+
if (!session) {
|
|
76
|
+
throw new Error(`ImportSession ${input.sessionId} not found`)
|
|
77
|
+
}
|
|
78
|
+
if (session.status !== 'completed') {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`ImportSession ${input.sessionId} is not completed (status=${session.status}); cannot materialize.`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
const boardModel = pickVariantBoardModel(session.result, input.variantId)
|
|
84
|
+
if (!boardModel) {
|
|
85
|
+
throw new Error(`ImportSession ${input.sessionId} has no boardModel in result.`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const existing = await deps.boardRepo.findOneBy({
|
|
89
|
+
name: input.name,
|
|
90
|
+
domain: { id: context.domain.id }
|
|
91
|
+
})
|
|
92
|
+
if (existing) {
|
|
93
|
+
throw new Error(`Board name '${input.name}' is already taken`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const group = input.groupId ? (await deps.groupRepo.findOneBy({ id: input.groupId })) ?? undefined : undefined
|
|
97
|
+
|
|
98
|
+
const nextSortOrder = await resolveNextSortOrder(deps.boardRepo, context.domain.id)
|
|
99
|
+
|
|
100
|
+
return await deps.boardRepo.save({
|
|
101
|
+
domain: context.domain,
|
|
102
|
+
name: input.name,
|
|
103
|
+
description: input.description,
|
|
104
|
+
type: input.type ?? 'main',
|
|
105
|
+
thumbnail: input.thumbnail || EMPTY_THUMBNAIL,
|
|
106
|
+
model: JSON.stringify(boardModel),
|
|
107
|
+
sourceImportSessionId: session.id,
|
|
108
|
+
group,
|
|
109
|
+
sortOrder: nextSortOrder,
|
|
110
|
+
state: 'draft',
|
|
111
|
+
creator: context.user,
|
|
112
|
+
updater: context.user
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 시안 (variant) 선택 — 사용자가 명시한 variantId > session.result.defaultVariantId > 첫 variant
|
|
118
|
+
* > 구버전 호환 (session.result.boardModel).
|
|
119
|
+
*/
|
|
120
|
+
function pickVariantBoardModel(
|
|
121
|
+
result: SessionLike['result'],
|
|
122
|
+
variantId?: string
|
|
123
|
+
): any | undefined {
|
|
124
|
+
if (!result) return undefined
|
|
125
|
+
const variants = Array.isArray(result.variants) ? result.variants : []
|
|
126
|
+
if (variants.length > 0) {
|
|
127
|
+
if (variantId) {
|
|
128
|
+
const found = variants.find(v => v.id === variantId)
|
|
129
|
+
if (found?.boardModel) return found.boardModel
|
|
130
|
+
}
|
|
131
|
+
if (result.defaultVariantId) {
|
|
132
|
+
const found = variants.find(v => v.id === result.defaultVariantId)
|
|
133
|
+
if (found?.boardModel) return found.boardModel
|
|
134
|
+
}
|
|
135
|
+
if (variants[0]?.boardModel) return variants[0].boardModel
|
|
136
|
+
}
|
|
137
|
+
// back-compat — 구버전 ImportSession (variants 미존재) 의 단일 boardModel
|
|
138
|
+
return result.boardModel
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* sortOrder = MIN(sortOrder) - 1, 미존재 시 0. createBoard 의 "맨 앞에 배치" 정책과 동일.
|
|
143
|
+
* createQueryBuilder 미지원 환경 (예: 단위 테스트의 minimal mock) 에서는 0 fallback.
|
|
144
|
+
*/
|
|
145
|
+
async function resolveNextSortOrder(repo: BoardRepoLike, domainId: string): Promise<number> {
|
|
146
|
+
if (!repo.createQueryBuilder) return 0
|
|
147
|
+
try {
|
|
148
|
+
const raw = await repo
|
|
149
|
+
.createQueryBuilder('board')
|
|
150
|
+
.select('MIN(board.sortOrder)', 'minSortOrder')
|
|
151
|
+
.where('board.domain = :domainId', { domainId })
|
|
152
|
+
.getRawOne()
|
|
153
|
+
const min = raw?.minSortOrder
|
|
154
|
+
return typeof min === 'number' && Number.isFinite(min) ? min - 1 : 0
|
|
155
|
+
} catch {
|
|
156
|
+
return 0
|
|
157
|
+
}
|
|
158
|
+
}
|