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
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import { MENU_NO, REPORT_CD } from '../../constants'
|
|
4
4
|
import { buildMentoringListParams, parseSearchQuery } from './mentoring-params'
|
|
5
5
|
|
|
6
6
|
describe('parseSearchQuery', () => {
|
|
7
|
-
|
|
7
|
+
it('defaults plain text to a title search', () => {
|
|
8
8
|
expect(parseSearchQuery('OpenCode')).toEqual({ field: 'title', value: 'OpenCode' })
|
|
9
9
|
})
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
it('parses the "title:" prefix as a title search', () => {
|
|
12
12
|
expect(parseSearchQuery('title:OpenCode')).toEqual({ field: 'title', value: 'OpenCode' })
|
|
13
13
|
})
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
it('parses the "author:" prefix as an author search', () => {
|
|
16
16
|
expect(parseSearchQuery('author:전수열')).toEqual({ field: 'author', value: '전수열' })
|
|
17
17
|
})
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
it('sets the "me" flag when parsing "author:@me"', () => {
|
|
20
20
|
expect(parseSearchQuery('author:@me')).toEqual({ field: 'author', value: '@me', me: true })
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
it('parses the "content:" prefix as a content search', () => {
|
|
24
24
|
expect(parseSearchQuery('content:하네스')).toEqual({ field: 'content', value: '하네스' })
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
it('treats an unknown prefix as a plain title search', () => {
|
|
28
28
|
expect(parseSearchQuery('foo:bar')).toEqual({ field: 'title', value: 'foo:bar' })
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
it('preserves everything after the first colon when the value contains colons', () => {
|
|
32
32
|
expect(parseSearchQuery('title:foo:bar')).toEqual({ field: 'title', value: 'foo:bar' })
|
|
33
33
|
})
|
|
34
34
|
})
|
|
35
35
|
|
|
36
36
|
describe('buildMentoringListParams', () => {
|
|
37
|
-
|
|
37
|
+
it('returns only menuNo when no options are passed', () => {
|
|
38
38
|
expect(buildMentoringListParams()).toEqual({ menuNo: MENU_NO.MENTORING })
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
it('maps status open to "A" and closed to "C"', () => {
|
|
42
42
|
expect(buildMentoringListParams({ status: 'open' })).toEqual({
|
|
43
43
|
menuNo: MENU_NO.MENTORING,
|
|
44
44
|
searchStatMentolec: 'A',
|
|
@@ -49,7 +49,7 @@ describe('buildMentoringListParams', () => {
|
|
|
49
49
|
})
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
it('maps type public and lecture to their report codes', () => {
|
|
53
53
|
expect(buildMentoringListParams({ type: 'public' })).toEqual({
|
|
54
54
|
menuNo: MENU_NO.MENTORING,
|
|
55
55
|
searchGubunMentolec: REPORT_CD.PUBLIC_MENTORING,
|
|
@@ -60,7 +60,7 @@ describe('buildMentoringListParams', () => {
|
|
|
60
60
|
})
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
it('sets searchCnd=1 for a title search', () => {
|
|
64
64
|
expect(buildMentoringListParams({ search: { field: 'title', value: 'OpenCode' } })).toEqual({
|
|
65
65
|
menuNo: MENU_NO.MENTORING,
|
|
66
66
|
searchCnd: '1',
|
|
@@ -68,7 +68,7 @@ describe('buildMentoringListParams', () => {
|
|
|
68
68
|
})
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
it('sets searchCnd=2 for an author search', () => {
|
|
72
72
|
expect(buildMentoringListParams({ search: { field: 'author', value: '전수열' } })).toEqual({
|
|
73
73
|
menuNo: MENU_NO.MENTORING,
|
|
74
74
|
searchCnd: '2',
|
|
@@ -76,7 +76,7 @@ describe('buildMentoringListParams', () => {
|
|
|
76
76
|
})
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
it('sets searchId and searchWrd from the current user when "author:@me" is used', () => {
|
|
80
80
|
expect(
|
|
81
81
|
buildMentoringListParams({
|
|
82
82
|
search: { field: 'author', value: '@me', me: true },
|
|
@@ -90,7 +90,7 @@ describe('buildMentoringListParams', () => {
|
|
|
90
90
|
})
|
|
91
91
|
})
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
it('composes search with status and type filters', () => {
|
|
94
94
|
expect(
|
|
95
95
|
buildMentoringListParams({
|
|
96
96
|
status: 'open',
|
|
@@ -108,7 +108,7 @@ describe('buildMentoringListParams', () => {
|
|
|
108
108
|
})
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
it('sets pageIndex from the page option', () => {
|
|
112
112
|
expect(buildMentoringListParams({ page: 3 })).toEqual({
|
|
113
113
|
menuNo: MENU_NO.MENTORING,
|
|
114
114
|
pageIndex: '3',
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { describe, expect,
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import { resolveVenue } from './swmaestro'
|
|
3
|
+
import { buildRoomReservationPayload, resolveVenue } from './swmaestro'
|
|
4
4
|
|
|
5
5
|
describe('resolveVenue', () => {
|
|
6
|
-
|
|
6
|
+
it('prepends "토즈-" to bare TOZ location names', () => {
|
|
7
7
|
expect(resolveVenue('광화문점')).toBe('토즈-광화문점')
|
|
8
8
|
expect(resolveVenue('양재점')).toBe('토즈-양재점')
|
|
9
9
|
expect(resolveVenue('강남컨퍼런스센터점')).toBe('토즈-강남컨퍼런스센터점')
|
|
@@ -14,17 +14,17 @@ describe('resolveVenue', () => {
|
|
|
14
14
|
expect(resolveVenue('홍대점')).toBe('토즈-홍대점')
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
it('passes through TOZ locations that already have the prefix', () => {
|
|
18
18
|
expect(resolveVenue('토즈-광화문점')).toBe('토즈-광화문점')
|
|
19
19
|
expect(resolveVenue('토즈-강남역토즈타워점')).toBe('토즈-강남역토즈타워점')
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
it('resolves "신촌비즈니스센터점" to "연수센터-7"', () => {
|
|
23
23
|
expect(resolveVenue('신촌비즈니스센터점')).toBe('연수센터-7')
|
|
24
24
|
expect(resolveVenue('토즈-신촌비즈니스센터점')).toBe('연수센터-7')
|
|
25
25
|
})
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
it('passes through non-TOZ venues unchanged', () => {
|
|
28
28
|
expect(resolveVenue('온라인(Webex)')).toBe('온라인(Webex)')
|
|
29
29
|
expect(resolveVenue('스페이스 A1')).toBe('스페이스 A1')
|
|
30
30
|
expect(resolveVenue('스페이스 M1')).toBe('스페이스 M1')
|
|
@@ -33,12 +33,91 @@ describe('resolveVenue', () => {
|
|
|
33
33
|
expect(resolveVenue('(엑스퍼트) 외부_카페')).toBe('(엑스퍼트) 외부_카페')
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
it('trims surrounding whitespace from the input', () => {
|
|
37
37
|
expect(resolveVenue(' 강남역토즈타워점 ')).toBe('토즈-강남역토즈타워점')
|
|
38
38
|
expect(resolveVenue(' 스페이스 A1 ')).toBe('스페이스 A1')
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
it('passes through unknown venues unchanged', () => {
|
|
42
42
|
expect(resolveVenue('기타 장소')).toBe('기타 장소')
|
|
43
43
|
})
|
|
44
44
|
})
|
|
45
|
+
|
|
46
|
+
describe('buildRoomReservationPayload', () => {
|
|
47
|
+
it('sets rentEndde to the last selected slot so the server reserves only the chosen slots', () => {
|
|
48
|
+
const payload = buildRoomReservationPayload({
|
|
49
|
+
roomId: 17,
|
|
50
|
+
date: '2026-04-20',
|
|
51
|
+
slots: ['13:00', '13:30'],
|
|
52
|
+
title: '회의',
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(payload.rentBgnde).toBe('2026-04-20 13:00:00')
|
|
56
|
+
expect(payload.rentEndde).toBe('2026-04-20 13:30:00')
|
|
57
|
+
expect(payload['time[0]']).toBe('13:00')
|
|
58
|
+
expect(payload['time[1]']).toBe('13:30')
|
|
59
|
+
expect(payload['time[2]']).toBeUndefined()
|
|
60
|
+
expect(payload['chkData_1']).toBe('2026-04-20|13:00|17')
|
|
61
|
+
expect(payload['chkData_2']).toBe('2026-04-20|13:30|17')
|
|
62
|
+
expect(payload['chkData_3']).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('handles a single-slot reservation', () => {
|
|
66
|
+
const payload = buildRoomReservationPayload({
|
|
67
|
+
roomId: 17,
|
|
68
|
+
date: '2026-04-20',
|
|
69
|
+
slots: ['13:00'],
|
|
70
|
+
title: '회의',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(payload.rentBgnde).toBe('2026-04-20 13:00:00')
|
|
74
|
+
expect(payload.rentEndde).toBe('2026-04-20 13:00:00')
|
|
75
|
+
expect(payload['time[0]']).toBe('13:00')
|
|
76
|
+
expect(payload['time[1]']).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('handles a reservation ending at the last available slot', () => {
|
|
80
|
+
const payload = buildRoomReservationPayload({
|
|
81
|
+
roomId: 17,
|
|
82
|
+
date: '2026-04-20',
|
|
83
|
+
slots: ['23:00', '23:30'],
|
|
84
|
+
title: '회의',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(payload.rentBgnde).toBe('2026-04-20 23:00:00')
|
|
88
|
+
expect(payload.rentEndde).toBe('2026-04-20 23:30:00')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('rejects non-consecutive slots', () => {
|
|
92
|
+
expect(() =>
|
|
93
|
+
buildRoomReservationPayload({
|
|
94
|
+
roomId: 17,
|
|
95
|
+
date: '2026-04-20',
|
|
96
|
+
slots: ['13:00', '14:00'],
|
|
97
|
+
title: '회의',
|
|
98
|
+
}),
|
|
99
|
+
).toThrow('Time slots must be consecutive')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rejects invalid time slots', () => {
|
|
103
|
+
expect(() =>
|
|
104
|
+
buildRoomReservationPayload({
|
|
105
|
+
roomId: 17,
|
|
106
|
+
date: '2026-04-20',
|
|
107
|
+
slots: ['25:00'],
|
|
108
|
+
title: '회의',
|
|
109
|
+
}),
|
|
110
|
+
).toThrow('Invalid time slot')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('rejects empty slot lists', () => {
|
|
114
|
+
expect(() =>
|
|
115
|
+
buildRoomReservationPayload({
|
|
116
|
+
roomId: 17,
|
|
117
|
+
date: '2026-04-20',
|
|
118
|
+
slots: [],
|
|
119
|
+
title: '회의',
|
|
120
|
+
}),
|
|
121
|
+
).toThrow('At least one time slot is required')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -142,17 +142,12 @@ export function buildRoomReservationPayload(params: {
|
|
|
142
142
|
|
|
143
143
|
const firstSlot = params.slots[0]
|
|
144
144
|
const lastSlot = params.slots[params.slots.length - 1]
|
|
145
|
-
const endSlot = TIME_SLOTS[TIME_SLOTS.indexOf(lastSlot) + 1]
|
|
146
|
-
|
|
147
|
-
if (!endSlot) {
|
|
148
|
-
throw new Error('Reservation end time is out of range')
|
|
149
|
-
}
|
|
150
145
|
|
|
151
146
|
const payload: Record<string, string> = {
|
|
152
147
|
menuNo: MENU_NO.ROOM,
|
|
153
148
|
itemId: String(params.roomId),
|
|
154
149
|
rentBgnde: `${params.date} ${firstSlot}:00`,
|
|
155
|
-
rentEndde: `${params.date} ${
|
|
150
|
+
rentEndde: `${params.date} ${lastSlot}:00`,
|
|
156
151
|
title: params.title,
|
|
157
152
|
rentDt: params.date,
|
|
158
153
|
rentNum: String(params.attendees ?? 1),
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assertDurationInRange,
|
|
5
|
+
buildBranchIdsParam,
|
|
6
|
+
buildStartTimeParam,
|
|
7
|
+
formatDuration,
|
|
8
|
+
formatPhone,
|
|
9
|
+
formatStartTime,
|
|
10
|
+
parseDurationKey,
|
|
11
|
+
parseEmail,
|
|
12
|
+
parsePhone,
|
|
13
|
+
} from './toz'
|
|
14
|
+
|
|
15
|
+
describe('formatDuration', () => {
|
|
16
|
+
it('formats hours+minutes as HHMM', () => {
|
|
17
|
+
expect(formatDuration(120)).toBe('0200')
|
|
18
|
+
expect(formatDuration(150)).toBe('0230')
|
|
19
|
+
expect(formatDuration(180)).toBe('0300')
|
|
20
|
+
expect(formatDuration(90)).toBe('0130')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('throws on invalid input', () => {
|
|
24
|
+
expect(() => formatDuration(0)).toThrow()
|
|
25
|
+
expect(() => formatDuration(-30)).toThrow()
|
|
26
|
+
expect(() => formatDuration(1.5)).toThrow()
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('parseDurationKey', () => {
|
|
31
|
+
it('parses HHMM keys to minutes', () => {
|
|
32
|
+
expect(parseDurationKey('0200')).toBe(120)
|
|
33
|
+
expect(parseDurationKey('0230')).toBe(150)
|
|
34
|
+
expect(parseDurationKey('1300')).toBe(780)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('throws on invalid key', () => {
|
|
38
|
+
expect(() => parseDurationKey('0')).toThrow()
|
|
39
|
+
expect(() => parseDurationKey('abcd')).toThrow()
|
|
40
|
+
expect(() => parseDurationKey('20000')).toThrow()
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('formatStartTime / buildStartTimeParam', () => {
|
|
45
|
+
it('splits HH:MM', () => {
|
|
46
|
+
expect(formatStartTime('10:00')).toEqual({ hour: '10', minute: '00' })
|
|
47
|
+
expect(formatStartTime('9:30')).toEqual({ hour: '09', minute: '30' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('joins to 4-digit param', () => {
|
|
51
|
+
expect(buildStartTimeParam('10:00')).toBe('1000')
|
|
52
|
+
expect(buildStartTimeParam('9:05')).toBe('0905')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('rejects time without colon separator', () => {
|
|
56
|
+
expect(() => formatStartTime('1000')).toThrow()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('accepts out-of-range hours because the format matches', () => {
|
|
60
|
+
expect(formatStartTime('25:00')).toEqual({ hour: '25', minute: '00' })
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('buildBranchIdsParam', () => {
|
|
65
|
+
it('joins with trailing comma', () => {
|
|
66
|
+
expect(buildBranchIdsParam([27])).toBe('27,')
|
|
67
|
+
expect(buildBranchIdsParam([27, 145])).toBe('27,145,')
|
|
68
|
+
expect(buildBranchIdsParam([27, 145, 19, 20, 15, 139, 134, 30, 149])).toBe('27,145,19,20,15,139,134,30,149,')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('rejects empty list', () => {
|
|
72
|
+
expect(() => buildBranchIdsParam([])).toThrow()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('parsePhone', () => {
|
|
77
|
+
it('parses standard 010-XXXX-YYYY', () => {
|
|
78
|
+
expect(parsePhone('010-1234-5678')).toEqual({ phone1: '010', phone2: '1234', phone3: '5678' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('parses 010 with no dashes', () => {
|
|
82
|
+
expect(parsePhone('01012345678')).toEqual({ phone1: '010', phone2: '1234', phone3: '5678' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('parses old 011-XXX-YYYY', () => {
|
|
86
|
+
expect(parsePhone('011-123-4567')).toEqual({ phone1: '011', phone2: '123', phone3: '4567' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('rejects unsupported prefix', () => {
|
|
90
|
+
expect(() => parsePhone('012-1234-5678')).toThrow()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('rejects invalid length', () => {
|
|
94
|
+
expect(() => parsePhone('010-12-34')).toThrow()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('formatPhone', () => {
|
|
99
|
+
it('joins to dashed format', () => {
|
|
100
|
+
expect(formatPhone({ phone1: '010', phone2: '1234', phone3: '5678' })).toBe('010-1234-5678')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('parseEmail', () => {
|
|
105
|
+
it('uses select option for known domain', () => {
|
|
106
|
+
expect(parseEmail('user@gmail.com')).toEqual({ email1: 'user', email2: 'gmail.com', email3: '' })
|
|
107
|
+
expect(parseEmail('me@naver.com')).toEqual({ email1: 'me', email2: 'naver.com', email3: '' })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('uses 직접입력 for custom domain', () => {
|
|
111
|
+
expect(parseEmail('user@customdomain.io')).toEqual({
|
|
112
|
+
email1: 'user',
|
|
113
|
+
email2: '직접입력',
|
|
114
|
+
email3: 'customdomain.io',
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('rejects malformed email', () => {
|
|
119
|
+
expect(() => parseEmail('userdomain.com')).toThrow()
|
|
120
|
+
expect(() => parseEmail('@domain.com')).toThrow()
|
|
121
|
+
expect(() => parseEmail('user@')).toThrow()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('assertDurationInRange', () => {
|
|
126
|
+
it('accepts boundary and interior values', () => {
|
|
127
|
+
expect(assertDurationInRange(120)).toBeUndefined()
|
|
128
|
+
expect(assertDurationInRange(150)).toBeUndefined()
|
|
129
|
+
expect(assertDurationInRange(180)).toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('rejects values just outside the boundary', () => {
|
|
133
|
+
expect(() => assertDurationInRange(119)).toThrow()
|
|
134
|
+
expect(() => assertDurationInRange(181)).toThrow()
|
|
135
|
+
expect(() => assertDurationInRange(60)).toThrow()
|
|
136
|
+
expect(() => assertDurationInRange(210)).toThrow()
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TOZ_EMAIL_DOMAIN_CUSTOM,
|
|
3
|
+
TOZ_EMAIL_DOMAINS,
|
|
4
|
+
TOZ_MAX_DURATION_MINUTES,
|
|
5
|
+
TOZ_MIN_DURATION_MINUTES,
|
|
6
|
+
TOZ_PHONE_PREFIXES,
|
|
7
|
+
} from '../../constants'
|
|
8
|
+
|
|
9
|
+
export interface ParsedPhone {
|
|
10
|
+
phone1: string
|
|
11
|
+
phone2: string
|
|
12
|
+
phone3: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ParsedEmail {
|
|
16
|
+
email1: string
|
|
17
|
+
email2: string
|
|
18
|
+
email3: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatDuration(minutes: number): string {
|
|
22
|
+
if (!Number.isInteger(minutes) || minutes <= 0) {
|
|
23
|
+
throw new Error(`Invalid duration minutes: ${minutes}`)
|
|
24
|
+
}
|
|
25
|
+
const hh = Math.floor(minutes / 60)
|
|
26
|
+
const mm = minutes % 60
|
|
27
|
+
return `${String(hh).padStart(2, '0')}${String(mm).padStart(2, '0')}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseDurationKey(key: string): number {
|
|
31
|
+
if (!/^\d{4}$/.test(key)) {
|
|
32
|
+
throw new Error(`Invalid duration key: ${key}`)
|
|
33
|
+
}
|
|
34
|
+
const hh = Number.parseInt(key.slice(0, 2), 10)
|
|
35
|
+
const mm = Number.parseInt(key.slice(2, 4), 10)
|
|
36
|
+
return hh * 60 + mm
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatStartTime(time: string): { hour: string; minute: string } {
|
|
40
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(time)
|
|
41
|
+
if (!match) {
|
|
42
|
+
throw new Error(`Invalid time: ${time} (expected HH:MM)`)
|
|
43
|
+
}
|
|
44
|
+
return { hour: match[1].padStart(2, '0'), minute: match[2] }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildStartTimeParam(time: string): string {
|
|
48
|
+
const { hour, minute } = formatStartTime(time)
|
|
49
|
+
return `${hour}${minute}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildBranchIdsParam(branchIds: readonly number[]): string {
|
|
53
|
+
if (branchIds.length === 0) {
|
|
54
|
+
throw new Error('At least one branchId is required')
|
|
55
|
+
}
|
|
56
|
+
return `${branchIds.join(',')},`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parsePhone(phone: string): ParsedPhone {
|
|
60
|
+
const digits = phone.replace(/[^0-9]/g, '')
|
|
61
|
+
const prefix = TOZ_PHONE_PREFIXES.find((p) => digits.startsWith(p))
|
|
62
|
+
if (!prefix) {
|
|
63
|
+
throw new Error(`Unsupported phone prefix in: ${phone}`)
|
|
64
|
+
}
|
|
65
|
+
const rest = digits.slice(prefix.length)
|
|
66
|
+
if (rest.length !== 7 && rest.length !== 8) {
|
|
67
|
+
throw new Error(`Invalid phone number: ${phone}`)
|
|
68
|
+
}
|
|
69
|
+
const split = rest.length - 4
|
|
70
|
+
return {
|
|
71
|
+
phone1: prefix,
|
|
72
|
+
phone2: rest.slice(0, split),
|
|
73
|
+
phone3: rest.slice(split),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatPhone(parsed: ParsedPhone): string {
|
|
78
|
+
return `${parsed.phone1}-${parsed.phone2}-${parsed.phone3}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseEmail(email: string): ParsedEmail {
|
|
82
|
+
const at = email.indexOf('@')
|
|
83
|
+
if (at <= 0 || at === email.length - 1) {
|
|
84
|
+
throw new Error(`Invalid email: ${email}`)
|
|
85
|
+
}
|
|
86
|
+
const local = email.slice(0, at)
|
|
87
|
+
const domain = email.slice(at + 1)
|
|
88
|
+
const known = (TOZ_EMAIL_DOMAINS as readonly string[]).includes(domain)
|
|
89
|
+
return known
|
|
90
|
+
? { email1: local, email2: domain, email3: '' }
|
|
91
|
+
: { email1: local, email2: TOZ_EMAIL_DOMAIN_CUSTOM, email3: domain }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function assertDurationInRange(minutes: number): void {
|
|
95
|
+
if (minutes < TOZ_MIN_DURATION_MINUTES || minutes > TOZ_MAX_DURATION_MINUTES) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`SW마에스트로 partnership requires duration between ${TOZ_MIN_DURATION_MINUTES / 60}h and ${TOZ_MAX_DURATION_MINUTES / 60}h (got ${minutes} minutes)`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite'
|
|
2
|
-
import { afterEach, describe, expect,
|
|
2
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
3
3
|
import { execSync } from 'node:child_process'
|
|
4
4
|
import { createCipheriv, pbkdf2Sync } from 'node:crypto'
|
|
5
5
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
@@ -22,7 +22,7 @@ afterEach(() => {
|
|
|
22
22
|
})
|
|
23
23
|
|
|
24
24
|
describe('TokenExtractor', () => {
|
|
25
|
-
|
|
25
|
+
it('finds cookie databases for every supported browser on macOS', async () => {
|
|
26
26
|
const home = await makeTempDir()
|
|
27
27
|
|
|
28
28
|
for (const browser of BROWSERS) {
|
|
@@ -38,7 +38,7 @@ describe('TokenExtractor', () => {
|
|
|
38
38
|
}
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
it('finds cookie databases for every supported browser on Linux', async () => {
|
|
42
42
|
const home = await makeTempDir()
|
|
43
43
|
|
|
44
44
|
for (const browser of BROWSERS) {
|
|
@@ -51,12 +51,12 @@ describe('TokenExtractor', () => {
|
|
|
51
51
|
expect(paths).toHaveLength(BROWSERS.length)
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
it('returns null when no cookie databases exist', async () => {
|
|
55
55
|
const extractor = new TokenExtractor('linux', await makeTempDir())
|
|
56
56
|
expect(await extractor.extract()).toBeNull()
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
it('extracts a plaintext cookie value', async () => {
|
|
60
60
|
const home = await makeTempDir()
|
|
61
61
|
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
62
62
|
createCookieDbWithPlaintext(dbPath, 'my-session-id')
|
|
@@ -65,7 +65,7 @@ describe('TokenExtractor', () => {
|
|
|
65
65
|
expect(await extractor.extract()).toEqual({ sessionCookie: 'my-session-id' })
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
it('finds cookie databases across numbered browser profiles', async () => {
|
|
69
69
|
const home = await makeTempDir()
|
|
70
70
|
createCookieFile(join(home, '.config', 'google-chrome', 'Default', 'Cookies'))
|
|
71
71
|
createCookieFile(join(home, '.config', 'google-chrome', 'Profile 1', 'Cookies'))
|
|
@@ -80,7 +80,7 @@ describe('TokenExtractor', () => {
|
|
|
80
80
|
])
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
it('keeps the newest unique cookie across profiles when collecting candidates', async () => {
|
|
84
84
|
const home = await makeTempDir()
|
|
85
85
|
createCookieDbWithPlaintext(join(home, '.config', 'google-chrome', 'Default', 'Cookies'), 'stale-session', 10)
|
|
86
86
|
createCookieDbWithPlaintext(join(home, '.config', 'google-chrome', 'Profile 1', 'Cookies'), 'valid-session', 20)
|
|
@@ -104,7 +104,26 @@ describe('TokenExtractor', () => {
|
|
|
104
104
|
])
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
it('extracts a plaintext cookie from the opensoma.dev host', async () => {
|
|
108
|
+
const home = await makeTempDir()
|
|
109
|
+
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
110
|
+
createCookieDbWithPlaintext(dbPath, 'opensoma-dev-session', 0, 'opensoma.dev')
|
|
111
|
+
|
|
112
|
+
const extractor = new TokenExtractor('linux', home)
|
|
113
|
+
expect(await extractor.extract()).toEqual({ sessionCookie: 'opensoma-dev-session' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('decrypts an encrypted cookie from the opensoma.dev host on Linux', async () => {
|
|
117
|
+
const home = await makeTempDir()
|
|
118
|
+
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
119
|
+
const encrypted = encryptLinuxCookie('opensoma-dev-encrypted')
|
|
120
|
+
createCookieDbWithEncrypted(dbPath, encrypted, 'opensoma.dev')
|
|
121
|
+
|
|
122
|
+
const extractor = new TokenExtractor('linux', home)
|
|
123
|
+
expect(await extractor.extract()).toEqual({ sessionCookie: 'opensoma-dev-encrypted' })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('decrypts an encrypted cookie on Linux', async () => {
|
|
108
127
|
const home = await makeTempDir()
|
|
109
128
|
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
110
129
|
const encrypted = encryptLinuxCookie('decrypted-session')
|
|
@@ -114,7 +133,7 @@ describe('TokenExtractor', () => {
|
|
|
114
133
|
expect(await extractor.extract()).toEqual({ sessionCookie: 'decrypted-session' })
|
|
115
134
|
})
|
|
116
135
|
|
|
117
|
-
|
|
136
|
+
it('extracts a plaintext cookie when running under Node.js (compiled ESM)', async () => {
|
|
118
137
|
execSync('bun run build', { cwd: join(import.meta.dir, '..'), stdio: 'pipe' })
|
|
119
138
|
const home = await makeTempDir()
|
|
120
139
|
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
@@ -125,7 +144,7 @@ describe('TokenExtractor', () => {
|
|
|
125
144
|
expect(JSON.parse(result.trim())).toEqual({ sessionCookie: 'node-test-session' })
|
|
126
145
|
})
|
|
127
146
|
|
|
128
|
-
|
|
147
|
+
it('extracts an encrypted cookie when running under Node.js (compiled ESM)', async () => {
|
|
129
148
|
execSync('bun run build', { cwd: join(import.meta.dir, '..'), stdio: 'pipe' })
|
|
130
149
|
const home = await makeTempDir()
|
|
131
150
|
const dbPath = join(home, '.config', 'google-chrome', 'Default', 'Cookies')
|
|
@@ -148,26 +167,32 @@ function createCookieFile(filePath: string): void {
|
|
|
148
167
|
writeFileSync(filePath, '')
|
|
149
168
|
}
|
|
150
169
|
|
|
151
|
-
function createCookieDbWithPlaintext(
|
|
170
|
+
function createCookieDbWithPlaintext(
|
|
171
|
+
filePath: string,
|
|
172
|
+
value: string,
|
|
173
|
+
lastAccessUtc = 0,
|
|
174
|
+
hostKey = 'swmaestro.ai',
|
|
175
|
+
): void {
|
|
152
176
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
153
177
|
const db = new Database(filePath)
|
|
154
178
|
db.run(
|
|
155
179
|
'CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, creation_utc INTEGER, expires_utc INTEGER, is_httponly INTEGER, has_expires INTEGER, is_persistent INTEGER, priority INTEGER, samesite INTEGER, source_scheme INTEGER, is_secure INTEGER, path TEXT, last_access_utc INTEGER, last_update_utc INTEGER, source_port INTEGER, source_type INTEGER)',
|
|
156
180
|
)
|
|
157
181
|
db.run(
|
|
158
|
-
"INSERT INTO cookies (host_key, name, value, encrypted_value, last_access_utc) VALUES (
|
|
159
|
-
[value, lastAccessUtc],
|
|
182
|
+
"INSERT INTO cookies (host_key, name, value, encrypted_value, last_access_utc) VALUES (?, 'JSESSIONID', ?, '', ?)",
|
|
183
|
+
[hostKey, value, lastAccessUtc],
|
|
160
184
|
)
|
|
161
185
|
db.close()
|
|
162
186
|
}
|
|
163
187
|
|
|
164
|
-
function createCookieDbWithEncrypted(filePath: string, encrypted: Buffer): void {
|
|
188
|
+
function createCookieDbWithEncrypted(filePath: string, encrypted: Buffer, hostKey = 'swmaestro.ai'): void {
|
|
165
189
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
166
190
|
const db = new Database(filePath)
|
|
167
191
|
db.run(
|
|
168
192
|
'CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, creation_utc INTEGER, expires_utc INTEGER, is_httponly INTEGER, has_expires INTEGER, is_persistent INTEGER, priority INTEGER, samesite INTEGER, source_scheme INTEGER, is_secure INTEGER, path TEXT, last_access_utc INTEGER, last_update_utc INTEGER, source_port INTEGER, source_type INTEGER)',
|
|
169
193
|
)
|
|
170
|
-
db.run("INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (
|
|
194
|
+
db.run("INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (?, 'JSESSIONID', '', ?)", [
|
|
195
|
+
hostKey,
|
|
171
196
|
encrypted,
|
|
172
197
|
])
|
|
173
198
|
db.close()
|