opensoma 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/client.d.ts +7 -1
  3. package/dist/src/client.d.ts.map +1 -1
  4. package/dist/src/client.js +13 -11
  5. package/dist/src/client.js.map +1 -1
  6. package/dist/src/commands/auth.d.ts +1 -1
  7. package/dist/src/commands/auth.d.ts.map +1 -1
  8. package/dist/src/commands/auth.js +94 -52
  9. package/dist/src/commands/auth.js.map +1 -1
  10. package/dist/src/constants.d.ts +40 -0
  11. package/dist/src/constants.d.ts.map +1 -1
  12. package/dist/src/constants.js +42 -0
  13. package/dist/src/constants.js.map +1 -1
  14. package/dist/src/formatters.d.ts.map +1 -1
  15. package/dist/src/formatters.js +42 -16
  16. package/dist/src/formatters.js.map +1 -1
  17. package/dist/src/index.d.ts +3 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +2 -0
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  22. package/dist/src/shared/utils/swmaestro.js +1 -5
  23. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  24. package/dist/src/shared/utils/toz.d.ts +23 -0
  25. package/dist/src/shared/utils/toz.d.ts.map +1 -0
  26. package/dist/src/shared/utils/toz.js +72 -0
  27. package/dist/src/shared/utils/toz.js.map +1 -0
  28. package/dist/src/token-extractor.d.ts +9 -1
  29. package/dist/src/token-extractor.d.ts.map +1 -1
  30. package/dist/src/token-extractor.js +54 -10
  31. package/dist/src/token-extractor.js.map +1 -1
  32. package/dist/src/toz-formatters.d.ts +9 -0
  33. package/dist/src/toz-formatters.d.ts.map +1 -0
  34. package/dist/src/toz-formatters.js +151 -0
  35. package/dist/src/toz-formatters.js.map +1 -0
  36. package/dist/src/toz-http.d.ts +27 -0
  37. package/dist/src/toz-http.d.ts.map +1 -0
  38. package/dist/src/toz-http.js +154 -0
  39. package/dist/src/toz-http.js.map +1 -0
  40. package/dist/src/types.d.ts +52 -0
  41. package/dist/src/types.d.ts.map +1 -1
  42. package/dist/src/types.js +46 -0
  43. package/dist/src/types.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/__fixtures__/toz/toz_all_branches.json +211 -0
  46. package/src/__fixtures__/toz/toz_booking.html +2190 -0
  47. package/src/__fixtures__/toz/toz_boothes.json +59 -0
  48. package/src/__fixtures__/toz/toz_duration.json +25 -0
  49. package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
  50. package/src/__fixtures__/toz/toz_page.html +211 -0
  51. package/src/client.test.ts +135 -117
  52. package/src/client.ts +16 -12
  53. package/src/commands/auth.test.ts +7 -7
  54. package/src/commands/auth.ts +107 -50
  55. package/src/commands/helpers.test.ts +8 -8
  56. package/src/commands/report.test.ts +7 -7
  57. package/src/constants.ts +50 -0
  58. package/src/credential-manager.test.ts +5 -5
  59. package/src/formatters.test.ts +22 -22
  60. package/src/formatters.ts +44 -16
  61. package/src/http.test.ts +37 -37
  62. package/src/index.ts +3 -0
  63. package/src/shared/utils/mentoring-params.test.ts +16 -16
  64. package/src/shared/utils/swmaestro.test.ts +87 -8
  65. package/src/shared/utils/swmaestro.ts +1 -6
  66. package/src/shared/utils/toz.test.ts +138 -0
  67. package/src/shared/utils/toz.ts +100 -0
  68. package/src/token-extractor.test.ts +40 -15
  69. package/src/token-extractor.ts +65 -13
  70. package/src/toz-formatters.test.ts +197 -0
  71. package/src/toz-formatters.ts +211 -0
  72. package/src/toz-http.test.ts +157 -0
  73. package/src/toz-http.ts +188 -0
  74. package/src/types.test.ts +220 -204
  75. package/src/types.ts +58 -0
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
 
