opensoma 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/package.json +5 -1
  2. package/dist/src/agent-browser-launcher.d.ts +43 -0
  3. package/dist/src/agent-browser-launcher.d.ts.map +1 -0
  4. package/dist/src/agent-browser-launcher.js +97 -0
  5. package/dist/src/agent-browser-launcher.js.map +1 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +3 -2
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/client.d.ts +36 -7
  10. package/dist/src/client.d.ts.map +1 -1
  11. package/dist/src/client.js +231 -63
  12. package/dist/src/client.js.map +1 -1
  13. package/dist/src/commands/agent-browser.d.ts +3 -0
  14. package/dist/src/commands/agent-browser.d.ts.map +1 -0
  15. package/dist/src/commands/agent-browser.js +27 -0
  16. package/dist/src/commands/agent-browser.js.map +1 -0
  17. package/dist/src/commands/auth.d.ts +1 -1
  18. package/dist/src/commands/auth.d.ts.map +1 -1
  19. package/dist/src/commands/auth.js +4 -2
  20. package/dist/src/commands/auth.js.map +1 -1
  21. package/dist/src/commands/dashboard.d.ts +13 -0
  22. package/dist/src/commands/dashboard.d.ts.map +1 -1
  23. package/dist/src/commands/dashboard.js +10 -18
  24. package/dist/src/commands/dashboard.js.map +1 -1
  25. package/dist/src/commands/helpers.d.ts +1 -1
  26. package/dist/src/commands/helpers.d.ts.map +1 -1
  27. package/dist/src/commands/helpers.js +2 -2
  28. package/dist/src/commands/helpers.js.map +1 -1
  29. package/dist/src/commands/index.d.ts +2 -1
  30. package/dist/src/commands/index.d.ts.map +1 -1
  31. package/dist/src/commands/index.js +2 -1
  32. package/dist/src/commands/index.js.map +1 -1
  33. package/dist/src/commands/mentoring.d.ts.map +1 -1
  34. package/dist/src/commands/mentoring.js +54 -29
  35. package/dist/src/commands/mentoring.js.map +1 -1
  36. package/dist/src/commands/notice.d.ts.map +1 -1
  37. package/dist/src/commands/notice.js +2 -1
  38. package/dist/src/commands/notice.js.map +1 -1
  39. package/dist/src/commands/report.d.ts.map +1 -1
  40. package/dist/src/commands/report.js +4 -2
  41. package/dist/src/commands/report.js.map +1 -1
  42. package/dist/src/commands/room.d.ts.map +1 -1
  43. package/dist/src/commands/room.js +125 -2
  44. package/dist/src/commands/room.js.map +1 -1
  45. package/dist/src/commands/schedule.d.ts +3 -0
  46. package/dist/src/commands/schedule.d.ts.map +1 -0
  47. package/dist/src/commands/schedule.js +27 -0
  48. package/dist/src/commands/schedule.js.map +1 -0
  49. package/dist/src/commands/team.d.ts.map +1 -1
  50. package/dist/src/commands/team.js +55 -4
  51. package/dist/src/commands/team.js.map +1 -1
  52. package/dist/src/constants.d.ts +5 -5
  53. package/dist/src/constants.d.ts.map +1 -1
  54. package/dist/src/constants.js +20 -8
  55. package/dist/src/constants.js.map +1 -1
  56. package/dist/src/credential-manager.d.ts +9 -0
  57. package/dist/src/credential-manager.d.ts.map +1 -1
  58. package/dist/src/credential-manager.js +24 -0
  59. package/dist/src/credential-manager.js.map +1 -1
  60. package/dist/src/formatters.d.ts +11 -3
  61. package/dist/src/formatters.d.ts.map +1 -1
  62. package/dist/src/formatters.js +281 -52
  63. package/dist/src/formatters.js.map +1 -1
  64. package/dist/src/http.d.ts +8 -0
  65. package/dist/src/http.d.ts.map +1 -1
  66. package/dist/src/http.js +29 -1
  67. package/dist/src/http.js.map +1 -1
  68. package/dist/src/index.d.ts +4 -1
  69. package/dist/src/index.d.ts.map +1 -1
  70. package/dist/src/index.js +2 -1
  71. package/dist/src/index.js.map +1 -1
  72. package/dist/src/shared/utils/swmaestro.d.ts +34 -1
  73. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  74. package/dist/src/shared/utils/swmaestro.js +102 -43
  75. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  76. package/dist/src/shared/utils/team-action-params.d.ts +3 -0
  77. package/dist/src/shared/utils/team-action-params.d.ts.map +1 -0
  78. package/dist/src/shared/utils/team-action-params.js +10 -0
  79. package/dist/src/shared/utils/team-action-params.js.map +1 -0
  80. package/dist/src/shared/utils/team-params.d.ts +12 -0
  81. package/dist/src/shared/utils/team-params.d.ts.map +1 -0
  82. package/dist/src/shared/utils/team-params.js +38 -0
  83. package/dist/src/shared/utils/team-params.js.map +1 -0
  84. package/dist/src/types.d.ts +147 -10
  85. package/dist/src/types.d.ts.map +1 -1
  86. package/dist/src/types.js +74 -6
  87. package/dist/src/types.js.map +1 -1
  88. package/package.json +5 -1
  89. package/src/agent-browser-launcher.test.ts +263 -0
  90. package/src/agent-browser-launcher.ts +159 -0
  91. package/src/cli.ts +4 -2
  92. package/src/client.test.ts +801 -140
  93. package/src/client.ts +293 -79
  94. package/src/commands/agent-browser.ts +33 -0
  95. package/src/commands/auth.test.ts +83 -32
  96. package/src/commands/auth.ts +5 -3
  97. package/src/commands/dashboard.test.ts +57 -0
  98. package/src/commands/dashboard.ts +22 -19
  99. package/src/commands/helpers.test.ts +79 -32
  100. package/src/commands/helpers.ts +3 -3
  101. package/src/commands/index.ts +2 -1
  102. package/src/commands/mentoring.ts +60 -29
  103. package/src/commands/notice.ts +2 -1
  104. package/src/commands/report.test.ts +7 -7
  105. package/src/commands/report.ts +4 -2
  106. package/src/commands/room.ts +160 -1
  107. package/src/commands/schedule.ts +32 -0
  108. package/src/commands/team.ts +73 -5
  109. package/src/constants.ts +20 -8
  110. package/src/credential-manager.test.ts +49 -5
  111. package/src/credential-manager.ts +27 -0
  112. package/src/formatters.test.ts +548 -53
  113. package/src/formatters.ts +309 -55
  114. package/src/http.test.ts +108 -39
  115. package/src/http.ts +41 -2
  116. package/src/index.ts +10 -1
  117. package/src/shared/utils/mentoring-params.test.ts +16 -16
  118. package/src/shared/utils/swmaestro.test.ts +326 -11
  119. package/src/shared/utils/swmaestro.ts +150 -52
  120. package/src/shared/utils/team-action-params.test.ts +32 -0
  121. package/src/shared/utils/team-action-params.ts +10 -0
  122. package/src/shared/utils/team-params.test.ts +141 -0
  123. package/src/shared/utils/team-params.ts +53 -0
  124. package/src/shared/utils/toz.test.ts +12 -7
  125. package/src/token-extractor.test.ts +12 -12
  126. package/src/toz-http.test.ts +11 -11
  127. package/src/types.test.ts +235 -206
  128. package/src/types.ts +87 -7
  129. package/dist/src/commands/event.d.ts +0 -3
  130. package/dist/src/commands/event.d.ts.map +0 -1
  131. package/dist/src/commands/event.js +0 -58
  132. package/dist/src/commands/event.js.map +0 -1
  133. package/src/commands/event.ts +0 -73
