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.
- package/dist/package.json +1 -1
- package/dist/src/client.d.ts +7 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +13 -11
- package/dist/src/client.js.map +1 -1
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js +1 -5
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/package.json +1 -1
- package/src/client.test.ts +135 -117
- package/src/client.ts +16 -12
- package/src/commands/auth.test.ts +7 -7
- package/src/commands/helpers.test.ts +8 -8
- package/src/commands/report.test.ts +7 -7
- package/src/credential-manager.test.ts +5 -5
- package/src/formatters.test.ts +22 -22
- package/src/http.test.ts +37 -37
- package/src/shared/utils/mentoring-params.test.ts +16 -16
- package/src/shared/utils/swmaestro.test.ts +87 -8
- package/src/shared/utils/swmaestro.ts +1 -6
- package/src/shared/utils/toz.test.ts +12 -7
- package/src/token-extractor.test.ts +12 -12
- package/src/toz-http.test.ts +11 -11
- package/src/types.test.ts +220 -204
package/src/client.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect,
|
|
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
|
|
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
|
-
|
|
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(
|
|
22
|
-
|
|
71
|
+
expect(client.getSessionData()).toEqual({
|
|
72
|
+
sessionCookie: 'session-1',
|
|
73
|
+
csrfToken: 'csrf-1',
|
|
74
|
+
})
|
|
23
75
|
})
|
|
24
76
|
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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(
|
|
170
|
+
expect(writes[0]?.data).toMatchObject({
|
|
128
171
|
menuNo: MENU_NO.MENTORING,
|
|
129
172
|
reportCd: 'MRC020',
|
|
130
173
|
qustnrSj: '새 멘토링',
|
|
131
174
|
})
|
|
132
|
-
expect(
|
|
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(
|
|
181
|
+
expect(writes[2]?.data).toEqual({
|
|
139
182
|
menuNo: MENU_NO.MENTORING,
|
|
140
183
|
qustnrSn: '7',
|
|
141
184
|
pageQueryString: '',
|
|
142
185
|
})
|
|
143
|
-
expect(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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="아침 회의<br>예약자 : 김오픈">오전 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
|
-
|
|
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
|
-
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
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(
|
|
353
|
+
expect(loginCalls).toEqual(['neo@example.com:secret'])
|
|
321
354
|
await expect(client.isLoggedIn()).resolves.toBe(true)
|
|
322
355
|
})
|
|
323
356
|
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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(
|
|
370
|
+
expect(loginCalls).toEqual(['neo@example.com:secret'])
|
|
344
371
|
})
|
|
345
372
|
|
|
346
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
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(
|
|
403
|
+
expect(logoutCalls).toEqual(['logout'])
|
|
382
404
|
})
|
|
383
405
|
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
|
|
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
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
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,
|
|
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 =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
+
it('skips browser extraction when no credentials exist', async () => {
|
|
194
194
|
let browserExtractionCalled = false
|
|
195
195
|
const manager = {
|
|
196
196
|
getCredentials: async () => null,
|