@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/testing",
3
- "version": "1.0.0-alpha.24",
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
- "./postgres": "./src/postgres/index.ts"
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.24",
25
- "@strav/kernel": "1.0.0-alpha.24"
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.24",
30
+ "@strav/brain": "1.0.0-alpha.27",
29
31
  "@types/bun": ">=1.3.14"
30
32
  },
31
33
  "peerDependenciesMeta": {
@@ -0,0 +1,2 @@
1
+ export { isMemcachedAvailable } from './is_memcached_available.ts'
2
+ export { isRedisAvailable } from './is_redis_available.ts'
@@ -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,3 @@
1
+ export { createTempStorageRoot, type TempStorageRoot } from './create_temp_storage_root.ts'
2
+ export { ensureS3Bucket } from './ensure_s3_bucket.ts'
3
+ export { isS3Available } from './is_s3_available.ts'
@@ -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
+ }