aurabase 0.1.1

Official Rust SDK for Aurabase: high-performance open-source Backend-as-a-Service (BaaS)
Documentation
//! N29 — le SDK Rust jetait silencieusement TOUTES les erreurs serveur.
//!
//! `RealtimeService::dispatch_message` retombait sur `_ => {}` pour `ServerMessage::Error`, et
//! `ServerErrorMsg` ne déclarait aucun champ `channel`. Conséquence pour l'appelant : un refus
//! d'abonnement (policy RLS d'un canal privé), un refus d'écriture, une déconnexion pour
//! violations répétées — tout cela lui était **totalement invisible**.
//!
//! Contrat serveur de référence, capturé tel quel sur la stack (aura-realtime reconstruit le
//! 2026-08-02) en émettant un `broadcast` sur un canal non souscrit :
//!
//! ```json
//! {"type":"error",
//!  "message":"Non abonné au canal '0c4fb174-…:canal-x' — Subscribe requis avant Broadcast",
//!  "channel":"0c4fb174-…:canal-x"}
//! ```
//!
//! Le champ `channel` est `skip_serializing_if = "Option::is_none"` côté serveur : il est absent
//! des erreurs GLOBALES (heartbeat, token expiré, quota). C'est cette présence/absence qui permet
//! de distinguer « ce canal-là » de « toute la connexion » — exactement le contrat porté par le
//! SDK Python (`aurabase-py/.../realtime.py`, finding N26).

use aurabase::types::{ServerErrorMsg, ServerMessage};

/// Message d'erreur RÉEL du serveur, recopié octet pour octet.
const ERREUR_CANAL_REELLE: &str = r#"{"type":"error","message":"Non abonné au canal '0c4fb174-ff01-468a-b623-fe75ac679d75:canal-x' — Subscribe requis avant Broadcast","channel":"0c4fb174-ff01-468a-b623-fe75ac679d75:canal-x"}"#;

/// Erreur GLOBALE : le serveur n'émet alors aucun champ `channel`.
const ERREUR_GLOBALE_REELLE: &str =
    r#"{"type":"error","message":"Trop de violations rate limit — déconnexion"}"#;

fn erreur(json: &str) -> ServerErrorMsg {
    match serde_json::from_str::<ServerMessage>(json).expect("le SDK doit décoder ce message") {
        ServerMessage::Error(e) => e,
        autre => panic!("attendu ServerMessage::Error, obtenu {autre:?}"),
    }
}

/// Ce que le SDK a RETENU du message, exprimé sans dépendre des champs du type — de sorte que
/// le test compile aussi bien avant qu'après le correctif, et échoue donc à l'EXÉCUTION.
fn retenu(json: &str) -> serde_json::Value {
    serde_json::to_value(erreur(json)).expect("ServerErrorMsg est Serialize")
}

#[test]
fn le_champ_channel_du_serveur_est_conserve() {
    let v = retenu(ERREUR_CANAL_REELLE);
    assert_eq!(
        v.get("channel").and_then(|c| c.as_str()),
        Some("0c4fb174-ff01-468a-b623-fe75ac679d75:canal-x"),
        "le SDK a PERDU le champ `channel` du serveur. Sans lui il ne peut pas distinguer un \
         refus PROPRE À UN CANAL d'une panne globale de connexion — c'est le contrat B9/N23 \
         émis par le serveur. Ce que le SDK a retenu : {v}"
    );
    assert!(
        v.get("message")
            .and_then(|m| m.as_str())
            .unwrap_or_default()
            .contains("Non abonné"),
        "le message doit être conservé tel quel : {v}"
    );
}

#[test]
fn une_erreur_globale_na_pas_de_channel() {
    let v = retenu(ERREUR_GLOBALE_REELLE);
    assert!(
        v.get("channel").map(|c| c.is_null()).unwrap_or(true),
        "une erreur sans `channel` est GLOBALE : le champ doit rester optionnel, sinon le \
         décodage d'un message de déconnexion échouerait. Reçu : {v}"
    );
}

