opensoma 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.json +1 -1
- package/dist/src/client.d.ts +7 -1
- package/dist/src/client.d.ts.map +1 -1
- package/dist/src/client.js +13 -11
- package/dist/src/client.js.map +1 -1
- package/dist/src/commands/auth.d.ts +1 -1
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +94 -52
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/constants.d.ts +40 -0
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +42 -0
- package/dist/src/constants.js.map +1 -1
- package/dist/src/formatters.d.ts.map +1 -1
- package/dist/src/formatters.js +42 -16
- package/dist/src/formatters.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
- package/dist/src/shared/utils/swmaestro.js +1 -5
- package/dist/src/shared/utils/swmaestro.js.map +1 -1
- package/dist/src/shared/utils/toz.d.ts +23 -0
- package/dist/src/shared/utils/toz.d.ts.map +1 -0
- package/dist/src/shared/utils/toz.js +72 -0
- package/dist/src/shared/utils/toz.js.map +1 -0
- package/dist/src/token-extractor.d.ts +9 -1
- package/dist/src/token-extractor.d.ts.map +1 -1
- package/dist/src/token-extractor.js +54 -10
- package/dist/src/token-extractor.js.map +1 -1
- package/dist/src/toz-formatters.d.ts +9 -0
- package/dist/src/toz-formatters.d.ts.map +1 -0
- package/dist/src/toz-formatters.js +151 -0
- package/dist/src/toz-formatters.js.map +1 -0
- package/dist/src/toz-http.d.ts +27 -0
- package/dist/src/toz-http.d.ts.map +1 -0
- package/dist/src/toz-http.js +154 -0
- package/dist/src/toz-http.js.map +1 -0
- package/dist/src/types.d.ts +52 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +46 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/toz/toz_all_branches.json +211 -0
- package/src/__fixtures__/toz/toz_booking.html +2190 -0
- package/src/__fixtures__/toz/toz_boothes.json +59 -0
- package/src/__fixtures__/toz/toz_duration.json +25 -0
- package/src/__fixtures__/toz/toz_mypage_response.html +388 -0
- package/src/__fixtures__/toz/toz_page.html +211 -0
- package/src/client.test.ts +135 -117
- package/src/client.ts +16 -12
- package/src/commands/auth.test.ts +7 -7
- package/src/commands/auth.ts +107 -50
- package/src/commands/helpers.test.ts +8 -8
- package/src/commands/report.test.ts +7 -7
- package/src/constants.ts +50 -0
- package/src/credential-manager.test.ts +5 -5
- package/src/formatters.test.ts +22 -22
- package/src/formatters.ts +44 -16
- package/src/http.test.ts +37 -37
- package/src/index.ts +3 -0
- package/src/shared/utils/mentoring-params.test.ts +16 -16
- package/src/shared/utils/swmaestro.test.ts +87 -8
- package/src/shared/utils/swmaestro.ts +1 -6
- package/src/shared/utils/toz.test.ts +138 -0
- package/src/shared/utils/toz.ts +100 -0
- package/src/token-extractor.test.ts +40 -15
- package/src/token-extractor.ts +65 -13
- package/src/toz-formatters.test.ts +197 -0
- package/src/toz-formatters.ts +211 -0
- package/src/toz-http.test.ts +157 -0
- package/src/toz-http.ts +188 -0
- package/src/types.test.ts +220 -204
- package/src/types.ts +58 -0
package/src/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,
|
|
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 =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
package/src/commands/auth.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
20
|
+
async function promptPasswordTTY(message: string): Promise<string> {
|
|
17
21
|
process.stdout.write(message)
|
|
18
22
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|