opensoma 0.6.0 → 0.8.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 (55) hide show
  1. package/dist/package.json +1 -1
  2. package/dist/src/cli.d.ts.map +1 -1
  3. package/dist/src/cli.js +6 -4
  4. package/dist/src/cli.js.map +1 -1
  5. package/dist/src/client.d.ts +4 -0
  6. package/dist/src/client.d.ts.map +1 -1
  7. package/dist/src/client.js +6 -0
  8. package/dist/src/client.js.map +1 -1
  9. package/dist/src/commands/index.d.ts +1 -0
  10. package/dist/src/commands/index.d.ts.map +1 -1
  11. package/dist/src/commands/index.js +1 -0
  12. package/dist/src/commands/index.js.map +1 -1
  13. package/dist/src/commands/toz.d.ts +16 -0
  14. package/dist/src/commands/toz.d.ts.map +1 -0
  15. package/dist/src/commands/toz.js +488 -0
  16. package/dist/src/commands/toz.js.map +1 -0
  17. package/dist/src/credential-manager.d.ts +8 -2
  18. package/dist/src/credential-manager.d.ts.map +1 -1
  19. package/dist/src/credential-manager.js +27 -5
  20. package/dist/src/credential-manager.js.map +1 -1
  21. package/dist/src/index.d.ts +4 -0
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/index.js +2 -0
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/session-recovery.js +2 -0
  26. package/dist/src/session-recovery.js.map +1 -1
  27. package/dist/src/shared/utils/config-dir.d.ts +11 -0
  28. package/dist/src/shared/utils/config-dir.d.ts.map +1 -0
  29. package/dist/src/shared/utils/config-dir.js +19 -0
  30. package/dist/src/shared/utils/config-dir.js.map +1 -0
  31. package/dist/src/toz-client.d.ts +89 -0
  32. package/dist/src/toz-client.d.ts.map +1 -0
  33. package/dist/src/toz-client.js +204 -0
  34. package/dist/src/toz-client.js.map +1 -0
  35. package/dist/src/toz-pending-store.d.ts +30 -0
  36. package/dist/src/toz-pending-store.d.ts.map +1 -0
  37. package/dist/src/toz-pending-store.js +36 -0
  38. package/dist/src/toz-pending-store.js.map +1 -0
  39. package/package.json +1 -1
  40. package/src/cli.ts +6 -3
  41. package/src/client.ts +10 -0
  42. package/src/commands/helpers.test.ts +4 -0
  43. package/src/commands/index.ts +1 -0
  44. package/src/commands/toz.test.ts +51 -0
  45. package/src/commands/toz.ts +607 -0
  46. package/src/credential-manager.test.ts +54 -0
  47. package/src/credential-manager.ts +28 -5
  48. package/src/index.ts +13 -0
  49. package/src/session-recovery.ts +2 -0
  50. package/src/shared/utils/config-dir.test.ts +41 -0
  51. package/src/shared/utils/config-dir.ts +20 -0
  52. package/src/toz-client.test.ts +243 -0
  53. package/src/toz-client.ts +311 -0
  54. package/src/toz-pending-store.test.ts +91 -0
  55. package/src/toz-pending-store.ts +63 -0
@@ -1,9 +1,9 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
4
- import { homedir } from 'node:os'
5
4
  import { join } from 'node:path'
6
5
 
6
+ import { getConfigDir } from './shared/utils/config-dir'
7
7
  import type { Credentials } from './types'
8
8
 
