opensoma 0.5.0 → 0.6.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 (133) 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 +3 -2
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/client.d.ts +36 -7
  10. package/dist/src/client.d.ts.map +1 -1
  11. package/dist/src/client.js +231 -63
  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 +2 -1
  30. package/dist/src/commands/index.d.ts.map +1 -1
  31. package/dist/src/commands/index.js +2 -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/constants.d.ts +5 -5
  53. package/dist/src/constants.d.ts.map +1 -1
  54. package/dist/src/constants.js +20 -8
  55. package/dist/src/constants.js.map +1 -1
  56. package/dist/src/credential-manager.d.ts +9 -0
  57. package/dist/src/credential-manager.d.ts.map +1 -1
  58. package/dist/src/credential-manager.js +24 -0
  59. package/dist/src/credential-manager.js.map +1 -1
  60. package/dist/src/formatters.d.ts +11 -3
  61. package/dist/src/formatters.d.ts.map +1 -1
  62. package/dist/src/formatters.js +281 -52
  63. package/dist/src/formatters.js.map +1 -1
  64. package/dist/src/http.d.ts +8 -0
  65. package/dist/src/http.d.ts.map +1 -1
  66. package/dist/src/http.js +29 -1
  67. package/dist/src/http.js.map +1 -1
  68. package/dist/src/index.d.ts +4 -1
  69. package/dist/src/index.d.ts.map +1 -1
  70. package/dist/src/index.js +2 -1
  71. package/dist/src/index.js.map +1 -1
  72. package/dist/src/shared/utils/swmaestro.d.ts +34 -1
  73. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  74. package/dist/src/shared/utils/swmaestro.js +102 -43
  75. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  76. package/dist/src/shared/utils/team-action-params.d.ts +3 -0
  77. package/dist/src/shared/utils/team-action-params.d.ts.map +1 -0
  78. package/dist/src/shared/utils/team-action-params.js +10 -0
  79. package/dist/src/shared/utils/team-action-params.js.map +1 -0
  80. package/dist/src/shared/utils/team-params.d.ts +12 -0
  81. package/dist/src/shared/utils/team-params.d.ts.map +1 -0
  82. package/dist/src/shared/utils/team-params.js +38 -0
  83. package/dist/src/shared/utils/team-params.js.map +1 -0
  84. package/dist/src/types.d.ts +147 -10
  85. package/dist/src/types.d.ts.map +1 -1
  86. package/dist/src/types.js +74 -6
  87. package/dist/src/types.js.map +1 -1
  88. package/package.json +5 -1
  89. package/src/agent-browser-launcher.test.ts +263 -0
  90. package/src/agent-browser-launcher.ts +159 -0
  91. package/src/cli.ts +4 -2
  92. package/src/client.test.ts +801 -140
  93. package/src/client.ts +293 -79
  94. package/src/commands/agent-browser.ts +33 -0
  95. package/src/commands/auth.test.ts +83 -32
  96. package/src/commands/auth.ts +5 -3
  97. package/src/commands/dashboard.test.ts +57 -0
  98. package/src/commands/dashboard.ts +22 -19
  99. package/src/commands/helpers.test.ts +79 -32
  100. package/src/commands/helpers.ts +3 -3
  101. package/src/commands/index.ts +2 -1
  102. package/src/commands/mentoring.ts +60 -29
  103. package/src/commands/notice.ts +2 -1
  104. package/src/commands/report.test.ts +7 -7
  105. package/src/commands/report.ts +4 -2
  106. package/src/commands/room.ts +160 -1
  107. package/src/commands/schedule.ts +32 -0
  108. package/src/commands/team.ts +73 -5
  109. package/src/constants.ts +20 -8
  110. package/src/credential-manager.test.ts +49 -5
  111. package/src/credential-manager.ts +27 -0
  112. package/src/formatters.test.ts +548 -53
  113. package/src/formatters.ts +309 -55
  114. package/src/http.test.ts +108 -39
  115. package/src/http.ts +41 -2
  116. package/src/index.ts +10 -1
  117. package/src/shared/utils/mentoring-params.test.ts +16 -16
  118. package/src/shared/utils/swmaestro.test.ts +326 -11
  119. package/src/shared/utils/swmaestro.ts +150 -52
  120. package/src/shared/utils/team-action-params.test.ts +32 -0
  121. package/src/shared/utils/team-action-params.ts +10 -0
  122. package/src/shared/utils/team-params.test.ts +141 -0
  123. package/src/shared/utils/team-params.ts +53 -0
  124. package/src/shared/utils/toz.test.ts +12 -7
  125. package/src/token-extractor.test.ts +12 -12
  126. package/src/toz-http.test.ts +11 -11
  127. package/src/types.test.ts +235 -206
  128. package/src/types.ts +87 -7
  129. package/dist/src/commands/event.d.ts +0 -3
  130. package/dist/src/commands/event.d.ts.map +0 -1
  131. package/dist/src/commands/event.js +0 -58
  132. package/dist/src/commands/event.js.map +0 -1
  133. package/src/commands/event.ts +0 -73
