screeps-connectivity 0.2.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 (75) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/eslint.config.js +54 -0
  3. package/package.json +45 -0
  4. package/src/ScreepsClient.ts +172 -0
  5. package/src/badge/colors.ts +83 -0
  6. package/src/badge/generateSvg.ts +70 -0
  7. package/src/badge/index.ts +1 -0
  8. package/src/badge/paths.ts +385 -0
  9. package/src/cache/Cache.ts +71 -0
  10. package/src/cache/Map2Storage.ts +112 -0
  11. package/src/file-storage.ts +2 -0
  12. package/src/http/HttpClient.ts +160 -0
  13. package/src/http/auth/AuthStrategy.ts +5 -0
  14. package/src/http/auth/GuestAuth.ts +13 -0
  15. package/src/http/auth/PasswordAuth.ts +17 -0
  16. package/src/http/auth/SteamTicketAuth.ts +17 -0
  17. package/src/http/auth/TokenAuth.ts +14 -0
  18. package/src/http/decompress.ts +37 -0
  19. package/src/http/endpoints/auth.ts +23 -0
  20. package/src/http/endpoints/experimental.ts +13 -0
  21. package/src/http/endpoints/game.ts +103 -0
  22. package/src/http/endpoints/leaderboard.ts +16 -0
  23. package/src/http/endpoints/power-creeps.ts +24 -0
  24. package/src/http/endpoints/register.ts +19 -0
  25. package/src/http/endpoints/user-messages.ts +20 -0
  26. package/src/http/endpoints/user.ts +95 -0
  27. package/src/http/fetchServerVersion.ts +151 -0
  28. package/src/index.ts +55 -0
  29. package/src/logger.ts +25 -0
  30. package/src/socket/MessageParser.ts +44 -0
  31. package/src/socket/SocketClient.ts +203 -0
  32. package/src/storage/FileStorage.ts +44 -0
  33. package/src/storage/IndexedDBStorage.ts +77 -0
  34. package/src/storage/NullStorage.ts +8 -0
  35. package/src/storage/StorageAdapter.ts +6 -0
  36. package/src/stores/MapStatsStore.ts +115 -0
  37. package/src/stores/MapStore.ts +254 -0
  38. package/src/stores/NavigationStore.ts +61 -0
  39. package/src/stores/RoomStore.ts +264 -0
  40. package/src/stores/ServerStore.ts +128 -0
  41. package/src/stores/TypedStore.ts +31 -0
  42. package/src/stores/UserStore.ts +189 -0
  43. package/src/subscription/index.ts +18 -0
  44. package/src/types/api.ts +252 -0
  45. package/src/types/events.ts +72 -0
  46. package/src/types/game.ts +160 -0
  47. package/tests/.gitkeep +0 -0
  48. package/tests/ScreepsClient.test.ts +229 -0
  49. package/tests/badge/generateSvg.test.ts +174 -0
  50. package/tests/cache/Cache.test.ts +99 -0
  51. package/tests/cache/Map2Storage.test.ts +130 -0
  52. package/tests/http/HttpClient.test.ts +188 -0
  53. package/tests/http/decompress.test.ts +52 -0
  54. package/tests/http/endpoints/auth.test.ts +126 -0
  55. package/tests/http/endpoints/game.test.ts +210 -0
  56. package/tests/http/endpoints/power-creeps.test.ts +81 -0
  57. package/tests/http/endpoints/user-messages.test.ts +68 -0
  58. package/tests/http/endpoints/user.test.ts +139 -0
  59. package/tests/socket/MessageParser.test.ts +55 -0
  60. package/tests/socket/SocketClient.test.ts +144 -0
  61. package/tests/storage/FileStorage.test.ts +64 -0
  62. package/tests/storage/IndexedDBStorage.test.ts +36 -0
  63. package/tests/storage/NullStorage.test.ts +24 -0
  64. package/tests/stores/MapStatsStore.test.ts +234 -0
  65. package/tests/stores/MapStore.test.ts +537 -0
  66. package/tests/stores/NavigationStore.test.ts +166 -0
  67. package/tests/stores/RoomStore.test.ts +130 -0
  68. package/tests/stores/ServerStore.test.ts +48 -0
  69. package/tests/stores/TypedStore.test.ts +54 -0
  70. package/tests/stores/UserStore.test.ts +136 -0
  71. package/tests/subscription/SubscriptionGroup.test.ts +34 -0
  72. package/tests/types/game.test.ts +42 -0
  73. package/tsconfig.json +17 -0
  74. package/tsup.config.ts +9 -0
  75. package/vitest.config.ts +7 -0
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import 'fake-indexeddb/auto'
3
+ import { IndexedDBStorage } from '../../src/storage/IndexedDBStorage.js'
4
+ import { Map2Storage } from '../../src/cache/Map2Storage.js'
5
+ import type { RoomMap2Data } from '../../src/types/game.js'
6
+
7
+ const DATA_A: RoomMap2Data = { s: [[10, 20]], c: [[25, 25]] }
8
+ const DATA_B: RoomMap2Data = { m: [[30, 30]] }
9
+
10
+ function makeStorage(maxEntries = 100) {
11
+ const adapter = new IndexedDBStorage(`map2-test-${Math.random()}`)
12
+ const storage = new Map2Storage({ adapter, namespace: 'test.local', maxEntries })
13
+ return storage
14
+ }
15
+
16
+ describe('Map2Storage — memory path', () => {
17
+ it('getMemory() returns null when entry not yet loaded', () => {
18
+ const storage = makeStorage()
19
+ expect(storage.getMemory('W7N7', 'shard0')).toBeNull()
20
+ })
21
+
22
+ it('get() returns null when adapter has no entry', async () => {
23
+ const storage = makeStorage()
24
+ expect(await storage.get('W7N7', 'shard0')).toBeNull()
25
+ })
26
+
27
+ it('put() makes data immediately available via getMemory()', async () => {
28
+ const storage = makeStorage()
29
+ void storage.put('W7N7', 'shard0', DATA_A) // don't await — memory is sync
30
+ expect(storage.getMemory('W7N7', 'shard0')).toEqual(DATA_A)
31
+ })
32
+ })
33
+
34
+ describe('Map2Storage — IndexedDB persistence', () => {
35
+ it('put() persists data; get() retrieves it after memory is cold', async () => {
36
+ const adapter = new IndexedDBStorage(`map2-test-${Math.random()}`)
37
+ const storage1 = new Map2Storage({ adapter, namespace: 'test.local', maxEntries: 100 })
38
+ await storage1.put('W7N7', 'shard0', DATA_A)
39
+
40
+ // Simulate cold start: fresh storage instance sharing the same adapter
41
+ const storage2 = new Map2Storage({ adapter, namespace: 'test.local', maxEntries: 100 })
42
+ const result = await storage2.get('W7N7', 'shard0')
43
+ expect(result).toEqual(DATA_A)
44
+ })
45
+
46
+ it('get() hydrates memory from IndexedDB on hit', async () => {
47
+ const adapter = new IndexedDBStorage(`map2-test-${Math.random()}`)
48
+ const storage1 = new Map2Storage({ adapter, namespace: 'test.local', maxEntries: 100 })
49
+ await storage1.put('W7N7', 'shard0', DATA_A)
50
+
51
+ const storage2 = new Map2Storage({ adapter, namespace: 'test.local', maxEntries: 100 })
52
+ await storage2.get('W7N7', 'shard0')
53
+ // After hydration, getMemory() must return the data synchronously
54
+ expect(storage2.getMemory('W7N7', 'shard0')).toEqual(DATA_A)
55
+ })
56
+
57
+ it('put() with null shard stores under _ namespace', async () => {
58
+ const adapter = new IndexedDBStorage(`map2-test-${Math.random()}`)
59
+ const storage1 = new Map2Storage({ adapter, namespace: 'test.local', maxEntries: 100 })
60
+ await storage1.put('E9N3', null, DATA_B)
61
+
62
+ const storage2 = new Map2Storage({ adapter, namespace: 'test.local', maxEntries: 100 })
63
+ expect(await storage2.get('E9N3', null)).toEqual(DATA_B)
64
+ expect(await storage2.get('E9N3', 'shard0')).toBeNull()
65
+ })
66
+
67
+ it('different Map2Storage instances with different adapters do not share data', async () => {
68
+ const adapter1 = new IndexedDBStorage(`map2-test-${Math.random()}`)
69
+ const adapter2 = new IndexedDBStorage(`map2-test-${Math.random()}`)
70
+ const s1 = new Map2Storage({ adapter: adapter1, namespace: 'a', maxEntries: 100 })
71
+ const s2 = new Map2Storage({ adapter: adapter2, namespace: 'b', maxEntries: 100 })
72
+
73
+ await s1.put('W7N7', 'shard0', DATA_A)
74
+ expect(await s2.get('W7N7', 'shard0')).toBeNull()
75
+ })
76
+ })
77
+
78
+ describe('Map2Storage — LRU eviction with IndexedDB', () => {
79
+ it('evicts LRU entry from both memory and IndexedDB when over maxEntries', async () => {
80
+ const adapter = new IndexedDBStorage(`map2-test-${Math.random()}`)
81
+ const storage = new Map2Storage({ adapter, namespace: 'test.local', maxEntries: 2 })
82
+
83
+ await storage.put('W1N1', null, DATA_A)
84
+ await storage.put('W2N2', null, DATA_A)
85
+ storage.getMemory('W1N1', null) // touch W1N1 → W2N2 becomes oldest
86
+ await storage.put('W3N3', null, DATA_A) // triggers eviction of W2N2
87
+
88
+ // Memory check
89
+ expect(storage.getMemory('W1N1', null)).not.toBeNull()
90
+ expect(storage.getMemory('W2N2', null)).toBeNull()
91
+ expect(storage.getMemory('W3N3', null)).not.toBeNull()
92
+
93
+ // IndexedDB check via a cold storage instance
94
+ const cold = new Map2Storage({ adapter, namespace: 'test.local', maxEntries: 100 })
95
+ expect(await cold.get('W1N1', null)).toEqual(DATA_A)
96
+ expect(await cold.get('W2N2', null)).toBeNull()
97
+ expect(await cold.get('W3N3', null)).toEqual(DATA_A)
98
+ })
99
+
100
+ it('memory-only eviction still works when adapter is null', async () => {
101
+ const storage = new Map2Storage({ adapter: null, namespace: 'test.local', maxEntries: 2 })
102
+ const data: RoomMap2Data = {}
103
+
104
+ void storage.put('W1N1', null, data)
105
+ void storage.put('W2N2', null, data)
106
+ storage.getMemory('W1N1', null) // touch W1N1
107
+ void storage.put('W3N3', null, data)
108
+
109
+ expect(storage.getMemory('W1N1', null)).not.toBeNull()
110
+ expect(storage.getMemory('W2N2', null)).toBeNull()
111
+ expect(storage.getMemory('W3N3', null)).not.toBeNull()
112
+ })
113
+ })
114
+
115
+ describe('Map2Storage — null adapter', () => {
116
+ let storage: Map2Storage
117
+
118
+ beforeEach(() => {
119
+ storage = new Map2Storage({ adapter: null, namespace: 'offline', maxEntries: 10 })
120
+ })
121
+
122
+ it('get() returns null when adapter is null and memory is empty', async () => {
123
+ expect(await storage.get('W7N7', 'shard0')).toBeNull()
124
+ })
125
+
126
+ it('put() + get() works via memory only when adapter is null', async () => {
127
+ await storage.put('W7N7', 'shard0', DATA_A)
128
+ expect(await storage.get('W7N7', 'shard0')).toEqual(DATA_A)
129
+ })
130
+ })
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { HttpClient } from '../../src/http/HttpClient.js'
3
+ import { TokenAuth } from '../../src/http/auth/TokenAuth.js'
4
+
5
+ function mockResponse(body: unknown, opts: ResponseInit = {}): Response {
6
+ return new Response(JSON.stringify(body), {
7
+ status: 200,
8
+ headers: { 'content-type': 'application/json', ...opts.headers },
9
+ ...opts,
10
+ })
11
+ }
12
+
13
+ describe('HttpClient', () => {
14
+ let fetchMock: ReturnType<typeof vi.fn>
15
+
16
+ beforeEach(() => {
17
+ fetchMock = vi.fn()
18
+ vi.stubGlobal('fetch', fetchMock)
19
+ })
20
+
21
+ afterEach(() => {
22
+ vi.unstubAllGlobals()
23
+ })
24
+
25
+ it('attaches X-Token and X-Username headers after authenticate()', async () => {
26
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
27
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'tok123' }) })
28
+ await http.authenticate()
29
+ await http.request('GET', '/api/version')
30
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
31
+ const headers = init.headers as Record<string, string>
32
+ expect(headers['X-Token']).toBe('tok123')
33
+ // passport-token requires both x-token and x-username; server ignores the username value
34
+ expect(headers['X-Username']).toBe('tok123')
35
+ })
36
+
37
+ it('attaches X-Server-Password header when serverPassword is set', async () => {
38
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
39
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }), serverPassword: 'secret' })
40
+ await http.request('GET', '/api/version')
41
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
42
+ const headers = init.headers as Record<string, string>
43
+ expect(headers['X-Server-Password']).toBe('secret')
44
+ })
45
+
46
+ it('omits X-Server-Password header when serverPassword is not set', async () => {
47
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
48
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
49
+ await http.request('GET', '/api/version')
50
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
51
+ const headers = init.headers as Record<string, string>
52
+ expect(headers['X-Server-Password']).toBeUndefined()
53
+ })
54
+
55
+ it('sends GET params as query string', async () => {
56
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
57
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
58
+ await http.request('GET', '/api/game/time', { shard: 'shard0' })
59
+ const [url] = fetchMock.mock.calls[0] as [string]
60
+ expect(url).toContain('shard=shard0')
61
+ })
62
+
63
+ it('omits null GET params from query string', async () => {
64
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
65
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
66
+ await http.request('GET', '/api/game/time', { shard: null, room: 'E9N3' })
67
+ const [url] = fetchMock.mock.calls[0] as [string]
68
+ expect(url).not.toContain('shard')
69
+ expect(url).toContain('room=E9N3')
70
+ })
71
+
72
+ it('sends POST body as JSON', async () => {
73
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
74
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
75
+ await http.request('POST', '/api/user/console', { expression: 'Game.time', shard: 'shard0' })
76
+ const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
77
+ expect(JSON.parse(init.body as string)).toEqual({ expression: 'Game.time', shard: 'shard0' })
78
+ })
79
+
80
+ it('updates token from x-token response header', async () => {
81
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
82
+ headers: { 'content-type': 'application/json', 'x-token': 'refreshed' },
83
+ }))
84
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'old' }) })
85
+ http.token = 'old'
86
+ await http.request('GET', '/api/version')
87
+ expect(http.token).toBe('refreshed')
88
+ })
89
+
90
+ it('retries once on 401 after re-authenticating', async () => {
91
+ let calls = 0
92
+ fetchMock.mockImplementation(() => {
93
+ calls++
94
+ if (calls === 1) return Promise.resolve(new Response('Unauthorized', { status: 401 }))
95
+ return Promise.resolve(mockResponse({ ok: 1 }))
96
+ })
97
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'tok' }) })
98
+ http.token = 'tok'
99
+ await http.request('GET', '/api/version')
100
+ expect(calls).toBe(2)
101
+ })
102
+
103
+ it('throws immediately on 401 during authenticate() — no recursion', async () => {
104
+ fetchMock.mockResolvedValue(new Response('Unauthorized', { status: 401 }))
105
+ const badAuth = {
106
+ authenticate: (http: HttpClient) => http.request<{ token: string }>('POST', '/api/auth/signin', { email: 'x', password: 'wrong' }).then(r => r.token),
107
+ }
108
+ const http = new HttpClient({ url: 'http://test.local', auth: badAuth })
109
+ await expect(http.authenticate()).rejects.toThrow('HTTP 401')
110
+ // fetchMock called exactly once — no retry loop
111
+ expect(fetchMock).toHaveBeenCalledOnce()
112
+ })
113
+
114
+ it('does not retry a second time if re-auth 401 persists', async () => {
115
+ fetchMock.mockResolvedValue(new Response('Unauthorized', { status: 401 }))
116
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'tok' }) })
117
+ http.token = 'tok'
118
+ await expect(http.request('GET', '/api/version')).rejects.toThrow('HTTP 401')
119
+ })
120
+
121
+ it('throws on non-401 error status', async () => {
122
+ fetchMock.mockResolvedValue(new Response('Server Error', { status: 500 }))
123
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
124
+ await expect(http.request('GET', '/api/version')).rejects.toThrow('HTTP 500')
125
+ })
126
+
127
+ it('decompresses gz: data field', async () => {
128
+ // Non-gz response data passes through unchanged
129
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1, data: { result: 42 } }))
130
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
131
+ const res = await http.request<{ ok: number; data: { result: number } }>('GET', '/api/test')
132
+ expect(res.data.result).toBe(42)
133
+ })
134
+
135
+ it('emits http:tokenRefresh when x-token header is present', async () => {
136
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }, {
137
+ headers: { 'content-type': 'application/json', 'x-token': 'new-tok' },
138
+ }))
139
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 'old' }) })
140
+ const handler = vi.fn()
141
+ http.on('http:tokenRefresh', handler)
142
+ await http.request('GET', '/api/version')
143
+ expect(handler).toHaveBeenCalledWith({ token: 'new-tok' })
144
+ })
145
+
146
+ it('emits http:success on 200 response', async () => {
147
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1 }))
148
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
149
+ const handler = vi.fn()
150
+ http.on('http:success', handler)
151
+ await http.request('GET', '/api/version')
152
+ expect(handler).toHaveBeenCalledWith({ method: 'GET', path: '/api/version', status: 200 })
153
+ })
154
+
155
+ it('emits http:error on non-2xx response', async () => {
156
+ fetchMock.mockResolvedValue(new Response('Server Error', { status: 500 }))
157
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
158
+ const handler = vi.fn()
159
+ http.on('http:error', handler)
160
+ await expect(http.request('GET', '/api/version')).rejects.toThrow('HTTP 500')
161
+ expect(handler).toHaveBeenCalledOnce()
162
+ const arg = handler.mock.calls[0][0]
163
+ expect(arg.method).toBe('GET')
164
+ expect(arg.path).toBe('/api/version')
165
+ expect(arg.status).toBe(500)
166
+ expect(arg.error).toBeInstanceOf(Error)
167
+ })
168
+
169
+ it('throws on 200 with error in response body', async () => {
170
+ fetchMock.mockResolvedValue(mockResponse({ ok: 0, error: 'invalid params' }))
171
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
172
+ await expect(http.request('GET', '/api/game/create-flag')).rejects.toThrow('Screeps API error: invalid params')
173
+ })
174
+
175
+ it('emits http:error on 200 with error in response body', async () => {
176
+ fetchMock.mockResolvedValue(mockResponse({ ok: 0, error: 'flags limit exceeded' }))
177
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
178
+ const handler = vi.fn()
179
+ http.on('http:error', handler)
180
+ await expect(http.request('POST', '/api/game/create-flag')).rejects.toThrow('Screeps API error: flags limit exceeded')
181
+ expect(handler).toHaveBeenCalledOnce()
182
+ const arg = handler.mock.calls[0][0]
183
+ expect(arg.method).toBe('POST')
184
+ expect(arg.path).toBe('/api/game/create-flag')
185
+ expect(arg.status).toBe(200)
186
+ expect(arg.error).toBeInstanceOf(Error)
187
+ })
188
+ })
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createGzip, createDeflate } from 'node:zlib'
3
+ import { promisify } from 'node:util'
4
+ import { pipeline } from 'node:stream'
5
+ import { Readable } from 'node:stream'
6
+ import { decompressGzip, decompressZlib } from '../../src/http/decompress.js'
7
+
8
+ const pipelineAsync = promisify(pipeline)
9
+
10
+ async function gzipEncode(json: unknown): Promise<string> {
11
+ const input = Buffer.from(JSON.stringify(json))
12
+ const chunks: Buffer[] = []
13
+ const gz = createGzip()
14
+ await pipelineAsync(Readable.from(input), gz, async function*(source) {
15
+ for await (const chunk of source) {
16
+ chunks.push(chunk as Buffer)
17
+ yield chunk
18
+ }
19
+ })
20
+ return 'gz:' + Buffer.concat(chunks).toString('base64')
21
+ }
22
+
23
+ async function zlibEncode(json: unknown): Promise<string> {
24
+ const input = Buffer.from(JSON.stringify(json))
25
+ const chunks: Buffer[] = []
26
+ const def = createDeflate()
27
+ await pipelineAsync(Readable.from(input), def, async function*(source) {
28
+ for await (const chunk of source) {
29
+ chunks.push(chunk as Buffer)
30
+ yield chunk
31
+ }
32
+ })
33
+ return 'gz:' + Buffer.concat(chunks).toString('base64')
34
+ }
35
+
36
+ describe('decompressGzip', () => {
37
+ it('decompresses a gzip-encoded gz: string', async () => {
38
+ const payload = { message: 'hello', value: 42 }
39
+ const encoded = await gzipEncode(payload)
40
+ const result = await decompressGzip(encoded)
41
+ expect(result).toEqual(payload)
42
+ })
43
+ })
44
+
45
+ describe('decompressZlib', () => {
46
+ it('decompresses a zlib-encoded gz: string', async () => {
47
+ const payload = [{ channel: 'user:x/cpu', data: { cpu: 10 } }]
48
+ const encoded = await zlibEncode(payload)
49
+ const result = await decompressZlib(encoded)
50
+ expect(result).toEqual(payload)
51
+ })
52
+ })
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { HttpClient } from '../../../src/http/HttpClient.js'
3
+ import { TokenAuth } from '../../../src/http/auth/TokenAuth.js'
4
+ import { SteamTicketAuth } from '../../../src/http/auth/SteamTicketAuth.js'
5
+
6
+ function mockResponse(body: unknown, opts: ResponseInit = {}): Response {
7
+ return new Response(JSON.stringify(body), {
8
+ status: 200,
9
+ headers: { 'content-type': 'application/json', ...opts.headers },
10
+ ...opts,
11
+ })
12
+ }
13
+
14
+ describe('auth endpoints', () => {
15
+ let fetchMock: ReturnType<typeof vi.fn>
16
+ let http: HttpClient
17
+
18
+ beforeEach(() => {
19
+ fetchMock = vi.fn().mockResolvedValue(mockResponse({ ok: 1 }))
20
+ vi.stubGlobal('fetch', fetchMock)
21
+ http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
22
+ })
23
+
24
+ afterEach(() => {
25
+ vi.unstubAllGlobals()
26
+ })
27
+
28
+ it('steamTicket sends POST with ticket', async () => {
29
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1, token: 'tok', steamid: 'steam123' }))
30
+ const res = await http.auth.steamTicket('ticket-value')
31
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
32
+ expect(init.method).toBe('POST')
33
+ expect(url).toContain('/api/auth/steam-ticket')
34
+ expect(JSON.parse(init.body as string)).toEqual({ ticket: 'ticket-value' })
35
+ expect(res.token).toBe('tok')
36
+ expect(res.steamid).toBe('steam123')
37
+ })
38
+
39
+ it('steamTicket includes useNativeAuth when provided', async () => {
40
+ fetchMock.mockResolvedValue(mockResponse({ ok: 1, token: 'tok', steamid: 'steam123' }))
41
+ await http.auth.steamTicket('ticket-value', true)
42
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body as string)
43
+ expect(body).toEqual({ ticket: 'ticket-value', useNativeAuth: true })
44
+ })
45
+ })
46
+
47
+ describe('SteamTicketAuth strategy', () => {
48
+ let fetchMock: ReturnType<typeof vi.fn>
49
+
50
+ beforeEach(() => {
51
+ fetchMock = vi.fn()
52
+ vi.stubGlobal('fetch', fetchMock)
53
+ })
54
+
55
+ afterEach(() => {
56
+ vi.unstubAllGlobals()
57
+ })
58
+
59
+ it('authenticate() calls steam-ticket and returns the token', async () => {
60
+ fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: 1, token: 'steam-tok', steamid: 'sid' }), {
61
+ status: 200, headers: { 'content-type': 'application/json' },
62
+ }))
63
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: '' }) })
64
+ const strategy = new SteamTicketAuth({ ticket: 'my-steam-ticket' })
65
+ const token = await strategy.authenticate(http)
66
+ expect(token).toBe('steam-tok')
67
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body as string)
68
+ expect(body).toEqual({ ticket: 'my-steam-ticket' })
69
+ })
70
+
71
+ it('passes useNativeAuth when set', async () => {
72
+ fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: 1, token: 't', steamid: 's' }), {
73
+ status: 200, headers: { 'content-type': 'application/json' },
74
+ }))
75
+ const http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: '' }) })
76
+ const strategy = new SteamTicketAuth({ ticket: 'ticket', useNativeAuth: true })
77
+ await strategy.authenticate(http)
78
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body as string)
79
+ expect(body.useNativeAuth).toBe(true)
80
+ })
81
+ })
82
+
83
+ describe('register endpoints', () => {
84
+ let fetchMock: ReturnType<typeof vi.fn>
85
+ let http: HttpClient
86
+
87
+ beforeEach(() => {
88
+ fetchMock = vi.fn().mockResolvedValue(mockResponse({ ok: 1 }))
89
+ vi.stubGlobal('fetch', fetchMock)
90
+ http = new HttpClient({ url: 'http://test.local', auth: new TokenAuth({ token: 't' }) })
91
+ })
92
+
93
+ afterEach(() => {
94
+ vi.unstubAllGlobals()
95
+ })
96
+
97
+ it('checkEmail sends GET with email query param', async () => {
98
+ await http.register.checkEmail('test@example.com')
99
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
100
+ expect(init.method).toBe('GET')
101
+ expect(url).toContain('/api/register/check-email')
102
+ expect(url).toContain('email=test%40example.com')
103
+ })
104
+
105
+ it('checkUsername sends GET with username query param', async () => {
106
+ await http.register.checkUsername('Tigga')
107
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
108
+ expect(init.method).toBe('GET')
109
+ expect(url).toContain('/api/register/check-username')
110
+ expect(url).toContain('username=Tigga')
111
+ })
112
+
113
+ it('setUsername sends POST with username', async () => {
114
+ await http.register.setUsername('Tigga')
115
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
116
+ expect(init.method).toBe('POST')
117
+ expect(url).toContain('/api/register/set-username')
118
+ expect(JSON.parse(init.body as string)).toEqual({ username: 'Tigga' })
119
+ })
120
+
121
+ it('setUsername includes email when provided', async () => {
122
+ await http.register.setUsername('Tigga', 'tigga@example.com')
123
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body as string)
124
+ expect(body).toEqual({ username: 'Tigga', email: 'tigga@example.com' })
125
+ })
126
+ })