9
9
  interface EncryptedSecret {
@@ -26,7 +26,7 @@ export class CredentialManager {
26
26
  private encryptionKeyPath: string
27
27
 
28
28
  constructor(configDir?: string) {
29
- this.configDir = configDir ?? join(homedir(), '.config', 'opensoma')
29
+ this.configDir = configDir ?? getConfigDir()
30
30
  this.credentialsPath = join(this.configDir, 'credentials.json')
31
31
  this.encryptionKeyPath = join(this.configDir, 'credentials.key')
32
32
  }
@@ -78,8 +78,8 @@ export class CredentialManager {
78
78
 
79
79
  /**
80
80
  * Wipe ephemeral session fields (sessionCookie, csrfToken, loggedInAt) while
81
- * preserving long-term re-login material (username, password). Used when the
82
- * server-side session expired but we still want automatic recovery on the
81
+ * preserving long-term material (username, password, TOZ identity). Used when
82
+ * the server-side session expired but we still want automatic recovery on the
83
83
  * next run via `recoverSession()`.
84
84
  *
85
85
  * No-op when no credentials file exists or no recovery material is stored.
@@ -90,7 +90,7 @@ export class CredentialManager {
90
90
  return
91
91
  }
92
92
 
93
- if (!current.username && !current.password) {
93
+ if (!current.username && !current.password && !current.tozName && !current.tozPhone) {
94
94
  await this.remove()
95
95
  return
96
96
  }
@@ -100,9 +100,32 @@ export class CredentialManager {
100
100
  csrfToken: '',
101
101
  username: current.username,
102
102
  password: current.password,
103
+ tozName: current.tozName,
104
+ tozPhone: current.tozPhone,
103
105
  })
104
106
  }
105
107
 
108
+ async setTozIdentity(name: string, phone: string): Promise<void> {
109
+ const existing = await this.getCredentials()
110
+ if (!existing) {
111
+ throw new Error('SWMaestro credentials not found. Run: opensoma auth login first.')
112
+ }
113
+ await this.setCredentials({ ...existing, tozName: name, tozPhone: phone })
114
+ }
115
+
116
+ async clearTozIdentity(): Promise<void> {
117
+ const existing = await this.getCredentials()
118
+ if (!existing) return
119
+ const { tozName: _name, tozPhone: _phone, ...rest } = existing
120
+ await this.setCredentials(rest)
121
+ }
122
+
123
+ async getTozIdentity(): Promise<{ name: string; phone: string } | null> {
124
+ const creds = await this.getCredentials()
125
+ if (!creds?.tozName || !creds?.tozPhone) return null
126
+ return { name: creds.tozName, phone: creds.tozPhone }
127
+ }
128
+
106
129
  private async hydrateCredentials(credentials: StoredCredentials): Promise<Credentials> {
107
130
  if (!credentials.encryptedPassword) {
108
131
  return credentials
package/src/index.ts CHANGED
@@ -14,6 +14,19 @@ export type { UserIdentity } from './http'
14
14
  export { CredentialManager } from './credential-manager'
15
15
  export { TozHttp } from './toz-http'
16
16
  export type { TozHttpOptions, TozHttpState } from './toz-http'
17
+ export { TozClient } from './toz-client'
18
+ export type {
19
+ TozAvailabilityQuery,
20
+ TozCancelArgs,
21
+ TozCheckQuery,
22
+ TozCheckResult,
23
+ TozClientOptions,
24
+ TozConfirmArgs,
25
+ TozReserveBoothArgs,
26
+ TozSearchArgs,
27
+ } from './toz-client'
28
+ export { TozPendingStore } from './toz-pending-store'
29
+ export type { TozPendingReservation } from './toz-pending-store'
17
30
  export * from './toz-formatters'
18
31
  export * from './types'
19
32
  export * from './constants'
@@ -51,6 +51,8 @@ function buildRefreshedCredentials(
51
51
  csrfToken,
52
52
  username: identity.userId || credentials.username,
53
53
  password: credentials.password,
54
+ tozName: credentials.tozName,
55
+ tozPhone: credentials.tozPhone,
54
56
  loggedInAt: new Date().toISOString(),
55
57
  }
56
58
  }
@@ -0,0 +1,41 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { CONFIG_DIR_ENV_VAR, getConfigDir } from './config-dir'
6
+
7
+ let originalValue: string | undefined
8
+
9
+ beforeEach(() => {
10
+ originalValue = process.env[CONFIG_DIR_ENV_VAR]
11
+ delete process.env[CONFIG_DIR_ENV_VAR]
12
+ })
13
+
14
+ afterEach(() => {
15
+ if (originalValue === undefined) {
16
+ delete process.env[CONFIG_DIR_ENV_VAR]
17
+ } else {
18
+ process.env[CONFIG_DIR_ENV_VAR] = originalValue
19
+ }
20
+ })
21
+
22
+ describe('getConfigDir', () => {
23
+ it('falls back to ~/.config/opensoma when env var is unset', () => {
24
+ expect(getConfigDir()).toBe(join(homedir(), '.config', 'opensoma'))
25
+ })
26
+
27
+ it('returns OPENSOMA_CONFIG_DIR when set', () => {
28
+ process.env[CONFIG_DIR_ENV_VAR] = '/custom/config/path'
29
+ expect(getConfigDir()).toBe('/custom/config/path')
30
+ })
31
+
32
+ it('falls back to default when env var is empty string', () => {
33
+ process.env[CONFIG_DIR_ENV_VAR] = ''
34
+ expect(getConfigDir()).toBe(join(homedir(), '.config', 'opensoma'))
35
+ })
36
+
37
+ it('preserves relative paths verbatim', () => {
38
+ process.env[CONFIG_DIR_ENV_VAR] = './local-config'
39
+ expect(getConfigDir()).toBe('./local-config')
40
+ })
41
+ })
@@ -0,0 +1,20 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ export const CONFIG_DIR_ENV_VAR = 'OPENSOMA_CONFIG_DIR'
5
+
6
+ /**
7
+ * Resolves the directory used to persist opensoma state (credentials, pending
8
+ * reservations, etc.).
9
+ *
10
+ * Resolution order:
11
+ * 1. `OPENSOMA_CONFIG_DIR` environment variable (if set and non-empty)
12
+ * 2. `~/.config/opensoma`
13
+ */
14
+ export function getConfigDir(): string {
15
+ const fromEnv = process.env[CONFIG_DIR_ENV_VAR]
16
+ if (fromEnv && fromEnv.length > 0) {
17
+ return fromEnv
18
+ }
19
+ return join(homedir(), '.config', 'opensoma')
20
+ }
@@ -0,0 +1,243 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test'
2
+
3
+ import { TOZ_BASE_URL } from './constants'
4
+ import { TozClient } from './toz-client'
5
+
6
+ const originalFetch = globalThis.fetch
7
+
8
+ afterEach(() => {
9
+ globalThis.fetch = originalFetch
10
+ mock.restore()
11
+ })
12
+
13
+ function jsonResponse(body: unknown, cookies: string[] = []): Response {
14
+ const response = new Response(JSON.stringify(body), {
15
+ status: 200,
16
+ headers: { 'Content-Type': 'application/json' },
17
+ })
18
+ Object.defineProperty(response.headers, 'getSetCookie', {
19
+ value: () => cookies,
20
+ configurable: true,
21
+ })
22
+ return response
23
+ }
24
+
25
+ function textResponse(body: string, cookies: string[] = []): Response {
26
+ const response = new Response(body, { status: 200, headers: { 'Content-Type': 'text/html' } })
27
+ Object.defineProperty(response.headers, 'getSetCookie', {
28
+ value: () => cookies,
29
+ configurable: true,
30
+ })
31
+ return response
32
+ }
33
+
34
+ describe('TozClient.available', () => {
35
+ test('builds correct AJAX payload and parses booth response', async () => {
36
+ const calls: { url: string; body: string }[] = []
37
+ globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
38
+ const url = String(input)
39
+ const body = String(init?.body ?? '')
40
+ calls.push({ url, body })
41
+
42
+ if (url.endsWith('/index.htm')) return textResponse('ok', ['JSESSIONID=ABC'])
43
+ if (url.endsWith('/ajaxGetEnableBoothes.htm')) {
44
+ return jsonResponse([
45
+ {
46
+ resultMsg: 'SUCCESS',
47
+ id: 740,
48
+ name: '304 _ A',
49
+ branchName: '토즈타워점',
50
+ branchTel: '02-3454-0116',
51
+ minUseUserCount: 1,
52
+ enableMaxUserCount: 2,
53
+ boothGroupName: '2인부스 A타입',
54
+ boothGroupUrl: null,
55
+ boothMemoForUser: '15,000원',
56
+ isLargeBooth: false,
57
+ },
58
+ ])
59
+ }
60
+ throw new Error(`Unexpected URL: ${url}`)
61
+ }) as typeof fetch
62
+
63
+ const client = new TozClient()
64
+ const booths = await client.available({
65
+ date: '2026-04-21',
66
+ startTime: '14:00',
67
+ durationMinutes: 120,
68
+ userCount: 2,
69
+ branchIds: [27, 145],
70
+ })
71
+
72
+ expect(booths).toHaveLength(1)
73
+ expect(booths[0]?.id).toBe(740)
74
+ expect(calls[1]?.url).toBe(`${TOZ_BASE_URL}/ajaxGetEnableBoothes.htm`)
75
+ expect(calls[1]?.body).toBe(
76
+ 'basedate=2026-04-21&starttime=1400&durationTime=0200&userCount=2&branchIds=27%2C145%2C',
77
+ )
78
+ })
79
+
80
+ test('throws on out-of-range duration', async () => {
81
+ const client = new TozClient()
82
+ await expect(
83
+ client.available({ date: '2026-04-21', startTime: '14:00', durationMinutes: 60, userCount: 2, branchIds: [27] }),
84
+ ).rejects.toThrow(/2h and 3h/)
85
+ })
86
+ })
87
+
88
+ describe('TozClient.check', () => {
89
+ test('rejects empty time list', async () => {
90
+ const client = new TozClient()
91
+ await expect(
92
+ client.check({ date: '2026-04-21', startTimes: [], durationMinutes: 120, userCount: 2, branchIds: [27] }),
93
+ ).rejects.toThrow(/at least one --time/)
94
+ })
95
+
96
+ test('rejects more than 6 times', async () => {
97
+ const client = new TozClient()
98
+ await expect(
99
+ client.check({
100
+ date: '2026-04-21',
101
+ startTimes: ['10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00'],
102
+ durationMinutes: 120,
103
+ userCount: 2,
104
+ branchIds: [27],
105
+ }),
106
+ ).rejects.toThrow(/Too many times/)
107
+ })
108
+
109
+ test('returns one result per time, captures errors per slot', async () => {
110
+ let call = 0
111
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
112
+ const url = String(input)
113
+ if (url.endsWith('/index.htm')) return textResponse('ok', ['JSESSIONID=A'])
114
+ call += 1
115
+ if (call === 1) return jsonResponse([])
116
+ if (call === 2) return jsonResponse([{ resultMsg: '예약 가능한 부스가 없습니다.' }])
117
+ throw new Error(`Unexpected call ${call}`)
118
+ }) as typeof fetch
119
+
120
+ const client = new TozClient()
121
+ const results = await client.check({
122
+ date: '2026-04-21',
123
+ startTimes: ['10:00', '14:00'],
124
+ durationMinutes: 120,
125
+ userCount: 2,
126
+ branchIds: [27],
127
+ })
128
+
129
+ expect(results).toHaveLength(2)
130
+ expect(results[0]).toEqual({ startTime: '10:00', booths: [] })
131
+ expect(results[1]?.startTime).toBe('14:00')
132
+ expect(results[1]?.error).toContain('예약 가능한 부스가 없습니다.')
133
+ })
134
+
135
+ test('does not sleep after the last slot (single-time check is near-instant)', async () => {
136
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
137
+ const url = String(input)
138
+ if (url.endsWith('/index.htm')) return textResponse('ok', ['JSESSIONID=A'])
139
+ return jsonResponse([])
140
+ }) as typeof fetch
141
+
142
+ const client = new TozClient()
143
+ const start = Date.now()
144
+ await client.check({
145
+ date: '2026-04-21',
146
+ startTimes: ['10:00'],
147
+ durationMinutes: 120,
148
+ userCount: 2,
149
+ branchIds: [27],
150
+ })
151
+ expect(Date.now() - start).toBeLessThan(400)
152
+ })
153
+ })
154
+
155
+ describe('TozClient identity fallback', () => {
156
+ test('uses constructor-provided name/phone when args omit them', async () => {
157
+ const calls: { url: string; body: string }[] = []
158
+ globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
159
+ const url = String(input)
160
+ calls.push({ url, body: String(init?.body ?? '') })
161
+ if (url.endsWith('/index.htm')) return textResponse('ok', ['JSESSIONID=A'])
162
+ if (url.endsWith('/mypage_login_.htm')) return textResponse('ok')
163
+ if (url.endsWith('/mypage.htm')) return textResponse('<html></html>')
164
+ throw new Error(`Unexpected ${url}`)
165
+ }) as typeof fetch
166
+
167
+ const client = new TozClient({ name: '홍길동', phone: '010-1234-5678' })
168
+ await client.myReservations()
169
+
170
+ const loginCall = calls.find((c) => c.url.endsWith('/mypage_login_.htm'))
171
+ expect(loginCall?.body).toContain('name=%ED%99%8D%EA%B8%B8%EB%8F%99')
172
+ expect(loginCall?.body).toContain('phone1=010')
173
+ expect(loginCall?.body).toContain('phone2=1234')
174
+ expect(loginCall?.body).toContain('phone3=5678')
175
+ })
176
+
177
+ test('throws a clear error when neither args nor identity are provided', async () => {
178
+ const client = new TozClient()
179
+ await expect(client.myReservations()).rejects.toThrow(/name is required/)
180
+ })
181
+ })
182
+
183
+ describe('TozClient.reserveBooth', () => {
184
+ test('rejects large booth', async () => {
185
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
186
+ const url = String(input)
187
+ if (url.endsWith('/index.htm')) return textResponse('ok', ['JSESSIONID=A'])
188
+ if (url.endsWith('/ajaxReservationBooth.htm')) {
189
+ return jsonResponse({
190
+ result: 'SUCCESS',
191
+ resultMsg: 'res-1',
192
+ branchName: 'X',
193
+ branchTel: 'tel',
194
+ boothGroupName: '대형부스',
195
+ boothIsLarge: true,
196
+ })
197
+ }
198
+ throw new Error(url)
199
+ }) as typeof fetch
200
+
201
+ const client = new TozClient()
202
+ await expect(
203
+ client.reserveBooth({ date: '2026-04-21', startTime: '14:00', durationMinutes: 120, userCount: 8, boothId: 999 }),
204
+ ).rejects.toThrow(/대형부스/)
205
+ })
206
+ })
207
+
208
+ describe('TozClient.confirm', () => {
209
+ test('throws on non-success message', async () => {
210
+ globalThis.fetch = mock(async () => jsonResponse({ resultMsg: '인증번호가 올바르지 않습니다.' })) as typeof fetch
211
+
212
+ const client = new TozClient({ sessionCookie: 'A' })
213
+ await expect(
214
+ client.confirm({
215
+ reservationId: 'r1',
216
+ date: '2026-04-21',
217
+ startTime: '14:00',
218
+ durationMinutes: 120,
219
+ name: '홍길동',
220
+ phone: '010-1234-5678',
221
+ email: 'me@gmail.com',
222
+ pinNum: '000000',
223
+ meetingId: 1234,
224
+ }),
225
+ ).rejects.toThrow('인증번호가 올바르지 않습니다.')
226
+ })
227
+
228
+ test('requires meetingId or newMeetingName', async () => {
229
+ const client = new TozClient({ sessionCookie: 'A' })
230
+ await expect(
231
+ client.confirm({
232
+ reservationId: 'r1',
233
+ date: '2026-04-21',
234
+ startTime: '14:00',
235
+ durationMinutes: 120,
236
+ name: '홍길동',
237
+ phone: '010-1234-5678',
238
+ email: 'me@gmail.com',
239
+ pinNum: '123456',
240
+ }),
241
+ ).rejects.toThrow(/meetingId/)
242
+ })
243
+ })