@@ -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,124 @@ 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, UserGb, 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
+ postJsonBody?: (path: string, data: Record<string, string>) => unknown
24
+ sessionCookie?: string
25
+ csrfToken?: string | null
26
+ onLogin?: (username: string, password: string) => void
27
+ onLogout?: () => void
28
+ checkLoginSequence?: Array<UserIdentity | null>
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
+
70
+ function createFakeHttp(config: FakeHttpConfig = {}): { http: SomaHttp; calls: HttpCall[] } {
71
+ const calls: HttpCall[] = []
72
+ const sequence = config.checkLoginSequence ? [...config.checkLoginSequence] : null
73
+
74
+ const fake = {
75
+ checkLogin: async () => {
76
+ if (sequence) {
77
+ return sequence.shift() ?? config.identity ?? null
78
+ }
79
+ return config.identity ?? null
80
+ },
81
+ get: async (path: string, data?: Record<string, string>) => {
82
+ calls.push({ method: 'get', path, data })
83
+ return config.getBody ? config.getBody(path, data) : ''
84
+ },
85
+ post: async (path: string, data: Record<string, string>) => {
86
+ calls.push({ method: 'post', path, data })
87
+ return config.postBody ? config.postBody(path, data) : ''
88
+ },
89
+ postForm: async (path: string, data: Record<string, string>) => {
90
+ calls.push({ method: 'postForm', path, data })
91
+ return config.postFormBody ? config.postFormBody(path, data) : ''
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
+ },
97
+ postMultipart: async () => '',
98
+ login: async (username: string, password: string) => {
99
+ config.onLogin?.(username, password)
100
+ },
101
+ logout: async () => {
102
+ config.onLogout?.()
103
+ },
104
+ getSessionCookie: () => config.sessionCookie,
105
+ getCsrfToken: () => config.csrfToken ?? null,
106
+ }
107
+
108
+ return { http: fake as unknown as SomaHttp, calls }
109
+ }
110
+
16
111
  describe('SomaClient', () => {
17
- test('constructor initializes SomaHttp with provided session state', () => {
112
+ it('exposes session state passed through options', () => {
18
113
  const client = new SomaClient({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
19
- const http = Reflect.get(client, 'http') as SomaHttp
20
114
 
21
- expect(http.getSessionCookie()).toBe('session-1')
22
- expect(http.getCsrfToken()).toBe('csrf-1')
115
+ expect(client.getSessionData()).toEqual({
116
+ sessionCookie: 'session-1',
117
+ csrfToken: 'csrf-1',
118
+ })
23
119
  })
24
120
 
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
- },
121
+ it('lists mentoring sessions with parsed items and pagination', async () => {
122
+ const { http, calls } = createFakeHttp({
123
+ identity: { userId: 'user@example.com', userNm: 'Test' },
124
+ getBody: () =>
125
+ '<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
126
  })
127
+ const client = new SomaClient({ http })
35
128
 
36
129
  const result = await client.mentoring.list({ status: 'open', type: 'public', page: 2 })
37
130
 
@@ -51,41 +144,49 @@ describe('SomaClient', () => {
51
144
  expect(result.pagination).toEqual({ total: 1, currentPage: 1, totalPages: 1 })
52
145
  })
53
146
 
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
- },
147
+ it('fetches a single mentoring session from the detail endpoint', async () => {
148
+ const { http, calls } = createFakeHttp({
149
+ identity: { userId: 'user@example.com', userNm: 'Test' },
150
+ getBody: () =>
151
+ '<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
152
  })
153
+ const client = new SomaClient({ http })
64
154
 
65
155
  const result = await client.mentoring.get(99)
66
156
 
67
- expect(captured).toEqual({
68
- path: '/mypage/mentoLec/view.do',
69
- data: { menuNo: MENU_NO.MENTORING, qustnrSn: '99' },
70
- })
157
+ expect(calls).toEqual([
158
+ {
159
+ method: 'get',
160
+ path: '/mypage/mentoLec/view.do',
161
+ data: { menuNo: MENU_NO.MENTORING, qustnrSn: '99' },
162
+ },
163
+ ])
71
164
  expect(result).toMatchObject({ id: 99, title: '상세', venue: '온라인(Webex)' })
72
165
  })
73
166
 
74
- test('mutating operations post expected payloads', async () => {
167
+ it('posts the expected payloads for create, update, delete, apply, cancel, and reserve', async () => {
75
168
  const mentoringDetailHtml =
76
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>'
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'),
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)',
88
184
  })
185
+ const { http, calls } = createFakeHttp({
186
+ identity: { userId: 'user@example.com', userNm: 'Test' },
187
+ getBody: (path) => (path === '/mypage/mentoLec/forUpdate.do' ? mentoringEditFormHtml : mentoringDetailHtml),
188
+ })
189
+ const client = new SomaClient({ http })
89
190
 
90
191
  await client.mentoring.create({
91
192
  title: '새 멘토링',
@@ -113,75 +214,326 @@ describe('SomaClient', () => {
113
214
  slots: ['10:00', '10:30'],
114
215
  title: '회의',
115
216
  })
116
- await client.event.apply(11)
117
217
 
118
- expect(calls.map((call) => `${call.method}:${call.path}`)).toEqual([
218
+ const writes = calls.filter((call) => call.method !== 'get')
219
+ expect(writes.map((call) => `${call.method}:${call.path}`)).toEqual([
119
220
  'postForm:/mypage/mentoLec/insert.do',
120
221
  'postForm:/mypage/mentoLec/update.do',
121
222
  'post:/mypage/mentoLec/delete.do',
122
223
  'post:/application/application/application.do',
123
224
  'post:/mypage/userAnswer/cancel.do',
124
225
  'post:/mypage/itemRent/insert.do',
125
- 'post:/application/application/application.do',
126
226
  ])
127
- expect(calls[0]?.data).toMatchObject({
227
+ expect(writes[0]?.data).toMatchObject({
128
228
  menuNo: MENU_NO.MENTORING,
129
229
  reportCd: 'MRC020',
130
230
  qustnrSj: '새 멘토링',
131
231
  })
132
- expect(calls[1]?.data).toMatchObject({
232
+ expect(writes[1]?.data).toMatchObject({
133
233
  menuNo: MENU_NO.MENTORING,
134
234
  reportCd: 'MRC010',
135
235
  qustnrSj: '수정된 멘토링',
136
236
  qustnrSn: '42',
137
237
  })
138
- expect(calls[2]?.data).toEqual({
238
+ expect(writes[2]?.data).toEqual({
139
239
  menuNo: MENU_NO.MENTORING,
140
240
  qustnrSn: '7',
141
241
  pageQueryString: '',
142
242
  })
143
- expect(calls[3]?.data).toEqual({
144
- menuNo: MENU_NO.EVENT,
243
+ expect(writes[3]?.data).toEqual({
244
+ menuNo: MENU_NO.MENTORING,
145
245
  qustnrSn: '8',
146
246
  applyGb: 'C',
147
247
  stepHeader: '0',
148
248
  })
149
- expect(calls[4]?.data).toEqual({
249
+ expect(writes[4]?.data).toEqual({
150
250
  menuNo: MENU_NO.APPLICATION_HISTORY,
151
251
  applySn: '9',
152
252
  qustnrSn: '10',
153
253
  })
154
- expect(calls[5]?.data).toMatchObject({
254
+ expect(writes[5]?.data).toMatchObject({
155
255
  menuNo: MENU_NO.ROOM,
156
256
  itemId: '17',
157
257
  title: '회의',
158
258
  'time[0]': '10:00',
159
259
  'time[1]': '10:30',
160
260
  })
161
- expect(calls[6]?.data).toEqual({
162
- menuNo: MENU_NO.EVENT,
163
- qustnrSn: '11',
164
- applyGb: 'C',
165
- 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 : ''),
166
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)
167
333
  })
168
334
 
169
- test('mentoring.update merges partial params with existing data', async () => {
170
- const mentoringDetailHtml =
171
- '<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 })
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
+ }
179
356
  return ''
180
357
  },
181
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('권한이 없습니다.')
508
+ })
509
+
510
+ it('merges partial update params with the existing mentoring data', async () => {
511
+ const mentoringDetailHtml =
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
+ })
528
+ const { http, calls } = createFakeHttp({
529
+ identity: { userId: 'user@example.com', userNm: 'Test' },
530
+ getBody: (path) => (path === '/mypage/mentoLec/forUpdate.do' ? mentoringEditFormHtml : mentoringDetailHtml),
531
+ })
532
+ const client = new SomaClient({ http })
182
533
 
183
534
  await client.mentoring.update(9572, { title: '변경된 제목' })
184
535
 
536
+ const postFormCalls = calls.filter((c) => c.method === 'postForm')
185
537
  expect(postFormCalls).toHaveLength(1)
186
538
  expect(postFormCalls[0]?.data).toMatchObject({
187
539
  qustnrSj: '변경된 제목',
@@ -192,19 +544,21 @@ describe('SomaClient', () => {
192
544
  eventEtime: '15:30',
193
545
  place: '온라인(Webex)',
194
546
  applyCnt: '20',
195
- bgnde: '2026-04-01',
196
- 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',
197
554
  qestnarCn: '<p>세션 본문</p>',
198
555
  })
199
556
  })
200
557
 
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 })
558
+ it('routes room, dashboard, notice, team, member, and history calls to the expected endpoints', async () => {
559
+ const { http, calls } = createFakeHttp({
560
+ identity: { userId: 'neo@example.com', userNm: '전수열' },
561
+ getBody: (path) => {
208
562
  if (path === '/mypage/myMain/dashboard.do') {
209
563
  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
564
  }
@@ -223,32 +577,24 @@ describe('SomaClient', () => {
223
577
  if (path === '/mypage/myInfo/forUpdateMy.do') {
224
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>'
225
579
  }
226
- if (path === '/mypage/applicants/list.do') {
227
- 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>'
228
- }
229
- if (path === '/mypage/applicants/view.do') {
230
- return '<input name="bbsId" value="1"><table><tr><th>제목</th><td>행사 상세</td></tr></table><div data-content><p>본문</p></div>'
231
- }
232
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>'
233
581
  },
234
- post: async (path: string, data: Record<string, string>) => {
235
- calls.push({ method: 'post', path, data })
582
+ postBody: (path) => {
236
583
  if (path === '/mypage/officeMng/rentTime.do') {
237
584
  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
585
  }
239
586
  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
587
  },
241
588
  })
589
+ const client = new SomaClient({ http })
242
590
 
243
591
  const roomList = await client.room.list({ date: '2026-04-01', room: 'A1', includeReservations: true })
244
592
  const roomSlots = await client.room.available(17, '2026-04-01')
245
593
  const dashboard = await client.dashboard.get()
246
594
  const noticeList = await client.notice.list({ page: 2 })
247
595
  const noticeDetail = await client.notice.get(1)
248
- const team = await client.team.show()
596
+ const team = await client.team.list()
249
597
  const member = await client.member.show()
250
- const events = await client.event.list({ page: 3 })
251
- const eventDetail = await client.event.get(1)
252
598
  const history = await client.mentoring.history({ page: 4 })
253
599
 
254
600
  expect(roomList[0]?.itemId).toBe(17)
@@ -270,16 +616,16 @@ describe('SomaClient', () => {
270
616
  type: '멘토 특강',
271
617
  },
272
618
  ])
619
+ expect(dashboard.teams.map((t) => t.name)).toEqual(['오픈소마'])
273
620
  expect(noticeList.items[0]?.title).toBe('공지')
274
621
  expect(noticeDetail).toMatchObject({ id: 1, title: '공지' })
275
622
  expect(team.teams[0]?.name).toBe('오픈소마')
276
623
  expect(member.email).toBe('neo@example.com')
277
- expect(events.items[0]?.title).toBe('행사')
278
- expect(eventDetail).toMatchObject({ id: 1, title: '행사 상세' })
279
624
  expect(history.items[0]).toEqual({
280
625
  id: 1,
281
626
  category: '멘토 특강',
282
627
  title: '접수내역',
628
+ url: '/sw/mypage/mentoLec/view.do?qustnrSn=1',
283
629
  author: '전수열',
284
630
  sessionDate: '2026-04-11',
285
631
  appliedAt: '2026-04-01',
@@ -289,8 +635,7 @@ describe('SomaClient', () => {
289
635
  note: '-',
290
636
  })
291
637
 
292
- const dashboardCallIndex = calls.findIndex((c) => c.path === '/mypage/myMain/dashboard.do')
293
- expect(dashboardCallIndex).toBeGreaterThanOrEqual(0)
638
+ expect(calls.some((c) => c.path === '/mypage/myMain/dashboard.do')).toBe(true)
294
639
  expect(calls).toContainEqual({
295
640
  method: 'post',
296
641
  path: '/mypage/officeMng/rentTime.do',
@@ -305,53 +650,330 @@ describe('SomaClient', () => {
305
650
  })
306
651
  })
307
652
 
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}`)
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 ''
314
678
  },
315
- checkLogin: async () => ({ userId: 'neo@example.com', userNm: '전수열' }),
316
679
  })
680
+ const client = new SomaClient({ http })
317
681
 
318
- await client.login()
682
+ const dashboard = await client.dashboard.get()
319
683
 
320
- expect(calls).toEqual(['neo@example.com:secret'])
321
- await expect(client.isLoggedIn()).resolves.toBe(true)
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)
322
711
  })
323
712
 
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
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>'
332
726
  },
333
- login: async (username: string, password: string) => {
334
- calls.push(`${username}:${password}`)
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>'
335
760
  },
336
- get: async () =>
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
+
939
+ it('delegates login and isLoggedIn to SomaHttp', async () => {
940
+ const loginCalls: string[] = []
941
+ const { http } = createFakeHttp({
942
+ identity: { userId: 'neo@example.com', userNm: '전수열' },
943
+ onLogin: (username, password) => loginCalls.push(`${username}:${password}`),
944
+ })
945
+ const client = new SomaClient({ username: 'neo@example.com', password: 'secret', http })
946
+
947
+ await client.login()
948
+
949
+ expect(loginCalls).toEqual(['neo@example.com:secret'])
950
+ await expect(client.isLoggedIn()).resolves.toBe(true)
951
+ })
952
+
953
+ it('re-logs in automatically when username and password are configured', async () => {
954
+ const loginCalls: string[] = []
955
+ const { http } = createFakeHttp({
956
+ checkLoginSequence: [null, { userId: 'neo@example.com', userNm: '전수열' }],
957
+ onLogin: (username, password) => loginCalls.push(`${username}:${password}`),
958
+ getBody: () =>
337
959
  '<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
960
  })
961
+ const client = new SomaClient({ username: 'neo@example.com', password: 'secret', http })
339
962
 
340
963
  await expect(client.mentoring.list()).resolves.toMatchObject({
341
964
  items: [expect.objectContaining({ id: 123, title: '제목' })],
342
965
  })
343
- expect(calls).toEqual(['neo@example.com:secret'])
966
+ expect(loginCalls).toEqual(['neo@example.com:secret'])
344
967
  })
345
968
 
346
- test('saveCredentials persists the credentials used by login()', async () => {
347
- const client = new SomaClient()
969
+ it('persists the credentials used by login() when saveCredentials is called', async () => {
348
970
  const dir = await mkdtemp(join(tmpdir(), 'opensoma-client-save-'))
349
971
  const manager = new CredentialManager(dir)
350
- Reflect.set(client, 'http', {
351
- login: async () => {},
352
- getSessionCookie: () => 'session-1',
353
- getCsrfToken: () => 'csrf-1',
972
+ const { http } = createFakeHttp({
973
+ sessionCookie: 'session-1',
974
+ csrfToken: 'csrf-1',
354
975
  })
976
+ const client = new SomaClient({ http })
355
977
 
356
978
  await client.login('neo@example.com', 'secret')
357
979
  await client.saveCredentials(manager)
@@ -367,25 +989,19 @@ describe('SomaClient', () => {
367
989
  await manager.remove()
368
990
  })
369
991
 
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
- })
992
+ it('delegates logout to SomaHttp', async () => {
993
+ const logoutCalls: string[] = []
994
+ const { http } = createFakeHttp({ onLogout: () => logoutCalls.push('logout') })
995
+ const client = new SomaClient({ http })
378
996
 
379
997
  await client.logout()
380
998
 
381
- expect(calls).toEqual(['logout'])
999
+ expect(logoutCalls).toEqual(['logout'])
382
1000
  })
383
1001
 
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
- })
1002
+ it('throws AuthenticationError from every auth-required operation when not logged in', async () => {
1003
+ const { http } = createFakeHttp({ identity: null })
1004
+ const client = new SomaClient({ http })
389
1005
 
390
1006
  await expect(client.mentoring.list()).rejects.toBeInstanceOf(AuthenticationError)
391
1007
  await expect(client.mentoring.get(1)).rejects.toBeInstanceOf(AuthenticationError)
@@ -418,21 +1034,22 @@ describe('SomaClient', () => {
418
1034
  await expect(
419
1035
  client.room.reserve({ roomId: 1, date: '2026-04-01', slots: ['10:00'], title: 'Test' }),
420
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)
421
1040
  await expect(client.dashboard.get()).rejects.toBeInstanceOf(AuthenticationError)
422
1041
  await expect(client.notice.list()).rejects.toBeInstanceOf(AuthenticationError)
423
1042
  await expect(client.notice.get(1)).rejects.toBeInstanceOf(AuthenticationError)
424
- 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)
425
1046
  await expect(client.member.show()).rejects.toBeInstanceOf(AuthenticationError)
426
- await expect(client.event.list()).rejects.toBeInstanceOf(AuthenticationError)
427
- await expect(client.event.get(1)).rejects.toBeInstanceOf(AuthenticationError)
428
- await expect(client.event.apply(1)).rejects.toBeInstanceOf(AuthenticationError)
1047
+ await expect(client.schedule.list()).rejects.toBeInstanceOf(AuthenticationError)
429
1048
  })
430
1049
 
431
- test('AuthenticationError has helpful message', async () => {
432
- const client = new SomaClient()
433
- Reflect.set(client, 'http', {
434
- checkLogin: async () => null,
435
- })
1050
+ it('includes helpful login hints in the AuthenticationError message', async () => {
1051
+ const { http } = createFakeHttp({ identity: null })
1052
+ const client = new SomaClient({ http })
436
1053
 
437
1054
  try {
438
1055
  await client.mentoring.list()
@@ -443,4 +1060,48 @@ describe('SomaClient', () => {
443
1060
  expect((error as Error).message).toContain('opensoma auth extract')
444
1061
  }
445
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
+ })
446
1107
  })