opensoma 0.5.1 → 0.7.0

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 (149) hide show
  1. package/dist/package.json +5 -1
  2. package/dist/src/agent-browser-launcher.d.ts +43 -0
  3. package/dist/src/agent-browser-launcher.d.ts.map +1 -0
  4. package/dist/src/agent-browser-launcher.js +97 -0
  5. package/dist/src/agent-browser-launcher.js.map +1 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +8 -5
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/client.d.ts +34 -7
  10. package/dist/src/client.d.ts.map +1 -1
  11. package/dist/src/client.js +224 -52
  12. package/dist/src/client.js.map +1 -1
  13. package/dist/src/commands/agent-browser.d.ts +3 -0
  14. package/dist/src/commands/agent-browser.d.ts.map +1 -0
  15. package/dist/src/commands/agent-browser.js +27 -0
  16. package/dist/src/commands/agent-browser.js.map +1 -0
  17. package/dist/src/commands/auth.d.ts +1 -1
  18. package/dist/src/commands/auth.d.ts.map +1 -1
  19. package/dist/src/commands/auth.js +4 -2
  20. package/dist/src/commands/auth.js.map +1 -1
  21. package/dist/src/commands/dashboard.d.ts +13 -0
  22. package/dist/src/commands/dashboard.d.ts.map +1 -1
  23. package/dist/src/commands/dashboard.js +10 -18
  24. package/dist/src/commands/dashboard.js.map +1 -1
  25. package/dist/src/commands/helpers.d.ts +1 -1
  26. package/dist/src/commands/helpers.d.ts.map +1 -1
  27. package/dist/src/commands/helpers.js +2 -2
  28. package/dist/src/commands/helpers.js.map +1 -1
  29. package/dist/src/commands/index.d.ts +3 -1
  30. package/dist/src/commands/index.d.ts.map +1 -1
  31. package/dist/src/commands/index.js +3 -1
  32. package/dist/src/commands/index.js.map +1 -1
  33. package/dist/src/commands/mentoring.d.ts.map +1 -1
  34. package/dist/src/commands/mentoring.js +54 -29
  35. package/dist/src/commands/mentoring.js.map +1 -1
  36. package/dist/src/commands/notice.d.ts.map +1 -1
  37. package/dist/src/commands/notice.js +2 -1
  38. package/dist/src/commands/notice.js.map +1 -1
  39. package/dist/src/commands/report.d.ts.map +1 -1
  40. package/dist/src/commands/report.js +4 -2
  41. package/dist/src/commands/report.js.map +1 -1
  42. package/dist/src/commands/room.d.ts.map +1 -1
  43. package/dist/src/commands/room.js +125 -2
  44. package/dist/src/commands/room.js.map +1 -1
  45. package/dist/src/commands/schedule.d.ts +3 -0
  46. package/dist/src/commands/schedule.d.ts.map +1 -0
  47. package/dist/src/commands/schedule.js +27 -0
  48. package/dist/src/commands/schedule.js.map +1 -0
  49. package/dist/src/commands/team.d.ts.map +1 -1
  50. package/dist/src/commands/team.js +55 -4
  51. package/dist/src/commands/team.js.map +1 -1
  52. package/dist/src/commands/toz.d.ts +16 -0
  53. package/dist/src/commands/toz.d.ts.map +1 -0
  54. package/dist/src/commands/toz.js +488 -0
  55. package/dist/src/commands/toz.js.map +1 -0
  56. package/dist/src/constants.d.ts +5 -5
  57. package/dist/src/constants.d.ts.map +1 -1
  58. package/dist/src/constants.js +20 -8
  59. package/dist/src/constants.js.map +1 -1
  60. package/dist/src/credential-manager.d.ts +15 -0
  61. package/dist/src/credential-manager.d.ts.map +1 -1
  62. package/dist/src/credential-manager.js +46 -0
  63. package/dist/src/credential-manager.js.map +1 -1
  64. package/dist/src/formatters.d.ts +11 -3
  65. package/dist/src/formatters.d.ts.map +1 -1
  66. package/dist/src/formatters.js +281 -52
  67. package/dist/src/formatters.js.map +1 -1
  68. package/dist/src/http.d.ts +8 -0
  69. package/dist/src/http.d.ts.map +1 -1
  70. package/dist/src/http.js +29 -1
  71. package/dist/src/http.js.map +1 -1
  72. package/dist/src/index.d.ts +8 -1
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +4 -1
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/session-recovery.js +2 -0
  77. package/dist/src/session-recovery.js.map +1 -1
  78. package/dist/src/shared/utils/swmaestro.d.ts +34 -1
  79. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  80. package/dist/src/shared/utils/swmaestro.js +102 -39
  81. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  82. package/dist/src/shared/utils/team-action-params.d.ts +3 -0
  83. package/dist/src/shared/utils/team-action-params.d.ts.map +1 -0
  84. package/dist/src/shared/utils/team-action-params.js +10 -0
  85. package/dist/src/shared/utils/team-action-params.js.map +1 -0
  86. package/dist/src/shared/utils/team-params.d.ts +12 -0
  87. package/dist/src/shared/utils/team-params.d.ts.map +1 -0
  88. package/dist/src/shared/utils/team-params.js +38 -0
  89. package/dist/src/shared/utils/team-params.js.map +1 -0
  90. package/dist/src/toz-client.d.ts +89 -0
  91. package/dist/src/toz-client.d.ts.map +1 -0
  92. package/dist/src/toz-client.js +204 -0
  93. package/dist/src/toz-client.js.map +1 -0
  94. package/dist/src/toz-pending-store.d.ts +30 -0
  95. package/dist/src/toz-pending-store.d.ts.map +1 -0
  96. package/dist/src/toz-pending-store.js +36 -0
  97. package/dist/src/toz-pending-store.js.map +1 -0
  98. package/dist/src/types.d.ts +147 -10
  99. package/dist/src/types.d.ts.map +1 -1
  100. package/dist/src/types.js +74 -6
  101. package/dist/src/types.js.map +1 -1
  102. package/package.json +5 -1
  103. package/src/agent-browser-launcher.test.ts +263 -0
  104. package/src/agent-browser-launcher.ts +159 -0
  105. package/src/cli.ts +10 -5
  106. package/src/client.test.ts +673 -30
  107. package/src/client.ts +287 -67
  108. package/src/commands/agent-browser.ts +33 -0
  109. package/src/commands/auth.test.ts +77 -26
  110. package/src/commands/auth.ts +5 -3
  111. package/src/commands/dashboard.test.ts +57 -0
  112. package/src/commands/dashboard.ts +22 -19
  113. package/src/commands/helpers.test.ts +76 -25
  114. package/src/commands/helpers.ts +3 -3
  115. package/src/commands/index.ts +3 -1
  116. package/src/commands/mentoring.ts +60 -29
  117. package/src/commands/notice.ts +2 -1
  118. package/src/commands/report.ts +4 -2
  119. package/src/commands/room.ts +160 -1
  120. package/src/commands/schedule.ts +32 -0
  121. package/src/commands/team.ts +73 -5
  122. package/src/commands/toz.test.ts +51 -0
  123. package/src/commands/toz.ts +607 -0
  124. package/src/constants.ts +20 -8
  125. package/src/credential-manager.test.ts +98 -0
  126. package/src/credential-manager.ts +50 -0
  127. package/src/formatters.test.ts +528 -33
  128. package/src/formatters.ts +309 -55
  129. package/src/http.test.ts +71 -2
  130. package/src/http.ts +41 -2
  131. package/src/index.ts +23 -1
  132. package/src/session-recovery.ts +2 -0
  133. package/src/shared/utils/swmaestro.test.ts +245 -9
  134. package/src/shared/utils/swmaestro.ts +150 -47
  135. package/src/shared/utils/team-action-params.test.ts +32 -0
  136. package/src/shared/utils/team-action-params.ts +10 -0
  137. package/src/shared/utils/team-params.test.ts +141 -0
  138. package/src/shared/utils/team-params.ts +53 -0
  139. package/src/toz-client.test.ts +243 -0
  140. package/src/toz-client.ts +311 -0
  141. package/src/toz-pending-store.test.ts +91 -0
  142. package/src/toz-pending-store.ts +62 -0
  143. package/src/types.test.ts +26 -13
  144. package/src/types.ts +87 -7
  145. package/dist/src/commands/event.d.ts +0 -3
  146. package/dist/src/commands/event.d.ts.map +0 -1
  147. package/dist/src/commands/event.js +0 -58
  148. package/dist/src/commands/event.js.map +0 -1
  149. package/src/commands/event.ts +0 -73