3
3
  import {
4
4
  parseApprovalList,
@@ -21,7 +21,7 @@ import {
21
21
  import { ApprovalListItemSchema, ReportDetailSchema, ReportListItemSchema } from './types'
22
22
 
23
23
  describe('formatters', () => {
24
- test('parseMentoringList parses real list rows', () => {
24
+ it('parses real mentoring list rows from the list page', () => {
25
25
  const html = `
26
26
  <table>
27
27
  <thead><tr><th>NO.</th><th>제목</th><th>접수기간</th><th>진행날짜</th><th>모집인원</th><th>개설승인</th><th>상태</th><th>작성자</th><th>등록일</th></tr></thead>
@@ -59,7 +59,7 @@ describe('formatters', () => {
59
59
  ])
60
60
  })
61
61
 
62
- test('parseMentoringDetail parses real key-value detail view', () => {
62
+ it('parses the key-value sections of a real mentoring detail view', () => {
63
63
  const html = `
64
64
  <input type="hidden" name="reportCd" value="MRC020">
65
65
  <div class="top">
@@ -94,7 +94,7 @@ describe('formatters', () => {
94
94
  })
95
95
  })
96
96
 
97
- test('parseMentoringDetail parses applicant list table', () => {
97
+ it('parses the applicant list table on the mentoring detail view', () => {
98
98
  const html = `
99
99
  <div class="group"><strong class="t">모집 명</strong><div class="c">[자유 멘토링] 테스트 멘토링</div></div>
100
100
  <div class="group"><strong class="t">접수 기간</strong><div class="c">2026.04.01 ~ 2026.04.10</div></div>
@@ -133,7 +133,7 @@ describe('formatters', () => {
133
133
  ])
134
134
  })
135
135
 
136
- test('parseMentoringDetail returns empty applicants when no applicant table exists', () => {
136
+ it('returns an empty applicant list when no applicant table is present', () => {
137
137
  const html = `
138
138
  <div class="group"><strong class="t">모집 명</strong><div class="c">[자유 멘토링] 빈 멘토링</div></div>
139
139
  <div class="group"><strong class="t">접수 기간</strong><div class="c">2026.04.01 ~ 2026.04.10</div></div>
@@ -146,7 +146,7 @@ describe('formatters', () => {
146
146
  expect(parseMentoringDetail(html, 5678).applicants).toEqual([])
147
147
  })
148
148
 
149
- test('parseMentoringDetail ignores unrelated 5-column tables in content', () => {
149
+ it('ignores unrelated 5-column tables embedded in the detail content', () => {
150
150
  const html = `
151
151
  <div class="group"><strong class="t">모집 명</strong><div class="c">[자유 멘토링] 콘텐츠 테이블 멘토링</div></div>
152
152
  <div class="group"><strong class="t">접수 기간</strong><div class="c">2026.04.01 ~ 2026.04.10</div></div>
@@ -166,7 +166,7 @@ describe('formatters', () => {
166
166
  expect(parseMentoringDetail(html, 9999).applicants).toEqual([])
167
167
  })
168
168
 
169
- test('parseRoomList parses real room cards with embedded time slots', () => {
169
+ it('parses real room cards along with their embedded time slots', () => {
170
170
  const html = `
171
171
  <ul class="bbs-reserve">
172
172
  <li class="item">
@@ -205,7 +205,7 @@ describe('formatters', () => {
205
205
  ])
206
206
  })
207
207
 
208
- test('parseRoomSlots parses rentTime fragment', () => {
208
+ it('parses the rentTime fragment for room slot availability and reservation info', () => {
209
209
  const html = `
210
210
  <span class="ck-st2" data-hour="09" data-minute="00">
211
211
  <input type="checkbox" name="time" id="time1_1" value="1">
@@ -229,7 +229,7 @@ describe('formatters', () => {
229
229
  ])
230
230
  })
231
231
 
232
- test('parseDashboard parses real dashboard sections', () => {
232
+ it('parses every section of a real dashboard page', () => {
233
233
  const html = `
234
234
  <ul class="dash-top">
235
235
  <li class="dash-card">
@@ -287,7 +287,7 @@ describe('formatters', () => {
287
287
  })
288
288
  })
289
289
 
290
- test('parseNoticeList and parseNoticeDetail parse real notice structures', () => {
290
+ it('parses real notice list and detail page structures', () => {
291
291
  const listHtml = `
292
292
  <table>
293
293
  <thead><tr><th>NO.</th><th>제목</th><th>작성자</th><th>등록일</th></tr></thead>
@@ -331,7 +331,7 @@ describe('formatters', () => {
331
331
  })
332
332
  })
333
333
 
334
- test('parseTeamInfo parses team cards and summary', () => {
334
+ it('parses team cards together with the team count summary', () => {
335
335
  const html = `
336
336
  <ul class="bbs-team">
337
337
  <li>
@@ -362,7 +362,7 @@ describe('formatters', () => {
362
362
  })
363
363
  })
364
364
 
365
- test('parseMemberInfo parses dl pairs', () => {
365
+ it('parses member info from <dl> definition pairs', () => {
366
366
  const html = `
367
367
  <dl><dt><span class="point">아이디</span></dt><dd>devxoul@gmail.com</dd></dl>
368
368
  <dl><dt><span class="point">이름</span></dt><dd>전수열</dd></dl>
@@ -384,7 +384,7 @@ describe('formatters', () => {
384
384
  })
385
385
  })
386
386
 
387
- test('parseEventList parses 7-column event table', () => {
387
+ it('parses the 7-column event list table', () => {
388
388
  const html = `
389
389
  <table>
390
390
  <thead><tr><th>NO.</th><th>구분</th><th>제목</th><th>접수기간</th><th>행사기간</th><th>상태</th><th>등록일</th></tr></thead>
@@ -407,7 +407,7 @@ describe('formatters', () => {
407
407
  ])
408
408
  })
409
409
 
410
- test('parseApplicationHistory parses 10-column mentoring history table', () => {
410
+ it('parses the 10-column mentoring application history table', () => {
411
411
  const html = `
412
412
  <table>
413
413
  <thead>
@@ -446,7 +446,7 @@ describe('formatters', () => {
446
446
  ])
447
447
  })
448
448
 
449
- test('parsePagination parses bbs-total block', () => {
449
+ it('parses the bbs-total pagination block', () => {
450
450
  const html = `
451
451
  <ul class="bbs-total">
452
452
  <li>Total : 11</li>
@@ -457,12 +457,12 @@ describe('formatters', () => {
457
457
  expect(parsePagination(html)).toEqual({ total: 11, currentPage: 1, totalPages: 2 })
458
458
  })
459
459
 
460
- test('parseCsrfToken extracts hidden input', () => {
460
+ it('extracts the CSRF token from a hidden input field', () => {
461
461
  expect(parseCsrfToken('<form><input type="hidden" name="csrfToken" value="csrf-123"></form>')).toBe('csrf-123')
462
462
  })
463
463
 
464
464
  describe('parseReportList', () => {
465
- test('parses report list table with all fields', () => {
465
+ it('parses every field of the report list table', () => {
466
466
  const html = `
467
467
  <ul class="bbs-total"><li><strong class="color-blue">Total :</strong> 2</li><li><span class="color-blue">1</span>/1 Page</li></ul>
468
468
  <table class=" t">
@@ -532,7 +532,7 @@ describe('formatters', () => {
532
532
  })
533
533
  })
534
534
 
535
- test('returns empty array for empty table', () => {
535
+ it('returns an empty array when the report list table has no rows', () => {
536
536
  const html = `
537
537
  <ul class="bbs-total"><li><strong class="color-blue">Total :</strong> 0</li><li><span class="color-blue">1</span>/1 Page</li></ul>
538
538
  <table class=" t">
@@ -552,7 +552,7 @@ describe('formatters', () => {
552
552
  })
553
553
 
554
554
  describe('parseReportDetail', () => {
555
- test('parses report detail view with all fields', () => {
555
+ it('parses every field of the report detail view', () => {
556
556
  const html = `
557
557
  <div class="board-view">
558
558
  <table>
@@ -590,7 +590,7 @@ describe('formatters', () => {
590
590
  ReportDetailSchema.parse(result)
591
591
  })
592
592
 
593
- test('uses provided id in result', () => {
593
+ it('uses the provided id in the parsed report detail', () => {
594
594
  const html = `
595
595
  <div class="board-view">
596
596
  <table>
@@ -612,7 +612,7 @@ describe('formatters', () => {
612
612
  })
613
613
 
614
614
  describe('parseApprovalList', () => {
615
- test('parses approval list table with 10 columns', () => {
615
+ it('parses every column of the 10-column approval list table', () => {
616
616
  const html = `
617
617
  <ul class="bbs-total"><li><strong class="color-blue">Total :</strong> 1</li><li><span class="color-blue">1</span>/1 Page</li></ul>
618
618
  <table class=" t">
@@ -662,7 +662,7 @@ describe('formatters', () => {
662
662
  })
663
663
  })
664
664
 
665
- test('returns empty array for nodata table', () => {
665
+ it('returns an empty array when the approval table shows the nodata row', () => {
666
666
  const html = `
667
667
  <table class=" t"><tbody><tr><td colspan="10" class="nodata">데이터가 없습니다.</td></tr></tbody></table>
668
668
  `
package/src/formatters.ts CHANGED
@@ -299,12 +299,40 @@ export function parseReportList(html: string): ReportListItem[] {
299
299
 
300
300
  export function parseReportDetail(html: string, id = 0): ReportDetail {
301
301
  const root = parse(html)
302
- const labels = extractLabelMap(root)
302
+ const labels = { ...extractLabelMap(root), ...extractGroupMap(root) }
303
303
 
304
304
  const progressTimeText = labels['진행시간'] || ''
305
305
  const exceptTimeText = labels['제외시간'] || ''
306
- const progressTimes = progressTimeText.split(/\s*~\s*/)
307
- const exceptTimes = exceptTimeText.split(/\s*~\s*/)
306
+ const progressTimeMatch = progressTimeText.match(/(\d{2}:\d{2})\s*~\s*(\d{2}:\d{2})/)
307
+ const exceptTimeMatch = exceptTimeText.match(/(\d{2}:\d{2})\s*~\s*(\d{2}:\d{2})/)
308
+
309
+ let subject = labels['주제'] || ''
310
+ if (!subject) {
311
+ for (const group of root.querySelectorAll('.group')) {
312
+ if (cleanText(group.querySelector('strong.t')) === '주제') {
313
+ subject = group.querySelector('input')?.getAttribute('value')?.trim() || ''
314
+ break
315
+ }
316
+ }
317
+ }
318
+
319
+ const findGroupTextarea = (...names: string[]): string => {
320
+ for (const group of root.querySelectorAll('.group')) {
321
+ const label = cleanText(group.querySelector('strong.t')).replace(/:$/, '')
322
+ if (names.includes(label)) {
323
+ const textarea = group.querySelector('textarea')
324
+ if (textarea) return textarea.text.trim()
325
+ return cleanText(group.querySelector('.c'))
326
+ }
327
+ }
328
+ return ''
329
+ }
330
+
331
+ const files = root
332
+ .querySelectorAll('.file_list_new a')
333
+ .map((a) => a.getAttribute('href') || '')
334
+ .filter(Boolean)
335
+ .map((href) => (href.startsWith('http') ? href : `https://www.swmaestro.ai${href}`))
308
336
 
309
337
  return ReportDetailSchema.parse({
310
338
  id,
@@ -312,27 +340,27 @@ export function parseReportDetail(html: string, id = 0): ReportDetail {
312
340
  title: labels['제목'] || '',
313
341
  progressDate: labels['진행 날짜'] || '',
314
342
  status: labels['상태'] || '',
315
- author: labels['작성자'] || '',
343
+ author: labels['작성자'] || labels['진행 멘토 명'] || '',
316
344
  createdAt: labels['등록일'] || '',
317
345
  acceptedTime: labels['인정시간'] || '',
318
346
  payAmount: labels['지급액'] || '',
319
- content: labels['추진 내용'] || '',
320
- subject: labels['주제'] || '',
321
- menteeRegion: labels['멘토링 대상'] || '',
347
+ content: findGroupTextarea('추진내용', '추진 내용') || labels['추진내용'] || labels['추진 내용'] || '',
348
+ subject,
349
+ menteeRegion: labels['멘토링대상'] || labels['멘토링 대상'] || '',
322
350
  reportType: labels['구분'] || '',
323
351
  teamNames: labels['팀명'] || '',
324
352
  venue: labels['진행 장소'] || '',
325
- attendanceCount: extractNumber(labels['참석 연수생'] || ''),
353
+ attendanceCount: extractNumber(labels['참석자 인원'] || labels['참석 연수생'] || ''),
326
354
  attendanceNames: labels['참석자 이름'] || '',
327
- progressStartTime: progressTimes[0] || '',
328
- progressEndTime: progressTimes[1] || '',
329
- exceptStartTime: exceptTimes[0] || '',
330
- exceptEndTime: exceptTimes[1] || '',
331
- exceptReason: labels['제외 사유'] || labels['제외이유'] || '',
332
- mentorOpinion: labels['멘토 의견'] || '',
355
+ progressStartTime: progressTimeMatch?.[1] || '',
356
+ progressEndTime: progressTimeMatch?.[2] || '',
357
+ exceptStartTime: exceptTimeMatch?.[1] || '',
358
+ exceptEndTime: exceptTimeMatch?.[2] || '',
359
+ exceptReason: labels['제외사유'] || labels['제외 사유'] || labels['제외이유'] || '',
360
+ mentorOpinion: findGroupTextarea('멘토의견', '멘토 의견') || labels['멘토의견'] || labels['멘토 의견'] || '',
333
361
  nonAttendanceNames: labels['무단불참자'] || '',
334
- etc: labels['특이사항'] || '',
335
- files: [],
362
+ etc: findGroupTextarea('기타', '특이사항') || labels['기타'] || labels['특이사항'] || '',
363
+ files,
336
364
  })
337
365
  }
338
366
 
package/src/http.test.ts CHANGED
@@ -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
 
3
3
  import { MENU_NO } from './constants'
4
4
  import { AuthenticationError } from './errors'
@@ -12,7 +12,7 @@ afterEach(() => {
12
12
  })
13
13
 
14
14
  describe('SomaHttp', () => {
15
- test('get sends query params and stores cookies', async () => {
15
+ it('sends query params on GET and stores returned cookies', async () => {
16
16
  const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
17
17
  expect(String(input)).toBe(`https://www.swmaestro.ai/sw/member/user/forLogin.do?menuNo=${MENU_NO.LOGIN}`)
18
18
  expect(init?.headers).toEqual({
@@ -32,7 +32,7 @@ describe('SomaHttp', () => {
32
32
  expect(fetchMock).toHaveBeenCalledTimes(1)
33
33
  })
34
34
 
35
- test('post encodes body and injects csrf token', async () => {
35
+ it('encodes the POST body and injects the CSRF token', async () => {
36
36
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
37
37
  expect(init?.method).toBe('POST')
38
38
  expect(init?.headers).toEqual({
@@ -53,7 +53,7 @@ describe('SomaHttp', () => {
53
53
  expect(html).toBe('<html>ok</html>')
54
54
  })
55
55
 
56
- test('post surfaces alert errors from script tags with attributes', async () => {
56
+ it('surfaces alert() errors from script tags that have attributes', async () => {
57
57
  const fetchMock = mock(async () =>
58
58
  createResponse(
59
59
  `<html><head><title>빈페이지</title></head><body><script type='text/javascript'>alert('아이디 혹은 비밀번호가 일치 하지 않습니다.');location.href='/login';</script></body></html>`,
@@ -68,7 +68,7 @@ describe('SomaHttp', () => {
68
68
  )
69
69
  })
70
70
 
71
- test('post surfaces alert errors followed by history.back()', async () => {
71
+ it('surfaces alert() errors that are followed by history.back()', async () => {
72
72
  const fetchMock = mock(async () =>
73
73
  createResponse(
74
74
  `<html><head></head><body><script language='JavaScript'>\nalert('잘못된 접근입니다.');\nhistory.back();\n</script></body></html>`,
@@ -81,7 +81,7 @@ describe('SomaHttp', () => {
81
81
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow('잘못된 접근입니다.')
82
82
  })
83
83
 
84
- test('post ignores alert() inside function bodies (form validation scripts)', async () => {
84
+ it('ignores alert() calls nested inside function bodies (form validation scripts)', async () => {
85
85
  const pageWithValidationScript = `<html><head><title>AI·SW마에스트로 서울</title></head><body>
86
86
  <ul class="bbs-reserve"><li class="item">room data</li></ul>
87
87
  <script>
@@ -106,7 +106,7 @@ describe('SomaHttp', () => {
106
106
  })
107
107
 
108
108
  describe('postMultipart', () => {
109
- test('passes FormData to fetch', async () => {
109
+ it('passes the FormData instance through to fetch', async () => {
110
110
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
111
111
  expect(init?.body).toBeInstanceOf(FormData)
112
112
  return createResponse('<html>ok</html>')
@@ -118,7 +118,7 @@ describe('SomaHttp', () => {
118
118
  await expect(http.postMultipart('/test', new FormData())).resolves.toBe('<html>ok</html>')
119
119
  })
120
120
 
121
- test('does not set Content-Type manually', async () => {
121
+ it('does not set the Content-Type header manually (lets fetch set the boundary)', async () => {
122
122
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
123
123
  expect(init?.headers).toEqual({
124
124
  'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
@@ -139,7 +139,7 @@ describe('SomaHttp', () => {
139
139
  await http.postMultipart('/test', new FormData())
140
140
  })
141
141
 
142
- test('appends csrf token to FormData', async () => {
142
+ it('appends the CSRF token to the FormData body', async () => {
143
143
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
144
144
  const body = init?.body
145
145
  expect(body).toBeInstanceOf(FormData)
@@ -153,7 +153,7 @@ describe('SomaHttp', () => {
153
153
  await http.postMultipart('/test', new FormData())
154
154
  })
155
155
 
156
- test('follows redirects manually', async () => {
156
+ it('follows redirects manually after a multipart POST', async () => {
157
157
  const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
158
158
  const url = String(input)
159
159
 
@@ -189,7 +189,7 @@ describe('SomaHttp', () => {
189
189
  expect(fetchMock).toHaveBeenCalledTimes(2)
190
190
  })
191
191
 
192
- test('keeps existing post() behavior unchanged', async () => {
192
+ it('keeps the existing post() behavior unchanged', async () => {
193
193
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
194
194
  expect(init?.method).toBe('POST')
195
195
  expect(init?.headers).toEqual({
@@ -211,7 +211,7 @@ describe('SomaHttp', () => {
211
211
  })
212
212
 
213
213
  describe('postForm', () => {
214
- test('converts Record to FormData and delegates to postMultipart', async () => {
214
+ it('converts a Record body to FormData and delegates to postMultipart', async () => {
215
215
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
216
216
  const body = init?.body
217
217
  expect(body).toBeInstanceOf(FormData)
@@ -230,7 +230,7 @@ describe('SomaHttp', () => {
230
230
  ).resolves.toBe('<html>ok</html>')
231
231
  })
232
232
 
233
- test('does not set Content-Type header (lets FormData set multipart boundary)', async () => {
233
+ it('does not set the Content-Type header so FormData can set the multipart boundary', async () => {
234
234
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
235
235
  const headers = init?.headers as Record<string, string> | undefined
236
236
  expect(headers?.['Content-Type']).toBeUndefined()
@@ -245,7 +245,7 @@ describe('SomaHttp', () => {
245
245
  })
246
246
  })
247
247
 
248
- test('postJson returns parsed json', async () => {
248
+ it('returns the parsed JSON body from postJson', async () => {
249
249
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
250
250
  expect(init?.headers).toEqual({
251
251
  'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
@@ -267,7 +267,7 @@ describe('SomaHttp', () => {
267
267
  expect(json).toEqual({ resultCode: 'SUCCESS' })
268
268
  })
269
269
 
270
- test('login fetches csrf token then posts credentials', async () => {
270
+ it('fetches the CSRF token before posting credentials during login', async () => {
271
271
  const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
272
272
  const url = String(input)
273
273
 
@@ -309,7 +309,7 @@ describe('SomaHttp', () => {
309
309
  expect(http.getCsrfToken()).toBe('csrf-login')
310
310
  })
311
311
 
312
- test('checkLogin returns user identity when logged in, null otherwise', async () => {
312
+ it('returns the user identity when logged in and null when not', async () => {
313
313
  const loggedInMock = mock(async (_input: RequestInfo | URL, _init?: RequestInit) =>
314
314
  createResponse(
315
315
  JSON.stringify({
@@ -345,7 +345,7 @@ describe('SomaHttp', () => {
345
345
  await expect(new SomaHttp().checkLogin()).resolves.toBeNull()
346
346
  })
347
347
 
348
- test('checkLogin returns null when the server redirects to login', async () => {
348
+ it('returns null from checkLogin when the server redirects to the login page', async () => {
349
349
  const fetchMock = mock(async () =>
350
350
  createResponse('', [], 'text/html', {
351
351
  status: 302,
@@ -357,7 +357,7 @@ describe('SomaHttp', () => {
357
357
  await expect(new SomaHttp({ sessionCookie: 'session-1' }).checkLogin()).resolves.toBeNull()
358
358
  })
359
359
 
360
- test('checkLogin returns null when the server serves login html instead of json', async () => {
360
+ it('returns null from checkLogin when the server serves login HTML instead of JSON', async () => {
361
361
  const fetchMock = mock(async () =>
362
362
  createResponse(
363
363
  '<html><head><title>AI·SW마에스트로</title></head><body><form><input name="username"><input name="password"></form></body></html>',
@@ -368,7 +368,7 @@ describe('SomaHttp', () => {
368
368
  await expect(new SomaHttp({ sessionCookie: 'session-1' }).checkLogin()).resolves.toBeNull()
369
369
  })
370
370
 
371
- test('logout calls logout endpoint', async () => {
371
+ it('calls the logout endpoint', async () => {
372
372
  const fetchMock = mock(async (input: RequestInfo | URL) => {
373
373
  expect(String(input)).toBe('https://www.swmaestro.ai/sw/member/user/logout.do')
374
374
  return createResponse('<html>bye</html>')
@@ -380,7 +380,7 @@ describe('SomaHttp', () => {
380
380
  expect(fetchMock).toHaveBeenCalledTimes(1)
381
381
  })
382
382
 
383
- test('extractCsrfToken reads hidden input', async () => {
383
+ it('reads the CSRF token from a hidden input field', async () => {
384
384
  const fetchMock = mock(async () => createResponse('<input type="hidden" name="csrfToken" value="csrf-token">'))
385
385
  globalThis.fetch = fetchMock as typeof fetch
386
386
 
@@ -397,7 +397,7 @@ describe('SomaHttp', () => {
397
397
 
398
398
  const expiredMessage = '잘못된 접근입니다. 해당 세션을 전체 초기화 하였습니다.'
399
399
 
400
- test('post throws AuthenticationError for session-expired alert', async () => {
400
+ it('throws AuthenticationError from post when alert indicates session expiry', async () => {
401
401
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert(expiredMessage)))
402
402
  globalThis.fetch = fetchMock as typeof fetch
403
403
 
@@ -406,7 +406,7 @@ describe('SomaHttp', () => {
406
406
  await expect(http.post('/mypage/officeMng/list.do', { menuNo: '200058' })).rejects.toThrow(AuthenticationError)
407
407
  })
408
408
 
409
- test('get throws AuthenticationError for session-expired alert', async () => {
409
+ it('throws AuthenticationError from get when alert indicates session expiry', async () => {
410
410
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert(expiredMessage)))
411
411
  globalThis.fetch = fetchMock as typeof fetch
412
412
 
@@ -415,7 +415,7 @@ describe('SomaHttp', () => {
415
415
  await expect(http.get('/mypage/officeMng/list.do')).rejects.toThrow(AuthenticationError)
416
416
  })
417
417
 
418
- test('postMultipart throws AuthenticationError for session-expired alert', async () => {
418
+ it('throws AuthenticationError from postMultipart when alert indicates session expiry', async () => {
419
419
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert(expiredMessage)))
420
420
  globalThis.fetch = fetchMock as typeof fetch
421
421
 
@@ -424,7 +424,7 @@ describe('SomaHttp', () => {
424
424
  await expect(http.postMultipart('/mypage/test.do', new FormData())).rejects.toThrow(AuthenticationError)
425
425
  })
426
426
 
427
- test('alert with 세션 + invalidation keyword variants are treated as auth error', async () => {
427
+ it('treats alerts containing "세션" plus an invalidation keyword as auth errors', async () => {
428
428
  const variants = ['세션이 만료되었습니다.', '해당 세션을 초기화하였습니다.', '세션 정보가 유효하지 않습니다.']
429
429
 
430
430
  for (const message of variants) {
@@ -436,7 +436,7 @@ describe('SomaHttp', () => {
436
436
  }
437
437
  })
438
438
 
439
- test('alert containing 세션 without invalidation keyword is not treated as auth error', async () => {
439
+ it('does not treat an alert containing "세션" without an invalidation keyword as an auth error', async () => {
440
440
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert('멘토링 세션이 이미 마감되었습니다.')))
441
441
  globalThis.fetch = fetchMock as typeof fetch
442
442
 
@@ -445,7 +445,7 @@ describe('SomaHttp', () => {
445
445
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow('멘토링 세션이 이미 마감되었습니다.')
446
446
  })
447
447
 
448
- test('non-session alert still throws regular Error', async () => {
448
+ it('throws a regular Error from post when the alert is unrelated to the session', async () => {
449
449
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert('잘못된 접근입니다.')))
450
450
  globalThis.fetch = fetchMock as typeof fetch
451
451
 
@@ -454,7 +454,7 @@ describe('SomaHttp', () => {
454
454
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow('잘못된 접근입니다.')
455
455
  })
456
456
 
457
- test('get throws regular Error for non-session alert', async () => {
457
+ it('throws a regular Error from get when the alert is unrelated to the session', async () => {
458
458
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert('잘못된 접근입니다.')))
459
459
  globalThis.fetch = fetchMock as typeof fetch
460
460
 
@@ -463,7 +463,7 @@ describe('SomaHttp', () => {
463
463
  await expect(http.get('/mypage/test.do')).rejects.toThrow('잘못된 접근입니다.')
464
464
  })
465
465
 
466
- test('session-expired alert with double quotes is detected', async () => {
466
+ it('detects session-expired alerts that use double quotes', async () => {
467
467
  const doubleQuoteAlert = `<html><body><script language='JavaScript'>\nalert("${expiredMessage}");\nhistory.back();\n</script></body></html>`
468
468
  const fetchMock = mock(async () => createResponse(doubleQuoteAlert))
469
469
  globalThis.fetch = fetchMock as typeof fetch
@@ -480,7 +480,7 @@ describe('SomaHttp', () => {
480
480
  })
481
481
  const genericErrorJson = JSON.stringify({ error: '처리 중 오류가 발생했습니다.' })
482
482
 
483
- test('post throws AuthenticationError for session-expired JSON', async () => {
483
+ it('throws AuthenticationError from post when the JSON response indicates session expiry', async () => {
484
484
  const fetchMock = mock(async () => createResponse(sessionExpiredJson, [], 'application/json'))
485
485
  globalThis.fetch = fetchMock as typeof fetch
486
486
 
@@ -489,7 +489,7 @@ describe('SomaHttp', () => {
489
489
  await expect(http.post('/mypage/officeMng/list.do', { menuNo: '200058' })).rejects.toThrow(AuthenticationError)
490
490
  })
491
491
 
492
- test('post throws Error for non-session JSON error', async () => {
492
+ it('throws a regular Error from post for non-session JSON errors', async () => {
493
493
  const fetchMock = mock(async () => createResponse(genericErrorJson, [], 'application/json'))
494
494
  globalThis.fetch = fetchMock as typeof fetch
495
495
 
@@ -498,7 +498,7 @@ describe('SomaHttp', () => {
498
498
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow('처리 중 오류가 발생했습니다.')
499
499
  })
500
500
 
501
- test('postJson throws AuthenticationError for session-expired JSON', async () => {
501
+ it('throws AuthenticationError from postJson when the JSON response indicates session expiry', async () => {
502
502
  const fetchMock = mock(async () => createResponse(sessionExpiredJson, [], 'application/json'))
503
503
  globalThis.fetch = fetchMock as typeof fetch
504
504
 
@@ -509,7 +509,7 @@ describe('SomaHttp', () => {
509
509
  )
510
510
  })
511
511
 
512
- test('postJson throws Error for non-session JSON error', async () => {
512
+ it('throws a regular Error from postJson for non-session JSON errors', async () => {
513
513
  const fetchMock = mock(async () => createResponse(genericErrorJson, [], 'application/json'))
514
514
  globalThis.fetch = fetchMock as typeof fetch
515
515
 
@@ -518,7 +518,7 @@ describe('SomaHttp', () => {
518
518
  await expect(http.postJson('/mypage/test.do', {})).rejects.toThrow('처리 중 오류가 발생했습니다.')
519
519
  })
520
520
 
521
- test('postJson still parses valid JSON when no error field', async () => {
521
+ it('still parses valid JSON from postJson when no error field is present', async () => {
522
522
  const fetchMock = mock(async () =>
523
523
  createResponse(JSON.stringify({ resultCode: 'SUCCESS' }), [], 'application/json'),
524
524
  )
@@ -529,7 +529,7 @@ describe('SomaHttp', () => {
529
529
  await expect(http.postJson('/mypage/test.do', {})).resolves.toEqual({ resultCode: 'SUCCESS' })
530
530
  })
531
531
 
532
- test('non-JSON body starting with { does not false-positive', async () => {
532
+ it('does not false-positive on non-JSON bodies that happen to start with "{"', async () => {
533
533
  const fetchMock = mock(async () => createResponse('{malformed json', [], 'text/html'))
534
534
  globalThis.fetch = fetchMock as typeof fetch
535
535
 
@@ -538,7 +538,7 @@ describe('SomaHttp', () => {
538
538
  await expect(http.get('/test')).resolves.toBe('{malformed json')
539
539
  })
540
540
 
541
- test('JSON error with leading whitespace is still detected', async () => {
541
+ it('still detects JSON error responses that have leading whitespace', async () => {
542
542
  const paddedJson = ` \n ${sessionExpiredJson}`
543
543
  const fetchMock = mock(async () => createResponse(paddedJson, [], 'application/json'))
544
544
  globalThis.fetch = fetchMock as typeof fetch
@@ -548,7 +548,7 @@ describe('SomaHttp', () => {
548
548
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow(AuthenticationError)
549
549
  })
550
550
 
551
- test('get throws Error for non-session JSON error', async () => {
551
+ it('throws a regular Error from get for non-session JSON errors', async () => {
552
552
  const fetchMock = mock(async () => createResponse(genericErrorJson, [], 'application/json'))
553
553
  globalThis.fetch = fetchMock as typeof fetch
554
554
 
@@ -557,7 +557,7 @@ describe('SomaHttp', () => {
557
557
  await expect(http.get('/mypage/test.do')).rejects.toThrow('처리 중 오류가 발생했습니다.')
558
558
  })
559
559
 
560
- test('postJson throws AuthenticationError for HTML login page response', async () => {
560
+ it('throws AuthenticationError from postJson when the response is an HTML login page', async () => {
561
561
  const loginPageHtml =
562
562
  '<html><head><title>AI·SW마에스트로</title></head><body><form><input name="username"><input name="password"></form></body></html>'
563
563
  const fetchMock = mock(async () => createResponse(loginPageHtml))
package/src/index.ts CHANGED
@@ -3,5 +3,8 @@ export type { SomaClientOptions } from './client'
3
3
  export { AuthenticationError } from './errors'
4
4
  export { SomaHttp } from './http'
5
5
  export { CredentialManager } from './credential-manager'
6
+ export { TozHttp } from './toz-http'
7
+ export type { TozHttpOptions, TozHttpState } from './toz-http'
8
+ export * from './toz-formatters'
6
9
  export * from './types'
7
10
  export * from './constants'