digdigdig3-station 0.3.21

Consumer-facing builder over digdigdig3 ExchangeHub. Persistence, cache, replay, cure, orderbook tracker, multiplex, reconnect.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Wave 3 wasm end-to-end test suite — Workstream E.
//!
//! Tests Wave 3 features through the public Station / ExchangeHub API in a
//! real browser context.  Covers:
//!
//! - `rest_via_encoded_proxy_get_klines` — REST override end-to-end: Binance
//!   klines fetched from the browser through the codetabs encoded-proxy.
//!   Uses the `{url}` placeholder mode added in this wave: the full real target
//!   URL is percent-encoded and substituted into the proxy template.
//! - `polling_lsr_returns_unsupported_on_wasm` — negative test: LSR subscribe
//!   lands in `report.failed` with `StreamNotSupported` on wasm (native-only
//!   timer dependency; Station gracefully degrades).
//! - `persistence_round_trip_via_station_builder` — Station builder with
//!   `PersistenceConfig::default()` constructs without panic on wasm; DiskStore
//!   round-trip is verified separately in `wasm_opfs_round_trip.rs`.
//!
//! Run with:
//!   cargo test --target wasm32-unknown-unknown -p digdigdig3-station \
//!       --test wasm_wave3_e2e
//!
//! Requires: dig2-wasm-test runner (configured in .cargo/config.toml) + a
//! browser with OPFS support (Chrome 86+, Firefox 111+, Safari 15.2+).
//!
//! Network tests (Test 1) require outbound HTTPS from the browser process.
//! In headless CI without outbound access, Test 1 will fail at the REST call;
//! the coordinator is expected to run these against a network-enabled browser.

#![cfg(target_arch = "wasm32")]

use wasm_bindgen_test::*;

wasm_bindgen_test_configure!(run_in_browser);

use digdigdig3::connector_manager::ExchangeHub;
use digdigdig3::core::types::{AccountType, ExchangeId, SymbolInput};
use digdigdig3_station::{Station, Stream, SubscriptionSet};

// ─── CORS proxy template ──────────────────────────────────────────────────────

/// Returns the CORS proxy URL template for REST calls in browser context.
///
/// The template MUST contain `{url}` — `assemble_rest_url` percent-encodes the
/// full target URL and substitutes it into `{url}` before the request is made.
///
/// ## Configuring a custom proxy (compile-time):
///
///   ```sh
///   DIG3_CORS_PROXY="https://my-proxy.example.com/?url={url}" \
///     cargo test --target wasm32-unknown-unknown -p digdigdig3-station
///   ```
///
/// Production consumers should pass their own backend proxy via
/// `DIG3_CORS_PROXY` at build time, or call `ExchangeHub::set_rest_base_override`
/// at runtime. The public codetabs.com default is for unattended CI only and
/// may be rate-limited.
fn cors_proxy_template() -> &'static str {
    option_env!("DIG3_CORS_PROXY").unwrap_or("https://api.codetabs.com/v1/proxy/?quest={url}")
}

// ─── Test 1: REST via encoded-proxy — end-to-end klines fetch ────────────────