// ── Bout-en-bout : vraie socket WebSocket, vraie boucle de lecture du SDK ───────────────

use futures::{SinkExt, StreamExt};

/// Démarre un serveur WebSocket local qui accepte UNE connexion, attend le premier message du
/// SDK (le `subscribe`) puis lui renvoie `reponse` — puis reste ouvert. Rend le port écouté.
async fn serveur_ws_qui_repond(reponse: String) -> u16 {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
        .await
        .expect("bind");
    let port = listener.local_addr().unwrap().port();
    tokio::spawn(async move {
        let (stream, _) = listener.accept().await.expect("accept");
        let mut ws = tokio_tungstenite::accept_async(stream)
            .await
            .expect("handshake");
        // Le SDK envoie son `subscribe` dès la connexion établie.
        let _ = ws.next().await;
        let _ = ws
            .send(tokio_tungstenite::tungstenite::Message::Text(reponse))
            .await;
        // Maintenir la socket ouverte : une fermeture déclencherait la reconnexion du SDK.
        tokio::time::sleep(std::time::Duration::from_secs(20)).await;
    });
    port
}

fn client_sur(port: u16) -> aurabase::AuraClient {
    aurabase::AuraClient::new(&format!("http://127.0.0.1:{port}"), "aura_anon_test", None)
}

/// N29 — cœur du finding : un refus PROPRE À UN CANAL doit parvenir à l'appelant, nommé.
#[tokio::test]
async fn une_erreur_de_canal_est_remontee_a_lappelant() {
    let port = serveur_ws_qui_repond(ERREUR_CANAL_REELLE.to_string()).await;
    let client = client_sur(port);
    let rt = client.realtime();
    // S'abonner AVANT de déclencher : `broadcast` ne rejoue pas l'historique.
    let mut erreurs = rt.on_error();

    let ch = rt.channel("canal-x", None);
    rt.subscribe_channel(ch).await.expect("subscribe_channel");

    let recu = tokio::time::timeout(std::time::Duration::from_secs(5), erreurs.recv())
        .await
        .expect(
            "AUCUNE erreur remontée en 5 s — le SDK a avalé `ServerMessage::Error` \
             (`_ => {}` dans dispatch_message) : un refus RLS d'abonnement est invisible \
             pour l'appelant",
        )
        .expect("canal d'erreurs fermé");

    assert!(
        recu.message.contains("Non abonné"),
        "le message du serveur doit être transmis tel quel, reçu: {recu:?}"
    );
    assert_eq!(
        recu.channel.as_deref(),
        Some("canal-x"),
        "l'erreur doit être attribuée au canal concerné, en nom COURT (celui donné à \
         `channel()`) — sinon l'appelant ne peut pas savoir lequel de ses canaux est tombé. \
         Reçu: {recu:?}"
    );
}

/// Symétrique : une erreur GLOBALE remonte aussi, mais sans nom de canal — l'appelant doit
/// pouvoir la distinguer d'un refus circonscrit.
#[tokio::test]
async fn une_erreur_globale_est_remontee_sans_canal() {
    let port = serveur_ws_qui_repond(ERREUR_GLOBALE_REELLE.to_string()).await;
    let client = client_sur(port);
    let rt = client.realtime();
    let mut erreurs = rt.on_error();

    let ch = rt.channel("canal-x", None);
    rt.subscribe_channel(ch).await.expect("subscribe_channel");

    let recu = tokio::time::timeout(std::time::Duration::from_secs(5), erreurs.recv())
        .await
        .expect("une erreur GLOBALE doit elle aussi être remontée")
        .expect("canal d'erreurs fermé");

    assert!(recu.message.contains("Trop de violations"), "{recu:?}");
    assert_eq!(
        recu.channel, None,
        "sans `channel` émis par le serveur, l'erreur est globale — l'attribuer à un canal \
         serait faux. Reçu: {recu:?}"
    );
}