@@ -7,7 +7,7 @@ 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 { SomaHttp, type UserIdentity } from './http'
10
+ import { SomaHttp, UserGb, type UserIdentity } from './http'
11
11
 
12
12
  afterEach(() => {
13
13
  mock.restore()
@@ -20,6 +20,7 @@ interface FakeHttpConfig {
20
20
  getBody?: (path: string, data?: Record<string, string>) => string
21
21
  postBody?: (path: string, data: Record<string, string>) => string
22
22
  postFormBody?: (path: string, data: Record<string, string>) => string
23
+ postJsonBody?: (path: string, data: Record<string, string>) => unknown
23
24
  sessionCookie?: string
24
25
  csrfToken?: string | null
25
26
  onLogin?: (username: string, password: string) => void
@@ -27,6 +28,45 @@ interface FakeHttpConfig {
27
28
  checkLoginSequence?: Array<UserIdentity | null>
28
29
  }
29
30
 
31
+ function buildMentoringEditFormFixture(fields: {
32
+ qustnrSn: string
33
+ qustnrSj: string
34
+ reportCd: 'MRC010' | 'MRC020'
35
+ receiptType: 'UNTIL_LECTURE' | 'DIRECT'
36
+ bgndeDate: string
37
+ bgndeTime: string
38
+ enddeDate: string
39
+ enddeTime: string
40
+ eventDt: string
41
+ eventStime: string
42
+ eventEtime: string
43
+ applyCnt: string
44
+ place: string
45
+ }): string {
46
+ const option = (id: string, value: string, selected: string) =>
47
+ `<option value="${value}"${value === selected ? ' selected' : ''}>${id}</option>`
48
+ const radio = (name: string, value: string, checked: string) =>
49
+ `<input type="radio" name="${name}" value="${value}"${value === checked ? ' checked' : ''} />`
50
+ return `<form id="board">
51
+ <input type="hidden" name="qustnrSn" value="${fields.qustnrSn}" />
52
+ <input type="hidden" name="stateCd" value="A" />
53
+ ${radio('reportCd', 'MRC010', fields.reportCd)}
54
+ ${radio('reportCd', 'MRC020', fields.reportCd)}
55
+ <input type="text" name="qustnrSj" value="${fields.qustnrSj}" />
56
+ ${radio('receiptType', 'UNTIL_LECTURE', fields.receiptType)}
57
+ ${radio('receiptType', 'DIRECT', fields.receiptType)}
58
+ <input type="text" name="bgndeDate" value="${fields.bgndeDate}" />
59
+ <select name="bgndeTime">${option(fields.bgndeTime, fields.bgndeTime, fields.bgndeTime)}</select>
60
+ <input type="text" name="enddeDate" value="${fields.enddeDate}" />
61
+ <select name="enddeTime">${option(fields.enddeTime, fields.enddeTime, fields.enddeTime)}</select>
62
+ <input type="text" name="eventDt" value="${fields.eventDt}" />
63
+ <select name="eventStime">${option(fields.eventStime, fields.eventStime, fields.eventStime)}</select>
64
+ <select name="eventEtime">${option(fields.eventEtime, fields.eventEtime, fields.eventEtime)}</select>
65
+ <input type="text" name="applyCnt" value="${fields.applyCnt}" />
66
+ <select name="place">${option(fields.place, fields.place, fields.place)}</select>
67
+ </form>`
68
+ }
69
+
30
70
  function createFakeHttp(config: FakeHttpConfig = {}): { http: SomaHttp; calls: HttpCall[] } {
31
71
  const calls: HttpCall[] = []
32
72
  const sequence = config.checkLoginSequence ? [...config.checkLoginSequence] : null
@@ -50,6 +90,10 @@ function createFakeHttp(config: FakeHttpConfig = {}): { http: SomaHttp; calls: H
50
90
  calls.push({ method: 'postForm', path, data })
51
91
  return config.postFormBody ? config.postFormBody(path, data) : ''
52
92
  },
93
+ postJson: async (path: string, data: Record<string, string>) => {
94
+ calls.push({ method: 'postJson', path, data })
95
+ return config.postJsonBody ? config.postJsonBody(path, data) : {}
96
+ },
53
97
  postMultipart: async () => '',
54
98
  login: async (username: string, password: string) => {
55
99
  config.onLogin?.(username, password)
@@ -120,12 +164,27 @@ describe('SomaClient', () => {
120
164
  expect(result).toMatchObject({ id: 99, title: '상세', venue: '온라인(Webex)' })
121
165
  })
122
166
 
123
- it('posts the expected payloads for create, update, delete, apply, cancel, reserve, and event apply', async () => {
167
+ it('posts the expected payloads for create, update, delete, apply, cancel, and reserve', async () => {
124
168
  const mentoringDetailHtml =
125
169
  '<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>'
170
+ const mentoringEditFormHtml = buildMentoringEditFormFixture({
171
+ qustnrSn: '42',
172
+ qustnrSj: '기존 멘토링',
173
+ reportCd: 'MRC010',
174
+ receiptType: 'UNTIL_LECTURE',
175
+ bgndeDate: '2026-03-01',
176
+ bgndeTime: '00:00',
177
+ enddeDate: '2026-03-20',
178
+ enddeTime: '10:00',
179
+ eventDt: '2026-03-20',
180
+ eventStime: '10:00',
181
+ eventEtime: '12:00',
182
+ applyCnt: '5',
183
+ place: '온라인(Webex)',
184
+ })
126
185
  const { http, calls } = createFakeHttp({
127
186
  identity: { userId: 'user@example.com', userNm: 'Test' },
128
- getBody: () => mentoringDetailHtml,
187
+ getBody: (path) => (path === '/mypage/mentoLec/forUpdate.do' ? mentoringEditFormHtml : mentoringDetailHtml),
129
188
  })
130
189
  const client = new SomaClient({ http })
131
190
 
@@ -155,7 +214,6 @@ describe('SomaClient', () => {
155
214
  slots: ['10:00', '10:30'],
156
215
  title: '회의',
157
216
  })
158
- await client.event.apply(11)
159
217
 
160
218
  const writes = calls.filter((call) => call.method !== 'get')
161
219
  expect(writes.map((call) => `${call.method}:${call.path}`)).toEqual([
@@ -165,7 +223,6 @@ describe('SomaClient', () => {
165
223
  'post:/application/application/application.do',
166
224
  'post:/mypage/userAnswer/cancel.do',
167
225
  'post:/mypage/itemRent/insert.do',
168
- 'post:/application/application/application.do',
169
226
  ])
170
227
  expect(writes[0]?.data).toMatchObject({
171
228
  menuNo: MENU_NO.MENTORING,
@@ -184,7 +241,7 @@ describe('SomaClient', () => {
184
241
  pageQueryString: '',
185
242
  })
186
243
  expect(writes[3]?.data).toEqual({
187
- menuNo: MENU_NO.EVENT,
244
+ menuNo: MENU_NO.MENTORING,
188
245
  qustnrSn: '8',
189
246
  applyGb: 'C',
190
247
  stepHeader: '0',
@@ -201,20 +258,276 @@ describe('SomaClient', () => {
201
258
  'time[0]': '10:00',
202
259
  'time[1]': '10:30',
203
260
  })
204
- expect(writes[6]?.data).toEqual({
205
- menuNo: MENU_NO.EVENT,
206
- qustnrSn: '11',
207
- applyGb: 'C',
208
- stepHeader: '0',
261
+ })
262
+
263
+ it('fetches, updates, and cancels room reservations via itemRent endpoints', async () => {
264
+ const detailHtml = `
265
+ <form id="frm" method="post">
266
+ <input type="hidden" name="rentId" value="18718" />
267
+ <input type="hidden" name="itemId" value="17" />
268
+ <input type="hidden" name="receiptStatCd" value="RS001" />
269
+ <input type="hidden" name="title" value="멘토링" />
270
+ <input type="hidden" name="rentDt" value="2026-05-31" />
271
+ <input type="hidden" name="rentBgnde" value="2026-05-31 21:00:00.0" />
272
+ <input type="hidden" name="rentEndde" value="2026-05-31 21:30:00.0" />
273
+ <input type="hidden" name="infoCn" value="" />
274
+ <input type="hidden" name="rentNum" value="4" />
275
+ </form>
276
+ `
277
+ const { http, calls } = createFakeHttp({
278
+ identity: { userId: 'user@example.com', userNm: 'Test' },
279
+ getBody: (path) => (path === '/mypage/itemRent/view.do' ? detailHtml : ''),
280
+ })
281
+ const client = new SomaClient({ http })
282
+
283
+ const detail = await client.room.get(18718)
284
+ expect(detail).toEqual({
285
+ rentId: 18718,
286
+ itemId: 17,
287
+ title: '멘토링',
288
+ date: '2026-05-31',
289
+ startTime: '21:00',
290
+ endTime: '21:30',
291
+ attendees: 4,
292
+ notes: '',
293
+ status: 'confirmed',
294
+ statusCode: 'RS001',
295
+ })
296
+
297
+ await client.room.update(18718, { title: '스터디', notes: '회고 공유' })
298
+ await client.room.update(18718, { slots: ['22:00', '22:30'] })
299
+ await client.room.cancel(18718)
300
+
301
+ const updateCalls = calls.filter((call) => call.path === '/mypage/itemRent/update.do')
302
+ expect(updateCalls).toHaveLength(3)
303
+ expect(updateCalls[0]?.data).toMatchObject({
304
+ rentId: '18718',
305
+ itemId: '17',
306
+ receiptStatCd: 'RS001',
307
+ title: '스터디',
308
+ infoCn: '회고 공유',
309
+ rentBgnde: '2026-05-31 21:00:00',
310
+ rentEndde: '2026-05-31 21:30:00',
311
+ })
312
+ expect(updateCalls[0]?.data?.['time[0]']).toBeUndefined()
313
+ expect(updateCalls[1]?.data).toMatchObject({
314
+ rentId: '18718',
315
+ title: '멘토링',
316
+ rentBgnde: '2026-05-31 22:00:00',
317
+ rentEndde: '2026-05-31 22:59:00',
318
+ 'time[0]': '22:00',
319
+ 'time[1]': '22:30',
320
+ chkData_1: '2026-05-31|22:00|17',
321
+ chkData_2: '2026-05-31|22:30|17',
322
+ })
323
+ expect(updateCalls[2]?.data).toMatchObject({
324
+ rentId: '18718',
325
+ receiptStatCd: 'RS002',
326
+ title: '멘토링',
327
+ rentBgnde: '2026-05-31 21:00:00',
328
+ rentEndde: '2026-05-31 21:30:00',
329
+ })
330
+
331
+ const viewCalls = calls.filter((call) => call.path === '/mypage/itemRent/view.do')
332
+ expect(viewCalls).toHaveLength(4)
333
+ })
334
+
335
+ it('swallows the SWMaestro success-alert thrown by SomaHttp on room update', async () => {
336
+ const detailHtml = `
337
+ <form id="frm" method="post">
338
+ <input type="hidden" name="rentId" value="18718" />
339
+ <input type="hidden" name="itemId" value="17" />
340
+ <input type="hidden" name="receiptStatCd" value="RS001" />
341
+ <input type="hidden" name="title" value="멘토링" />
342
+ <input type="hidden" name="rentDt" value="2026-05-31" />
343
+ <input type="hidden" name="rentBgnde" value="2026-05-31 21:00:00.0" />
344
+ <input type="hidden" name="rentEndde" value="2026-05-31 21:30:00.0" />
345
+ <input type="hidden" name="infoCn" value="" />
346
+ <input type="hidden" name="rentNum" value="4" />
347
+ </form>
348
+ `
349
+ const { http } = createFakeHttp({
350
+ identity: { userId: 'user@example.com', userNm: 'Test' },
351
+ getBody: () => detailHtml,
352
+ postBody: (path) => {
353
+ if (path === '/mypage/itemRent/update.do') {
354
+ throw new Error('정상적으로 수정하였습니다.')
355
+ }
356
+ return ''
357
+ },
209
358
  })
359
+ const client = new SomaClient({ http })
360
+
361
+ await expect(client.room.update(18718, { title: '변경' })).resolves.toBeUndefined()
362
+ await expect(client.room.cancel(18718)).resolves.toBeUndefined()
363
+ })
364
+
365
+ it('lists room reservations filtered by default to confirmed status via itemRent list endpoint', async () => {
366
+ const listHtml = `
367
+ <ul class="bbs-total">
368
+ <li><strong>Total :</strong> 2</li>
369
+ <li><span>1</span>/1 Page</li>
370
+ </ul>
371
+ <table>
372
+ <thead>
373
+ <tr><th>NO.</th><th>회의실 명</th><th>제목</th><th>사용기간</th><th>작성자</th><th>상태</th><th>등록일</th></tr>
374
+ </thead>
375
+ <tbody>
376
+ <tr>
377
+ <td>2</td>
378
+ <td><a href="/sw/mypage/itemRent/view.do?rentId=18618">스페이스 M1</a></td>
379
+ <td>
380
+ <div class="rel">
381
+ <a href="/sw/mypage/itemRent/view.do?rentId=18618">멘토 특강</a>
382
+ <span>예약완료</span>
383
+ </div>
384
+ </td>
385
+ <td>2026.05.31 16:00 ~ 17:30</td>
386
+ <td>전수열</td>
387
+ <td>예약완료</td>
388
+ <td>2026.04.20</td>
389
+ </tr>
390
+ <tr>
391
+ <td>1</td>
392
+ <td><a href="/sw/mypage/itemRent/view.do?rentId=18616">스페이스 A3</a></td>
393
+ <td>
394
+ <div class="rel">
395
+ <a href="/sw/mypage/itemRent/view.do?rentId=18616">자유 멘토링</a>
396
+ <span>예약완료</span>
397
+ </div>
398
+ </td>
399
+ <td>2026.05.24 10:00 ~ 11:00</td>
400
+ <td>전수열</td>
401
+ <td>예약완료</td>
402
+ <td>2026.04.15</td>
403
+ </tr>
404
+ </tbody>
405
+ </table>
406
+ `
407
+ const { http, calls } = createFakeHttp({
408
+ identity: { userId: 'user@example.com', userNm: 'Test' },
409
+ getBody: (path) => (path === '/mypage/itemRent/list.do' ? listHtml : ''),
410
+ })
411
+ const client = new SomaClient({ http })
412
+
413
+ const result = await client.room.reservations({ startDate: '2026-01-01', endDate: '2026-12-31' })
414
+
415
+ expect(calls).toEqual([
416
+ {
417
+ method: 'get',
418
+ path: '/mypage/itemRent/list.do',
419
+ data: {
420
+ menuNo: MENU_NO.ROOM,
421
+ pageIndex: '1',
422
+ sdate: '2026-01-01',
423
+ edate: '2026-12-31',
424
+ searchStat: 'RS001',
425
+ },
426
+ },
427
+ ])
428
+ expect(result.pagination).toEqual({ total: 2, currentPage: 1, totalPages: 1 })
429
+ expect(result.items).toEqual([
430
+ {
431
+ rentId: 18618,
432
+ venue: '스페이스 M1',
433
+ title: '멘토 특강',
434
+ date: '2026-05-31',
435
+ startTime: '16:00',
436
+ endTime: '17:30',
437
+ author: '전수열',
438
+ status: 'confirmed',
439
+ statusLabel: '예약완료',
440
+ registeredAt: '2026.04.20',
441
+ },
442
+ {
443
+ rentId: 18616,
444
+ venue: '스페이스 A3',
445
+ title: '자유 멘토링',
446
+ date: '2026-05-24',
447
+ startTime: '10:00',
448
+ endTime: '11:00',
449
+ author: '전수열',
450
+ status: 'confirmed',
451
+ statusLabel: '예약완료',
452
+ registeredAt: '2026.04.15',
453
+ },
454
+ ])
455
+ })
456
+
457
+ it('omits the searchStat filter when status is "all"', async () => {
458
+ const { http, calls } = createFakeHttp({
459
+ identity: { userId: 'user@example.com', userNm: 'Test' },
460
+ getBody: () => '<ul class="bbs-total"><li>Total : 0</li></ul>',
461
+ })
462
+ const client = new SomaClient({ http })
463
+
464
+ await client.room.reservations({ status: 'all', page: 3 })
465
+
466
+ expect(calls[0]?.data).toEqual({ menuNo: MENU_NO.ROOM, pageIndex: '3' })
467
+ })
468
+
469
+ it('sends searchStat=RS002 when listing cancelled reservations', async () => {
470
+ const { http, calls } = createFakeHttp({
471
+ identity: { userId: 'user@example.com', userNm: 'Test' },
472
+ getBody: () => '<ul class="bbs-total"><li>Total : 0</li></ul>',
473
+ })
474
+ const client = new SomaClient({ http })
475
+
476
+ await client.room.reservations({ status: 'cancelled' })
477
+
478
+ expect(calls[0]?.data).toMatchObject({ searchStat: 'RS002' })
479
+ })
480
+
481
+ it('re-raises genuine errors thrown by SomaHttp on room update', async () => {
482
+ const detailHtml = `
483
+ <form id="frm" method="post">
484
+ <input type="hidden" name="rentId" value="18718" />
485
+ <input type="hidden" name="itemId" value="17" />
486
+ <input type="hidden" name="receiptStatCd" value="RS001" />
487
+ <input type="hidden" name="title" value="멘토링" />
488
+ <input type="hidden" name="rentDt" value="2026-05-31" />
489
+ <input type="hidden" name="rentBgnde" value="2026-05-31 21:00:00.0" />
490
+ <input type="hidden" name="rentEndde" value="2026-05-31 21:30:00.0" />
491
+ <input type="hidden" name="infoCn" value="" />
492
+ <input type="hidden" name="rentNum" value="4" />
493
+ </form>
494
+ `
495
+ const { http } = createFakeHttp({
496
+ identity: { userId: 'user@example.com', userNm: 'Test' },
497
+ getBody: () => detailHtml,
498
+ postBody: (path) => {
499
+ if (path === '/mypage/itemRent/update.do') {
500
+ throw new Error('권한이 없습니다.')
501
+ }
502
+ return ''
503
+ },
504
+ })
505
+ const client = new SomaClient({ http })
506
+
507
+ await expect(client.room.update(18718, { title: '변경' })).rejects.toThrow('권한이 없습니다.')
210
508
  })
211
509
 
212
510
  it('merges partial update params with the existing mentoring data', async () => {
213
511
  const mentoringDetailHtml =
214
512
  '<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>'
513
+ const mentoringEditFormHtml = buildMentoringEditFormFixture({
514
+ qustnrSn: '9572',
515
+ qustnrSj: '웹 성능 특강',
516
+ reportCd: 'MRC020',
517
+ receiptType: 'UNTIL_LECTURE',
518
+ bgndeDate: '2026-04-01',
519
+ bgndeTime: '00:00',
520
+ enddeDate: '2026-04-11',
521
+ enddeTime: '14:00',
522
+ eventDt: '2026-04-11',
523
+ eventStime: '14:00',
524
+ eventEtime: '15:30',
525
+ applyCnt: '20',
526
+ place: '온라인(Webex)',
527
+ })
215
528
  const { http, calls } = createFakeHttp({
216
529
  identity: { userId: 'user@example.com', userNm: 'Test' },
217
- getBody: () => mentoringDetailHtml,
530
+ getBody: (path) => (path === '/mypage/mentoLec/forUpdate.do' ? mentoringEditFormHtml : mentoringDetailHtml),
218
531
  })
219
532
  const client = new SomaClient({ http })
220
533
 
@@ -231,13 +544,18 @@ describe('SomaClient', () => {
231
544
  eventEtime: '15:30',
232
545
  place: '온라인(Webex)',
233
546
  applyCnt: '20',
234
- bgnde: '2026-04-01',
235
- endde: '2026-04-10',
547
+ bgndeDate: '2026-04-01',
548
+ bgndeTime: '00:00',
549
+ enddeDate: '2026-04-11',
550
+ enddeTime: '14:00',
551
+ receiptType: 'UNTIL_LECTURE',
552
+ stateCd: 'A',
553
+ qustnrAt: 'N',
236
554
  qestnarCn: '<p>세션 본문</p>',
237
555
  })
238
556
  })
239
557
 
240
- it('routes room, dashboard, notice, team, member, event, and history calls to the expected endpoints', async () => {
558
+ it('routes room, dashboard, notice, team, member, and history calls to the expected endpoints', async () => {
241
559
  const { http, calls } = createFakeHttp({
242
560
  identity: { userId: 'neo@example.com', userNm: '전수열' },
243
561
  getBody: (path) => {
@@ -259,12 +577,6 @@ describe('SomaClient', () => {
259
577
  if (path === '/mypage/myInfo/forUpdateMy.do') {
260
578
  return '<dl><dt><span class="point">아이디</span></dt><dd>neo@example.com</dd></dl><dl><dt><span class="point">이름</span></dt><dd>전수열</dd></dl><dl><dt><span class="point">성별</span></dt><dd>남자</dd></dl><dl><dt><span class="point">생년월일</span></dt><dd>1995-01-14</dd></dl><dl><dt><span class="point">연락처</span></dt><dd>01012345678</dd></dl><dl><dt><span class="point">소속</span></dt><dd>Indent</dd></dl><dl><dt><span class="point">직책</span></dt><dd></dd></dl>'
261
579
  }
262
- if (path === '/mypage/applicants/list.do') {
263
- return '<table><tbody><tr><td>1</td><td>행사</td><td>행사</td><td>2026-04-01 ~ 2026-04-02</td><td>2026-04-03 ~ 2026-04-03</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>'
264
- }
265
- if (path === '/mypage/applicants/view.do') {
266
- return '<input name="bbsId" value="1"><table><tr><th>제목</th><td>행사 상세</td></tr></table><div data-content><p>본문</p></div>'
267
- }
268
580
  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>'
269
581
  },
270
582
  postBody: (path) => {
@@ -281,10 +593,8 @@ describe('SomaClient', () => {
281
593
  const dashboard = await client.dashboard.get()
282
594
  const noticeList = await client.notice.list({ page: 2 })
283
595
  const noticeDetail = await client.notice.get(1)
284
- const team = await client.team.show()
596
+ const team = await client.team.list()
285
597
  const member = await client.member.show()
286
- const events = await client.event.list({ page: 3 })
287
- const eventDetail = await client.event.get(1)
288
598
  const history = await client.mentoring.history({ page: 4 })
289
599
 
290
600
  expect(roomList[0]?.itemId).toBe(17)
@@ -306,16 +616,16 @@ describe('SomaClient', () => {
306
616
  type: '멘토 특강',
307
617
  },
308
618
  ])
619
+ expect(dashboard.teams.map((t) => t.name)).toEqual(['오픈소마'])
309
620
  expect(noticeList.items[0]?.title).toBe('공지')
310
621
  expect(noticeDetail).toMatchObject({ id: 1, title: '공지' })
311
622
  expect(team.teams[0]?.name).toBe('오픈소마')
312
623
  expect(member.email).toBe('neo@example.com')
313
- expect(events.items[0]?.title).toBe('행사')
314
- expect(eventDetail).toMatchObject({ id: 1, title: '행사 상세' })
315
624
  expect(history.items[0]).toEqual({
316
625
  id: 1,
317
626
  category: '멘토 특강',
318
627
  title: '접수내역',
628
+ url: '/sw/mypage/mentoLec/view.do?qustnrSn=1',
319
629
  author: '전수열',
320
630
  sessionDate: '2026-04-11',
321
631
  appliedAt: '2026-04-01',
@@ -340,6 +650,292 @@ describe('SomaClient', () => {
340
650
  })
341
651
  })
342
652
 
653
+ it('fills trainee dashboard mentoring sessions from all upcoming application history pages', async () => {
654
+ const today = new Date().toISOString().slice(0, 10)
655
+ const todayDotted = today.replaceAll('-', '.')
656
+ const { http, calls } = createFakeHttp({
657
+ identity: { userId: 'trainee@example.com', userNm: '김연수' },
658
+ getBody: (path, data) => {
659
+ if (path === '/mypage/myMain/dashboard.do') {
660
+ return '<ul class="dash-top"><li class="dash-card"><div class="dash-etc"><span>소속 :<br> OpenSoma</span><span>직책 :<br> </span></div><div class="dash-state"><div class="top"><span class="bg-blue label"><span>17기 연수생</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=999">네이티브 항목 접수중</a></li></li></ul>'
661
+ }
662
+ if (path === '/mypage/userAnswer/history.do') {
663
+ const page = Number(data?.pageIndex ?? '1')
664
+ if (page === 1) {
665
+ return `<table><tbody>
666
+ <tr><td>45</td><td>멘토특강</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=39">미래 특강</a></td><td>김멘토</td><td>2099.01.01(목) 10:00:00 ~ 12:00:00</td><td>2026-04-20 22:41</td><td>[접수완료]</td><td>[OK]</td><td>-</td><td>-</td></tr>
667
+ <tr><td>44</td><td>멘토특강</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=40">이미 지난 특강</a></td><td>장영원</td><td>2000.01.01(토) 20:00:00 ~ 22:30:00</td><td>2026-04-24 22:41</td><td>[접수완료]</td><td>[OK]</td><td>-</td><td>-</td></tr>
668
+ <tr><td>43</td><td>자유멘토링</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=41">취소한 멘토링</a></td><td>이현수</td><td>2099.01.01(목) 20:00:00 ~ 21:30:00</td><td>2026-04-26 03:18</td><td>[접수취소]</td><td>[OK]</td><td>-</td><td>-</td></tr>
669
+ </tbody></table><ul class="bbs-total"><li>Total : 5</li><li>1/2 Page</li></ul>`
670
+ }
671
+
672
+ return `<table><tbody>
673
+ <tr><td>42</td><td>자유멘토링</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=44">팀 프로젝트 방향성 피드백</a></td><td>이상철</td><td>${todayDotted}(화) 22:00:00 ~ 24:00:00</td><td>2026-04-27 22:41</td><td>[접수완료]</td><td>[OK]</td><td>-</td><td>-</td></tr>
674
+ <tr><td>41</td><td>행사</td><td><a href="/sw/mypage/applicants/view.do?bbsId=38">멘토링이 아닌 행사</a></td><td>관리자</td><td>2099.01.02(금) 10:00:00 ~ 12:00:00</td><td>2026-04-20 22:41</td><td>[접수완료]</td><td>[OK]</td><td>-</td><td>-</td></tr>
675
+ </tbody></table><ul class="bbs-total"><li>Total : 5</li><li>2/2 Page</li></ul>`
676
+ }
677
+ return ''
678
+ },
679
+ })
680
+ const client = new SomaClient({ http })
681
+
682
+ const dashboard = await client.dashboard.get()
683
+
684
+ expect(dashboard.role).toBe('17기 연수생')
685
+ expect(dashboard.mentoringSessions).toEqual([
686
+ {
687
+ title: '팀 프로젝트 방향성 피드백',
688
+ url: '/sw/mypage/mentoLec/view.do?qustnrSn=44',
689
+ status: '접수완료',
690
+ date: today,
691
+ time: '22:00',
692
+ timeEnd: '24:00',
693
+ type: '자유 멘토링',
694
+ },
695
+ {
696
+ title: '미래 특강',
697
+ url: '/sw/mypage/mentoLec/view.do?qustnrSn=39',
698
+ status: '접수완료',
699
+ date: '2099-01-01',
700
+ time: '10:00',
701
+ timeEnd: '12:00',
702
+ type: '멘토 특강',
703
+ },
704
+ ])
705
+ const historyPages = calls
706
+ .filter((c) => c.path === '/mypage/userAnswer/history.do')
707
+ .map((c) => c.data?.pageIndex ?? '1')
708
+ .sort()
709
+ expect(historyPages).toEqual(['1', '2'])
710
+ expect(calls.some((c) => c.path === '/mypage/mentoLec/list.do')).toBe(false)
711
+ })
712
+
713
+ it('enriches dashboard with every team the mentor belongs to', async () => {
714
+ const teamCard = (name: string, ownerId: string, teamId: string) =>
715
+ `<li><div class="top"><strong class="t"><a href="javascript:void(0);" onclick="teamPageGo('${name}','${ownerId}','${teamId}');">${name}</a></strong></div><p>팀원 3명</p><button type="button">참여중</button></li>`
716
+ const { http, calls } = createFakeHttp({
717
+ identity: { userId: 'mentor@example.com', userNm: 'Mentor One' },
718
+ getBody: (path) => {
719
+ if (path === '/mypage/myMain/dashboard.do') {
720
+ return '<ul class="dash-top"><li class="dash-card"><div class="dash-etc"><span>소속 :<br> Org</span><span>직책 :<br> </span></div><div class="dash-state"><div class="top"><span class="bg-orange label"><span>멘토</span></span><div class="welcome"><strong>Mentor One</strong>님 안녕하세요.</div></div><ul class="dash-box"><li><strong class="t">팀명</strong><div class="c">Team Alpha</div></li><li><strong class="t">팀원</strong><div class="c">Member A,Member B,Member C</div></li><li><strong class="t">멘토</strong><div class="c">Mentor One,Mentor Two</div></li></ul></div></li></ul>'
721
+ }
722
+ if (path === '/mypage/myTeam/team.do') {
723
+ return `<ul class="bbs-team">${teamCard('Team Alpha', 'Mentor One', 'team-alpha')}${teamCard('Team Beta', 'Mentor Three', 'team-beta')}</ul><p class="ico-team">현재 참여중인 방은 <strong class="color-blue">2</strong>/100팀 입니다</p>`
724
+ }
725
+ return '<table><tbody></tbody></table><ul class="bbs-total"><li>Total : 0</li><li>1/1 Page</li></ul>'
726
+ },
727
+ })
728
+ const client = new SomaClient({ http })
729
+
730
+ const dashboard = await client.dashboard.get()
731
+
732
+ expect(dashboard.team).toEqual({
733
+ name: 'Team Alpha',
734
+ members: 'Member A,Member B,Member C',
735
+ mentor: 'Mentor One,Mentor Two',
736
+ })
737
+ expect(dashboard.teams.map((t) => ({ name: t.name, teamId: t.teamId }))).toEqual([
738
+ { name: 'Team Alpha', teamId: 'team-alpha' },
739
+ { name: 'Team Beta', teamId: 'team-beta' },
740
+ ])
741
+ const teamCall = calls.find((c) => c.path === '/mypage/myTeam/team.do')
742
+ expect(teamCall?.data).toMatchObject({
743
+ menuNo: MENU_NO.TEAM,
744
+ searchCnd: '2',
745
+ searchWrd: 'Mentor One',
746
+ })
747
+ })
748
+
749
+ it('enriches trainee dashboard with every team the trainee belongs to', async () => {
750
+ const { http, calls } = createFakeHttp({
751
+ identity: { userId: 'trainee@example.com', userNm: 'Trainee One' },
752
+ getBody: (path) => {
753
+ if (path === '/mypage/myMain/dashboard.do') {
754
+ return '<ul class="dash-top"><li class="dash-card"><div class="dash-etc"><span>소속 :<br> Org</span><span>직책 :<br> </span></div><div class="dash-state"><div class="top"><span class="bg-blue label"><span>17기 연수생</span></span><div class="welcome"><strong>Trainee One</strong>님 안녕하세요.</div></div></div></li></ul>'
755
+ }
756
+ if (path === '/mypage/myTeam/team.do') {
757
+ return `<ul class="bbs-team"><li><div class="top"><strong class="t"><a href="javascript:void(0);" onclick="teamPageGo('Mentor One','owner-1','team-1');">Team Alpha</a></strong></div><p>팀원 3명</p><button type="button">참여중</button></li></ul><p class="ico-team">현재 참여중인 방은 <strong class="color-blue">1</strong>/100팀 입니다</p>`
758
+ }
759
+ return '<table><tbody></tbody></table><ul class="bbs-total"><li>Total : 0</li><li>1/1 Page</li></ul>'
760
+ },
761
+ })
762
+ const client = new SomaClient({ http })
763
+
764
+ const dashboard = await client.dashboard.get()
765
+
766
+ expect(dashboard.role).toBe('17기 연수생')
767
+ expect(dashboard.teams.map((t) => t.name)).toEqual(['Team Alpha'])
768
+ const teamCall = calls.find((c) => c.path === '/mypage/myTeam/team.do')
769
+ expect(teamCall?.data).toMatchObject({
770
+ menuNo: MENU_NO.TEAM,
771
+ searchCnd: '1',
772
+ searchWrd: 'Trainee One',
773
+ })
774
+ })
775
+
776
+ it('routes schedule calls to the expected endpoint', async () => {
777
+ const { http, calls } = createFakeHttp({
778
+ identity: { userId: 'neo@example.com', userNm: '전수열' },
779
+ getBody: (path) => {
780
+ if (path === '/mypage/schedule/list.do') {
781
+ return '<table><tbody><tr><td>1</td><td>팀78</td><td>배준서</td><td>이유제, 이중곤</td><td>-</td><td>-</td><td>-</td><td>-</td></tr></tbody></table><table><thead><tr><th>날짜</th><th>구분</th><th>제목</th></tr></thead><tbody><tr><td>2026-04-01 ~ 2026-04-02</td><td>행사</td><td>행사</td></tr></tbody></table>'
782
+ }
783
+
784
+ return ''
785
+ },
786
+ })
787
+ const client = new SomaClient({ http })
788
+
789
+ const schedule = await client.schedule.list({ page: 5 })
790
+
791
+ expect(schedule.items).toEqual([
792
+ {
793
+ id: 1,
794
+ category: '행사',
795
+ title: '행사',
796
+ period: { start: '2026-04-01', end: '2026-04-02' },
797
+ },
798
+ ])
799
+ expect(schedule.pagination).toEqual({ total: 1, currentPage: 1, totalPages: 1 })
800
+ expect(calls).toContainEqual({
801
+ method: 'get',
802
+ path: '/mypage/schedule/list.do',
803
+ data: { menuNo: MENU_NO.SCHEDULE, pageIndex: '5' },
804
+ })
805
+ })
806
+
807
+ it('joins a team by POSTing the native payload to updateUserTeamIn.json', async () => {
808
+ const { http, calls } = createFakeHttp({
809
+ identity: {
810
+ userId: 'neo@example.com',
811
+ userNm: '전수열',
812
+ userNo: 'f6d192ad3b3e4ee29f1d238714ab92c1',
813
+ userGb: UserGb.Mentor,
814
+ },
815
+ postJsonBody: () => ({ resultCode: 'success' }),
816
+ })
817
+ const client = new SomaClient({ http })
818
+
819
+ await client.team.join('60e6785c8c404142b12cf9ed2a3d811f')
820
+
821
+ expect(calls).toContainEqual({
822
+ method: 'postJson',
823
+ path: '/mypage/myTeam/updateUserTeamIn.json',
824
+ data: {
825
+ userNo: 'f6d192ad3b3e4ee29f1d238714ab92c1',
826
+ userNm: '전수열',
827
+ userGb: UserGb.Mentor,
828
+ teamNo: '60e6785c8c404142b12cf9ed2a3d811f',
829
+ },
830
+ })
831
+ })
832
+
833
+ it('leaves a team by POSTing the native payload to updateUserTeamOut.json', async () => {
834
+ const { http, calls } = createFakeHttp({
835
+ identity: {
836
+ userId: 'neo@example.com',
837
+ userNm: '전수열',
838
+ userNo: 'f6d192ad3b3e4ee29f1d238714ab92c1',
839
+ userGb: UserGb.Mentor,
840
+ },
841
+ postJsonBody: () => ({ resultCode: 'success' }),
842
+ })
843
+ const client = new SomaClient({ http })
844
+
845
+ await client.team.leave('60e6785c8c404142b12cf9ed2a3d811f')
846
+
847
+ expect(calls).toContainEqual({
848
+ method: 'postJson',
849
+ path: '/mypage/myTeam/updateUserTeamOut.json',
850
+ data: {
851
+ userNo: 'f6d192ad3b3e4ee29f1d238714ab92c1',
852
+ userNm: '전수열',
853
+ userGb: UserGb.Mentor,
854
+ teamNo: '60e6785c8c404142b12cf9ed2a3d811f',
855
+ },
856
+ })
857
+ })
858
+
859
+ it('throws when team.join receives a non-success resultCode', async () => {
860
+ const { http } = createFakeHttp({
861
+ identity: {
862
+ userId: 'neo@example.com',
863
+ userNm: '전수열',
864
+ userNo: 'f6d192ad3b3e4ee29f1d238714ab92c1',
865
+ userGb: UserGb.Mentor,
866
+ },
867
+ postJsonBody: () => ({ resultCode: 'fail' }),
868
+ })
869
+ const client = new SomaClient({ http })
870
+
871
+ await expect(client.team.join('team-1')).rejects.toThrow('팀 참여에 실패했습니다.')
872
+ })
873
+
874
+ it('throws when team.leave receives a non-success resultCode', async () => {
875
+ const { http } = createFakeHttp({
876
+ identity: {
877
+ userId: 'neo@example.com',
878
+ userNm: '전수열',
879
+ userNo: 'f6d192ad3b3e4ee29f1d238714ab92c1',
880
+ userGb: UserGb.Mentor,
881
+ },
882
+ postJsonBody: () => ({ resultCode: 'fail' }),
883
+ })
884
+ const client = new SomaClient({ http })
885
+
886
+ await expect(client.team.leave('team-1')).rejects.toThrow('팀 탈퇴에 실패했습니다.')
887
+ })
888
+
889
+ it('throws when team.join cannot resolve userNo', async () => {
890
+ const { http } = createFakeHttp({
891
+ identity: {
892
+ userId: 'neo@example.com',
893
+ userNm: '전수열',
894
+ userNo: '',
895
+ userGb: UserGb.Mentor,
896
+ },
897
+ })
898
+ const client = new SomaClient({ http })
899
+
900
+ await expect(client.team.join('team-1')).rejects.toThrow('userNo')
901
+ })
902
+
903
+ it('exhausts mentoring pagination so dashboard sessions span all pages', async () => {
904
+ const buildMentoringRow = (id: number, sessionDate: string) =>
905
+ `<tr><td>1</td><td><a href="/sw/mypage/mentoLec/view.do?qustnrSn=${id}">[멘토 특강] 세션 ${id} [접수중]</a></td><td>${sessionDate} ~ ${sessionDate}</td><td>${sessionDate}(목) 10:00 ~ 11:00</td><td>1 /4</td><td>OK</td><td>[접수중]</td><td>전수열</td><td>${sessionDate}</td></tr>`
906
+ const buildPaginatedListBody = (rows: string, currentPage: number, totalPages: number, total: number) =>
907
+ `<table><tbody>${rows}</tbody></table><ul class="bbs-total"><li>Total : ${total}</li><li>${currentPage}/${totalPages} Page</li></ul>`
908
+ const { http, calls } = createFakeHttp({
909
+ identity: { userId: 'neo@example.com', userNm: '전수열' },
910
+ getBody: (path, data) => {
911
+ if (path === '/mypage/myMain/dashboard.do') {
912
+ return ''
913
+ }
914
+ if (path === '/mypage/mentoLec/list.do') {
915
+ const page = Number(data?.pageIndex ?? '1')
916
+ if (page === 1) return buildPaginatedListBody(buildMentoringRow(103, '2026-04-17'), 1, 3, 3)
917
+ if (page === 2) return buildPaginatedListBody(buildMentoringRow(101, '2026-04-03'), 2, 3, 3)
918
+ if (page === 3) return buildPaginatedListBody(buildMentoringRow(102, '2026-04-10'), 3, 3, 3)
919
+ }
920
+ return ''
921
+ },
922
+ })
923
+ const client = new SomaClient({ http })
924
+
925
+ const dashboard = await client.dashboard.get()
926
+
927
+ expect(dashboard.mentoringSessions.map((s) => s.url)).toEqual([
928
+ '/mypage/mentoLec/view.do?qustnrSn=101',
929
+ '/mypage/mentoLec/view.do?qustnrSn=102',
930
+ '/mypage/mentoLec/view.do?qustnrSn=103',
931
+ ])
932
+ const listPages = calls
933
+ .filter((c) => c.path === '/mypage/mentoLec/list.do')
934
+ .map((c) => c.data?.pageIndex ?? '1')
935
+ .sort()
936
+ expect(listPages).toEqual(['1', '2', '3'])
937
+ })
938
+
343
939
  it('delegates login and isLoggedIn to SomaHttp', async () => {
344
940
  const loginCalls: string[] = []
345
941
  const { http } = createFakeHttp({
@@ -438,14 +1034,17 @@ describe('SomaClient', () => {
438
1034
  await expect(
439
1035
  client.room.reserve({ roomId: 1, date: '2026-04-01', slots: ['10:00'], title: 'Test' }),
440
1036
  ).rejects.toBeInstanceOf(AuthenticationError)
1037
+ await expect(client.room.get(1)).rejects.toBeInstanceOf(AuthenticationError)
1038
+ await expect(client.room.update(1, { title: 'x' })).rejects.toBeInstanceOf(AuthenticationError)
1039
+ await expect(client.room.cancel(1)).rejects.toBeInstanceOf(AuthenticationError)
441
1040
  await expect(client.dashboard.get()).rejects.toBeInstanceOf(AuthenticationError)
442
1041
  await expect(client.notice.list()).rejects.toBeInstanceOf(AuthenticationError)
443
1042
  await expect(client.notice.get(1)).rejects.toBeInstanceOf(AuthenticationError)
444
- await expect(client.team.show()).rejects.toBeInstanceOf(AuthenticationError)
1043
+ await expect(client.team.list()).rejects.toBeInstanceOf(AuthenticationError)
1044
+ await expect(client.team.join('team-1')).rejects.toBeInstanceOf(AuthenticationError)
1045
+ await expect(client.team.leave('team-1')).rejects.toBeInstanceOf(AuthenticationError)
445
1046
  await expect(client.member.show()).rejects.toBeInstanceOf(AuthenticationError)
446
- await expect(client.event.list()).rejects.toBeInstanceOf(AuthenticationError)
447
- await expect(client.event.get(1)).rejects.toBeInstanceOf(AuthenticationError)
448
- await expect(client.event.apply(1)).rejects.toBeInstanceOf(AuthenticationError)
1047
+ await expect(client.schedule.list()).rejects.toBeInstanceOf(AuthenticationError)
449
1048
  })
450
1049
 
451
1050
  it('includes helpful login hints in the AuthenticationError message', async () => {
@@ -461,4 +1060,48 @@ describe('SomaClient', () => {
461
1060
  expect((error as Error).message).toContain('opensoma auth extract')
462
1061
  }
463
1062
  })
1063
+
1064
+ it('single-flights relogin across concurrent auth-required calls', async () => {
1065
+ const loginCalls: string[] = []
1066
+ let resolveLoginStarted: (() => void) | undefined
1067
+ const loginStarted = new Promise<void>((resolve) => {
1068
+ resolveLoginStarted = resolve
1069
+ })
1070
+ let allowLogin: (() => void) | undefined
1071
+ const loginGate = new Promise<void>((resolve) => {
1072
+ allowLogin = resolve
1073
+ })
1074
+ let loggedIn = false
1075
+
1076
+ const fake = {
1077
+ checkLogin: async () => (loggedIn ? { userId: 'neo@example.com', userNm: '전수열' } : null),
1078
+ get: async () =>
1079
+ '<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>',
1080
+ post: async () => '',
1081
+ postForm: async () => '',
1082
+ postMultipart: async () => '',
1083
+ login: async (username: string, password: string) => {
1084
+ loginCalls.push(`${username}:${password}`)
1085
+ resolveLoginStarted?.()
1086
+ await loginGate
1087
+ loggedIn = true
1088
+ },
1089
+ logout: async () => {},
1090
+ getSessionCookie: () => 'session-after-relogin',
1091
+ getCsrfToken: () => 'csrf-after-relogin',
1092
+ }
1093
+ const client = new SomaClient({
1094
+ username: 'neo@example.com',
1095
+ password: 'secret',
1096
+ http: fake as unknown as SomaHttp,
1097
+ })
1098
+
1099
+ const parallel = Promise.all([client.mentoring.list(), client.mentoring.list(), client.mentoring.list()])
1100
+ await loginStarted
1101
+ expect(loginCalls).toEqual(['neo@example.com:secret'])
1102
+ allowLogin?.()
1103
+ await parallel
1104
+
1105
+ expect(loginCalls).toEqual(['neo@example.com:secret'])
1106
+ })
464
1107
  })