@@ -1,30 +1,56 @@
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 {
4
+ buildMentoringPayload,
5
+ buildRoomCancelPayload,
6
+ buildRoomReservationPayload,
7
+ buildRoomUpdatePayload,
8
+ buildUpdateMentoringPayload,
9
+ resolveVenue,
10
+ validateAttendeeCount,
11
+ } from './swmaestro'
12
+
13
+ const baseExisting = {
14
+ rentId: 18718,
15
+ itemId: 17,
16
+ title: '멘토링',
17
+ date: '2026-05-31',
18
+ startTime: '21:00',
19
+ endTime: '21:30',
20
+ attendees: 4,
21
+ notes: '',
22
+ statusCode: 'RS001',
23
+ }
4
24
 
5
25
  describe('resolveVenue', () => {
6
- test('adds 토즈- prefix to bare toz location names', () => {
26
+ it('prepends "토즈-" to bare TOZ location names', () => {
7
27
  expect(resolveVenue('광화문점')).toBe('토즈-광화문점')
8
28
  expect(resolveVenue('양재점')).toBe('토즈-양재점')
9
29
  expect(resolveVenue('강남컨퍼런스센터점')).toBe('토즈-강남컨퍼런스센터점')
10
- expect(resolveVenue('건대점')).toBe('토즈-건대점')
11
30
  expect(resolveVenue('강남역토즈타워점')).toBe('토즈-강남역토즈타워점')
12
31
  expect(resolveVenue('선릉점')).toBe('토즈-선릉점')
13
- expect(resolveVenue('역삼점')).toBe('토즈-역삼점')
14
- expect(resolveVenue('홍대점')).toBe('토즈-홍대점')
15
32
  })
16
33
 
17
- test('passes through already-prefixed toz locations', () => {
34
+ it('preserves trailing spaces that native ships for 건대/역삼/홍대 <option> values', () => {
35
+ expect(resolveVenue('건대점')).toBe('토즈-건대점 ')
36
+ expect(resolveVenue('역삼점')).toBe('토즈-역삼점 ')
37
+ expect(resolveVenue('홍대점')).toBe('토즈-홍대점 ')
38
+ expect(resolveVenue('토즈-건대점')).toBe('토즈-건대점 ')
39
+ expect(resolveVenue('토즈-역삼점')).toBe('토즈-역삼점 ')
40
+ expect(resolveVenue('토즈-홍대점')).toBe('토즈-홍대점 ')
41
+ })
42
+
43
+ it('passes through TOZ locations that already have the prefix', () => {
18
44
  expect(resolveVenue('토즈-광화문점')).toBe('토즈-광화문점')
19
45
  expect(resolveVenue('토즈-강남역토즈타워점')).toBe('토즈-강남역토즈타워점')
20
46
  })
21
47
 
22
- test('resolves 신촌비즈니스센터점 to 연수센터-7', () => {
48
+ it('resolves "신촌비즈니스센터점" to "연수센터-7"', () => {
23
49
  expect(resolveVenue('신촌비즈니스센터점')).toBe('연수센터-7')
24
50
  expect(resolveVenue('토즈-신촌비즈니스센터점')).toBe('연수센터-7')
25
51
  })
26
52
 
27
- test('passes through non-toz venues unchanged', () => {
53
+ it('passes through non-TOZ venues unchanged', () => {
28
54
  expect(resolveVenue('온라인(Webex)')).toBe('온라인(Webex)')
29
55
  expect(resolveVenue('스페이스 A1')).toBe('스페이스 A1')
30
56
  expect(resolveVenue('스페이스 M1')).toBe('스페이스 M1')
@@ -33,12 +59,301 @@ describe('resolveVenue', () => {
33
59
  expect(resolveVenue('(엑스퍼트) 외부_카페')).toBe('(엑스퍼트) 외부_카페')
34
60
  })
35
61
 
36
- test('trims whitespace from input', () => {
62
+ it('trims surrounding whitespace from the input', () => {
37
63
  expect(resolveVenue(' 강남역토즈타워점 ')).toBe('토즈-강남역토즈타워점')
38
64
  expect(resolveVenue(' 스페이스 A1 ')).toBe('스페이스 A1')
39
65
  })
40
66
 
41
- test('passes through unknown venues unchanged', () => {
67
+ it('passes through unknown venues unchanged', () => {
42
68
  expect(resolveVenue('기타 장소')).toBe('기타 장소')
43
69
  })
44
70
  })
71
+
72
+ describe('buildRoomReservationPayload', () => {
73
+ it('sets rentEndde using the native lastSlot.minute+29 formula so tooltips render :59 like swmaestro.ai', () => {
74
+ const payload = buildRoomReservationPayload({
75
+ roomId: 17,
76
+ date: '2026-04-20',
77
+ slots: ['13:00', '13:30'],
78
+ title: '회의',
79
+ })
80
+
81
+ expect(payload.rentBgnde).toBe('2026-04-20 13:00:00')
82
+ expect(payload.rentEndde).toBe('2026-04-20 13:59:00')
83
+ expect(payload['time[0]']).toBe('13:00')
84
+ expect(payload['time[1]']).toBe('13:30')
85
+ expect(payload['time[2]']).toBeUndefined()
86
+ expect(payload['chkData_1']).toBe('2026-04-20|13:00|17')
87
+ expect(payload['chkData_2']).toBe('2026-04-20|13:30|17')
88
+ expect(payload['chkData_3']).toBeUndefined()
89
+ })
90
+
91
+ it('emits :29 for a single :00 slot so the tooltip mirrors native behavior', () => {
92
+ const payload = buildRoomReservationPayload({
93
+ roomId: 17,
94
+ date: '2026-04-20',
95
+ slots: ['13:00'],
96
+ title: '회의',
97
+ })
98
+
99
+ expect(payload.rentBgnde).toBe('2026-04-20 13:00:00')
100
+ expect(payload.rentEndde).toBe('2026-04-20 13:29:00')
101
+ expect(payload['time[0]']).toBe('13:00')
102
+ expect(payload['time[1]']).toBeUndefined()
103
+ })
104
+
105
+ it('handles a reservation ending at the last available slot', () => {
106
+ const payload = buildRoomReservationPayload({
107
+ roomId: 17,
108
+ date: '2026-04-20',
109
+ slots: ['23:00', '23:30'],
110
+ title: '회의',
111
+ })
112
+
113
+ expect(payload.rentBgnde).toBe('2026-04-20 23:00:00')
114
+ expect(payload.rentEndde).toBe('2026-04-20 23:59:00')
115
+ })
116
+
117
+ it('rejects non-consecutive slots', () => {
118
+ expect(() =>
119
+ buildRoomReservationPayload({
120
+ roomId: 17,
121
+ date: '2026-04-20',
122
+ slots: ['13:00', '14:00'],
123
+ title: '회의',
124
+ }),
125
+ ).toThrow('Time slots must be consecutive')
126
+ })
127
+
128
+ it('rejects invalid time slots', () => {
129
+ expect(() =>
130
+ buildRoomReservationPayload({
131
+ roomId: 17,
132
+ date: '2026-04-20',
133
+ slots: ['25:00'],
134
+ title: '회의',
135
+ }),
136
+ ).toThrow('Invalid time slot')
137
+ })
138
+
139
+ it('rejects empty slot lists', () => {
140
+ expect(() =>
141
+ buildRoomReservationPayload({
142
+ roomId: 17,
143
+ date: '2026-04-20',
144
+ slots: [],
145
+ title: '회의',
146
+ }),
147
+ ).toThrow('At least one time slot is required')
148
+ })
149
+ })
150
+
151
+ describe('buildRoomUpdatePayload', () => {
152
+ it('serialises the existing reservation unchanged when no overrides are supplied', () => {
153
+ const payload = buildRoomUpdatePayload(baseExisting)
154
+
155
+ expect(payload).toEqual({
156
+ menuNo: '200058',
157
+ rentId: '18718',
158
+ itemId: '17',
159
+ receiptStatCd: 'RS001',
160
+ title: '멘토링',
161
+ rentDt: '2026-05-31',
162
+ rentBgnde: '2026-05-31 21:00:00',
163
+ rentEndde: '2026-05-31 21:30:00',
164
+ infoCn: '',
165
+ rentNum: '4',
166
+ pageQueryString: '',
167
+ })
168
+ })
169
+
170
+ it('applies title, attendees, and notes overrides while keeping the schedule fields', () => {
171
+ const payload = buildRoomUpdatePayload(baseExisting, {
172
+ title: '스터디',
173
+ attendees: 6,
174
+ notes: '리뷰 세션',
175
+ })
176
+
177
+ expect(payload.title).toBe('스터디')
178
+ expect(payload.rentNum).toBe('6')
179
+ expect(payload.infoCn).toBe('리뷰 세션')
180
+ expect(payload.rentBgnde).toBe('2026-05-31 21:00:00')
181
+ expect(payload.rentEndde).toBe('2026-05-31 21:30:00')
182
+ expect(payload['time[0]']).toBeUndefined()
183
+ })
184
+
185
+ it('rewrites schedule fields and re-emits time/chkData entries when slots change', () => {
186
+ const payload = buildRoomUpdatePayload(baseExisting, {
187
+ slots: ['22:00', '22:30', '23:00'],
188
+ })
189
+
190
+ expect(payload.rentBgnde).toBe('2026-05-31 22:00:00')
191
+ expect(payload.rentEndde).toBe('2026-05-31 23:29:00')
192
+ expect(payload['time[0]']).toBe('22:00')
193
+ expect(payload['time[1]']).toBe('22:30')
194
+ expect(payload['time[2]']).toBe('23:00')
195
+ expect(payload['chkData_1']).toBe('2026-05-31|22:00|17')
196
+ expect(payload['chkData_3']).toBe('2026-05-31|23:00|17')
197
+ })
198
+
199
+ it('uses the new roomId and date when both schedule overrides are provided', () => {
200
+ const payload = buildRoomUpdatePayload(baseExisting, {
201
+ roomId: 22,
202
+ date: '2026-06-01',
203
+ slots: ['10:00', '10:30'],
204
+ })
205
+
206
+ expect(payload.itemId).toBe('22')
207
+ expect(payload.rentDt).toBe('2026-06-01')
208
+ expect(payload.rentBgnde).toBe('2026-06-01 10:00:00')
209
+ expect(payload.rentEndde).toBe('2026-06-01 10:59:00')
210
+ expect(payload['chkData_1']).toBe('2026-06-01|10:00|22')
211
+ })
212
+
213
+ it('rejects invalid slot overrides', () => {
214
+ expect(() => buildRoomUpdatePayload(baseExisting, { slots: ['22:00', '23:00'] })).toThrow(
215
+ 'Time slots must be consecutive',
216
+ )
217
+ })
218
+
219
+ it('preserves the existing status code so confirmed reservations stay confirmed', () => {
220
+ const payload = buildRoomUpdatePayload({ ...baseExisting, statusCode: 'RS001' }, { title: '수정본' })
221
+ expect(payload.receiptStatCd).toBe('RS001')
222
+ })
223
+ })
224
+
225
+ describe('buildRoomCancelPayload', () => {
226
+ it('flips receiptStatCd to RS002 while keeping every other field identical to the existing reservation', () => {
227
+ const payload = buildRoomCancelPayload(baseExisting)
228
+
229
+ expect(payload.receiptStatCd).toBe('RS002')
230
+ expect(payload.rentId).toBe('18718')
231
+ expect(payload.title).toBe('멘토링')
232
+ expect(payload.rentBgnde).toBe('2026-05-31 21:00:00')
233
+ expect(payload.rentEndde).toBe('2026-05-31 21:30:00')
234
+ expect(payload.rentNum).toBe('4')
235
+ })
236
+ })
237
+
238
+ const baseMentoring = {
239
+ title: '스터디',
240
+ type: 'public' as const,
241
+ date: '2026-05-10',
242
+ startTime: '14:00',
243
+ endTime: '15:00',
244
+ venue: '스페이스 A1',
245
+ }
246
+
247
+ describe('buildMentoringPayload', () => {
248
+ it('splits the registration period into date + time fields the new form expects', () => {
249
+ const payload = buildMentoringPayload(baseMentoring)
250
+
251
+ expect(payload.bgndeDate).toBe('2026-05-10')
252
+ expect(payload.bgndeTime).toBe('00:00')
253
+ expect('bgnde' in payload).toBe(false)
254
+ expect('endde' in payload).toBe(false)
255
+ })
256
+
257
+ it('defaults receiptType to UNTIL_LECTURE and aligns enddeDate/enddeTime with the lecture start', () => {
258
+ const payload = buildMentoringPayload(baseMentoring)
259
+
260
+ expect(payload.receiptType).toBe('UNTIL_LECTURE')
261
+ expect(payload.enddeDate).toBe('2026-05-10')
262
+ expect(payload.enddeTime).toBe('14:00')
263
+ })
264
+
265
+ it('uses the user-supplied registration end window when receiptType is DIRECT', () => {
266
+ const payload = buildMentoringPayload({
267
+ ...baseMentoring,
268
+ receiptType: 'DIRECT',
269
+ regStart: '2026-05-01',
270
+ regStartTime: '09:00',
271
+ regEnd: '2026-05-09',
272
+ regEndTime: '18:00',
273
+ })
274
+
275
+ expect(payload.receiptType).toBe('DIRECT')
276
+ expect(payload.bgndeDate).toBe('2026-05-01')
277
+ expect(payload.bgndeTime).toBe('09:00')
278
+ expect(payload.enddeDate).toBe('2026-05-09')
279
+ expect(payload.enddeTime).toBe('18:00')
280
+ })
281
+
282
+ it('sends the stateCd and qustnrAt values the current form posts', () => {
283
+ const payload = buildMentoringPayload(baseMentoring)
284
+
285
+ expect(payload.stateCd).toBe('A')
286
+ expect(payload.qustnrAt).toBe('N')
287
+ expect(payload.openAt).toBe('Y')
288
+ })
289
+
290
+ it('maps public/lecture types to MRC010/MRC020 and validates the default attendee count', () => {
291
+ expect(buildMentoringPayload({ ...baseMentoring, type: 'public' }).reportCd).toBe('MRC010')
292
+ expect(buildMentoringPayload({ ...baseMentoring, type: 'public' }).applyCnt).toBe('3')
293
+ expect(buildMentoringPayload({ ...baseMentoring, type: 'lecture' }).reportCd).toBe('MRC020')
294
+ expect(buildMentoringPayload({ ...baseMentoring, type: 'lecture' }).applyCnt).toBe('6')
295
+ })
296
+
297
+ it('rejects attendee counts that violate the form rules', () => {
298
+ expect(() => buildMentoringPayload({ ...baseMentoring, maxAttendees: 1 })).toThrow(
299
+ '자유 멘토링은 2명 이상 5명 이하로 설정해야 합니다.',
300
+ )
301
+ expect(() => buildMentoringPayload({ ...baseMentoring, maxAttendees: 6 })).toThrow(
302
+ '자유 멘토링은 2명 이상 5명 이하로 설정해야 합니다.',
303
+ )
304
+ expect(() => buildMentoringPayload({ ...baseMentoring, type: 'lecture', maxAttendees: 4 })).toThrow(
305
+ '멘토 특강은 6명 이상으로 설정해야 합니다.',
306
+ )
307
+ })
308
+
309
+ it('passes through the venue resolver so TOZ aliases are normalised', () => {
310
+ expect(buildMentoringPayload({ ...baseMentoring, venue: '광화문점' }).place).toBe('토즈-광화문점')
311
+ })
312
+
313
+ it('mirrors native checkForm() by replacing double quotes in qustnrSj with single quotes', () => {
314
+ const payload = buildMentoringPayload({ ...baseMentoring, title: '"테스트" 멘토링' })
315
+
316
+ expect(payload.qustnrSj).toBe("'테스트' 멘토링")
317
+ })
318
+
319
+ it('mirrors the native DEXT5 empty-body placeholder when content is missing', () => {
320
+ const nativeEmpty =
321
+ '<p style="font-family: 굴림; font-size: 12pt; line-height: 1.2; margin-top: 0px; margin-bottom: 0px;">&nbsp;</p>'
322
+
323
+ expect(buildMentoringPayload(baseMentoring).qestnarCn).toBe(nativeEmpty)
324
+ expect(buildMentoringPayload({ ...baseMentoring, content: '' }).qestnarCn).toBe(nativeEmpty)
325
+ expect(buildMentoringPayload({ ...baseMentoring, content: ' \n ' }).qestnarCn).toBe(nativeEmpty)
326
+ })
327
+
328
+ it('passes through rich HTML from the editor unchanged', () => {
329
+ const payload = buildMentoringPayload({ ...baseMentoring, content: '<p>세션 본문</p>' })
330
+
331
+ expect(payload.qestnarCn).toBe('<p>세션 본문</p>')
332
+ })
333
+ })
334
+
335
+ describe('buildUpdateMentoringPayload', () => {
336
+ it('injects the target qustnrSn while reusing the insert payload shape', () => {
337
+ const payload = buildUpdateMentoringPayload(9999, baseMentoring)
338
+
339
+ expect(payload.qustnrSn).toBe('9999')
340
+ expect(payload.bgndeDate).toBe('2026-05-10')
341
+ expect(payload.receiptType).toBe('UNTIL_LECTURE')
342
+ expect(payload.stateCd).toBe('A')
343
+ })
344
+ })
345
+
346
+ describe('validateAttendeeCount', () => {
347
+ it('accepts public counts within 2-5 and lecture counts of 6 or more', () => {
348
+ expect(() => validateAttendeeCount('public', 2)).not.toThrow()
349
+ expect(() => validateAttendeeCount('public', 5)).not.toThrow()
350
+ expect(() => validateAttendeeCount('lecture', 6)).not.toThrow()
351
+ expect(() => validateAttendeeCount('lecture', 100)).not.toThrow()
352
+ })
353
+
354
+ it('rejects counts outside the server-enforced bounds', () => {
355
+ expect(() => validateAttendeeCount('public', 1)).toThrow()
356
+ expect(() => validateAttendeeCount('public', 6)).toThrow()
357
+ expect(() => validateAttendeeCount('lecture', 5)).toThrow()
358
+ })
359
+ })
@@ -13,6 +13,29 @@ export function toMentoringType(type: string): 'public' | 'lecture' {
13
13
  return 'public'
14
14
  }
15
15
 
16
+ export type ReceiptType = 'UNTIL_LECTURE' | 'DIRECT'
17
+
18
+ function sanitizeTitle(title: string): string {
19
+ return title.replace(/"/g, "'")
20
+ }
21
+
22
+ export function resolveMaxAttendees(type: 'public' | 'lecture', maxAttendees?: number): number {
23
+ if (maxAttendees !== undefined) {
24
+ validateAttendeeCount(type, maxAttendees)
25
+ return maxAttendees
26
+ }
27
+ return type === 'lecture' ? 6 : 3
28
+ }
29
+
30
+ export function validateAttendeeCount(type: 'public' | 'lecture', count: number): void {
31
+ if (type === 'public' && (count < 2 || count > 5)) {
32
+ throw new Error('자유 멘토링은 2명 이상 5명 이하로 설정해야 합니다.')
33
+ }
34
+ if (type === 'lecture' && count < 6) {
35
+ throw new Error('멘토 특강은 6명 이상으로 설정해야 합니다.')
36
+ }
37
+ }
38
+
16
39
  export function buildMentoringPayload(params: {
17
40
  title: string
18
41
  type: 'public' | 'lecture'
@@ -22,16 +45,27 @@ export function buildMentoringPayload(params: {
22
45
  venue: string
23
46
  maxAttendees?: number
24
47
  regStart?: string
48
+ regStartTime?: string
25
49
  regEnd?: string
50
+ regEndTime?: string
51
+ receiptType?: ReceiptType
26
52
  content?: string
27
53
  }): Record<string, string> {
54
+ const receiptType: ReceiptType = params.receiptType ?? 'UNTIL_LECTURE'
55
+ const bgndeDate = params.regStart ?? params.date
56
+ const bgndeTime = params.regStartTime ?? '00:00'
57
+ const { enddeDate, enddeTime } = resolveReceiptEnd(receiptType, params)
58
+
28
59
  return {
29
60
  menuNo: MENU_NO.MENTORING,
30
61
  reportCd: toReportCd(params.type),
31
- qustnrSj: params.title,
32
- bgnde: params.regStart ?? params.date,
33
- endde: params.regEnd ?? params.date,
34
- applyCnt: String(params.maxAttendees ?? (params.type === 'lecture' ? 6 : 1)),
62
+ qustnrSj: sanitizeTitle(params.title),
63
+ receiptType,
64
+ bgndeDate,
65
+ bgndeTime,
66
+ enddeDate,
67
+ enddeTime,
68
+ applyCnt: String(resolveMaxAttendees(params.type, params.maxAttendees)),
35
69
  eventDt: params.date,
36
70
  eventStime: params.startTime,
37
71
  eventEtime: params.endTime,
@@ -39,14 +73,27 @@ export function buildMentoringPayload(params: {
39
73
  qestnarCn: formatEditorContent(params.content ?? ''),
40
74
  atchFileId: '',
41
75
  fileFieldNm_1: '',
42
- stateCd: 'QST020',
76
+ stateCd: 'A',
43
77
  openAt: 'Y',
44
- qustnrAt: 'Y',
78
+ qustnrAt: 'N',
45
79
  qustnrSn: '',
46
80
  pageQueryString: '',
47
81
  }
48
82
  }
49
83
 
84
+ function resolveReceiptEnd(
85
+ receiptType: ReceiptType,
86
+ params: { date: string; startTime: string; endTime: string; regEnd?: string; regEndTime?: string },
87
+ ): { enddeDate: string; enddeTime: string } {
88
+ if (receiptType === 'UNTIL_LECTURE') {
89
+ return { enddeDate: params.date, enddeTime: params.startTime }
90
+ }
91
+ return {
92
+ enddeDate: params.regEnd ?? params.date,
93
+ enddeTime: params.regEndTime ?? params.startTime,
94
+ }
95
+ }
96
+
50
97
  export function buildUpdateMentoringPayload(
51
98
  id: number,
52
99
  params: Parameters<typeof buildMentoringPayload>[0],
@@ -67,7 +114,7 @@ export function buildDeleteMentoringPayload(id: number): Record<string, string>
67
114
 
68
115
  export function buildApplicationPayload(id: number): Record<string, string> {
69
116
  return {
70
- menuNo: MENU_NO.EVENT,
117
+ menuNo: MENU_NO.MENTORING,
71
118
  qustnrSn: String(id),
72
119
  applyGb: 'C',
73
120
  stepHeader: '0',
@@ -130,6 +177,17 @@ export function validateReservationSlots(slots: string[]): void {
130
177
  }
131
178
  }
132
179
 
180
+ // Mirror the native /officeMng/view.do form, which sets rentEndde to
181
+ // `${lastSlot.hour}:${lastSlot.minute + 29}` (see the native JS:
182
+ // `et = last.data('hour')+':'+(last.data('minute')*1+29)`). Last slot '13:30'
183
+ // becomes '13:59'; last slot '12:00' becomes '12:29'. Native does not carry
184
+ // minutes into the hour, so we replicate that string-concat behavior exactly.
185
+ function slotNativeEnd(slot: string): string {
186
+ const [hourPart, minutePart] = slot.split(':')
187
+ const minute = Number(minutePart) + 29
188
+ return `${hourPart}:${String(minute).padStart(2, '0')}`
189
+ }
190
+
133
191
  export function buildRoomReservationPayload(params: {
134
192
  roomId: number
135
193
  date: string
@@ -142,17 +200,12 @@ export function buildRoomReservationPayload(params: {
142
200
 
143
201
  const firstSlot = params.slots[0]
144
202
  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
203
 
151
204
  const payload: Record<string, string> = {
152
205
  menuNo: MENU_NO.ROOM,
153
206
  itemId: String(params.roomId),
154
207
  rentBgnde: `${params.date} ${firstSlot}:00`,
155
- rentEndde: `${params.date} ${endSlot}:00`,
208
+ rentEndde: `${params.date} ${slotNativeEnd(lastSlot)}:00`,
156
209
  title: params.title,
157
210
  rentDt: params.date,
158
211
  rentNum: String(params.attendees ?? 1),
@@ -169,6 +222,87 @@ export function buildRoomReservationPayload(params: {
169
222
  return payload
170
223
  }
171
224
 
225
+ export function buildRoomUpdatePayload(
226
+ existing: {
227
+ rentId: number
228
+ itemId: number
229
+ title: string
230
+ date: string
231
+ startTime: string
232
+ endTime: string
233
+ attendees: number
234
+ notes: string
235
+ statusCode: string
236
+ },
237
+ params: {
238
+ title?: string
239
+ roomId?: number
240
+ date?: string
241
+ slots?: string[]
242
+ attendees?: number
243
+ notes?: string
244
+ } = {},
245
+ ): Record<string, string> {
246
+ const roomId = params.roomId ?? existing.itemId
247
+ const date = params.date ?? existing.date
248
+
249
+ let startTime = existing.startTime
250
+ let endTime = existing.endTime
251
+ if (params.slots?.length) {
252
+ validateReservationSlots(params.slots)
253
+ startTime = params.slots[0]
254
+ endTime = slotNativeEnd(params.slots[params.slots.length - 1])
255
+ }
256
+
257
+ const payload: Record<string, string> = {
258
+ menuNo: MENU_NO.ROOM,
259
+ rentId: String(existing.rentId),
260
+ itemId: String(roomId),
261
+ receiptStatCd: existing.statusCode || 'RS001',
262
+ title: params.title ?? existing.title,
263
+ rentDt: date,
264
+ rentBgnde: `${date} ${startTime}:00`,
265
+ rentEndde: `${date} ${endTime}:00`,
266
+ infoCn: params.notes ?? existing.notes,
267
+ rentNum: String(params.attendees ?? existing.attendees),
268
+ pageQueryString: '',
269
+ }
270
+
271
+ if (params.slots?.length) {
272
+ params.slots.forEach((slot, index) => {
273
+ payload[`time[${index}]`] = slot
274
+ payload[`chkData_${index + 1}`] = `${date}|${slot}|${roomId}`
275
+ })
276
+ }
277
+
278
+ return payload
279
+ }
280
+
281
+ export function buildRoomCancelPayload(existing: {
282
+ rentId: number
283
+ itemId: number
284
+ title: string
285
+ date: string
286
+ startTime: string
287
+ endTime: string
288
+ attendees: number
289
+ notes: string
290
+ }): Record<string, string> {
291
+ return {
292
+ menuNo: MENU_NO.ROOM,
293
+ rentId: String(existing.rentId),
294
+ itemId: String(existing.itemId),
295
+ receiptStatCd: 'RS002',
296
+ title: existing.title,
297
+ rentDt: existing.date,
298
+ rentBgnde: `${existing.date} ${existing.startTime}:00`,
299
+ rentEndde: `${existing.date} ${existing.endTime}:00`,
300
+ infoCn: existing.notes,
301
+ rentNum: String(existing.attendees),
302
+ pageQueryString: '',
303
+ }
304
+ }
305
+
172
306
  export function parseApplicationHistory(html: string): ApplicationHistoryItem[] {
173
307
  const root = parse(html)
174
308
  const rows =
@@ -189,49 +323,13 @@ export function parseApplicationHistory(html: string): ApplicationHistoryItem[]
189
323
  )
190
324
  }
191
325
 
192
- export function parseEventDetail(html: string): Record<string, unknown> {
193
- const root = parse(html)
194
- const labels = extractLabelMap(root)
195
- const contentNode =
196
- root.querySelector('[data-content]') ??
197
- root.querySelector('.board-view-content') ??
198
- root.querySelector('.view-content') ??
199
- root.querySelector('.content-body')
200
-
201
- return {
202
- id: extractNumber(labels.NO ?? labels.번호 ?? root.querySelector('[name="bbsId"]')?.getAttribute('value') ?? '0'),
203
- title: labels.제목 ?? cleanText(root.querySelector('h1, h2, .title')?.text),
204
- content: decodeHtmlEntities(contentNode?.innerHTML.trim() ?? ''),
205
- fields: labels,
206
- }
207
- }
208
-
209
- function extractLabelMap(root: ReturnType<typeof parse>): Record<string, string> {
210
- const map: Record<string, string> = {}
211
-
212
- for (const row of root.querySelectorAll('tr')) {
213
- const headers = row.querySelectorAll('th')
214
- const values = row.querySelectorAll('td')
215
-
216
- if (headers.length === 1 && values.length === 1) {
217
- map[cleanText(headers[0]?.text).replace(/:$/, '')] = cleanText(values[0]?.text)
218
- }
219
-
220
- if (headers.length > 1 && headers.length === values.length) {
221
- headers.forEach((header, index) => {
222
- map[cleanText(header.text).replace(/:$/, '')] = cleanText(values[index]?.text)
223
- })
224
- }
225
- }
226
-
227
- return map
228
- }
229
-
230
326
  const EDITOR_P_STYLE = 'font-family: 굴림; font-size: 12pt; line-height: 1.2; margin-top: 0px; margin-bottom: 0px;'
327
+ const EMPTY_EDITOR_HTML = `<p style="${EDITOR_P_STYLE}">&nbsp;</p>`
231
328
 
232
329
  function formatEditorContent(content: string): string {
233
- if (!content) return ''
330
+ if (!content) return EMPTY_EDITOR_HTML
234
331
  const decoded = decodeHtmlEntities(content)
332
+ if (!decoded.trim()) return EMPTY_EDITOR_HTML
235
333
  if (/<(?:p|div|h[1-6]|ul|ol|table|br)\b/i.test(decoded)) {
236
334
  return decoded
237
335
  }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { UserGb } from '../../http'
4
+ import { buildTeamActionPayload } from './team-action-params'
5
+
6
+ describe('buildTeamActionPayload', () => {
7
+ it('builds the native payload from teamId and user identity', () => {
8
+ expect(
9
+ buildTeamActionPayload('60e6785c8c404142b12cf9ed2a3d811f', {
10
+ userId: 'neo@example.com',
11
+ userNm: '전수열',
12
+ userNo: 'f6d192ad3b3e4ee29f1d238714ab92c1',
13
+ userGb: UserGb.Mentor,
14
+ }),
15
+ ).toEqual({
16
+ userNo: 'f6d192ad3b3e4ee29f1d238714ab92c1',
17
+ userNm: '전수열',
18
+ userGb: 'T',
19
+ teamNo: '60e6785c8c404142b12cf9ed2a3d811f',
20
+ })
21
+ })
22
+
23
+ it('falls back to userGb="T" when the identity has no userGb', () => {
24
+ const payload = buildTeamActionPayload('team-1', {
25
+ userId: 'neo@example.com',
26
+ userNm: '전수열',
27
+ userNo: 'abc',
28
+ userGb: UserGb.Unknown,
29
+ })
30
+ expect(payload.userGb).toBe('T')
31
+ })
32
+ })
@@ -0,0 +1,10 @@
1
+ import { UserGb, type UserIdentity } from '../../http'
2
+
3
+ export function buildTeamActionPayload(teamId: string, user: UserIdentity): Record<string, string> {
4
+ return {
5
+ userNo: user.userNo,
6
+ userNm: user.userNm,
7
+ userGb: user.userGb || UserGb.Mentor,
8
+ teamNo: teamId,
9
+ }
10
+ }