/// Set `rest_base_override` to an encoded-proxy template and call `get_klines`
/// for Binance BTCUSDT.  Asserts ≥1 real kline is returned.
///
/// ## How the `{url}` template mode works
///
/// When the override string contains the literal `{url}`, `assemble_rest_url`
/// percent-encodes the full real target (`real_base + path + query`) and
/// substitutes it for `{url}` before making the request.  Example:
///
/// ```text
/// override:     "https://api.codetabs.com/v1/proxy/?quest={url}"
/// real target:  "https://api.binance.com/api/v3/klines?symbol=BTCUSDT&..."
/// final URL:    "https://api.codetabs.com/v1/proxy/?quest=https%3A%2F%2Fapi.binance.com%2F..."
/// ```
///
/// `api.codetabs.com/v1/proxy/` returns the upstream body verbatim with
/// `Access-Control-Allow-Origin: *`, which satisfies the browser's preflight
/// check.
///
/// ## Failure modes
///
/// If the test fails at the REST call the error message distinguishes:
/// - Network error / wrong encoding → the encoded URL was malformed or
///   codetabs rejected it.
/// - Parse error → our JSON parser failed on the upstream body.
/// - HTTP 4xx/5xx → codetabs is down or Binance returned an error.
///
/// Prints kline count on success as proof that real data flowed through Chrome.
#[wasm_bindgen_test]
async fn rest_via_encoded_proxy_get_klines() {
    let hub = ExchangeHub::new();

    // Encoded-proxy template: api.codetabs.com/v1/proxy/ returns the upstream
    // body verbatim with Access-Control-Allow-Origin: *.  The {url} placeholder
    // is replaced at request time with the percent-encoded full Binance target.
    // Template is configurable at build time via DIG3_CORS_PROXY env var.
    hub.set_rest_base_override(
        ExchangeId::Binance,
        cors_proxy_template().to_string(),
    );

    hub.connect_public(ExchangeId::Binance, false)
        .await
        .expect("connect_public must succeed with codetabs encoded-proxy override set");

    let rest = hub
        .rest(ExchangeId::Binance)
        .expect("REST connector must be present after connect_public");

    // limit=1 keeps the response tiny.
    let klines = rest
        .get_klines(
            SymbolInput::Raw("BTCUSDT"),
            "1m",
            Some(1),
            AccountType::Spot,
            None,
        )
        .await
        .expect("get_klines via codetabs encoded-proxy must succeed");

    // Print kline count as proof that real data flowed through Chrome.
    web_sys::console::log_1(
        &format!("rest_via_encoded_proxy_get_klines: {} kline(s) received", klines.len()).into()
    );

    assert!(
        !klines.is_empty(),
        "expected ≥1 kline from Binance BTCUSDT via codetabs encoded proxy; got 0"
    );

    // Verify the kline has sensible data (open > 0).
    let k = &klines[0];
    assert!(
        k.open > 0.0,
        "kline.open must be positive; got {}",
        k.open
    );
}

// ─── Test 2: LSR polling returns StreamNotSupported on wasm ──────────────────

/// Subscribe to `Stream::LongShortRatio` for Binance BTCUSDT on wasm and assert
/// it lands in `report.failed` with a `StreamNotSupported`-shaped error.
///
/// Why this is the expected behavior:
///   `LongShortRatioPoll` compiles on wasm (types are un-gated) but
///   `spawn_poller` is `#[cfg(not(target_arch = "wasm32"))]`.  Station's
///   `acquire_or_spawn` path reaches the poll-spawn gate and returns
///   `StationError::StreamNotSupported`.  The consumer-facing contract is:
///   "LSR is unavailable in browser; subscribe returns a failure entry with a
///   clear reason so the consumer can fall back gracefully."
///
/// This is a NEGATIVE test — we assert the failure rather than success.
/// Passing proves graceful degradation, not a bug.
#[wasm_bindgen_test]
async fn polling_lsr_returns_unsupported_on_wasm() {
    let station = Station::builder()
        .build()
        .await
        .expect("Station::build must succeed on wasm");

    let set = SubscriptionSet::new().add(
        ExchangeId::Binance,
        "BTC-USDT",
        AccountType::FuturesCross,
        [Stream::LongShortRatio],
    );

    let report = station
        .subscribe(set)
        .await
        .expect("subscribe must not return Err at the batch level");

    // The LSR stream must land in `failed`, not in `ok`.
    assert!(
        report.ok.is_empty(),
        "LSR must not succeed on wasm; ok = {:?}",
        report.ok
    );
    assert!(
        !report.failed.is_empty(),
        "LSR subscribe must produce a failure entry on wasm"
    );

    // The failure reason must reference "not supported" or "StreamNotSupported".
    let failure = &report.failed[0];
    let err_str = failure.error.to_string().to_lowercase();
    assert!(
        err_str.contains("not supported") || err_str.contains("notsupported"),
        "failure reason must indicate stream-not-supported; got: {:?}",
        failure.error
    );
}

// ─── Test 3: Station builder round-trip — PersistenceConfig wasm path ────────

/// Verify that `Station::builder()` with default `PersistenceConfig` (disabled)
/// constructs successfully on wasm and `active_streams()` starts at 0.
///
/// This is a smoke test for the builder's wasm path.  It does NOT exercise OPFS
/// I/O — that is covered by `wasm_opfs_round_trip.rs` (Workstream C).
///
/// A subscribe call is omitted here because the OPFS-backed persistence pipeline
/// (writing live trade events to DiskStore) requires waiting for actual WS
/// events, which is covered end-to-end by the Workstream C integration tests.
/// Separating builder-construct from subscribe-and-wait keeps this test fast
/// and deterministic (no network dependency).
#[wasm_bindgen_test]
async fn persistence_station_builder_wasm_path() {
    use digdigdig3_station::PersistenceConfig;

    // Persistence disabled (default).  On wasm, storage_root is unused.
    let station = Station::builder()
        .persistence(PersistenceConfig::default())
        .build()
        .await
        .expect("Station with default PersistenceConfig must build on wasm");

    assert_eq!(
        station.active_streams(),
        0,
        "newly built station must have 0 active streams"
    );

    // Confirm the storage_root is accessible (path may be empty on wasm).
    let _root = station.storage_root();
}

