@things-factory/shell 10.0.0-zeta.8 → 10.0.3
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/client/entries/public/home.js +1 -1
- package/client/themes/app-theme.css +8 -24
- package/client/themes/calendar-theme.css +0 -7
- package/client/themes/grist-theme.css +13 -37
- package/client/themes/layout-theme.css +3 -23
- package/client/themes/report-theme.css +0 -9
- package/client/themes/theme-base.css +30 -0
- package/client/themes/tooltip-theme.css +0 -4
- package/dist-server/bootstrap-hooks.d.ts +51 -0
- package/dist-server/bootstrap-hooks.js +39 -0
- package/dist-server/bootstrap-hooks.js.map +1 -0
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +1 -0
- package/dist-server/index.js.map +1 -1
- package/dist-server/initializers/database.js +4 -1
- package/dist-server/initializers/database.js.map +1 -1
- package/dist-server/migrations/1785369600000-IndexDomainOwner.d.ts +5 -0
- package/dist-server/migrations/1785369600000-IndexDomainOwner.js +49 -0
- package/dist-server/migrations/1785369600000-IndexDomainOwner.js.map +1 -0
- package/dist-server/server-dev.js +13 -10
- package/dist-server/server-dev.js.map +1 -1
- package/dist-server/server.js +12 -8
- package/dist-server/server.js.map +1 -1
- package/dist-server/service/domain/domain-mutation.js +3 -2
- package/dist-server/service/domain/domain-mutation.js.map +1 -1
- package/dist-server/service/domain/domain.js +12 -1
- package/dist-server/service/domain/domain.js.map +1 -1
- package/dist-server/service/env-var/env-var-mutation.js +6 -1
- package/dist-server/service/env-var/env-var-mutation.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/dist-server/typeorm/get-data-encryption-key.js +11 -1
- package/dist-server/typeorm/get-data-encryption-key.js.map +1 -1
- package/dist-server/utils/apply-domain-filter.d.ts +17 -0
- package/dist-server/utils/apply-domain-filter.js +30 -0
- package/dist-server/utils/apply-domain-filter.js.map +1 -0
- package/dist-server/utils/condition-builder.js +15 -7
- package/dist-server/utils/condition-builder.js.map +1 -1
- package/dist-server/utils/get-query-builder-from-list-params.d.ts +39 -9
- package/dist-server/utils/get-query-builder-from-list-params.js +83 -40
- package/dist-server/utils/get-query-builder-from-list-params.js.map +1 -1
- package/dist-server/utils/index.d.ts +1 -0
- package/dist-server/utils/index.js +1 -0
- package/dist-server/utils/index.js.map +1 -1
- package/dist-server/utils/list-params-converter.js +8 -6
- package/dist-server/utils/list-params-converter.js.map +1 -1
- package/dist-server/utils/list-query-builder.js +2 -14
- package/dist-server/utils/list-query-builder.js.map +1 -1
- package/jest.config.js +5 -0
- package/package.json +12 -12
- package/tests/domain-owner-index.test.ts +121 -0
- package/tests/env-var-tenant-isolation.test.ts +118 -0
- package/tests/get-query-builder-features.test.ts +229 -0
- package/tests/list-param-operators.test.ts +87 -0
- package/tests/manifest-contract.test.ts +38 -0
- package/client/store.ts +0 -15
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 테넌트 격리 회귀 테스트 — 일괄 갱신이 다른 도메인의 행을 건드리지 못한다.
|
|
3
|
+
*
|
|
4
|
+
* `updateMultipleX` 계열은 스캐폴딩이 찍어낸 형태라 여러 패키지에 같은 코드가 복제돼 있었고,
|
|
5
|
+
* 클라이언트가 준 id 로 도메인 조건 없이 행을 집어 저장했다. 즉 자기 테넌트에서 해당 권한만
|
|
6
|
+
* 있으면 UUID 를 아는 다른 테넌트의 행을 조용히 수정할 수 있었다.
|
|
7
|
+
*
|
|
8
|
+
* 여기서는 `EnvVar`(테넌트별 환경변수 — 값이 가장 민감한 축) 로 그 형태를 잠근다.
|
|
9
|
+
* 리졸버를 직접 호출한다 — `@Directive` 는 GraphQL 스키마 계층이라 직접 호출에 개입하지 않고,
|
|
10
|
+
* 트랜잭션은 `context.state.tx` 로 주입한다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DataSource } from 'typeorm'
|
|
14
|
+
|
|
15
|
+
import { Domain, EnvVar, NamingStrategy, entities as shellEntities } from '@things-factory/shell'
|
|
16
|
+
|
|
17
|
+
/* 리졸버는 패키지 공개 표면이 아니라 dist-server 에서 직접 가져온다 (빌드 후 실행할 것). */
|
|
18
|
+
import { EnvVarMutation } from '../dist-server/service/env-var/env-var-mutation.js'
|
|
19
|
+
|
|
20
|
+
let dataSource: DataSource
|
|
21
|
+
|
|
22
|
+
const mutation = new EnvVarMutation()
|
|
23
|
+
|
|
24
|
+
function contextFor(domain: Domain) {
|
|
25
|
+
return { state: { domain, user: null, tx: dataSource.manager } } as any
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function createDomain(name: string): Promise<Domain> {
|
|
29
|
+
return await dataSource.getRepository(Domain).save({ name, subdomain: name } as any)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function createEnvVar(domain: Domain, name: string, value: string): Promise<EnvVar> {
|
|
33
|
+
return await dataSource.getRepository(EnvVar).save({ domain, name, value } as any)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
dataSource = new DataSource({
|
|
38
|
+
type: 'sqlite',
|
|
39
|
+
database: ':memory:',
|
|
40
|
+
synchronize: true,
|
|
41
|
+
dropSchema: true,
|
|
42
|
+
namingStrategy: new NamingStrategy(),
|
|
43
|
+
entities: [...shellEntities],
|
|
44
|
+
logging: false
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
await dataSource.initialize()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await dataSource?.destroy()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
await dataSource.query(`DELETE FROM "${dataSource.getMetadata(EnvVar).tableName}"`)
|
|
56
|
+
await dataSource.query(`DELETE FROM "${dataSource.getMetadata(Domain).tableName}"`)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('updateMultipleEnvVars 테넌트 격리', () => {
|
|
60
|
+
it('다른 도메인의 행은 갱신하지 못하고 실패한다', async () => {
|
|
61
|
+
const victim = await createDomain('victim')
|
|
62
|
+
const attacker = await createDomain('attacker')
|
|
63
|
+
const secret = await createEnvVar(victim, 'API_ENDPOINT', 'https://victim.example.com')
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
mutation.updateMultipleEnvVars(
|
|
67
|
+
[{ cuFlag: 'M', id: secret.id, value: 'https://attacker.example.com' } as any],
|
|
68
|
+
contextFor(attacker)
|
|
69
|
+
)
|
|
70
|
+
).rejects.toThrow(secret.id)
|
|
71
|
+
|
|
72
|
+
/* 피해 도메인의 값이 그대로여야 한다 */
|
|
73
|
+
const kept = await dataSource.getRepository(EnvVar).findOneBy({ id: secret.id })
|
|
74
|
+
expect(kept.value).toBe('https://victim.example.com')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('없는 id 는 조용히 새 행을 만들지 않고 실패한다', async () => {
|
|
78
|
+
const domain = await createDomain('acme')
|
|
79
|
+
|
|
80
|
+
await expect(
|
|
81
|
+
mutation.updateMultipleEnvVars(
|
|
82
|
+
[{ cuFlag: 'M', id: '00000000-0000-0000-0000-000000000000', value: 'x' } as any],
|
|
83
|
+
contextFor(domain)
|
|
84
|
+
)
|
|
85
|
+
).rejects.toThrow()
|
|
86
|
+
|
|
87
|
+
expect(await dataSource.getRepository(EnvVar).count()).toBe(0)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('자기 도메인의 행은 정상적으로 갱신한다', async () => {
|
|
91
|
+
const domain = await createDomain('acme')
|
|
92
|
+
const envVar = await createEnvVar(domain, 'API_ENDPOINT', 'https://old.example.com')
|
|
93
|
+
|
|
94
|
+
const results = await mutation.updateMultipleEnvVars(
|
|
95
|
+
[{ cuFlag: 'M', id: envVar.id, value: 'https://new.example.com' } as any],
|
|
96
|
+
contextFor(domain)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
expect(results).toHaveLength(1)
|
|
100
|
+
const updated = await dataSource.getRepository(EnvVar).findOneBy({ id: envVar.id })
|
|
101
|
+
expect(updated.value).toBe('https://new.example.com')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('생성은 현재 도메인으로 귀속된다', async () => {
|
|
105
|
+
const domain = await createDomain('acme')
|
|
106
|
+
|
|
107
|
+
await mutation.updateMultipleEnvVars(
|
|
108
|
+
[{ cuFlag: '+', name: 'NEW_VAR', value: 'v' } as any],
|
|
109
|
+
contextFor(domain)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const created = await dataSource.getRepository(EnvVar).findOne({
|
|
113
|
+
where: { name: 'NEW_VAR' },
|
|
114
|
+
relations: ['domain']
|
|
115
|
+
})
|
|
116
|
+
expect(created.domain.id).toBe(domain.id)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
import { Column, DataSource, Entity, ManyToOne, PrimaryColumn, Repository } from 'typeorm'
|
|
3
|
+
|
|
4
|
+
import { getQueryBuilderFromListParams } from '../server/utils/get-query-builder-from-list-params.js'
|
|
5
|
+
import { InheritedValueType, ListParam } from '../server/service/common-types/list-param.js'
|
|
6
|
+
import { Domain } from '../server/service/domain/domain.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* getQueryBuilderFromListParams 종합 회귀 테스트 (SQLite 실행).
|
|
10
|
+
*
|
|
11
|
+
* 121개 소비처가 의존하는 핵심 유틸의 동작을 실제 DB 실행으로 고정한다:
|
|
12
|
+
* 필터 · 검색(search/searchables) · 페이지네이션 · 정렬 · 도메인 상속(None/Include/Only) ·
|
|
13
|
+
* 관계(filtersMap+join) · 엣지(미지의 컬럼).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
@Entity()
|
|
17
|
+
class Category {
|
|
18
|
+
@PrimaryColumn({ type: 'integer' })
|
|
19
|
+
id!: number
|
|
20
|
+
|
|
21
|
+
@Column({ type: 'text', nullable: true })
|
|
22
|
+
name!: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Entity()
|
|
26
|
+
class Item {
|
|
27
|
+
@PrimaryColumn({ type: 'integer' })
|
|
28
|
+
id!: number
|
|
29
|
+
|
|
30
|
+
@Column({ type: 'text', nullable: true })
|
|
31
|
+
name!: string | null
|
|
32
|
+
|
|
33
|
+
@Column({ type: 'integer', nullable: true })
|
|
34
|
+
qty!: number | null
|
|
35
|
+
|
|
36
|
+
/* 멀티테넌트 도메인 컬럼(문자열 tenant id 저장) */
|
|
37
|
+
@Column({ type: 'text', nullable: true })
|
|
38
|
+
domain!: string | null
|
|
39
|
+
|
|
40
|
+
@ManyToOne(() => Category, { nullable: true })
|
|
41
|
+
category!: Category | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* 도메인 상속 테스트용 stub (빌더는 domain.id / domain.parentId 만 참조) */
|
|
45
|
+
const DOMAIN_A = { id: 'A', parentId: 'ROOT' } as unknown as Domain
|
|
46
|
+
|
|
47
|
+
let dataSource: DataSource
|
|
48
|
+
let repository: Repository<Item>
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
dataSource = new DataSource({
|
|
52
|
+
type: 'better-sqlite3',
|
|
53
|
+
database: ':memory:',
|
|
54
|
+
synchronize: true,
|
|
55
|
+
entities: [Item, Category]
|
|
56
|
+
})
|
|
57
|
+
await dataSource.initialize()
|
|
58
|
+
|
|
59
|
+
const catRepo = dataSource.getRepository(Category)
|
|
60
|
+
const books = await catRepo.save({ id: 1, name: 'books' })
|
|
61
|
+
const toys = await catRepo.save({ id: 2, name: 'toys' })
|
|
62
|
+
|
|
63
|
+
repository = dataSource.getRepository(Item)
|
|
64
|
+
await repository.save([
|
|
65
|
+
{ id: 1, name: 'Apple', qty: 10, domain: 'A', category: books },
|
|
66
|
+
{ id: 2, name: 'apricot', qty: 0, domain: 'A', category: toys },
|
|
67
|
+
{ id: 3, name: 'Banana', qty: 5, domain: 'B', category: books },
|
|
68
|
+
{ id: 4, name: 'cherry', qty: null, domain: 'ROOT', category: null },
|
|
69
|
+
{ id: 5, name: 'Date', qty: 20, domain: 'A', category: toys }
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
afterAll(async () => {
|
|
74
|
+
await dataSource?.destroy()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
type Opts = Parameters<typeof getQueryBuilderFromListParams>[0]
|
|
78
|
+
|
|
79
|
+
async function ids(opts: Omit<Opts, 'repository'>): Promise<number[]> {
|
|
80
|
+
const qb = getQueryBuilderFromListParams<Item>({ repository, ...(opts as any) })
|
|
81
|
+
const rows = (await qb.getMany()) as Item[]
|
|
82
|
+
return rows.map(r => r.id).sort((a, b) => a - b)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const P = (filters: any[] = [], extra: any = {}) => ({ filters, ...extra } as unknown as ListParam)
|
|
86
|
+
|
|
87
|
+
describe('컬럼 필터 연산자', () => {
|
|
88
|
+
it('eq / noteq', async () => {
|
|
89
|
+
expect(await ids({ params: P([{ name: 'name', operator: 'eq', value: 'Apple' }]) })).toEqual([1])
|
|
90
|
+
expect(await ids({ params: P([{ name: 'qty', operator: 'noteq', value: 0 }]) })).toEqual([1, 3, 5]) // null 은 != 0 에 안 걸림
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('in / notin', async () => {
|
|
94
|
+
expect(await ids({ params: P([{ name: 'qty', operator: 'in', value: [0, 5] }]) })).toEqual([2, 3])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('between', async () => {
|
|
98
|
+
expect(await ids({ params: P([{ name: 'qty', operator: 'between', value: [5, 20] }]) })).toEqual([1, 3, 5])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('lt / gte', async () => {
|
|
102
|
+
expect(await ids({ params: P([{ name: 'qty', operator: 'lt', value: 10 }]) })).toEqual([2, 3])
|
|
103
|
+
expect(await ids({ params: P([{ name: 'qty', operator: 'gte', value: 10 }]) })).toEqual([1, 5])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('is_null / is_not_null', async () => {
|
|
107
|
+
expect(await ids({ params: P([{ name: 'qty', operator: 'is_null', value: null }]) })).toEqual([4])
|
|
108
|
+
expect(await ids({ params: P([{ name: 'qty', operator: 'is_not_null', value: null }]) })).toEqual([1, 2, 3, 5])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('검색 (search / searchables)', () => {
|
|
113
|
+
it('search 는 대소문자 무시 부분일치 (LOWER LIKE %v%)', async () => {
|
|
114
|
+
expect(
|
|
115
|
+
await ids({ params: P([{ name: 'name', operator: 'search', value: 'AP' }]), searchables: ['name'] })
|
|
116
|
+
).toEqual([1, 2]) // Apple, apricot
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('LIKE 는 searchables 미지정 시 무시됨(무거운 스캔 방지) → 전체 반환', async () => {
|
|
120
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
121
|
+
expect(await ids({ params: P([{ name: 'name', operator: 'like', value: 'a' }]) })).toEqual([1, 2, 3, 4, 5])
|
|
122
|
+
warn.mockRestore()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('페이지네이션', () => {
|
|
127
|
+
it('page/limit 로 분할 (id 정렬 기준)', async () => {
|
|
128
|
+
const sortings = [{ name: 'id', desc: false }]
|
|
129
|
+
expect(await ids({ params: P([], { pagination: { page: 1, limit: 2 }, sortings }) })).toEqual([1, 2])
|
|
130
|
+
expect(await ids({ params: P([], { pagination: { page: 2, limit: 2 }, sortings }) })).toEqual([3, 4])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('limit 만 지정 시 take 만 적용', async () => {
|
|
134
|
+
const rows = await ids({ params: P([], { pagination: { limit: 3 }, sortings: [{ name: 'id' }] }) })
|
|
135
|
+
expect(rows.length).toBe(3)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('정렬', () => {
|
|
140
|
+
it('desc 정렬', async () => {
|
|
141
|
+
const qb = getQueryBuilderFromListParams({ repository, params: P([], { sortings: [{ name: 'id', desc: true }] }) })
|
|
142
|
+
const rows = await qb.getMany()
|
|
143
|
+
expect(rows.map(r => r.id)).toEqual([5, 4, 3, 2, 1])
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('도메인 상속 (None / Include / Only / 없음)', () => {
|
|
148
|
+
it('None: 해당 도메인만', async () => {
|
|
149
|
+
expect(await ids({ params: P([]), domain: DOMAIN_A })).toEqual([1, 2, 5])
|
|
150
|
+
})
|
|
151
|
+
it('Include: 도메인 + 부모', async () => {
|
|
152
|
+
expect(await ids({ params: P([], { inherited: InheritedValueType.Include }), domain: DOMAIN_A })).toEqual([
|
|
153
|
+
1, 2, 4, 5
|
|
154
|
+
])
|
|
155
|
+
})
|
|
156
|
+
it('Only: 부모만', async () => {
|
|
157
|
+
expect(await ids({ params: P([], { inherited: InheritedValueType.Only }), domain: DOMAIN_A })).toEqual([4])
|
|
158
|
+
})
|
|
159
|
+
it('domain 미전달 시 도메인 필터 없음', async () => {
|
|
160
|
+
expect(await ids({ params: P([]) })).toEqual([1, 2, 3, 4, 5])
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('관계 (filtersMap + join)', () => {
|
|
165
|
+
const filtersMap = { cat: { columnName: 'name', relationColumn: 'category' } }
|
|
166
|
+
|
|
167
|
+
it('관계 컬럼으로 필터(innerJoin)', async () => {
|
|
168
|
+
expect(
|
|
169
|
+
await ids({ params: P([{ name: 'cat', operator: 'eq', value: 'books' }]), filtersMap })
|
|
170
|
+
).toEqual([1, 3])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('관계 컬럼으로 정렬(leftJoin) — 크래시 없이 전체 반환', async () => {
|
|
174
|
+
const qb = getQueryBuilderFromListParams({
|
|
175
|
+
repository,
|
|
176
|
+
params: P([], { sortings: [{ name: 'cat', desc: false }] }),
|
|
177
|
+
filtersMap
|
|
178
|
+
})
|
|
179
|
+
const rows = await qb.getMany()
|
|
180
|
+
expect(rows.length).toBe(5) // category 없는 항목(4)도 leftJoin 이라 포함
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('검색 모드 (searchMode)', () => {
|
|
185
|
+
const searchables = ['name']
|
|
186
|
+
// 이름: Apple(1) apricot(2) Banana(3) cherry(4) Date(5) — 'a' 포함: 1,2,3,5 / 'a' 접두: 1,2
|
|
187
|
+
it('기본은 contains (어디든 포함) — 하위호환', async () => {
|
|
188
|
+
expect(await ids({ params: P([{ name: 'name', operator: 'search', value: 'a' }]), searchables })).toEqual([
|
|
189
|
+
1, 2, 3, 5
|
|
190
|
+
])
|
|
191
|
+
})
|
|
192
|
+
it('startsWith — 접두 일치', async () => {
|
|
193
|
+
expect(
|
|
194
|
+
await ids({ params: P([{ name: 'name', operator: 'search', value: 'a' }]), searchables, searchMode: 'startsWith' })
|
|
195
|
+
).toEqual([1, 2])
|
|
196
|
+
})
|
|
197
|
+
it('exact — 정확 일치(대소문자 무시)', async () => {
|
|
198
|
+
expect(
|
|
199
|
+
await ids({ params: P([{ name: 'name', operator: 'search', value: 'apple' }]), searchables, searchMode: 'exact' })
|
|
200
|
+
).toEqual([1])
|
|
201
|
+
expect(
|
|
202
|
+
await ids({ params: P([{ name: 'name', operator: 'search', value: 'a' }]), searchables, searchMode: 'exact' })
|
|
203
|
+
).toEqual([])
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('엣지 — 명확한 오류 가이드', () => {
|
|
208
|
+
it('미지의 컬럼 → 엔티티·컬럼을 안내하는 명확한 오류', () => {
|
|
209
|
+
expect(() =>
|
|
210
|
+
getQueryBuilderFromListParams({ repository, params: P([{ name: 'nope', operator: 'eq', value: 'x' }]) })
|
|
211
|
+
).toThrow(/unknown filter column "nope" on entity "Item"/)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('미지의 연산자 → 명확한 오류', () => {
|
|
215
|
+
expect(() =>
|
|
216
|
+
getQueryBuilderFromListParams({ repository, params: P([{ name: 'name', operator: 'bogus', value: 'x' }]) })
|
|
217
|
+
).toThrow(/unsupported filter operator "bogus"/)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('미지의 관계(정렬 relationColumn) → 명확한 오류', () => {
|
|
221
|
+
expect(() =>
|
|
222
|
+
getQueryBuilderFromListParams({
|
|
223
|
+
repository,
|
|
224
|
+
params: P([], { sortings: [{ name: 'x', desc: false }] }),
|
|
225
|
+
filtersMap: { x: { columnName: 'name', relationColumn: 'nosuch' } }
|
|
226
|
+
})
|
|
227
|
+
).toThrow(/relation "nosuch" not found on entity "Item"/)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
import { Column, DataSource, Entity, PrimaryColumn, Repository } from 'typeorm'
|
|
3
|
+
|
|
4
|
+
import { getQueryBuilderFromListParams } from '../server/utils/get-query-builder-from-list-params.js'
|
|
5
|
+
import { ListParam } from '../server/service/common-types/list-param.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 목록 필터 연산자의 SQL 이식성 회귀 테스트.
|
|
9
|
+
*
|
|
10
|
+
* 배경: 과거 boolean 연산자가 `IS TRUE/FALSE`(MSSQL/Oracle 미지원), 그리고
|
|
11
|
+
* `IS PRESENT/BLANK/EMPTY NUMERIC ID`(어떤 DB 에서도 무효) 를 raw SQL 로 방출했다.
|
|
12
|
+
* 파라미터 바인딩 기반의 이식성 SQL 로 교체한 뒤, SQLite 에서 실제 실행 결과를 고정한다.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
@Entity()
|
|
16
|
+
class Widget {
|
|
17
|
+
@PrimaryColumn({ type: 'integer' })
|
|
18
|
+
id!: number
|
|
19
|
+
|
|
20
|
+
@Column({ type: 'boolean', nullable: true })
|
|
21
|
+
flag!: boolean | null
|
|
22
|
+
|
|
23
|
+
@Column({ type: 'text', nullable: true })
|
|
24
|
+
title!: string | null
|
|
25
|
+
|
|
26
|
+
@Column({ type: 'integer', nullable: true })
|
|
27
|
+
refId!: number | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let dataSource: DataSource
|
|
31
|
+
let repository: Repository<Widget>
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
dataSource = new DataSource({
|
|
35
|
+
type: 'better-sqlite3',
|
|
36
|
+
database: ':memory:',
|
|
37
|
+
synchronize: true,
|
|
38
|
+
entities: [Widget]
|
|
39
|
+
})
|
|
40
|
+
await dataSource.initialize()
|
|
41
|
+
repository = dataSource.getRepository(Widget)
|
|
42
|
+
|
|
43
|
+
await repository.save([
|
|
44
|
+
{ id: 1, flag: true, title: 'a', refId: 10 },
|
|
45
|
+
{ id: 2, flag: false, title: '', refId: null },
|
|
46
|
+
{ id: 3, flag: null, title: null, refId: 0 }
|
|
47
|
+
])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await dataSource?.destroy()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
/** 단일 필터로 조회한 결과 id 목록(정렬) */
|
|
55
|
+
async function idsFor(name: string, operator: string): Promise<number[]> {
|
|
56
|
+
const params = { filters: [{ name, operator, value: null }] } as unknown as ListParam
|
|
57
|
+
const qb = getQueryBuilderFromListParams({ repository, params })
|
|
58
|
+
const rows = await qb.getMany()
|
|
59
|
+
return rows.map(r => r.id).sort((a, b) => a - b)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('list-param filter operators — SQLite 실행 결과 (이식성 SQL)', () => {
|
|
63
|
+
it('is_true / is_false (boolean 파라미터 바인딩)', async () => {
|
|
64
|
+
expect(await idsFor('flag', 'is_true')).toEqual([1])
|
|
65
|
+
expect(await idsFor('flag', 'is_false')).toEqual([2])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('is_not_true / is_not_false (NULL 포함 의미 보존)', async () => {
|
|
69
|
+
// NOT TRUE = false 또는 NULL, NOT FALSE = true 또는 NULL
|
|
70
|
+
expect(await idsFor('flag', 'is_not_true')).toEqual([2, 3])
|
|
71
|
+
expect(await idsFor('flag', 'is_not_false')).toEqual([1, 3])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('is_present / is_blank (값 있음 / 없음)', async () => {
|
|
75
|
+
expect(await idsFor('title', 'is_present')).toEqual([1, 2]) // IS NOT NULL ('' 은 not null)
|
|
76
|
+
expect(await idsFor('title', 'is_blank')).toEqual([3]) // IS NULL
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('is_empty_num_id (NULL 또는 0)', async () => {
|
|
80
|
+
expect(await idsFor('refId', 'is_empty_num_id')).toEqual([2, 3])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('is_null / is_not_null (기존 연산자 회귀 확인)', async () => {
|
|
84
|
+
expect(await idsFor('flag', 'is_null')).toEqual([3])
|
|
85
|
+
expect(await idsFor('flag', 'is_not_null')).toEqual([1, 2])
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ThingsFactoryClientConfig, ThingsFactoryServerModule } from '@things-factory/env'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 모듈 매니페스트 계약 파일럿 (P1 4단계).
|
|
5
|
+
*
|
|
6
|
+
* 실제 모듈이 취하는 형태(클라이언트 config / 서버 export)가 매니페스트 타입을 만족하는지
|
|
7
|
+
* 컴파일 타임(ts-jest)에 고정한다. 형태가 계약과 어긋나면 이 스위트가 컴파일에 실패한다.
|
|
8
|
+
*
|
|
9
|
+
* 참고: things-factory.config.js 는 babel(preset-typescript)로 컴파일되어 타입이 검사되지 않으므로
|
|
10
|
+
* (satisfies 도 그냥 제거됨), 계약 검증은 이처럼 tsc/ts-jest 가 타입 체크하는 지점에서 한다.
|
|
11
|
+
* 모듈이 자발적으로 계약을 채택하려면 tsc 로 검사되는 코드(서버 export 등)에서 satisfies 를 쓰거나
|
|
12
|
+
* 이와 같은 타입 어서션을 두면 된다.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// 클라이언트 매니페스트 — shell 의 실제 things-factory.config 형태
|
|
16
|
+
const clientConfig = {
|
|
17
|
+
bootstrap: () => {},
|
|
18
|
+
route: (page: string) => page,
|
|
19
|
+
routes: [{ tagname: 'page-404', page: 'page404' }]
|
|
20
|
+
} satisfies ThingsFactoryClientConfig
|
|
21
|
+
|
|
22
|
+
// 서버 모듈 — 대표적인 named export 형태(schema/entities/migrations/initMiddlewares)
|
|
23
|
+
const serverModule = {
|
|
24
|
+
schema: { resolverClasses: [class SampleResolver {}], typeDefs: {}, resolvers: {}, directives: {} },
|
|
25
|
+
entities: [class SampleEntity {}],
|
|
26
|
+
migrations: [],
|
|
27
|
+
subscribers: [],
|
|
28
|
+
initMiddlewares: (_app: unknown) => {}
|
|
29
|
+
} satisfies ThingsFactoryServerModule
|
|
30
|
+
|
|
31
|
+
describe('모듈 매니페스트 계약 (P1)', () => {
|
|
32
|
+
it('클라이언트/서버 형태가 매니페스트 타입을 만족한다 (컴파일 타임 검증)', () => {
|
|
33
|
+
expect(clientConfig.routes.length).toBe(1)
|
|
34
|
+
expect(clientConfig.routes[0].tagname).toBe('page-404')
|
|
35
|
+
expect(Array.isArray(serverModule.entities)).toBe(true)
|
|
36
|
+
expect(typeof serverModule.initMiddlewares).toBe('function')
|
|
37
|
+
})
|
|
38
|
+
})
|
package/client/store.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { configureStore } from '@reduxjs/toolkit'
|
|
2
|
-
|
|
3
|
-
import app from './reducers/app.js'
|
|
4
|
-
import route from './reducers/route.js'
|
|
5
|
-
|
|
6
|
-
export const store = configureStore({
|
|
7
|
-
// set false due to causing severe conflicts with redux-devtools.
|
|
8
|
-
// devTools: false // process.env['NODE-ENV'] !== 'production'
|
|
9
|
-
devTools: process.env['NODE-ENV'] !== 'production'
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
store.addReducers({
|
|
13
|
-
app,
|
|
14
|
-
route
|
|
15
|
-
})
|