@rip-lang/server 1.3.98 → 1.3.100

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/tests/acme.rip ADDED
@@ -0,0 +1,124 @@
1
+ # test-acme.rip — ACME challenges, store, and crypto tests
2
+
3
+ import { test, eq, ok } from '../test.rip'
4
+ import { createChallengeStore, handleChallengeRequest } from '../acme/store.rip'
5
+ import { defaultCertDir, ensureDir, saveCert, loadCert, needsRenewal, listCerts } from '../acme/store.rip'
6
+ import { b64url, generateAccountKeyPair, exportPublicJwk, thumbprint, signJws } from '../acme/crypto.rip'
7
+ import { mkdtempSync, rmSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ # --- Challenge store ---
12
+
13
+ test "createChallengeStore set/get/has/remove", ->
14
+ store = createChallengeStore()
15
+ eq store.has('tok1'), false
16
+ store.set('tok1', 'auth1')
17
+ eq store.has('tok1'), true
18
+ eq store.get('tok1'), 'auth1'
19
+ store.remove('tok1')
20
+ eq store.has('tok1'), false
21
+ eq store.get('tok1'), null
22
+
23
+ test "handleChallengeRequest serves token", ->
24
+ store = createChallengeStore()
25
+ store.set('abc123', 'abc123.thumbprint')
26
+ url = new URL('http://example.com/.well-known/acme-challenge/abc123')
27
+ res = handleChallengeRequest(url, store)
28
+ ok res
29
+ eq res.status, 200
30
+
31
+ test "handleChallengeRequest returns null for unknown token", ->
32
+ store = createChallengeStore()
33
+ url = new URL('http://example.com/.well-known/acme-challenge/unknown')
34
+ res = handleChallengeRequest(url, store)
35
+ eq res, null
36
+
37
+ test "handleChallengeRequest returns null for non-challenge path", ->
38
+ store = createChallengeStore()
39
+ url = new URL('http://example.com/other/path')
40
+ res = handleChallengeRequest(url, store)
41
+ eq res, null
42
+
43
+ # --- Cert store ---
44
+
45
+ test "saveCert and loadCert round-trip", ->
46
+ dir = mkdtempSync(join(tmpdir(), 'rip-test-'))
47
+ saveCert(dir, 'test.com', 'CERT-PEM', 'KEY-PEM')
48
+ rec = loadCert(dir, 'test.com')
49
+ ok rec
50
+ eq rec.cert, 'CERT-PEM'
51
+ eq rec.key, 'KEY-PEM'
52
+ rmSync(dir, { recursive: true })
53
+
54
+ test "loadCert returns null for missing domain", ->
55
+ dir = mkdtempSync(join(tmpdir(), 'rip-test-'))
56
+ rec = loadCert(dir, 'missing.com')
57
+ eq rec, null
58
+ rmSync(dir, { recursive: true })
59
+
60
+ test "needsRenewal returns true for missing cert", ->
61
+ dir = mkdtempSync(join(tmpdir(), 'rip-test-'))
62
+ eq needsRenewal(dir, 'missing.com'), true
63
+ rmSync(dir, { recursive: true })
64
+
65
+ test "listCerts returns domain directories", ->
66
+ dir = mkdtempSync(join(tmpdir(), 'rip-test-'))
67
+ saveCert(dir, 'a.com', 'C', 'K')
68
+ saveCert(dir, 'b.com', 'C', 'K')
69
+ certs = listCerts(dir)
70
+ ok certs.includes('a.com')
71
+ ok certs.includes('b.com')
72
+ rmSync(dir, { recursive: true })
73
+
74
+ test "defaultCertDir returns a path", ->
75
+ d = defaultCertDir()
76
+ ok typeof d is 'string'
77
+ ok d.includes('.rip')
78
+
79
+ # --- Crypto ---
80
+
81
+ test "b64url encodes correctly", ->
82
+ eq b64url('hello'), 'aGVsbG8'
83
+ eq b64url(''), ''
84
+
85
+ test "b64url strips padding", ->
86
+ encoded = b64url('a')
87
+ eq encoded.includes('='), false
88
+
89
+ test "generateAccountKeyPair returns keypair", ->
90
+ pair = generateAccountKeyPair()
91
+ ok pair.publicKey
92
+ ok pair.privateKey
93
+
94
+ test "exportPublicJwk returns EC P-256 JWK", ->
95
+ pair = generateAccountKeyPair()
96
+ jwk = exportPublicJwk(pair)
97
+ eq jwk.kty, 'EC'
98
+ eq jwk.crv, 'P-256'
99
+ ok jwk.x
100
+ ok jwk.y
101
+
102
+ test "thumbprint returns 43-char base64url", ->
103
+ pair = generateAccountKeyPair()
104
+ jwk = exportPublicJwk(pair)
105
+ tp = thumbprint(jwk)
106
+ eq tp.length, 43
107
+
108
+ test "signJws returns valid JWS structure", ->
109
+ pair = generateAccountKeyPair()
110
+ jwk = exportPublicJwk(pair)
111
+ jws = signJws({ test: true }, 'https://example.com', 'nonce1', pair.privateKey, jwk, null)
112
+ parsed = JSON.parse(jws)
113
+ ok parsed.protected
114
+ ok parsed.payload
115
+ ok parsed.signature
116
+
117
+ test "signJws with kid omits jwk from header", ->
118
+ pair = generateAccountKeyPair()
119
+ jwk = exportPublicJwk(pair)
120
+ jws = signJws({}, 'https://example.com', 'nonce1', pair.privateKey, jwk, 'https://acme/acct/1')
121
+ parsed = JSON.parse(jws)
122
+ header = JSON.parse(Buffer.from(parsed.protected.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString())
123
+ eq header.kid, 'https://acme/acct/1'
124
+ eq header.jwk, undefined
@@ -0,0 +1,90 @@
1
+ # test-helpers.rip — edge/helpers and forwarding response factory tests
2
+
3
+ import { test, eq, ok } from '../test.rip'
4
+ import { formatPort, maybeAddSecurityHeaders, buildStatusBody, buildRipdevUrl } from '../edge/forwarding.rip'
5
+ import { watchUnavailableResponse, serverBusyResponse, serviceUnavailableResponse, gatewayTimeoutResponse, queueTimeoutResponse } from '../edge/forwarding.rip'
6
+
7
+ # --- formatPort ---
8
+
9
+ test "formatPort hides default HTTPS port", ->
10
+ eq formatPort('https', 443), ''
11
+
12
+ test "formatPort hides default HTTP port", ->
13
+ eq formatPort('http', 80), ''
14
+
15
+ test "formatPort shows non-default port", ->
16
+ eq formatPort('https', 3443), ':3443'
17
+ eq formatPort('http', 8080), ':8080'
18
+
19
+ # --- maybeAddSecurityHeaders ---
20
+
21
+ test "maybeAddSecurityHeaders sets HSTS when active", ->
22
+ headers = new Headers()
23
+ maybeAddSecurityHeaders(true, true, headers)
24
+ ok headers.has('strict-transport-security')
25
+
26
+ test "maybeAddSecurityHeaders skips when not HTTPS", ->
27
+ headers = new Headers()
28
+ maybeAddSecurityHeaders(false, true, headers)
29
+ eq headers.has('strict-transport-security'), false
30
+
31
+ test "maybeAddSecurityHeaders skips when HSTS disabled", ->
32
+ headers = new Headers()
33
+ maybeAddSecurityHeaders(true, false, headers)
34
+ eq headers.has('strict-transport-security'), false
35
+
36
+ test "maybeAddSecurityHeaders does not overwrite existing", ->
37
+ headers = new Headers({ 'strict-transport-security': 'custom' })
38
+ maybeAddSecurityHeaders(true, true, headers)
39
+ eq headers.get('strict-transport-security'), 'custom'
40
+
41
+ # --- buildRipdevUrl ---
42
+
43
+ test "buildRipdevUrl with default port", ->
44
+ url = buildRipdevUrl('myapp', 'https', 443, formatPort)
45
+ eq url, 'https://myapp.ripdev.io'
46
+
47
+ test "buildRipdevUrl with custom port", ->
48
+ url = buildRipdevUrl('myapp', 'https', 3443, formatPort)
49
+ eq url, 'https://myapp.ripdev.io:3443'
50
+
51
+ # --- buildStatusBody ---
52
+
53
+ test "buildStatusBody returns valid JSON", ->
54
+ hostIndex = new Map([['localhost', 'app1']])
55
+ body = buildStatusBody(Date.now() - 5000, 2, hostIndex, '1.0.0', 'myapp', 80, 443, (-> Date.now()))
56
+ parsed = JSON.parse(body)
57
+ eq parsed.status, 'healthy'
58
+ eq parsed.workers, 2
59
+ eq parsed.app, 'myapp'
60
+ ok parsed.hosts.includes('localhost')
61
+
62
+ test "buildStatusBody degraded when no workers", ->
63
+ hostIndex = new Map()
64
+ body = buildStatusBody(Date.now(), 0, hostIndex, '1.0', 'app', 80, null, (-> Date.now()))
65
+ parsed = JSON.parse(body)
66
+ eq parsed.status, 'degraded'
67
+
68
+ # --- Response factories ---
69
+
70
+ test "watchUnavailableResponse returns 503", ->
71
+ res = watchUnavailableResponse()
72
+ eq res.status, 503
73
+ eq res.headers.get('Retry-After'), '2'
74
+
75
+ test "serverBusyResponse returns 503", ->
76
+ res = serverBusyResponse()
77
+ eq res.status, 503
78
+ eq res.headers.get('Retry-After'), '1'
79
+
80
+ test "serviceUnavailableResponse returns 503", ->
81
+ res = serviceUnavailableResponse()
82
+ eq res.status, 503
83
+
84
+ test "gatewayTimeoutResponse returns 504", ->
85
+ res = gatewayTimeoutResponse()
86
+ eq res.status, 504
87
+
88
+ test "queueTimeoutResponse returns 504", ->
89
+ res = queueTimeoutResponse()
90
+ eq res.status, 504
@@ -0,0 +1,73 @@
1
+ # ==============================================================================
2
+ # test-metrics.rip — edge/metrics.rip tests
3
+ # ==============================================================================
4
+
5
+ import { test, eq, ok, near } from '../test.rip'
6
+ import { createMetrics } from '../edge/metrics.rip'
7
+
8
+ test "createMetrics returns object with counters", ->
9
+ m = createMetrics()
10
+ eq m.requests, 0
11
+ eq m.forwarded, 0
12
+ eq m.responses2xx, 0
13
+ eq m.queueShed, 0
14
+
15
+ test "counter increments", ->
16
+ m = createMetrics()
17
+ m.requests++
18
+ m.requests++
19
+ m.forwarded++
20
+ eq m.requests, 2
21
+ eq m.forwarded, 1
22
+
23
+ test "recordStatus tracks 2xx", ->
24
+ m = createMetrics()
25
+ m.recordStatus(200)
26
+ m.recordStatus(201)
27
+ m.recordStatus(204)
28
+ eq m.responses2xx, 3
29
+ eq m.responses4xx, 0
30
+
31
+ test "recordStatus tracks 4xx and 5xx", ->
32
+ m = createMetrics()
33
+ m.recordStatus(404)
34
+ m.recordStatus(500)
35
+ m.recordStatus(503)
36
+ eq m.responses4xx, 1
37
+ eq m.responses5xx, 2
38
+
39
+ test "recordLatency and percentiles with single value", ->
40
+ m = createMetrics()
41
+ m.recordLatency(0.05)
42
+ p = m.percentiles()
43
+ near p.p50, 0.05
44
+ near p.p95, 0.05
45
+ near p.p99, 0.05
46
+
47
+ test "percentiles with multiple values", ->
48
+ m = createMetrics()
49
+ for i in [1..100]
50
+ m.recordLatency(i / 1000)
51
+ p = m.percentiles()
52
+ ok p.p50 >= 0.045 and p.p50 <= 0.055
53
+ ok p.p95 >= 0.090 and p.p95 <= 0.100
54
+ ok p.p99 >= 0.095 and p.p99 <= 0.105
55
+
56
+ test "percentiles empty returns zeros", ->
57
+ m = createMetrics()
58
+ p = m.percentiles()
59
+ eq p.p50, 0
60
+ eq p.p95, 0
61
+ eq p.p99, 0
62
+
63
+ test "snapshot returns expected shape", ->
64
+ m = createMetrics()
65
+ m.requests = 100
66
+ m.responses2xx = 90
67
+ m.responses5xx = 10
68
+ snap = m.snapshot(Date.now() - 60000, null)
69
+ ok snap.uptime >= 59 and snap.uptime <= 61
70
+ eq snap.counters.requests, 100
71
+ eq snap.counters.responses['2xx'], 90
72
+ eq snap.counters.responses['5xx'], 10
73
+ eq snap.apps, []
@@ -0,0 +1,99 @@
1
+ # proxy.rip — reverse proxy and config loader tests
2
+
3
+ import { test, eq, ok } from '../test.rip'
4
+ import { normalizeAppConfig, applyConfig } from '../edge/config.rip'
5
+ import { createAppRegistry, registerApp, resolveHost, getAppState } from '../edge/registry.rip'
6
+ import { broadcastBinary, createHub, addClient } from '../edge/realtime.rip'
7
+ import { setEventJsonMode, logEvent } from '../control/lifecycle.rip'
8
+
9
+ # --- Config normalization ---
10
+
11
+ test "normalizeAppConfig sets defaults", ->
12
+ config = normalizeAppConfig('myapp', {}, '/base')
13
+ eq config.id, 'myapp'
14
+ eq config.maxQueue, 512
15
+ eq config.queueTimeoutMs, 30000
16
+ eq config.readTimeoutMs, 30000
17
+ eq config.hosts.length, 0
18
+
19
+ test "normalizeAppConfig preserves explicit values", ->
20
+ config = normalizeAppConfig('api', { hosts: ['api.com'], workers: 8, maxQueue: 2048 }, '/base')
21
+ eq config.hosts[0], 'api.com'
22
+ eq config.workers, 8
23
+ eq config.maxQueue, 2048
24
+
25
+ test "normalizeAppConfig resolves relative entry path", ->
26
+ config = normalizeAppConfig('app', { entry: './index.rip' }, '/srv/apps')
27
+ ok config.entry.startsWith('/')
28
+ ok config.entry.includes('index.rip')
29
+
30
+ test "normalizeAppConfig keeps absolute entry path", ->
31
+ config = normalizeAppConfig('app', { entry: '/opt/app/index.rip' }, '/srv')
32
+ eq config.entry, '/opt/app/index.rip'
33
+
34
+ # --- Config apply ---
35
+
36
+ test "applyConfig registers apps in registry", ->
37
+ registry = createAppRegistry()
38
+ config =
39
+ apps:
40
+ web: { hosts: ['example.com'], workers: 4 }
41
+ api: { hosts: ['api.example.com'], workers: 2 }
42
+ registered = applyConfig(config, registry, registerApp, '/srv')
43
+ eq registered.length, 2
44
+ eq resolveHost(registry, 'example.com'), 'web'
45
+ eq resolveHost(registry, 'api.example.com'), 'api'
46
+
47
+ test "applyConfig handles empty apps", ->
48
+ registry = createAppRegistry()
49
+ registered = applyConfig({ apps: {} }, registry, registerApp, '/srv')
50
+ eq registered.length, 0
51
+
52
+ # --- Binary broadcast ---
53
+
54
+ test "broadcastBinary delivers to group members", ->
55
+ hub = createHub()
56
+ received = []
57
+ addClient(hub, 'c1', { send: ((data) -> received.push(data)) })
58
+ addClient(hub, 'c2', { send: ((data) -> received.push(data)) })
59
+ # Subscribe both to /room
60
+ hub.members.get('c1').add('/room')
61
+ hub.members.get('c2').add('/room')
62
+ hub.members.set('/room', new Set(['c1', 'c2']))
63
+ # Broadcast binary
64
+ buf = new Uint8Array([1, 2, 3, 4])
65
+ broadcastBinary(hub, ['/room'], buf, null)
66
+ eq received.length, 2
67
+
68
+ test "broadcastBinary excludes sender", ->
69
+ hub = createHub()
70
+ received = { c1: [], c2: [] }
71
+ addClient(hub, 'c1', { send: ((data) -> received.c1.push(data)) })
72
+ addClient(hub, 'c2', { send: ((data) -> received.c2.push(data)) })
73
+ hub.members.get('c1').add('/room')
74
+ hub.members.get('c2').add('/room')
75
+ hub.members.set('/room', new Set(['c1', 'c2']))
76
+ broadcastBinary(hub, ['/room'], new Uint8Array([5, 6]), 'c1')
77
+ eq received.c1.length, 0
78
+ eq received.c2.length, 1
79
+
80
+ test "broadcastBinary to direct client", ->
81
+ hub = createHub()
82
+ received = []
83
+ addClient(hub, 'c1', { send: ((data) -> received.push(data)) })
84
+ hub.members.set('c1', new Set())
85
+ broadcastBinary(hub, ['c1'], new Uint8Array([7, 8]), null)
86
+ eq received.length, 1
87
+
88
+ # --- Event logger ---
89
+
90
+ test "logEvent emits without error", ->
91
+ setEventJsonMode(false)
92
+ logEvent('test_event', { key: 'val' })
93
+ ok true
94
+
95
+ test "logEvent JSON mode emits without error", ->
96
+ setEventJsonMode(true)
97
+ logEvent('test_event', { key: 'val' })
98
+ setEventJsonMode(false)
99
+ ok true
@@ -7,7 +7,7 @@
7
7
  # Each test is self-contained: t(name, input, expected, fn)
8
8
  # No HTTP server needed — uses requestContext.run() directly.
9
9
  #
10
- # Run: rip packages/server/tests/read.test.rip
10
+ # Run: rip packages/server/test/tests/read.rip
11
11
  #
12
12
 
13
13
  import { read, requestContext } from '../api.rip'
@@ -33,16 +33,16 @@ t = (name, was, now, fn) ->
33
33
  actual = fn()
34
34
  if deepEq(actual, now)
35
35
  passed++
36
- console.log "✓ #{name}"
36
+ p "✓ #{name}"
37
37
  else
38
38
  failed++
39
- console.log "✗ #{name}"
40
- console.log " was: #{stringify(was)}"
41
- console.log " expected: #{stringify(now)}"
42
- console.log " actual: #{stringify(actual)}"
39
+ p "✗ #{name}"
40
+ p " was: #{stringify(was)}"
41
+ p " expected: #{stringify(now)}"
42
+ p " actual: #{stringify(actual)}"
43
43
  catch err
44
44
  failed++
45
- console.log "✗ #{name} — threw: #{err.message}"
45
+ p "✗ #{name} — threw: #{err.message}"
46
46
 
47
47
  # f(name, was, fn) — expect a throw
48
48
  f = (name, was, fn) ->
@@ -51,10 +51,10 @@ f = (name, was, fn) ->
51
51
  try
52
52
  fn()
53
53
  failed++
54
- console.log "✗ #{name} — expected throw"
54
+ p "✗ #{name} — expected throw"
55
55
  catch
56
56
  passed++
57
- console.log "✓ #{name}"
57
+ p "✓ #{name}"
58
58
 
59
59
  # ==============================================================================
60
60
  # read() basics (no validator)
@@ -250,5 +250,4 @@ t "required w/ miss()" , undefined, 'ok' -> read 'v', 'email!', -> 'ok'
250
250
  # Results
251
251
  # ==============================================================================
252
252
 
253
- console.log "\n#{passed} passed, #{failed} failed (#{total} total)"
254
- process.exit(if failed > 0 then 1 else 0)
253
+ p "\n#{passed} passed, #{failed} failed (#{total} total)"
@@ -0,0 +1,147 @@
1
+ # realtime.rip — edge/realtime.rip unit tests
2
+
3
+ import { test, eq, ok } from '../test.rip'
4
+ import { createHub, generateClientId, addClient, removeClient, processResponse, handlePublish, getRealtimeStats } from '../edge/realtime.rip'
5
+
6
+ # --- Hub lifecycle ---
7
+
8
+ test "createHub returns empty hub", ->
9
+ hub = createHub()
10
+ eq hub.sockets.size, 0
11
+ eq hub.members.size, 0
12
+ eq hub.deliveries, 0
13
+
14
+ test "generateClientId returns 32-char hex", ->
15
+ id = generateClientId()
16
+ eq id.length, 32
17
+ ok /^[0-9a-f]+$/.test(id)
18
+
19
+ test "generateClientId is unique", ->
20
+ a = generateClientId()
21
+ b = generateClientId()
22
+ ok a isnt b
23
+
24
+ # --- Client management ---
25
+
26
+ test "addClient stores socket and creates membership", ->
27
+ hub = createHub()
28
+ ws = { send: (->), readyState: 1 }
29
+ addClient(hub, 'c1', ws)
30
+ eq hub.sockets.size, 1
31
+ eq hub.sockets.get('c1'), ws
32
+ ok hub.members.has('c1')
33
+
34
+ test "removeClient cleans up socket and memberships", ->
35
+ hub = createHub()
36
+ addClient(hub, 'c1', { send: (->), readyState: 1 })
37
+ # Subscribe c1 to a group
38
+ processResponse(hub, JSON.stringify({ '+': ['room1'], '@': ['c1'] }), 'c1')
39
+ ok hub.members.has('room1')
40
+ # Remove client
41
+ removeClient(hub, 'c1')
42
+ eq hub.sockets.size, 0
43
+ eq hub.members.has('c1'), false
44
+
45
+ # --- Membership via processResponse ---
46
+
47
+ test "subscribe adds client to group", ->
48
+ hub = createHub()
49
+ addClient(hub, 'c1', { send: (->), readyState: 1 })
50
+ processResponse(hub, JSON.stringify({ '+': ['room1'] }), 'c1')
51
+ ok hub.members.has('room1')
52
+ ok hub.members.get('room1').has('c1')
53
+ ok hub.members.get('c1').has('room1')
54
+
55
+ test "unsubscribe removes client from group", ->
56
+ hub = createHub()
57
+ addClient(hub, 'c1', { send: (->), readyState: 1 })
58
+ processResponse(hub, JSON.stringify({ '+': ['room1'] }), 'c1')
59
+ processResponse(hub, JSON.stringify({ '-': ['room1'] }), 'c1')
60
+ eq hub.members.has('room1'), false
61
+
62
+ test "subscribe to multiple groups", ->
63
+ hub = createHub()
64
+ addClient(hub, 'c1', { send: (->), readyState: 1 })
65
+ processResponse(hub, JSON.stringify({ '+': ['room1', 'room2'] }), 'c1')
66
+ ok hub.members.get('c1').has('room1')
67
+ ok hub.members.get('c1').has('room2')
68
+
69
+ # --- Message delivery ---
70
+
71
+ test "deliver to direct client target", ->
72
+ hub = createHub()
73
+ received = []
74
+ addClient(hub, 'c1', { send: ((msg) -> received.push(msg)) })
75
+ addClient(hub, 'c2', { send: ((msg) -> received.push(msg)) })
76
+ # c1 sends, targeting c2 directly
77
+ processResponse(hub, JSON.stringify({ '@': ['c2'], 'chat': 'hello' }), 'c1')
78
+ eq received.length, 1
79
+ parsed = JSON.parse(received[0])
80
+ eq parsed.chat, 'hello'
81
+
82
+ test "deliver to channel group members", ->
83
+ hub = createHub()
84
+ received = { c1: [], c2: [] }
85
+ addClient(hub, 'c1', { send: ((msg) -> received.c1.push(msg)) })
86
+ addClient(hub, 'c2', { send: ((msg) -> received.c2.push(msg)) })
87
+ # Subscribe both to /room
88
+ processResponse(hub, JSON.stringify({ '+': ['/room'] }), 'c1')
89
+ processResponse(hub, JSON.stringify({ '+': ['/room'] }), 'c2')
90
+ # Send to /room
91
+ processResponse(hub, JSON.stringify({ '@': ['/room'], 'msg': 'hi' }), null)
92
+ ok received.c1.length > 0
93
+ ok received.c2.length > 0
94
+
95
+ test "sender exclusion via > key", ->
96
+ hub = createHub()
97
+ received = { c1: [], c2: [] }
98
+ addClient(hub, 'c1', { send: ((msg) -> received.c1.push(msg)) })
99
+ addClient(hub, 'c2', { send: ((msg) -> received.c2.push(msg)) })
100
+ # Both in /room
101
+ processResponse(hub, JSON.stringify({ '+': ['/room'] }), 'c1')
102
+ processResponse(hub, JSON.stringify({ '+': ['/room'] }), 'c2')
103
+ # Send from c1, exclude c1
104
+ processResponse(hub, JSON.stringify({ '>': ['c1'], '@': ['/room'], 'msg': 'hi' }), null)
105
+ eq received.c1.length, 0
106
+ ok received.c2.length > 0
107
+
108
+ # --- External publish ---
109
+
110
+ test "handlePublish delivers without clientId", ->
111
+ hub = createHub()
112
+ received = []
113
+ addClient(hub, 'c1', { send: ((msg) -> received.push(msg)) })
114
+ handlePublish(hub, JSON.stringify({ '@': ['c1'], 'event': 'data' }))
115
+ eq received.length, 1
116
+
117
+ # --- Stats ---
118
+
119
+ test "getRealtimeStats reflects hub state", ->
120
+ hub = createHub()
121
+ addClient(hub, 'c1', { send: (->), readyState: 1 })
122
+ addClient(hub, 'c2', { send: (->), readyState: 1 })
123
+ stats = getRealtimeStats(hub)
124
+ eq stats.clients, 2
125
+ eq stats.connections, 2
126
+
127
+ # --- Array response ---
128
+
129
+ test "processResponse handles array of items", ->
130
+ hub = createHub()
131
+ received = []
132
+ addClient(hub, 'c1', { send: ((msg) -> received.push(msg)) })
133
+ # Array with two items
134
+ processResponse(hub, JSON.stringify([
135
+ { '+': ['room1'] }
136
+ { '@': ['c1'], 'a': 1 }
137
+ ]), 'c1')
138
+ ok hub.members.get('c1').has('room1')
139
+ eq received.length, 1
140
+
141
+ # --- Invalid JSON ---
142
+
143
+ test "processResponse handles invalid JSON gracefully", ->
144
+ hub = createHub()
145
+ processResponse(hub, 'not json', 'c1')
146
+ # Should not throw — just log error
147
+ eq hub.sockets.size, 0