// ─── Test 3b: gap_heal module compiles and constructs on wasm ────────────────

/// Verify that `GapHealConfig` constructs correctly on wasm32 and that
/// `heal_limit` / `kline_interval_to_duration` compute deterministically.
///
/// Why we test at construction level rather than live disconnect:
///   Forcing a WS disconnect from a test is invasive — the actor owns the
///   connection and the only clean way to trigger a heal would be either:
///   (a) expose a "force-disconnect" hook (API pollution), or
///   (b) let the exchange time us out (~60s — too slow for a unit test).
///
///   The actual gap-fill REST call fires in the Station forwarder loop on
///   native integration tests (`gap_heal_e2e.rs`). Here we verify the
///   wasm-side plumbing: `GapHealConfig` is un-gated and all pure-compute
///   helpers compile + produce correct values on wasm32.
///
///   Live disconnect simulation remains a native-only integration test
///   (see `crates/digdigdig3-station/tests/gap_heal_e2e.rs`).
#[wasm_bindgen_test]
async fn gap_heal_module_compiles_and_config_constructs() {
    use digdigdig3_station::GapHealConfig;
    use digdigdig3_station::gap_heal::{heal_limit, kline_interval_to_duration};

    // Default config: disabled, default_limit=300, max_limit=1000.
    let cfg = GapHealConfig::default();
    assert!(!cfg.enabled, "default GapHealConfig must be disabled");
    assert_eq!(cfg.default_limit, 300);
    assert_eq!(cfg.max_limit, 1000);

    // Builder API.
    let cfg_on = GapHealConfig::on().default_limit(50).max_limit(500);
    assert!(cfg_on.enabled);
    assert_eq!(cfg_on.default_limit, 50);
    assert_eq!(cfg_on.max_limit, 500);

    // Interval parsing.
    use std::time::Duration;
    assert_eq!(kline_interval_to_duration("1m"), Some(Duration::from_secs(60)));
    assert_eq!(kline_interval_to_duration("1h"), Some(Duration::from_secs(3600)));
    assert_eq!(kline_interval_to_duration("1d"), Some(Duration::from_secs(86400)));
    assert_eq!(kline_interval_to_duration("bad"), None);

    // heal_limit: no gap, returns default_limit (capped to max_limit).
    let limit = heal_limit(&cfg_on, "1m", 0, 0);
    assert_eq!(limit, cfg_on.default_limit.min(cfg_on.max_limit));

    // heal_limit: 10-minute gap on 1m bars → need=10, but default_limit=50 wins.
    let now_ms = 1_700_000_600_000i64;
    let last_ms = now_ms - 10 * 60 * 1000; // 10 minutes ago
    let limit2 = heal_limit(&cfg_on, "1m", last_ms, now_ms);
    assert!(limit2 >= 10, "heal_limit must be ≥ gap/interval = 10; got {limit2}");
    assert!(limit2 <= cfg_on.max_limit, "heal_limit must not exceed max_limit");
}

// ─── Test 4: REST override survives connect_public → rest() round-trip ────────

/// End-to-end check that:
/// 1. Override set BEFORE `connect_public` is forwarded to the factory.
/// 2. `hub.rest()` returns a connector (factory did not reject it).
/// 3. `hub.get_rest_base_override()` still reflects the stored value.
///
/// This complements Test 1 (which proves the HTTP round-trip) and Test 3 in
/// `wasm_station_smoke.rs` (which proves plumbing in isolation).  Here we
/// verify that `connect_public` does NOT consume/clear the override.
#[wasm_bindgen_test]
async fn rest_override_persists_after_connect_public() {
    let hub = ExchangeHub::new();

    let proxy = cors_proxy_template().to_string();
    hub.set_rest_base_override(ExchangeId::Binance, proxy.clone());

    hub.connect_public(ExchangeId::Binance, false)
        .await
        .expect("connect_public must not error with override set");

    // REST connector must be present.
    assert!(
        hub.rest(ExchangeId::Binance).is_some(),
        "rest() must return Some after connect_public"
    );

    // Override must still be readable (connect_public must not clear it).
    assert_eq!(
        hub.get_rest_base_override(ExchangeId::Binance),
        Some(proxy),
        "override must survive connect_public"
    );
}

