@strav/testing 1.0.0-alpha.24 → 1.0.0-alpha.27
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/package.json +7 -5
- package/src/cache/index.ts +2 -0
- package/src/cache/is_memcached_available.ts +75 -0
- package/src/cache/is_redis_available.ts +39 -0
- package/src/index.ts +12 -0
- package/src/storage/create_temp_storage_root.ts +24 -0
- package/src/storage/ensure_s3_bucket.ts +53 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/is_s3_available.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/testing",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.27",
|
|
4
4
|
"description": "Strav testing utilities — in-memory stream, typed fetch stub, Postgres availability probe + schema reset, bootTestApp orchestrator, stub providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./brain": "./src/brain/index.ts",
|
|
11
|
-
"./
|
|
11
|
+
"./cache": "./src/cache/index.ts",
|
|
12
|
+
"./postgres": "./src/postgres/index.ts",
|
|
13
|
+
"./storage": "./src/storage/index.ts"
|
|
12
14
|
},
|
|
13
15
|
"files": [
|
|
14
16
|
"src",
|
|
@@ -21,11 +23,11 @@
|
|
|
21
23
|
"access": "public"
|
|
22
24
|
},
|
|
23
25
|
"dependencies": {
|
|
24
|
-
"@strav/database": "1.0.0-alpha.
|
|
25
|
-
"@strav/kernel": "1.0.0-alpha.
|
|
26
|
+
"@strav/database": "1.0.0-alpha.27",
|
|
27
|
+
"@strav/kernel": "1.0.0-alpha.27"
|
|
26
28
|
},
|
|
27
29
|
"peerDependencies": {
|
|
28
|
-
"@strav/brain": "1.0.0-alpha.
|
|
30
|
+
"@strav/brain": "1.0.0-alpha.27",
|
|
29
31
|
"@types/bun": ">=1.3.14"
|
|
30
32
|
},
|
|
31
33
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap connection probe over Bun's TCP socket — opens a connection,
|
|
3
|
+
* sends `version\r\n`, expects a `VERSION ...\r\n` reply. Cached for
|
|
4
|
+
* the lifetime of the test process.
|
|
5
|
+
*
|
|
6
|
+
* Returns `false` if `MEMCACHED_HOST` / `MEMCACHED_PORT` are missing
|
|
7
|
+
* OR the probe fails. Pair with
|
|
8
|
+
* `describe.skipIf(!await isMemcachedAvailable())`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let cachedAvailability: boolean | null = null
|
|
12
|
+
|
|
13
|
+
export async function isMemcachedAvailable(): Promise<boolean> {
|
|
14
|
+
if (cachedAvailability !== null) return cachedAvailability
|
|
15
|
+
const host = process.env['MEMCACHED_HOST']
|
|
16
|
+
const portStr = process.env['MEMCACHED_PORT']
|
|
17
|
+
if (host === undefined || host === '' || portStr === undefined || portStr === '') {
|
|
18
|
+
cachedAvailability = false
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
const port = Number(portStr)
|
|
22
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
23
|
+
cachedAvailability = false
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
cachedAvailability = await probe(host, port)
|
|
28
|
+
} catch {
|
|
29
|
+
cachedAvailability = false
|
|
30
|
+
}
|
|
31
|
+
return cachedAvailability
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function probe(host: string, port: number): Promise<boolean> {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
let settled = false
|
|
37
|
+
const finish = (ok: boolean): void => {
|
|
38
|
+
if (settled) return
|
|
39
|
+
settled = true
|
|
40
|
+
resolve(ok)
|
|
41
|
+
}
|
|
42
|
+
const timeout = setTimeout(() => finish(false), 2_000)
|
|
43
|
+
void Bun.connect({
|
|
44
|
+
hostname: host,
|
|
45
|
+
port,
|
|
46
|
+
socket: {
|
|
47
|
+
open(socket) {
|
|
48
|
+
socket.write('version\r\n')
|
|
49
|
+
},
|
|
50
|
+
data(socket, chunk) {
|
|
51
|
+
const bytes = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
52
|
+
const text = new TextDecoder().decode(bytes)
|
|
53
|
+
const ok = text.startsWith('VERSION')
|
|
54
|
+
// Settle before closing — `socket.end()` synchronously fires
|
|
55
|
+
// the `close` callback, which would beat `finish(ok)` to the
|
|
56
|
+
// settle line and report a false negative.
|
|
57
|
+
clearTimeout(timeout)
|
|
58
|
+
finish(ok)
|
|
59
|
+
socket.end()
|
|
60
|
+
},
|
|
61
|
+
error(_socket, _error) {
|
|
62
|
+
clearTimeout(timeout)
|
|
63
|
+
finish(false)
|
|
64
|
+
},
|
|
65
|
+
close() {
|
|
66
|
+
clearTimeout(timeout)
|
|
67
|
+
if (!settled) finish(false)
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
}).catch(() => {
|
|
71
|
+
clearTimeout(timeout)
|
|
72
|
+
finish(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap connection probe — opens `Bun.RedisClient` against
|
|
3
|
+
* `REDIS_URL`, sends `PING`, reports. Cached for the lifetime of the
|
|
4
|
+
* test process.
|
|
5
|
+
*
|
|
6
|
+
* Returns `false` if `REDIS_URL` is missing OR the connection / PING
|
|
7
|
+
* fails. Pair with `describe.skipIf(!await isRedisAvailable())`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { RedisClient } from 'bun'
|
|
11
|
+
|
|
12
|
+
let cachedAvailability: boolean | null = null
|
|
13
|
+
|
|
14
|
+
export async function isRedisAvailable(): Promise<boolean> {
|
|
15
|
+
if (cachedAvailability !== null) return cachedAvailability
|
|
16
|
+
const url = process.env['REDIS_URL']
|
|
17
|
+
if (url === undefined || url === '') {
|
|
18
|
+
cachedAvailability = false
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
let client: RedisClient | undefined
|
|
22
|
+
try {
|
|
23
|
+
client = new RedisClient(url)
|
|
24
|
+
// `send('PING', [])` is supported on every Bun.RedisClient build —
|
|
25
|
+
// safer than `ping()` which isn't on the typed surface.
|
|
26
|
+
const reply = await client.send('PING', [])
|
|
27
|
+
cachedAvailability = reply === 'PONG' || reply === 'OK' || typeof reply === 'string'
|
|
28
|
+
return cachedAvailability
|
|
29
|
+
} catch {
|
|
30
|
+
cachedAvailability = false
|
|
31
|
+
return false
|
|
32
|
+
} finally {
|
|
33
|
+
try {
|
|
34
|
+
client?.close()
|
|
35
|
+
} catch {
|
|
36
|
+
// Already closed / never connected — nothing to clean.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,3 +27,15 @@ export {
|
|
|
27
27
|
resetSchema,
|
|
28
28
|
testDatabaseUrl,
|
|
29
29
|
} from './postgres/index.ts'
|
|
30
|
+
|
|
31
|
+
// Cache availability probes — also re-exported under
|
|
32
|
+
// `@strav/testing/cache` for the same reason.
|
|
33
|
+
export { isMemcachedAvailable, isRedisAvailable } from './cache/index.ts'
|
|
34
|
+
|
|
35
|
+
// Storage helpers — also re-exported under `@strav/testing/storage`.
|
|
36
|
+
export {
|
|
37
|
+
createTempStorageRoot,
|
|
38
|
+
ensureS3Bucket,
|
|
39
|
+
isS3Available,
|
|
40
|
+
type TempStorageRoot,
|
|
41
|
+
} from './storage/index.ts'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a temp dir for `LocalStorage` integration tests. Returns the
|
|
3
|
+
* path and a cleanup function. Tests use this in `beforeAll` so the
|
|
4
|
+
* test suite doesn't litter `storage/` paths in the repo.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
export interface TempStorageRoot {
|
|
12
|
+
path: string
|
|
13
|
+
cleanup(): Promise<void>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function createTempStorageRoot(): Promise<TempStorageRoot> {
|
|
17
|
+
const path = await mkdtemp(join(tmpdir(), 'strav-storage-'))
|
|
18
|
+
return {
|
|
19
|
+
path,
|
|
20
|
+
async cleanup() {
|
|
21
|
+
await rm(path, { recursive: true, force: true })
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensure the configured test bucket exists. Creates it on MinIO via
|
|
3
|
+
* the admin endpoint when it's missing — AWS / R2 users typically
|
|
4
|
+
* create the bucket out of band, so this helper is a no-op when the
|
|
5
|
+
* list call already succeeds.
|
|
6
|
+
*
|
|
7
|
+
* Returns the bucket name on success. Throws if the bucket can't be
|
|
8
|
+
* reached or created.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { S3Client } from 'bun'
|
|
12
|
+
|
|
13
|
+
export async function ensureS3Bucket(): Promise<string> {
|
|
14
|
+
const endpoint = process.env['S3_ENDPOINT']
|
|
15
|
+
const bucket = process.env['S3_BUCKET']
|
|
16
|
+
const accessKeyId = process.env['S3_ACCESS_KEY_ID']
|
|
17
|
+
const secretAccessKey = process.env['S3_SECRET_ACCESS_KEY']
|
|
18
|
+
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'ensureS3Bucket: missing S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY env. Source .env.test or run docker-compose up.',
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
const client = new S3Client({
|
|
24
|
+
endpoint,
|
|
25
|
+
bucket,
|
|
26
|
+
accessKeyId,
|
|
27
|
+
secretAccessKey,
|
|
28
|
+
region: process.env['S3_REGION'] ?? 'us-east-1',
|
|
29
|
+
})
|
|
30
|
+
try {
|
|
31
|
+
await client.list({ maxKeys: 1 })
|
|
32
|
+
return bucket
|
|
33
|
+
} catch {
|
|
34
|
+
// Try MinIO-style auto-create via a PUT to the bucket root.
|
|
35
|
+
try {
|
|
36
|
+
const url = `${endpoint.replace(/\/$/, '')}/${bucket}`
|
|
37
|
+
// MinIO accepts an empty PUT against the bucket URL with the
|
|
38
|
+
// default region. Signing a CreateBucket call by hand is more
|
|
39
|
+
// work than this slice warrants — we shell to the s3 list path
|
|
40
|
+
// again after a best-effort PUT and report success on either
|
|
41
|
+
// outcome.
|
|
42
|
+
await fetch(url, { method: 'PUT' }).catch(() => undefined)
|
|
43
|
+
await client.list({ maxKeys: 1 })
|
|
44
|
+
return bucket
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`ensureS3Bucket: bucket "${bucket}" does not exist at ${endpoint} and could not be created. ` +
|
|
48
|
+
'Create it manually via the MinIO console (http://localhost:9001) or your provider.',
|
|
49
|
+
{ cause },
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap connection probe over `Bun.S3Client`. Opens against
|
|
3
|
+
* `S3_ENDPOINT` + creds, attempts to list one key, ensures the
|
|
4
|
+
* configured bucket exists (creates it lazily on MinIO when it
|
|
5
|
+
* doesn't). Cached for the lifetime of the test process.
|
|
6
|
+
*
|
|
7
|
+
* Returns `false` if env is missing OR the probe fails. Pair with
|
|
8
|
+
* `describe.skipIf(!await isS3Available())`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { S3Client } from 'bun'
|
|
12
|
+
|
|
13
|
+
let cachedAvailability: boolean | null = null
|
|
14
|
+
|
|
15
|
+
export async function isS3Available(): Promise<boolean> {
|
|
16
|
+
if (cachedAvailability !== null) return cachedAvailability
|
|
17
|
+
const endpoint = process.env['S3_ENDPOINT']
|
|
18
|
+
const bucket = process.env['S3_BUCKET']
|
|
19
|
+
const accessKeyId = process.env['S3_ACCESS_KEY_ID']
|
|
20
|
+
const secretAccessKey = process.env['S3_SECRET_ACCESS_KEY']
|
|
21
|
+
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
|
22
|
+
cachedAvailability = false
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const client = new S3Client({
|
|
27
|
+
endpoint,
|
|
28
|
+
bucket,
|
|
29
|
+
accessKeyId,
|
|
30
|
+
secretAccessKey,
|
|
31
|
+
region: process.env['S3_REGION'] ?? 'us-east-1',
|
|
32
|
+
})
|
|
33
|
+
// Probe: list one key. If the bucket doesn't exist, listing will
|
|
34
|
+
// throw; ensure-bucket via writing+deleting a sentinel works on
|
|
35
|
+
// MinIO (bucket auto-create defaults vary), but we lean on the
|
|
36
|
+
// user creating the bucket beforehand for AWS/R2 to avoid
|
|
37
|
+
// surprises. On MinIO the suite uses an ensure helper that's
|
|
38
|
+
// called separately by the test setup.
|
|
39
|
+
await client.list({ maxKeys: 1 })
|
|
40
|
+
cachedAvailability = true
|
|
41
|
+
return true
|
|
42
|
+
} catch {
|
|
43
|
+
cachedAvailability = false
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|