opensoma 0.5.0 → 0.5.1

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.
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, mock, test } from 'bun:test'
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test'
2
2
  import { mkdtemp } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -7,31 +7,80 @@ import { SomaClient } from './client'
7
7
  import { MENU_NO } from './constants'
8
8
  import { CredentialManager } from './credential-manager'
9
9
  import { AuthenticationError } from './errors'
10
- import type { SomaHttp } from './http'
10
+ import { SomaHttp, type UserIdentity } from './http'
11
11
 
12
12
  afterEach(() => {
13
13
  mock.restore()
14
14
  })
15
15
 
16
+ type HttpCall = { method: string; path: string; data: Record<string, string> | undefined }
17
+
18
+ interface FakeHttpConfig {
19
+ identity?: UserIdentity | null
20
+ getBody?: (path: string, data?: Record<string, string>) => string
21
+ postBody?: (path: string, data: Record<string, string>) => string
22
+ postFormBody?: (path: string, data: Record<string, string>) => string
23
+ sessionCookie?: string
24
+ csrfToken?: string | null
25
+ onLogin?: (username: string, password: string) => void
26
+ onLogout?: () => void
27
+ checkLoginSequence?: Array<UserIdentity | null>
28
+ }
29
+
30
+ function createFakeHttp(config: FakeHttpConfig = {}): { http: SomaHttp; calls: HttpCall[] } {
31
+ const calls: HttpCall[] = []
32
+ const sequence = config.checkLoginSequence ? [...config.checkLoginSequence] : null
33
+
34
+ const fake = {
35
+ checkLogin: async () => {
36
+ if (sequence) {
37
+ return sequence.shift() ?? config.identity ?? null
38
+ }
39
+ return config.identity ?? null
40
+ },
41
+ get: async (path: string, data?: Record<string, string>) => {
42
+ calls.push({ method: 'get', path, data })
43
+ return config.getBody ? config.getBody(path, data) : ''
44
+ },
45
+ post: async (path: string, data: Record<string, string>) => {
46
+ calls.push({ method: 'post', path, data })
47
+ return config.postBody ? config.postBody(path, data) : ''
48
+ },
49
+ postForm: async (path: string, data: Record<string, string>) => {
50
+ calls.push({ method: 'postForm', path, data })
51
+ return config.postFormBody ? config.postFormBody(path, data) : ''
52
+ },
53
+ postMultipart: async () => '',
54
+ login: async (username: string, password: string) => {
55
+ config.onLogin?.(username, password)
56
+ },
57
+ logout: async () => {
58
+ config.onLogout?.()
59
+ },
60
+ getSessionCookie: () => config.sessionCookie,
61
+ getCsrfToken: () => config.csrfToken ?? null,
62
+ }
63
+
64
+ return { http: fake as unknown as SomaHttp, calls }
65
+ }
66
+
16
67
  describe('SomaClient', () => {
17
- test('constructor initializes SomaHttp with provided session state', () => {
68
+ it('exposes session state passed through options', () => {
18
69
  const client = new SomaClient({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
19
- const http = Reflect.get(client, 'http') as SomaHttp
20
70
 
21
- expect(http.getSessionCookie()).toBe('session-1')
22
- expect(http.getCsrfToken()).toBe('csrf-1')
71
+ expect(client.getSessionData()).toEqual({
72
+ sessionCookie: 'session-1',
73
+ csrfToken: 'csrf-1',
74
+ })
23
75
  })
24
76
 
25
- test('mentoring list calls GET and parses list plus pagination', async () => {
26
- const client = new SomaClient()
27
- const calls: Array<{ method: string; path: string; data: Record<string, string> | undefined }> = []
28
- Reflect.set(client, 'http', {
29
- checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
30
- get: async (path: string, data?: Record<string, string>) => {
31
- calls.push({ method: 'get', path, data })
32
- return '<table><tbody><tr><td>1</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=123">[자유 멘토링] 제목 [접수중]</a></td><td>2026-04-01 ~ 2026-04-02</td><td>2026-04-03(목) 10:00 ~ 11:00</td><td>1 /4</td><td>OK</td><td>[접수중]</td><td>작성자</td><td>2026-04-01</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>'
33
- },
77
+ it('lists mentoring sessions with parsed items and pagination', async () => {
78
+ const { http, calls } = createFakeHttp({
79
+ identity: { userId: 'user@example.com', userNm: 'Test' },
80
+ getBody: () =>
81
+ '<table><tbody><tr><td>1</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=123">[자유 멘토링] 제목 [접수중]</a></td><td>2026-04-01 ~ 2026-04-02</td><td>2026-04-03() 10:00 ~ 11:00</td><td>1 /4</td><td>OK</td><td>[접수중]</td><td>작성자</td><td>2026-04-01</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>',
34
82
  })
83
+ const client = new SomaClient({ http })
35
84
 
36
85
  const result = await client.mentoring.list({ status: 'open', type: 'public', page: 2 })
37
86
 
@@ -51,41 +100,34 @@ describe('SomaClient', () => {
51
100
  expect(result.pagination).toEqual({ total: 1, currentPage: 1, totalPages: 1 })
52
101
  })
53
102
 
54
- test('mentoring get calls detail endpoint and parser', async () => {
55
- const client = new SomaClient()
56
- let captured: { path: string; data: Record<string, string> | undefined } | undefined
57
- Reflect.set(client, 'http', {
58
- checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
59
- get: async (path: string, data?: Record<string, string>) => {
60
- captured = { path, data }
61
- return '<input type="hidden" name="qustnrSn" value="99"><table><tr><th>모집 명</th><td>상세</td><th>상태</th><td>접수중</td></tr><tr><th>접수 기간</th><td>2026-04-01 ~ 2026-04-02</td><th>강의날짜</th><td>2026-04-03(목) 10:00 ~ 11:00</td></tr><tr><th>장소</th><td>온라인(Webex)</td><th>모집인원</th><td>4명</td></tr><tr><th>작성자</th><td>작성자</td><th>등록일</th><td>2026-04-01</td></tr></table><div data-content><p>본문</p></div>'
62
- },
103
+ it('fetches a single mentoring session from the detail endpoint', async () => {
104
+ const { http, calls } = createFakeHttp({
105
+ identity: { userId: 'user@example.com', userNm: 'Test' },
106
+ getBody: () =>
107
+ '<input type="hidden" name="qustnrSn" value="99"><table><tr><th>모집 명</th><td>상세</td><th>상태</th><td>접수중</td></tr><tr><th>접수 기간</th><td>2026-04-01 ~ 2026-04-02</td><th>강의날짜</th><td>2026-04-03(목) 10:00 ~ 11:00</td></tr><tr><th>장소</th><td>온라인(Webex)</td><th>모집인원</th><td>4명</td></tr><tr><th>작성자</th><td>작성자</td><th>등록일</th><td>2026-04-01</td></tr></table><div data-content><p>본문</p></div>',
63
108
  })
109
+ const client = new SomaClient({ http })
64
110
 
65
111
  const result = await client.mentoring.get(99)
66
112
 
67
- expect(captured).toEqual({
68
- path: '/mypage/mentoLec/view.do',
69
- data: { menuNo: MENU_NO.MENTORING, qustnrSn: '99' },
70
- })
113
+ expect(calls).toEqual([
114
+ {
115
+ method: 'get',
116
+ path: '/mypage/mentoLec/view.do',
117
+ data: { menuNo: MENU_NO.MENTORING, qustnrSn: '99' },
118
+ },
119
+ ])
71
120
  expect(result).toMatchObject({ id: 99, title: '상세', venue: '온라인(Webex)' })
72
121
  })
73
122
 
74
- test('mutating operations post expected payloads', async () => {
123
+ it('posts the expected payloads for create, update, delete, apply, cancel, reserve, and event apply', async () => {
75
124
  const mentoringDetailHtml =
76
125
  '<div class="group"><strong class="t">모집 명</strong><div class="c">[자유 멘토링] 기존 멘토링</div></div><div class="group"><strong class="t">접수 기간</strong><div class="c">2026.03.01 ~ 2026.03.15</div></div><div class="group"><strong class="t">강의날짜</strong><div class="c"><span>2026.03.20 10:00시 ~ 12:00시</span></div></div><div class="group"><strong class="t">장소</strong><div class="c">온라인(Webex)</div></div><div class="group"><strong class="t">모집인원</strong><div class="c">5명</div></div><div class="group"><strong class="t">작성자</strong><div class="c">전수열</div></div><div class="group"><strong class="t">등록일</strong><div class="c">2026.03.01</div></div><div class="cont"><p>기존 내용</p></div>'
77
- const client = new SomaClient()
78
- const calls: Array<{ method: string; path: string; data: Record<string, string> }> = []
79
- const captor = (method: string) => async (path: string, data: Record<string, string>) => {
80
- calls.push({ method, path, data })
81
- return ''
82
- }
83
- Reflect.set(client, 'http', {
84
- checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
85
- get: async () => mentoringDetailHtml,
86
- post: captor('post'),
87
- postForm: captor('postForm'),
126
+ const { http, calls } = createFakeHttp({
127
+ identity: { userId: 'user@example.com', userNm: 'Test' },
128
+ getBody: () => mentoringDetailHtml,
88
129
  })
130
+ const client = new SomaClient({ http })
89
131
 
90
132
  await client.mentoring.create({
91
133
  title: '새 멘토링',
@@ -115,7 +157,8 @@ describe('SomaClient', () => {
115
157
  })
116
158
  await client.event.apply(11)
117
159
 
118
- expect(calls.map((call) => `${call.method}:${call.path}`)).toEqual([
160
+ const writes = calls.filter((call) => call.method !== 'get')
161
+ expect(writes.map((call) => `${call.method}:${call.path}`)).toEqual([
119
162
  'postForm:/mypage/mentoLec/insert.do',
120
163
  'postForm:/mypage/mentoLec/update.do',
121
164
  'post:/mypage/mentoLec/delete.do',
@@ -124,41 +167,41 @@ describe('SomaClient', () => {
124
167
  'post:/mypage/itemRent/insert.do',
125
168
  'post:/application/application/application.do',
126
169
  ])
127
- expect(calls[0]?.data).toMatchObject({
170
+ expect(writes[0]?.data).toMatchObject({
128
171
  menuNo: MENU_NO.MENTORING,
129
172
  reportCd: 'MRC020',
130
173
  qustnrSj: '새 멘토링',
131
174
  })
132
- expect(calls[1]?.data).toMatchObject({
175
+ expect(writes[1]?.data).toMatchObject({
133
176
  menuNo: MENU_NO.MENTORING,
134
177
  reportCd: 'MRC010',
135
178
  qustnrSj: '수정된 멘토링',
136
179
  qustnrSn: '42',
137
180
  })
138
- expect(calls[2]?.data).toEqual({
181
+ expect(writes[2]?.data).toEqual({
139
182
  menuNo: MENU_NO.MENTORING,
140
183
  qustnrSn: '7',
141
184
  pageQueryString: '',
142
185
  })
143
- expect(calls[3]?.data).toEqual({
186
+ expect(writes[3]?.data).toEqual({
144
187
  menuNo: MENU_NO.EVENT,
145
188
  qustnrSn: '8',
146
189
  applyGb: 'C',
147
190
  stepHeader: '0',
148
191
  })
149
- expect(calls[4]?.data).toEqual({
192
+ expect(writes[4]?.data).toEqual({
150
193
  menuNo: MENU_NO.APPLICATION_HISTORY,
151
194
  applySn: '9',
152
195
  qustnrSn: '10',
153
196
  })
154
- expect(calls[5]?.data).toMatchObject({
197
+ expect(writes[5]?.data).toMatchObject({
155
198
  menuNo: MENU_NO.ROOM,
156
199
  itemId: '17',
157
200
  title: '회의',
158
201
  'time[0]': '10:00',
159
202
  'time[1]': '10:30',
160
203
  })
161
- expect(calls[6]?.data).toEqual({
204
+ expect(writes[6]?.data).toEqual({
162
205
  menuNo: MENU_NO.EVENT,
163
206
  qustnrSn: '11',
164
207
  applyGb: 'C',
@@ -166,22 +209,18 @@ describe('SomaClient', () => {
166
209
  })
167
210
  })
168
211
 
169
- test('mentoring.update merges partial params with existing data', async () => {
212
+ it('merges partial update params with the existing mentoring data', async () => {
170
213
  const mentoringDetailHtml =
171
214
  '<div class="group"><strong class="t">모집 명</strong><div class="c">[멘토 특강] 웹 성능 특강</div></div><div class="group"><strong class="t">접수 기간</strong><div class="c">2026.04.01 ~ 2026.04.10</div></div><div class="group"><strong class="t">강의날짜</strong><div class="c"><span>2026.04.11 14:00시 ~ 15:30시</span></div></div><div class="group"><strong class="t">장소</strong><div class="c">온라인(Webex)</div></div><div class="group"><strong class="t">모집인원</strong><div class="c">20명</div></div><div class="group"><strong class="t">작성자</strong><div class="c">전수열</div></div><div class="group"><strong class="t">등록일</strong><div class="c">2026.04.01</div></div><div class="cont"><p>세션 본문</p></div>'
172
- const client = new SomaClient()
173
- const postFormCalls: Array<{ path: string; data: Record<string, string> }> = []
174
- Reflect.set(client, 'http', {
175
- checkLogin: async () => ({ userId: 'user@example.com', userNm: 'Test' }),
176
- get: async () => mentoringDetailHtml,
177
- postForm: async (path: string, data: Record<string, string>) => {
178
- postFormCalls.push({ path, data })
179
- return ''
180
- },
215
+ const { http, calls } = createFakeHttp({
216
+ identity: { userId: 'user@example.com', userNm: 'Test' },
217
+ getBody: () => mentoringDetailHtml,
181
218
  })
219
+ const client = new SomaClient({ http })
182
220
 
183
221
  await client.mentoring.update(9572, { title: '변경된 제목' })
184
222
 
223
+ const postFormCalls = calls.filter((c) => c.method === 'postForm')
185
224
  expect(postFormCalls).toHaveLength(1)
186
225
  expect(postFormCalls[0]?.data).toMatchObject({
187
226
  qustnrSj: '변경된 제목',
@@ -198,13 +237,10 @@ describe('SomaClient', () => {
198
237
  })
199
238
  })
200
239
 
201
- test('room, dashboard, notice, team, member, event, and history routes use expected endpoints', async () => {
202
- const client = new SomaClient()
203
- const calls: Array<{ method: string; path: string; data: Record<string, string> | undefined }> = []
204
- Reflect.set(client, 'http', {
205
- checkLogin: async () => ({ userId: 'neo@example.com', userNm: '전수열' }),
206
- get: async (path: string, data?: Record<string, string>) => {
207
- calls.push({ method: 'get', path, data })
240
+ it('routes room, dashboard, notice, team, member, event, and history calls to the expected endpoints', async () => {
241
+ const { http, calls } = createFakeHttp({
242
+ identity: { userId: 'neo@example.com', userNm: '전수열' },
243
+ getBody: (path) => {
208
244
  if (path === '/mypage/myMain/dashboard.do') {
209
245
  return '<ul class="dash-top"><li class="dash-card"><div class="dash-etc"><span>소속 :<br> Indent</span><span>직책 :<br> </span></div><div class="dash-state"><div class="top"><span class="bg-orange label"><span>멘토</span></span><div class="welcome"><strong>전수열</strong>님 안녕하세요.</div></div></div></li></ul><ul class="bbs-dash_w"><li>멘토링 · 멘토특강<li><a href="/sw/mypage/mentoLec/view.do?qustnrSn=9582">게임 개발 AI 활용법 접수중</a></li></li></ul>'
210
246
  }
@@ -231,14 +267,14 @@ describe('SomaClient', () => {
231
267
  }
232
268
  return '<table><tbody><tr><td>1</td><td>멘토 특강</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=1">접수내역</a></td><td>전수열</td><td>2026.04.11</td><td>2026-04-01</td><td>[신청완료]</td><td>[OK]</td><td>승인대기</td><td>-</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>'
233
269
  },
234
- post: async (path: string, data: Record<string, string>) => {
235
- calls.push({ method: 'post', path, data })
270
+ postBody: (path) => {
236
271
  if (path === '/mypage/officeMng/rentTime.do') {
237
272
  return '<span class="ck-st2 disabled" data-hour="09" data-minute="00"><input type="checkbox" disabled="disabled"><label title="아침 회의&lt;br&gt;예약자 : 김오픈">오전 09:00</label></span>'
238
273
  }
239
274
  return '<ul class="bbs-reserve"><li class="item"><a href="javascript:void(0);" onclick="location.href=\'/sw/mypage/officeMng/view.do?itemId=17\';"><div class="cont"><h4 class="tit">스페이스 A1</h4><ul class="txt bul-dot grey"><li>이용기간 : 2026-04-01 ~ 2026-12-31</li><li><p>설명 : 4인</p></li><li class="time-list"><div class="time-grid"><span>09:00</span></div></li></ul></div></a></li></ul>'
240
275
  },
241
276
  })
277
+ const client = new SomaClient({ http })
242
278
 
243
279
  const roomList = await client.room.list({ date: '2026-04-01', room: 'A1', includeReservations: true })
244
280
  const roomSlots = await client.room.available(17, '2026-04-01')
@@ -289,8 +325,7 @@ describe('SomaClient', () => {
289
325
  note: '-',
290
326
  })
291
327
 
292
- const dashboardCallIndex = calls.findIndex((c) => c.path === '/mypage/myMain/dashboard.do')
293
- expect(dashboardCallIndex).toBeGreaterThanOrEqual(0)
328
+ expect(calls.some((c) => c.path === '/mypage/myMain/dashboard.do')).toBe(true)
294
329
  expect(calls).toContainEqual({
295
330
  method: 'post',
296
331
  path: '/mypage/officeMng/rentTime.do',
@@ -305,53 +340,44 @@ describe('SomaClient', () => {
305
340
  })
306
341
  })
307
342
 
308
- test('login and isLoggedIn delegate to SomaHttp', async () => {
309
- const client = new SomaClient({ username: 'neo@example.com', password: 'secret' })
310
- const calls: string[] = []
311
- Reflect.set(client, 'http', {
312
- login: async (username: string, password: string) => {
313
- calls.push(`${username}:${password}`)
314
- },
315
- checkLogin: async () => ({ userId: 'neo@example.com', userNm: '전수열' }),
343
+ it('delegates login and isLoggedIn to SomaHttp', async () => {
344
+ const loginCalls: string[] = []
345
+ const { http } = createFakeHttp({
346
+ identity: { userId: 'neo@example.com', userNm: '전수열' },
347
+ onLogin: (username, password) => loginCalls.push(`${username}:${password}`),
316
348
  })
349
+ const client = new SomaClient({ username: 'neo@example.com', password: 'secret', http })
317
350
 
318
351
  await client.login()
319
352
 
320
- expect(calls).toEqual(['neo@example.com:secret'])
353
+ expect(loginCalls).toEqual(['neo@example.com:secret'])
321
354
  await expect(client.isLoggedIn()).resolves.toBe(true)
322
355
  })
323
356
 
324
- test('auth-required operations re-login automatically when username/password are configured', async () => {
325
- const client = new SomaClient({ username: 'neo@example.com', password: 'secret' })
326
- const calls: string[] = []
327
- let authChecks = 0
328
- Reflect.set(client, 'http', {
329
- checkLogin: async () => {
330
- authChecks += 1
331
- return authChecks >= 2 ? { userId: 'neo@example.com', userNm: '전수열' } : null
332
- },
333
- login: async (username: string, password: string) => {
334
- calls.push(`${username}:${password}`)
335
- },
336
- get: async () =>
357
+ it('re-logs in automatically when username and password are configured', async () => {
358
+ const loginCalls: string[] = []
359
+ const { http } = createFakeHttp({
360
+ checkLoginSequence: [null, { userId: 'neo@example.com', userNm: '전수열' }],
361
+ onLogin: (username, password) => loginCalls.push(`${username}:${password}`),
362
+ getBody: () =>
337
363
  '<table><tbody><tr><td>1</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=123">[자유 멘토링] 제목 [접수중]</a></td><td>2026-04-01 ~ 2026-04-02</td><td>2026-04-03(목) 10:00 ~ 11:00</td><td>1 /4</td><td>OK</td><td>[접수중]</td><td>작성자</td><td>2026-04-01</td></tr></tbody></table><ul class="bbs-total"><li>Total : 1</li><li>1/1 Page</li></ul>',
338
364
  })
365
+ const client = new SomaClient({ username: 'neo@example.com', password: 'secret', http })
339
366
 
340
367
  await expect(client.mentoring.list()).resolves.toMatchObject({
341
368
  items: [expect.objectContaining({ id: 123, title: '제목' })],
342
369
  })
343
- expect(calls).toEqual(['neo@example.com:secret'])
370
+ expect(loginCalls).toEqual(['neo@example.com:secret'])
344
371
  })
345
372
 
346
- test('saveCredentials persists the credentials used by login()', async () => {
347
- const client = new SomaClient()
373
+ it('persists the credentials used by login() when saveCredentials is called', async () => {
348
374
  const dir = await mkdtemp(join(tmpdir(), 'opensoma-client-save-'))
349
375
  const manager = new CredentialManager(dir)
350
- Reflect.set(client, 'http', {
351
- login: async () => {},
352
- getSessionCookie: () => 'session-1',
353
- getCsrfToken: () => 'csrf-1',
376
+ const { http } = createFakeHttp({
377
+ sessionCookie: 'session-1',
378
+ csrfToken: 'csrf-1',
354
379
  })
380
+ const client = new SomaClient({ http })
355
381
 
356
382
  await client.login('neo@example.com', 'secret')
357
383
  await client.saveCredentials(manager)
@@ -367,25 +393,19 @@ describe('SomaClient', () => {
367
393
  await manager.remove()
368
394
  })
369
395
 
370
- test('logout delegates to SomaHttp', async () => {
371
- const client = new SomaClient()
372
- const calls: string[] = []
373
- Reflect.set(client, 'http', {
374
- logout: async () => {
375
- calls.push('logout')
376
- },
377
- })
396
+ it('delegates logout to SomaHttp', async () => {
397
+ const logoutCalls: string[] = []
398
+ const { http } = createFakeHttp({ onLogout: () => logoutCalls.push('logout') })
399
+ const client = new SomaClient({ http })
378
400
 
379
401
  await client.logout()
380
402
 
381
- expect(calls).toEqual(['logout'])
403
+ expect(logoutCalls).toEqual(['logout'])
382
404
  })
383
405
 
384
- test('auth-required operations throw AuthenticationError when not logged in', async () => {
385
- const client = new SomaClient()
386
- Reflect.set(client, 'http', {
387
- checkLogin: async () => null,
388
- })
406
+ it('throws AuthenticationError from every auth-required operation when not logged in', async () => {
407
+ const { http } = createFakeHttp({ identity: null })
408
+ const client = new SomaClient({ http })
389
409
 
390
410
  await expect(client.mentoring.list()).rejects.toBeInstanceOf(AuthenticationError)
391
411
  await expect(client.mentoring.get(1)).rejects.toBeInstanceOf(AuthenticationError)
@@ -428,11 +448,9 @@ describe('SomaClient', () => {
428
448
  await expect(client.event.apply(1)).rejects.toBeInstanceOf(AuthenticationError)
429
449
  })
430
450
 
431
- test('AuthenticationError has helpful message', async () => {
432
- const client = new SomaClient()
433
- Reflect.set(client, 'http', {
434
- checkLogin: async () => null,
435
- })
451
+ it('includes helpful login hints in the AuthenticationError message', async () => {
452
+ const { http } = createFakeHttp({ identity: null })
453
+ const client = new SomaClient({ http })
436
454
 
437
455
  try {
438
456
  await client.mentoring.list()
package/src/client.ts CHANGED
@@ -46,6 +46,8 @@ export interface SomaClientOptions {
46
46
  username?: string
47
47
  password?: string
48
48
  verbose?: boolean
49
+ /** @internal */
50
+ http?: SomaHttp
49
51
  }
50
52
 
51
53
  export class SomaClient {
@@ -109,7 +111,7 @@ export class SomaClient {
109
111
  searchKeyword?: string
110
112
  }): Promise<{ items: ReportListItem[]; pagination: Pagination }>
111
113
  get(id: number): Promise<ReportDetail>
112
- create(options: ReportCreateOptions, file: Buffer | string, fileName?: string): Promise<void>
114
+ create(options: ReportCreateOptions, files: Array<{ buffer: Buffer; name: string }>): Promise<void>
113
115
  update(
114
116
  id: number,
115
117
  options: Omit<ReportUpdateOptions, 'id'>,
@@ -141,11 +143,13 @@ export class SomaClient {
141
143
  this.options = options
142
144
  this.loginCredentials =
143
145
  options.username && options.password ? { username: options.username, password: options.password } : null
144
- this.http = new SomaHttp({
145
- sessionCookie: options.sessionCookie,
146
- csrfToken: options.csrfToken,
147
- verbose: options.verbose,
148
- })
146
+ this.http =
147
+ options.http ??
148
+ new SomaHttp({
149
+ sessionCookie: options.sessionCookie,
150
+ csrfToken: options.csrfToken,
151
+ verbose: options.verbose,
152
+ })
149
153
 
150
154
  this.mentoring = {
151
155
  list: async (options) => {
@@ -344,7 +348,7 @@ export class SomaClient {
344
348
  })
345
349
  return formatters.parseReportDetail(html, id)
346
350
  },
347
- create: async (options, file, fileName) => {
351
+ create: async (options, files) => {
348
352
  await this.requireAuth()
349
353
  const payload = buildReportPayload({
350
354
  menteeRegion: options.menteeRegion,
@@ -369,11 +373,11 @@ export class SomaClient {
369
373
  for (const [key, value] of Object.entries(payload)) {
370
374
  formData.append(key, value)
371
375
  }
372
- const isBuffer = Buffer.isBuffer(file)
373
- const fileBuffer = isBuffer ? file : await readFile(file)
374
- const resolvedFileName = isBuffer ? (fileName ?? 'file') : (file.split('/').pop() ?? 'file')
375
- const uint8Array = new Uint8Array(fileBuffer.buffer, fileBuffer.byteOffset, fileBuffer.byteLength)
376
- formData.append('file_1_1', new Blob([uint8Array as unknown as ArrayBuffer]), resolvedFileName)
376
+ for (let i = 0; i < files.length; i++) {
377
+ const { buffer, name } = files[i]
378
+ const uint8Array = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
379
+ formData.append(`file_1_${i + 1}`, new Blob([uint8Array as unknown as ArrayBuffer]), name)
380
+ }
377
381
  formData.append('fileFieldNm_1', 'file_1')
378
382
  formData.append('atchFileId', '')
379
383
  await this.http.postMultipart('/mypage/mentoringReport/insert.do', formData)
@@ -1,11 +1,11 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
 
3
3
  import { inspectStoredAuthStatus, resolveExtractedCredentials } from './auth'
4
4
 
5
5
  const noBrowserExtraction = async () => null
6
6
 
7
7
  describe('resolveExtractedCredentials', () => {
8
- test('returns the first candidate that validates successfully', async () => {
8
+ it('returns the first candidate that validates successfully', async () => {
9
9
  const calls: string[] = []
10
10
 
11
11
  const credentials = await resolveExtractedCredentials(
@@ -32,7 +32,7 @@ describe('resolveExtractedCredentials', () => {
32
32
  expect(calls).toEqual(['check:stale-session', 'check:valid-session', 'csrf:valid-session'])
33
33
  })
34
34
 
35
- test('returns null when every candidate is invalid or throws', async () => {
35
+ it('returns null when every candidate is invalid or throws', async () => {
36
36
  const credentials = await resolveExtractedCredentials(
37
37
  [
38
38
  { browser: 'Chrome', lastAccessUtc: 30, profile: 'Default', sessionCookie: 'stale-session' },
@@ -57,7 +57,7 @@ describe('resolveExtractedCredentials', () => {
57
57
  })
58
58
 
59
59
  describe('inspectStoredAuthStatus', () => {
60
- test('clears stale credentials when both recovery methods fail', async () => {
60
+ it('clears stale credentials when both recovery methods fail', async () => {
61
61
  let removed = false
62
62
 
63
63
  const status = await inspectStoredAuthStatus(
@@ -90,7 +90,7 @@ describe('inspectStoredAuthStatus', () => {
90
90
  expect(removed).toBe(true)
91
91
  })
92
92
 
93
- test('preserves credentials when session verification fails unexpectedly', async () => {
93
+ it('preserves credentials when session verification fails unexpectedly', async () => {
94
94
  let removed = false
95
95
 
96
96
  const status = await inspectStoredAuthStatus(
@@ -125,7 +125,7 @@ describe('inspectStoredAuthStatus', () => {
125
125
  expect(removed).toBe(false)
126
126
  })
127
127
 
128
- test('recovers via browser extraction when no stored password is available', async () => {
128
+ it('recovers via browser extraction when no stored password is available', async () => {
129
129
  let savedCredentials: Record<string, unknown> | null = null
130
130
 
131
131
  const status = await inspectStoredAuthStatus(
@@ -160,7 +160,7 @@ describe('inspectStoredAuthStatus', () => {
160
160
  expect(savedCredentials).toHaveProperty('loggedInAt')
161
161
  })
162
162
 
163
- test('refreshes the session automatically when encrypted login credentials are available', async () => {
163
+ it('refreshes the session automatically when stored username and password are available', async () => {
164
164
  let savedCredentials: Record<string, string> | null = null
165
165
 
166
166
  const status = await inspectStoredAuthStatus(
@@ -1,11 +1,11 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
 
3
3
  import { createAuthenticatedHttp } from './helpers'
4
4
 
5
5
  const noBrowserExtraction = async () => null
6
6
 
7
7
  describe('createAuthenticatedHttp', () => {
8
- test('throws a login hint when no credentials are stored', async () => {
8
+ it('throws a login hint when no credentials are stored', async () => {
9
9
  const manager = {
10
10
  getCredentials: async () => null,
11
11
  remove: async () => {},
@@ -16,7 +16,7 @@ describe('createAuthenticatedHttp', () => {
16
16
  )
17
17
  })
18
18
 
19
- test('clears stale credentials when both recovery methods fail', async () => {
19
+ it('clears stale credentials when both recovery methods fail', async () => {
20
20
  let removed = false
21
21
  const manager = {
22
22
  getCredentials: async () => ({
@@ -39,7 +39,7 @@ describe('createAuthenticatedHttp', () => {
39
39
  expect(removed).toBe(true)
40
40
  })
41
41
 
42
- test('returns the authenticated http client when the session is valid', async () => {
42
+ it('returns the authenticated http client when the session is valid', async () => {
43
43
  const http = {
44
44
  checkLogin: async () => ({ userId: 'neo@example.com', userNm: '전수열' }),
45
45
  get: async () => '',
@@ -60,7 +60,7 @@ describe('createAuthenticatedHttp', () => {
60
60
  await expect(createAuthenticatedHttp(manager, () => http)).resolves.toBe(http)
61
61
  })
62
62
 
63
- test('re-authenticates automatically when stored username/password are available', async () => {
63
+ it('re-authenticates automatically when stored username and password are available', async () => {
64
64
  let savedCredentials: Record<string, string> | null = null
65
65
  const manager = {
66
66
  getCredentials: async () => ({
@@ -110,7 +110,7 @@ describe('createAuthenticatedHttp', () => {
110
110
  })
111
111
  })
112
112
 
113
- test('recovers via browser extraction when no stored password is available', async () => {
113
+ it('recovers via browser extraction when no stored password is available', async () => {
114
114
  let savedCredentials: Record<string, unknown> | null = null
115
115
  const manager = {
116
116
  getCredentials: async () => ({
@@ -146,7 +146,7 @@ describe('createAuthenticatedHttp', () => {
146
146
  expect(savedCredentials).toHaveProperty('loggedInAt')
147
147
  })
148
148
 
149
- test('falls back to browser extraction when password re-login throws', async () => {
149
+ it('falls back to browser extraction when password re-login fails', async () => {
150
150
  let savedCredentials: Record<string, unknown> | null = null
151
151
  const manager = {
152
152
  getCredentials: async () => ({
@@ -190,7 +190,7 @@ describe('createAuthenticatedHttp', () => {
190
190
  })
191
191
  })
192
192
 
193
- test('does not attempt browser extraction when no credentials exist', async () => {
193
+ it('skips browser extraction when no credentials exist', async () => {
194
194
  let browserExtractionCalled = false
195
195
  const manager = {
196
196
  getCredentials: async () => null,