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
package/src/client.ts CHANGED
@@ -46,6 +46,8 @@ export interface SomaClientOptions {
46
46
  username?: string
47
47
  password?: string
48
48
  verbose?: boolean
49
+ /** @internal */
50
+ http?: SomaHttp
49
51
  }
50
52
 
51
53
  export class SomaClient {
@@ -109,7 +111,7 @@ export class SomaClient {
109
111
  searchKeyword?: string
110
112
  }): Promise<{ items: ReportListItem[]; pagination: Pagination }>
111
113
  get(id: number): Promise<ReportDetail>
112
- create(options: ReportCreateOptions, file: Buffer | string, fileName?: string): Promise<void>
114
+ create(options: ReportCreateOptions, files: Array<{ buffer: Buffer; name: string }>): Promise<void>
113
115
  update(
114
116
  id: number,
115
117
  options: Omit<ReportUpdateOptions, 'id'>,
@@ -141,11 +143,13 @@ export class SomaClient {
141
143
  this.options = options
142
144
  this.loginCredentials =
143
145
  options.username && options.password ? { username: options.username, password: options.password } : null
144
- this.http = new SomaHttp({
145
- sessionCookie: options.sessionCookie,
146
- csrfToken: options.csrfToken,
147
- verbose: options.verbose,
148
- })
146
+ this.http =
147
+ options.http ??
148
+ new SomaHttp({
149
+ sessionCookie: options.sessionCookie,
150
+ csrfToken: options.csrfToken,
151
+ verbose: options.verbose,
152
+ })
149
153
 
150
154
  this.mentoring = {
151
155
  list: async (options) => {
@@ -344,7 +348,7 @@ export class SomaClient {
344
348
  })
345
349
  return formatters.parseReportDetail(html, id)
346
350
  },
347
- create: async (options, file, fileName) => {
351
+ create: async (options, files) => {
348
352
  await this.requireAuth()
349
353
  const payload = buildReportPayload({
350
354
  menteeRegion: options.menteeRegion,
@@ -369,11 +373,11 @@ export class SomaClient {
369
373
  for (const [key, value] of Object.entries(payload)) {
370
374
  formData.append(key, value)
371
375
  }
372
- const isBuffer = Buffer.isBuffer(file)
373
- const fileBuffer = isBuffer ? file : await readFile(file)
374
- const resolvedFileName = isBuffer ? (fileName ?? 'file') : (file.split('/').pop() ?? 'file')
375
- const uint8Array = new Uint8Array(fileBuffer.buffer, fileBuffer.byteOffset, fileBuffer.byteLength)
376
- formData.append('file_1_1', new Blob([uint8Array as unknown as ArrayBuffer]), resolvedFileName)
376
+ for (let i = 0; i < files.length; i++) {
377
+ const { buffer, name } = files[i]
378
+ const uint8Array = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
379
+ formData.append(`file_1_${i + 1}`, new Blob([uint8Array as unknown as ArrayBuffer]), name)
380
+ }
377
381
  formData.append('fileFieldNm_1', 'file_1')
378
382
  formData.append('atchFileId', '')
379
383
  await this.http.postMultipart('/mypage/mentoringReport/insert.do', formData)
@@ -1,11 +1,11 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
 
3
3
  import { inspectStoredAuthStatus, resolveExtractedCredentials } from './auth'
4
4
 
5
5
  const noBrowserExtraction = async () => null
6
6
 
7
7
  describe('resolveExtractedCredentials', () => {
8
- test('returns the first candidate that validates successfully', async () => {
8
+ it('returns the first candidate that validates successfully', async () => {
9
9
  const calls: string[] = []
10
10
 
11
11
  const credentials = await resolveExtractedCredentials(
@@ -32,7 +32,7 @@ describe('resolveExtractedCredentials', () => {
32
32
  expect(calls).toEqual(['check:stale-session', 'check:valid-session', 'csrf:valid-session'])
33
33
  })
34
34
 
35
- test('returns null when every candidate is invalid or throws', async () => {
35
+ it('returns null when every candidate is invalid or throws', async () => {
36
36
  const credentials = await resolveExtractedCredentials(
37
37
  [
38
38
  { browser: 'Chrome', lastAccessUtc: 30, profile: 'Default', sessionCookie: 'stale-session' },
@@ -57,7 +57,7 @@ describe('resolveExtractedCredentials', () => {
57
57
  })
58
58
 
59
59
  describe('inspectStoredAuthStatus', () => {
60
- test('clears stale credentials when both recovery methods fail', async () => {
60
+ it('clears stale credentials when both recovery methods fail', async () => {
61
61
  let removed = false
62
62
 
63
63
  const status = await inspectStoredAuthStatus(
@@ -90,7 +90,7 @@ describe('inspectStoredAuthStatus', () => {
90
90
  expect(removed).toBe(true)
91
91
  })
92
92
 
93
- test('preserves credentials when session verification fails unexpectedly', async () => {
93
+ it('preserves credentials when session verification fails unexpectedly', async () => {
94
94
  let removed = false
95
95
 
96
96
  const status = await inspectStoredAuthStatus(
@@ -125,7 +125,7 @@ describe('inspectStoredAuthStatus', () => {
125
125
  expect(removed).toBe(false)
126
126
  })
127
127
 
128
- test('recovers via browser extraction when no stored password is available', async () => {
128
+ it('recovers via browser extraction when no stored password is available', async () => {
129
129
  let savedCredentials: Record<string, unknown> | null = null
130
130
 
131
131
  const status = await inspectStoredAuthStatus(
@@ -160,7 +160,7 @@ describe('inspectStoredAuthStatus', () => {
160
160
  expect(savedCredentials).toHaveProperty('loggedInAt')
161
161
  })
162
162
 
163
- test('refreshes the session automatically when encrypted login credentials are available', async () => {
163
+ it('refreshes the session automatically when stored username and password are available', async () => {
164
164
  let savedCredentials: Record<string, string> | null = null
165
165
 
166
166
  const status = await inspectStoredAuthStatus(
@@ -1,3 +1,5 @@
1
+ import { createInterface, type Interface as ReadlineInterface } from 'node:readline'
2
+
1
3
  import { Command } from 'commander'
2
4
 
3
5
  import { CredentialManager } from '../credential-manager'
@@ -7,63 +9,99 @@ import { handleError } from '../shared/utils/error-handler'
7
9
  import { formatOutput } from '../shared/utils/output'
8
10
  import type { ExtractedSessionCandidate } from '../token-extractor'
9
11
 
10
- async function promptInput(message: string): Promise<string> {
11
- process.stdout.write(message)
12
- const input = await Bun.stdin.text()
13
- return input.trim()
12
+ function ask(rl: ReadlineInterface, message: string): Promise<string> {
13
+ return new Promise((resolve) => {
14
+ rl.question(message, (answer) => {
15
+ resolve(answer.trim())
16
+ })
17
+ })
14
18
  }
15
19
 
16
- async function promptPassword(message: string): Promise<string> {
20
+ async function promptPasswordTTY(message: string): Promise<string> {
17
21
  process.stdout.write(message)
18
22
 
19
- const originalStdin = process.stdin.isTTY
20
- ? (process.stdin as typeof process.stdin & { setRawMode?: (mode: boolean) => void })
21
- : null
22
-
23
- if (originalStdin?.setRawMode) {
24
- originalStdin.setRawMode(true)
23
+ const stdin = process.stdin as typeof process.stdin & { setRawMode?: (mode: boolean) => void }
24
+ if (stdin.setRawMode) {
25
+ stdin.setRawMode(true)
25
26
  }
27
+ stdin.resume()
26
28
 
27
29
  let password = ''
28
- const decoder = new TextDecoder()
29
30
 
30
31
  try {
31
- for await (const chunk of Bun.stdin.stream()) {
32
- const text = decoder.decode(chunk)
33
- for (const char of text) {
34
- const code = char.charCodeAt(0)
35
- if (code === 13 || code === 10) {
36
- // Enter key
37
- process.stdout.write('\n')
38
- return password
39
- } else if (code === 3) {
40
- // Ctrl+C
41
- process.exit(1)
42
- } else if (code === 127) {
43
- // Backspace
44
- if (password.length > 0) {
45
- password = password.slice(0, -1)
46
- process.stdout.write('\b \b')
32
+ return await new Promise<string>((resolve) => {
33
+ const onData = (chunk: Buffer | string): void => {
34
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8')
35
+ for (const char of text) {
36
+ const code = char.charCodeAt(0)
37
+ if (code === 13 || code === 10) {
38
+ // Enter key
39
+ stdin.removeListener('data', onData)
40
+ process.stdout.write('\n')
41
+ resolve(password)
42
+ return
43
+ } else if (code === 3) {
44
+ // Ctrl+C
45
+ stdin.removeListener('data', onData)
46
+ process.exit(1)
47
+ } else if (code === 127 || code === 8) {
48
+ // Backspace / Delete
49
+ if (password.length > 0) {
50
+ password = password.slice(0, -1)
51
+ process.stdout.write('\b \b')
52
+ }
53
+ } else if (code >= 32 && code <= 126) {
54
+ // Printable characters
55
+ password += char
56
+ process.stdout.write('*')
47
57
  }
48
- } else if (code >= 32 && code <= 126) {
49
- // Printable characters
50
- password += char
51
- process.stdout.write('*')
52
58
  }
53
59
  }
54
- }
60
+
61
+ stdin.on('data', onData)
62
+ })
55
63
  } finally {
56
- if (originalStdin?.setRawMode) {
57
- originalStdin.setRawMode(false)
64
+ if (stdin.setRawMode) {
65
+ stdin.setRawMode(false)
58
66
  }
67
+ stdin.pause()
59
68
  }
69
+ }
70
+
71
+ async function promptCredentials(
72
+ needUsername: boolean,
73
+ needPassword: boolean,
74
+ ): Promise<{ username?: string; password?: string }> {
75
+ const result: { username?: string; password?: string } = {}
60
76
 
61
- return password
77
+ if (needUsername) {
78
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false })
79
+ try {
80
+ result.username = await ask(rl, 'Username: ')
81
+ } finally {
82
+ rl.close()
83
+ }
84
+ }
85
+
86
+ if (needPassword) {
87
+ if (process.stdin.isTTY) {
88
+ result.password = await promptPasswordTTY('Password: ')
89
+ } else {
90
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: false })
91
+ try {
92
+ result.password = await ask(rl, 'Password: ')
93
+ } finally {
94
+ rl.close()
95
+ }
96
+ }
97
+ }
98
+
99
+ return result
62
100
  }
63
101
 
64
102
  type LoginOptions = { username?: string; password?: string; pretty?: boolean }
65
103
  type StatusOptions = { pretty?: boolean }
66
- type ExtractOptions = { pretty?: boolean }
104
+ type ExtractOptions = { debug?: boolean; pretty?: boolean }
67
105
  type ExtractedSessionValidator = Pick<SomaHttp, 'checkLogin' | 'extractCsrfToken'>
68
106
  type CredentialStore = Pick<CredentialManager, 'getCredentials' | 'remove' | 'setCredentials'>
69
107
  type StatusValidator = Pick<SomaHttp, 'checkLogin'>
@@ -87,21 +125,34 @@ export async function resolveExtractedCredentials(
87
125
  candidates: ExtractedSessionCandidate[],
88
126
  createValidator: (sessionCookie: string) => ExtractedSessionValidator = (sessionCookie) =>
89
127
  new SomaHttp({ sessionCookie }),
128
+ debug?: (message: string) => void,
90
129
  ): Promise<{ csrfToken: string; sessionCookie: string } | null> {
130
+ debug?.(`Validating ${candidates.length} candidate(s) against server...`)
131
+
91
132
  for (const candidate of candidates) {
92
133
  const http = createValidator(candidate.sessionCookie)
134
+ debug?.(` ${candidate.browser} / ${candidate.profile}: checkLogin...`)
93
135
 
94
136
  try {
95
137
  const valid = Boolean(await http.checkLogin())
96
138
  if (!valid) {
139
+ debug?.(` ${candidate.browser} / ${candidate.profile}: session invalid`)
97
140
  continue
98
141
  }
99
142
 
143
+ debug?.(` ${candidate.browser} / ${candidate.profile}: valid! Extracting CSRF token...`)
144
+ const csrfToken = await http.extractCsrfToken()
145
+ debug?.(` CSRF token obtained (${csrfToken.length} chars)`)
146
+
100
147
  return {
101
148
  sessionCookie: candidate.sessionCookie,
102
- csrfToken: await http.extractCsrfToken(),
149
+ csrfToken,
103
150
  }
104
- } catch {}
151
+ } catch (error) {
152
+ debug?.(
153
+ ` ${candidate.browser} / ${candidate.profile}: error: ${error instanceof Error ? error.message : String(error)}`,
154
+ )
155
+ }
105
156
  }
106
157
 
107
158
  return null
@@ -112,12 +163,9 @@ async function loginAction(options: LoginOptions): Promise<void> {
112
163
  let username = options.username ?? process.env.OPENSOMA_USERNAME
113
164
  let password = options.password ?? process.env.OPENSOMA_PASSWORD
114
165
 
115
- if (!username) {
116
- username = await promptInput('Username: ')
117
- }
118
- if (!password) {
119
- password = await promptPassword('Password: ')
120
- }
166
+ const prompted = await promptCredentials(!username, !password)
167
+ username ??= prompted.username
168
+ password ??= prompted.password
121
169
 
122
170
  if (!username || !password) {
123
171
  throw new Error('Username and password are required')
@@ -264,22 +312,30 @@ async function statusAction(options: StatusOptions): Promise<void> {
264
312
  }
265
313
 
266
314
  async function extractAction(options: ExtractOptions): Promise<void> {
315
+ const log = options.debug ? (message: string) => process.stderr.write(`[extract] ${message}\n`) : undefined
316
+
267
317
  try {
268
318
  const { TokenExtractor } = (await import('../token-extractor')) as {
269
- TokenExtractor: new () => { extractCandidates: () => Promise<ExtractedSessionCandidate[]> }
319
+ TokenExtractor: new (options?: { debug?: boolean }) => {
320
+ extractCandidates: () => Promise<ExtractedSessionCandidate[]>
321
+ }
270
322
  }
271
- const extractor = new TokenExtractor()
323
+ const extractor = new TokenExtractor({ debug: options.debug })
272
324
  const candidates = await extractor.extractCandidates()
273
325
  if (candidates.length === 0) {
274
326
  throw new Error(
275
- 'No SWMaestro session found in any browser. Login to swmaestro.ai in a supported Chromium browser (Chrome, Edge, Brave, Arc, Vivaldi) first.',
327
+ 'No SWMaestro session found in any browser. Login to swmaestro.ai or opensoma.dev in a supported Chromium browser (Chrome, Edge, Brave, Arc, Vivaldi) first.',
276
328
  )
277
329
  }
278
330
 
279
- const credentials = await resolveExtractedCredentials(candidates)
331
+ log?.(
332
+ `Extracted ${candidates.length} candidate(s): ${candidates.map((c) => `${c.browser}/${c.profile}`).join(', ')}`,
333
+ )
334
+
335
+ const credentials = await resolveExtractedCredentials(candidates, undefined, log)
280
336
  if (!credentials) {
281
337
  throw new Error(
282
- 'Found SWMaestro session cookies in supported browsers, but none are valid. Refresh your swmaestro.ai login in a supported Chromium browser and try again.',
338
+ 'Found SWMaestro session cookies in supported browsers, but none are valid. Refresh your swmaestro.ai or opensoma.dev login in a supported Chromium browser and try again.',
283
339
  )
284
340
  }
285
341
 
@@ -319,6 +375,7 @@ export const authCommand = new Command('auth')
319
375
  .addCommand(
320
376
  new Command('extract')
321
377
  .description('Extract browser credentials')
378
+ .option('--debug', 'Show debug output')
322
379
  .option('--pretty', 'Pretty print JSON output')
323
380
  .action(extractAction),
324
381
  )
@@ -1,11 +1,11 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
 
3
3
  import { createAuthenticatedHttp } from './helpers'
4
4
 
5
5
  const noBrowserExtraction = async () => null
6
6
 
7
7
  describe('createAuthenticatedHttp', () => {
8
- test('throws a login hint when no credentials are stored', async () => {
8
+ it('throws a login hint when no credentials are stored', async () => {
9
9
  const manager = {
10
10
  getCredentials: async () => null,
11
11
  remove: async () => {},
@@ -16,7 +16,7 @@ describe('createAuthenticatedHttp', () => {
16
16
  )
17
17
  })
18
18
 
19
- test('clears stale credentials when both recovery methods fail', async () => {
19
+ it('clears stale credentials when both recovery methods fail', async () => {
20
20
  let removed = false
21
21
  const manager = {
22
22
  getCredentials: async () => ({
@@ -39,7 +39,7 @@ describe('createAuthenticatedHttp', () => {
39
39
  expect(removed).toBe(true)
40
40
  })
41
41
 
42
- test('returns the authenticated http client when the session is valid', async () => {
42
+ it('returns the authenticated http client when the session is valid', async () => {
43
43
  const http = {
44
44
  checkLogin: async () => ({ userId: 'neo@example.com', userNm: '전수열' }),
45
45
  get: async () => '',
@@ -60,7 +60,7 @@ describe('createAuthenticatedHttp', () => {
60
60
  await expect(createAuthenticatedHttp(manager, () => http)).resolves.toBe(http)
61
61
  })
62
62
 
63
- test('re-authenticates automatically when stored username/password are available', async () => {
63
+ it('re-authenticates automatically when stored username and password are available', async () => {
64
64
  let savedCredentials: Record<string, string> | null = null
65
65
  const manager = {
66
66
  getCredentials: async () => ({
@@ -110,7 +110,7 @@ describe('createAuthenticatedHttp', () => {
110
110
  })
111
111
  })
112
112
 
113
- test('recovers via browser extraction when no stored password is available', async () => {
113
+ it('recovers via browser extraction when no stored password is available', async () => {
114
114
  let savedCredentials: Record<string, unknown> | null = null
115
115
  const manager = {
116
116
  getCredentials: async () => ({
@@ -146,7 +146,7 @@ describe('createAuthenticatedHttp', () => {
146
146
  expect(savedCredentials).toHaveProperty('loggedInAt')
147
147
  })
148
148
 
149
- test('falls back to browser extraction when password re-login throws', async () => {
149
+ it('falls back to browser extraction when password re-login fails', async () => {
150
150
  let savedCredentials: Record<string, unknown> | null = null
151
151
  const manager = {
152
152
  getCredentials: async () => ({
@@ -190,7 +190,7 @@ describe('createAuthenticatedHttp', () => {
190
190
  })
191
191
  })
192
192
 
193
- test('does not attempt browser extraction when no credentials exist', async () => {
193
+ it('skips browser extraction when no credentials exist', async () => {
194
194
  let browserExtractionCalled = false
195
195
  const manager = {
196
196
  getCredentials: async () => null,
@@ -1,4 +1,4 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
  import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -6,18 +6,18 @@ import { join } from 'node:path'
6
6
  import { resolveContent } from './report'
7
7
 
8
8
  describe('resolveContent', () => {
9
- test('returns inline text from --content', async () => {
9
+ it('returns inline text passed via --content', async () => {
10
10
  const result = await resolveContent({ content: 'inline content' })
11
11
  expect(result).toBe('inline content')
12
12
  })
13
13
 
14
- test('reads from stdin when --content is -', async () => {
14
+ it('reads from stdin when --content is "-"', async () => {
15
15
  const fakeStdin = async () => 'stdin content'
16
16
  const result = await resolveContent({ content: '-' }, fakeStdin)
17
17
  expect(result).toBe('stdin content')
18
18
  })
19
19
 
20
- test('reads from file when --content-file is provided', async () => {
20
+ it('reads from a file when --content-file is provided', async () => {
21
21
  const dir = await mkdtemp(join(tmpdir(), 'report-test-'))
22
22
  const filePath = join(dir, 'content.txt')
23
23
  await writeFile(filePath, '파일에서 읽은 내용입니다.')
@@ -30,18 +30,18 @@ describe('resolveContent', () => {
30
30
  }
31
31
  })
32
32
 
33
- test('prefers --content over --content-file when both provided', async () => {
33
+ it('prefers --content over --content-file when both are provided', async () => {
34
34
  const result = await resolveContent({ content: 'inline', contentFile: '/nonexistent' })
35
35
  expect(result).toBe('inline')
36
36
  })
37
37
 
38
- test('--content - takes priority over --content-file', async () => {
38
+ it('prefers stdin (--content -) over --content-file', async () => {
39
39
  const fakeStdin = async () => 'from stdin'
40
40
  const result = await resolveContent({ content: '-', contentFile: '/nonexistent' }, fakeStdin)
41
41
  expect(result).toBe('from stdin')
42
42
  })
43
43
 
44
- test('throws when neither --content nor --content-file is provided', async () => {
44
+ it('throws when neither --content nor --content-file is provided', async () => {
45
45
  await expect(resolveContent({})).rejects.toThrow(
46
46
  'Either --content <text> or --content-file <path> is required. Use --content - to read from stdin.',
47
47
  )
package/src/constants.ts CHANGED
@@ -85,3 +85,53 @@ function createTimeSlots(): string[] {
85
85
  function formatTime(hour: number, minute: number): string {
86
86
  return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
87
87
  }
88
+
89
+ export const TOZ_BASE_URL = 'http://partner.toz.co.kr/partner/reservation/fkii3/swmaestro'
90
+ export const TOZ_PARTNER = 'fkii3'
91
+ export const TOZ_COMPANY = 'swmaestro'
92
+ export const TOZ_MEMBER_COMPANY_ID = '25408'
93
+
94
+ // Static fallback list extracted from booking.htm. Use TozClient.branches() at runtime
95
+ // to fetch the live list — the SW마에스트로 partnership branch set may change.
96
+ export const TOZ_BRANCHES = [
97
+ { id: 27, name: '강남토즈타워점' },
98
+ { id: 145, name: '강남컨퍼런스센터' },
99
+ { id: 19, name: '양재점' },
100
+ { id: 20, name: '건대점' },
101
+ { id: 15, name: '선릉점' },
102
+ { id: 139, name: '마이스 역삼센터' },
103
+ { id: 134, name: '마이스 광화문센터' },
104
+ { id: 30, name: '신촌비즈센터' },
105
+ { id: 149, name: '홍대점' },
106
+ ] as const
107
+
108
+ export const TOZ_PHONE_PREFIXES = ['010', '011', '016', '017', '018', '019'] as const
109
+
110
+ export const TOZ_EMAIL_DOMAINS = [
111
+ 'hanmail.net',
112
+ 'gmail.com',
113
+ 'nate.com',
114
+ 'naver.com',
115
+ 'daum.net',
116
+ 'dreamwiz.com',
117
+ 'yahoo.com',
118
+ 'yahoo.co.kr',
119
+ 'msn.com',
120
+ 'paran.com',
121
+ 'korea.com',
122
+ 'freechal.com',
123
+ 'lycos.co.kr',
124
+ 'msn.co.kr',
125
+ 'empal.com',
126
+ 'hotmail.com',
127
+ ] as const
128
+
129
+ export const TOZ_EMAIL_DOMAIN_CUSTOM = '직접입력'
130
+
131
+ export const TOZ_NEW_MEETING_VALUE = '새모임'
132
+
133
+ export const TOZ_MIN_DURATION_MINUTES = 120
134
+ export const TOZ_MAX_DURATION_MINUTES = 180
135
+ export const TOZ_SESSION_HOLD_SECONDS = 300
136
+
137
+ export const TOZ_MAX_CHECK_TIMES = 6
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, test } from 'bun:test'
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
2
  import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -15,7 +15,7 @@ afterEach(async () => {
15
15
  })
16
16
 
17
17
  describe('CredentialManager', () => {
18
- test('loads empty config when file does not exist', async () => {
18
+ it('loads an empty config when the credentials file does not exist', async () => {
19
19
  const dir = await makeTempDir()
20
20
  const manager = new CredentialManager(dir)
21
21
 
@@ -23,7 +23,7 @@ describe('CredentialManager', () => {
23
23
  await expect(manager.getCredentials()).resolves.toBeNull()
24
24
  })
25
25
 
26
- test('saves and loads credentials with secure permissions', async () => {
26
+ it('saves and loads credentials with secure file permissions', async () => {
27
27
  const dir = await makeTempDir()
28
28
  const manager = new CredentialManager(dir)
29
29
 
@@ -53,7 +53,7 @@ describe('CredentialManager', () => {
53
53
  expect(keyFileStat.mode & 0o777).toBe(0o600)
54
54
  })
55
55
 
56
- test('removes credentials file', async () => {
56
+ it('removes the credentials file', async () => {
57
57
  const dir = await makeTempDir()
58
58
  const manager = new CredentialManager(dir)
59
59
 
@@ -66,7 +66,7 @@ describe('CredentialManager', () => {
66
66
  await expect(manager.getCredentials()).resolves.toBeNull()
67
67
  })
68
68
 
69
- test('preserves session credentials when the encryption key is missing', async () => {
69
+ it('preserves session credentials but drops the password when the encryption key is missing', async () => {
70
70
  const dir = await makeTempDir()
71
71
  const manager = new CredentialManager(dir)
72
72