opensoma 0.4.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.
Files changed (75) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/client.d.ts +7 -1
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +13 -11
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +1 -1
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +94 -52
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/constants.d.ts +40 -0
  11. package/dist/src/constants.d.ts.map +1 -1
  12. package/dist/src/constants.js +42 -0
  13. package/dist/src/constants.js.map +1 -1
  14. package/dist/src/formatters.d.ts.map +1 -1
  15. package/dist/src/formatters.js +42 -16
  16. package/dist/src/formatters.js.map +1 -1
  17. package/dist/src/index.d.ts +3 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +2 -0
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  22. package/dist/src/shared/utils/swmaestro.js +1 -5
  23. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  24. package/dist/src/shared/utils/toz.d.ts +23 -0
  25. package/dist/src/shared/utils/toz.d.ts.map +1 -0
  26. package/dist/src/shared/utils/toz.js +72 -0
  27. package/dist/src/shared/utils/toz.js.map +1 -0
  28. package/dist/src/token-extractor.d.ts +9 -1
  29. package/dist/src/token-extractor.d.ts.map +1 -1
  30. package/dist/src/token-extractor.js +54 -10
  31. package/dist/src/token-extractor.js.map +1 -1
  32. package/dist/src/toz-formatters.d.ts +9 -0
  33. package/dist/src/toz-formatters.d.ts.map +1 -0
  34. package/dist/src/toz-formatters.js +151 -0
  35. package/dist/src/toz-formatters.js.map +1 -0
  36. package/dist/src/toz-http.d.ts +27 -0
  37. package/dist/src/toz-http.d.ts.map +1 -0
  38. package/dist/src/toz-http.js +154 -0
  39. package/dist/src/toz-http.js.map +1 -0
  40. package/dist/src/types.d.ts +52 -0
  41. package/dist/src/types.d.ts.map +1 -1
  42. package/dist/src/types.js +46 -0
  43. package/dist/src/types.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/__fixtures__/toz/toz_all_branches.json +211 -0
  46. package/src/__fixtures__/toz/toz_booking.html +2190 -0
  47. package/src/__fixtures__/toz/toz_boothes.json +59 -0
  48. package/src/__fixtures__/toz/toz_duration.json +25 -0
  49. package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
  50. package/src/__fixtures__/toz/toz_page.html +211 -0
  51. package/src/client.test.ts +135 -117
  52. package/src/client.ts +16 -12
  53. package/src/commands/auth.test.ts +7 -7
  54. package/src/commands/auth.ts +107 -50
  55. package/src/commands/helpers.test.ts +8 -8
  56. package/src/commands/report.test.ts +7 -7
  57. package/src/constants.ts +50 -0
  58. package/src/credential-manager.test.ts +5 -5
  59. package/src/formatters.test.ts +22 -22
  60. package/src/formatters.ts +44 -16
  61. package/src/http.test.ts +37 -37
  62. package/src/index.ts +3 -0
  63. package/src/shared/utils/mentoring-params.test.ts +16 -16
  64. package/src/shared/utils/swmaestro.test.ts +87 -8
  65. package/src/shared/utils/swmaestro.ts +1 -6
  66. package/src/shared/utils/toz.test.ts +138 -0
  67. package/src/shared/utils/toz.ts +100 -0
  68. package/src/token-extractor.test.ts +40 -15
  69. package/src/token-extractor.ts +65 -13
  70. package/src/toz-formatters.test.ts +197 -0
  71. package/src/toz-formatters.ts +211 -0
  72. package/src/toz-http.test.ts +157 -0
  73. package/src/toz-http.ts +188 -0
  74. package/src/types.test.ts +220 -204
  75. package/src/types.ts +58 -0
