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,44 +1,44 @@
1
- import { describe, expect, test } from 'bun:test'
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
- test('plain text defaults to title', () => {
7
+ it('defaults plain text to a title search', () => {
8
8
  expect(parseSearchQuery('OpenCode')).toEqual({ field: 'title', value: 'OpenCode' })
9
9
  })
10
10
 
11
- test('title: prefix', () => {
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
- test('author: prefix', () => {
15
+ it('parses the "author:" prefix as an author search', () => {
16
16
  expect(parseSearchQuery('author:전수열')).toEqual({ field: 'author', value: '전수열' })
17
17
  })
18
18
 
19
- test('author:@me sets me flag', () => {
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
- test('content: prefix', () => {
23
+ it('parses the "content:" prefix as a content search', () => {
24
24
  expect(parseSearchQuery('content:하네스')).toEqual({ field: 'content', value: '하네스' })
25
25
  })
26
26
 
27
- test('unknown prefix treated as plain title search', () => {
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
- test('value with colons preserves everything after first colon', () => {
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
- test('no options returns only menuNo', () => {
37
+ it('returns only menuNo when no options are passed', () => {
38
38
  expect(buildMentoringListParams()).toEqual({ menuNo: MENU_NO.MENTORING })
39
39
  })
40
40
 
41
- test('status maps open to A, closed to C', () => {
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
- test('type maps public and lecture to report codes', () => {
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
- test('title search sets searchCnd=1', () => {
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
- test('author search sets searchCnd=2', () => {
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
- test('author:@me with user sets searchId and userNm', () => {
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
- test('search composes with status and type', () => {
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
- test('page sets pageIndex', () => {
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, test } from 'bun:test'
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
- test('adds 토즈- prefix to bare toz location names', () => {
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
- test('passes through already-prefixed toz locations', () => {
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
- test('resolves 신촌비즈니스센터점 to 연수센터-7', () => {
22
+ it('resolves "신촌비즈니스센터점" to "연수센터-7"', () => {
23
23
  expect(resolveVenue('신촌비즈니스센터점')).toBe('연수센터-7')
24
24
  expect(resolveVenue('토즈-신촌비즈니스센터점')).toBe('연수센터-7')
25
25
  })
26
26
 
27
- test('passes through non-toz venues unchanged', () => {
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
- test('trims whitespace from input', () => {
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
- test('passes through unknown venues unchanged', () => {
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} ${endSlot}:00`,
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, test } from 'bun:test'
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
- test('finds cookie databases for all browsers on macOS', async () => {
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
- test('finds cookie databases for all browsers on Linux', async () => {
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
- test('returns null when no cookie databases exist', async () => {
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
- test('extracts plaintext cookie value', async () => {
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
- test('finds cookie databases across numbered browser profiles', async () => {
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
- test('extractCandidates keeps the newest unique cookie across profiles', async () => {
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
- test('decrypts encrypted cookie on Linux', async () => {
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
- test('extracts plaintext cookie under Node.js (compiled ESM)', async () => {
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
- test('extracts encrypted cookie under Node.js (compiled ESM)', async () => {
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(filePath: string, value: string, lastAccessUtc = 0): void {
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 ('swmaestro.ai', 'JSESSIONID', ?, '', ?)",
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 ('swmaestro.ai', 'JSESSIONID', '', ?)", [
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()