// ─── Deferred tests — cure + replay (Wave 4 followup) ────────────────────────
//
// `cure_in_browser` (Wave 3 Workstream E target #5):
//   `IntegrityChecker` and `RepairPipeline` depend on `StorageManager` which in
//   turn uses `sled` (BTree on-disk) and `tokio::fs` — both unavailable on
//   wasm32. All cure types are cfg-gated `#[cfg(not(target_arch = "wasm32"))]`
//   in `lib.rs`. Porting cure to wasm would require replacing StorageManager
//   with an OPFS-backed equivalent — a Wave 4 task.
//
//   Until that port lands, `cure` is a native-only module and no wasm test
//   for it is possible. The native integration tests live in
//   `crates/digdigdig3-station/tests/cure.rs`.
//
// `replay_from_opfs` (Wave 3 Workstream E target #6):
//   `ReplayHub` reads from `StorageManager` which has the same sled/tokio::fs
//   dependency. It is cfg-gated identically. The native replay integration tests
//   live in `crates/digdigdig3-station/tests/replay.rs`.
//
//   Wave 4 plan: port StorageManager to a cfg-split design (native: sled/tokio::fs;
//   wasm: OPFS DiskStore index), then un-gate cure + replay and add wasm tests
//   for both modules here.
//
// No placeholder test functions are emitted — a `#[wasm_bindgen_test]` with a
// `compile_error!` body would fail to compile; a no-op test would give a
// misleading "passed" count. The deferred state is correctly captured here in
// documentation only.

// ─── KuCoin WS via CORS proxy ────────────────────────────────────────────────
//
// KuCoin is the only WS venue that needs a pre-WS REST POST (/bullet-public) for
// its token; in-browser that's CORS-blocked unless proxied. This drives it through
// the same encoded-proxy used for REST. Reports the concrete failure mode if it
// can't (POST through a GET-only proxy / {url} encoding) so the limitation is exact.
#[wasm_bindgen_test]
async fn ws_kucoin_via_proxy() {
    use digdigdig3::core::types::{StreamType, SubscriptionRequest, Symbol};
    use futures_util::future::{select, Either};
    use futures_util::{pin_mut, StreamExt};
    use std::time::Duration;

    let hub = ExchangeHub::new();
    hub.set_rest_base_override(ExchangeId::KuCoin, cors_proxy_template().to_string());

    let connect = hub.connect_websocket(ExchangeId::KuCoin, AccountType::Spot, false);
    let ct = gloo_timers::future::sleep(Duration::from_secs(20));
    pin_mut!(connect, ct);
    match select(connect, ct).await {
        Either::Left((Ok(()), _)) => {}
        Either::Left((Err(e), _)) => panic!("KuCoin WS connect (bullet-token via proxy) failed: {e}"),
        Either::Right(_) => panic!("KuCoin WS connect timed out"),
    }

    let ws = hub
        .ws(ExchangeId::KuCoin, AccountType::Spot)
        .expect("ws present after connect_websocket");
    ws.subscribe(SubscriptionRequest {
        symbol: Symbol::with_raw("BTC", "USDT", "BTC-USDT".to_string()),
        stream_type: StreamType::Trade,
        account_type: AccountType::Spot,
        depth: None,
        update_speed_ms: None,
    })
    .await
    .expect("KuCoin WS subscribe");

    let mut stream = ws.event_stream();
    let deadline = gloo_timers::future::sleep(Duration::from_secs(20));
    pin_mut!(deadline);
    let mut got = false;
    loop {
        let next = stream.next();
        pin_mut!(next);
        match select(next, &mut deadline).await {
            Either::Left((Some(Ok(_)), _)) => {
                got = true;
                break;
            }
            Either::Left((_, _)) => break,
            Either::Right(_) => break,
        }
    }
    web_sys::console::log_1(&format!("[kucoin-proxy] got_event={got}").into());
    assert!(got, "KuCoin WS via proxy should deliver >=1 event");
}