@@ -0,0 +1,211 @@
1
+ <!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
5
+ <title>토즈</title>
6
+
7
+ <script type="text/javascript" src="/js/jquery-1.4.4.min.js"></script>
8
+ <script type="text/javascript" src="/js/jquery-ui-1.8.9.custom.min.js"></script>
9
+ <script type="text/javascript" src="/js/jquery.ui.datepicker-ko.js"></script>
10
+ <link type="text/css" href="/css/blitzer/jquery-ui-1.8.13.custom.css" rel="stylesheet" />
11
+ <link type="text/css" href="/css/partnerReservation.css" rel="stylesheet" />
12
+ <style>
13
+ .header {
14
+ background: #2c2c2c;
15
+ height: 80px;
16
+ border-bottom: 5px solid #ccc;
17
+ }
18
+
19
+ .header .logo {
20
+ position: relative;
21
+ width: 950px;
22
+ margin: 0 auto;
23
+ padding: 40px 0 0 0;
24
+ height: 40px;
25
+ }
26
+ .header .partner_logo {
27
+ color: white;
28
+ font-weight: bold;
29
+ font-size: 17px;
30
+ letter-spacing: -1px;
31
+ position: absolute;
32
+ top: 0;
33
+ right: 0;
34
+ padding: 50px 0 0 0;
35
+ height: 40px;
36
+ }
37
+ ul.btn-list {
38
+ position: relative;
39
+ list-style: none;
40
+ margin: 0;
41
+ padding: 0;
42
+ height: 100%;
43
+ }
44
+
45
+ ul.btn-list li {
46
+ position: relative;
47
+ margin: 0;
48
+ text-align: left;
49
+ width: 100%;
50
+ }
51
+
52
+ ul.btn-list li.btn-list-1 {
53
+ padding: 42px 0px;
54
+ }
55
+ ul.btn-list li.btn-list-3 {
56
+ padding: 42px 0px;
57
+ }
58
+ ul.btn-list li.btn-list-4 {
59
+ padding: 49px 0px;
60
+ }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <!-- top -->
65
+ <div class="header" style="padding-bottom: 6px">
66
+ <div class="logo" style="padding-bottom: 6px">
67
+ <div>
68
+ <a href="/partner/reservation/fkii3/swmaestro/index.htm?key=&projectSeq=&addedInfo=&tozApplyType="
69
+ ><img src="/images/partner/toz_logo.png"
70
+ /></a>
71
+ </div>
72
+ <div class="partner_logo">
73
+ 기업전용 문의접수: <a href="mailto: b2bsb@toz.co.kr" style="color: white">b2bsb@toz.co.kr</a>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ <div style="display: flex; flex-direction: row; justify-content: center; margin: -5px 0 0 0">
78
+ <div
79
+ class="layoutBody"
80
+ style="
81
+ position: relative;
82
+ display: flex;
83
+ flex-direction: column;
84
+ justify-content: center;
85
+ border-top: 5px solid #b00e33;
86
+ "
87
+ >
88
+ <div style="display: flex; flex-direction: row; width: 950px; border-bottom: 2px solid #d03f5f">
89
+ <div style="position: relative; width: 700px; z-index: 1">
90
+ <div style="padding-left: 30px; width: 380px; word-break: keep-all">
91
+ <p style="font-weight: bold; color: #890624; font-size: 18px; margin: 20px 0 10px 0">
92
+ (필독) 외부 회의실 예약 및 이용시 주의사항
93
+ </p>
94
+ <p></p>
95
+ <p style="font-size: 13px; color: #939393; font-weight: bold; line-height: 25px">
96
+ · 최소 2시간 부터 사용 가능하며 최대 3시간까지 사용합니다.<br />
97
+ · 예약 후 멘토링/특강 일정 변경시 반드시 예약 취소합니다.<br />
98
+ · 본인의 예약 미취소로 인해 발생한 비용은 개인에게<br />
99
+ <span style="display: inline-block; padding-left: 10px">직접 청구 될 수 있습니다.</span><br />
100
+ · 이용자(연수생/엑스퍼트/멘토) 경고에 해당하는 경우<br />
101
+ <span style="display: inline-block; padding-left: 14px">(1) 예약 후 예약 취소 없이 일방적인 노쇼</span
102
+ ><br />
103
+ <span style="display: inline-block; padding-left: 14px"
104
+ >(2) 멘토-연수생, 엑스퍼트-연수생과의 활동이 아닌</span
105
+ ><br />
106
+ <span style="display: inline-block; padding-left: 36px">목적 외 활동을 한 경우</span><br />
107
+ <span style="display: inline-block; padding-left: 14px">(3) 기타 부적합한 용도로 사용한 경우</span>
108
+ </p>
109
+ </div>
110
+
111
+ <div style="position: absolute; bottom: 10px; left: 40px">
112
+ <div class="companyName" style="padding: 5px 0">
113
+ <span style="font-weight: bold; color: #2c2c2c; border-bottom: 1px solid #939393; padding-bottom: 2px"
114
+ >AI‧SW마에스트로</span
115
+ >
116
+ </div>
117
+ <div
118
+ class="text"
119
+ style="padding: 5px 0; font-size: 14px; font-weight: bold; color: #939393; letter-spacing: -1px"
120
+ >
121
+ 제휴내용은
122
+ </div>
123
+ <div style="padding: 5px 0">
124
+ <span class="desc" style="color: #890624; border-bottom: 1px solid #939393; padding-bottom: 2px"
125
+ >20% 할인</span
126
+ ><span class="text"> 입니다.</span>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ <div style="position: relative; width: 250px; height: 388px">
131
+ <img src="/images/partner/deco_toz_main_1.png" style="position: absolute; bottom: 0px; right: 250px" />
132
+ <ul class="btn-list">
133
+ <li class="btn-list-1" style="background: #890624">
134
+ <a href="/partner/reservation/fkii3/swmaestro/booking.htm?key=&projectSeq=&addedInfo=&tozApplyType="
135
+ ><img alt="예약하기" src="/images/partner/btn_toz_main_1.png" style="margin-left: 30px"
136
+ /></a>
137
+ </li>
138
+
139
+ <li class="btn-list-3" style="background: #c42649">
140
+ <a href="/partner/reservation/fkii3/swmaestro/startmypage.htm?key=&projectSeq=&addedInfo=&tozApplyType="
141
+ ><img alt="예약확인하기" src="/images/partner/btn_toz_main_3.png" style="margin-left: 30px"
142
+ /></a>
143
+ </li>
144
+ <li class="btn-list-4" style="background: #d03f5f">
145
+ <a href="https://work.toz.co.kr/branchSearch?page=1&onesBranchType=TMC" target="_blank"
146
+ ><img alt="토즈모임센터 지점안내" src="/images/partner/btn_toz_main_4.png" style="margin-left: 30px"
147
+ /></a>
148
+ </li>
149
+ </ul>
150
+ </div>
151
+ </div>
152
+
153
+ <div
154
+ style="
155
+ display: flex;
156
+ flex-direction: row;
157
+ justify-content: center;
158
+ position: relative;
159
+ width: 950px;
160
+ margin: 0 auto;
161
+ text-align: center;
162
+ border-bottom: 2px solid #d03f5f;
163
+ "
164
+ >
165
+ <img src="/images/partner/btn_link_map.png" usemap="#map_link" style="width: 808px; height: 168px" />
166
+ <map name="map_link">
167
+ <area
168
+ shape="rect"
169
+ coords="10,30,210,160"
170
+ href="https://moim.toz.co.kr"
171
+ title="토즈 서비스 그룹"
172
+ target="_blank"
173
+ />
174
+ <area
175
+ shape="rect"
176
+ coords="280,30,510,160"
177
+ href="https://moim.toz.co.kr/customerCenter/posts/all"
178
+ title="토즈 고객센터"
179
+ target="_blank"
180
+ />
181
+ <area
182
+ shape="rect"
183
+ coords="570,25,810,160"
184
+ href="https://moim.toz.co.kr/customerCenter/posts/event"
185
+ title="토즈 이벤트"
186
+ target="_blank"
187
+ />
188
+ </map>
189
+ </div>
190
+ </div>
191
+ </div>
192
+
193
+ <!-- google analytics for (http)partner.toz.co.kr -->
194
+ <script type="text/javascript">
195
+ var _gaq = _gaq || []
196
+ _gaq.push(['_setAccount', 'UA-23075111-3'])
197
+ _gaq.push(['_trackPageview'])
198
+
199
+ ;(function () {
200
+ var ga = document.createElement('script')
201
+ ga.type = 'text/javascript'
202
+ ga.async = true
203
+ ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'
204
+ var s = document.getElementsByTagName('script')[0]
205
+ s.parentNode.insertBefore(ga, s)
206
+ })()
207
+ </script>
208
+ <!-- // google analytics for (http)partner.toz.co.kr -->
209
+ <!-- // bottom -->
210
+ </body>
211
+ </html>
@@ -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()