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.
- package/dist/package.json +1 -1
- package/dist/src/client.d.ts +7 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +13 -11
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/auth.d.ts +1 -1
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +94 -52
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/constants.d.ts +40 -0
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +42 -0
- package/dist/src/constants.js.map +1 -1
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +42 -16
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js +1 -5
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/dist/src/shared/utils/toz.d.ts +23 -0
- package/dist/src/shared/utils/toz.d.ts.map +1 -0
- package/dist/src/shared/utils/toz.js +72 -0
- package/dist/src/shared/utils/toz.js.map +1 -0
- package/dist/src/token-extractor.d.ts +9 -1
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +54 -10
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/toz-formatters.d.ts +9 -0
- package/dist/src/toz-formatters.d.ts.map +1 -0
- package/dist/src/toz-formatters.js +151 -0
- package/dist/src/toz-formatters.js.map +1 -0
- package/dist/src/toz-http.d.ts +27 -0
- package/dist/src/toz-http.d.ts.map +1 -0
- package/dist/src/toz-http.js +154 -0
- package/dist/src/toz-http.js.map +1 -0
- package/dist/src/types.d.ts +52 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +46 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/toz/toz_all_branches.json +211 -0
- package/src/__fixtures__/toz/toz_booking.html +2190 -0
- package/src/__fixtures__/toz/toz_boothes.json +59 -0
- package/src/__fixtures__/toz/toz_duration.json +25 -0
- package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
- package/src/__fixtures__/toz/toz_page.html +211 -0
- package/src/client.test.ts +135 -117
- package/src/client.ts +16 -12
- package/src/commands/auth.test.ts +7 -7
- package/src/commands/auth.ts +107 -50
- package/src/commands/helpers.test.ts +8 -8
- package/src/commands/report.test.ts +7 -7
- package/src/constants.ts +50 -0
- package/src/credential-manager.test.ts +5 -5
- package/src/formatters.test.ts +22 -22
- package/src/formatters.ts +44 -16
- package/src/http.test.ts +37 -37
- package/src/index.ts +3 -0
- package/src/shared/utils/mentoring-params.test.ts +16 -16
- package/src/shared/utils/swmaestro.test.ts +87 -8
- package/src/shared/utils/swmaestro.ts +1 -6
- package/src/shared/utils/toz.test.ts +138 -0
- package/src/shared/utils/toz.ts +100 -0
- package/src/token-extractor.test.ts +40 -15
- package/src/token-extractor.ts +65 -13
- package/src/toz-formatters.test.ts +197 -0
- package/src/toz-formatters.ts +211 -0
- package/src/toz-http.test.ts +157 -0
- package/src/toz-http.ts +188 -0
- package/src/types.test.ts +220 -204
- package/src/types.ts +58 -0
package/src/formatters.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
307
|
-
const
|
|
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
|
|
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:
|
|
328
|
-
progressEndTime:
|
|
329
|
-
exceptStartTime:
|
|
330
|
-
exceptEndTime:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|