#!/bin/sh
# autopkgtest for rust-nostr-gossip-memory.
#
# The upstream test target cannot be compiled: the #[cfg(test)] module in
# src/store.rs imports `nostr_gossip_test_suite`, a workspace-only crate that
# upstream does not publish (the published nostr-gossip-memory tarball ships an
# empty [dev-dependencies] section), so it is unavailable in Debian.
# `cargo-auto-test` is therefore useless here, so instead of testing the crate
# in place we build a small downstream crate against the installed
# librust-nostr-gossip-memory-dev and check that the in-memory gossip store
# honours the NostrGossip contract (NIP-65 read/write relays, NIP-17 inbox
# relays, p tag hints, most-received relays, the All selection,
# GossipAllowedRelays filtering, fetch-attempt/status bookkeeping, the
# outdated-public-key work queue and LRU eviction in a bounded store).  This
# also proves that the -dev package is usable by consumers.
#
# Modelled on debian/tests/run-tests of the blight package and on
# debian/tests/smoke-test of the rust-nostr-memory package.

set -eu

work=$(mktemp -d "${AUTOPKGTEST_TMP:-${TMPDIR:-/tmp}}/nostr-gossip-memory-smoke.XXXXXX")
trap 'rm -rf -- "$work"' EXIT HUP INT TERM

mkdir -p "$work/consumer/src" "$work/cargo-home"

# Build offline against the Debian-managed crate registry.
cat > "$work/cargo-home/config.toml" <<'CONFIG'
[source.crates-io]
replace-with = "debian"

[source.debian]
directory = "/usr/share/cargo/registry"
CONFIG

cat > "$work/consumer/Cargo.toml" <<'CONFIG'
[package]
name = "nostr-gossip-memory-smoke"
version = "0.0.0"
edition = "2021"
publish = false

[dependencies]
nostr = { version = "0.45", features = ["std", "rand", "os-rng"] }
nostr-gossip = "0.45"
nostr-gossip-memory = "0.45"
tokio = { version = "1", features = ["macros", "rt"] }
CONFIG

cat > "$work/consumer/src/main.rs" <<'SOURCE'
// Smoke test for the nostr-gossip-memory in-memory gossip store.
//
// The crate's own `#[cfg(test)]` target cannot be compiled in Debian: it
// imports `nostr_gossip_test_suite`, a workspace-only path dependency that
// upstream does not publish (the crates.io tarball ships an empty
// [dev-dependencies] section), so `cargo-auto-test` is useless here.  Instead
// of testing the crate in place we build a small downstream crate against the
// installed librust-nostr-gossip-memory-dev and replay the scenarios that
// upstream's own gossip test suite covers.  This also proves that the -dev
// package is usable by consumers.
//
// Modelled on debian/tests/smoke-test of the rust-nostr-memory package.

use std::collections::HashSet;
use std::num::NonZeroUsize;

use nostr::prelude::*;
use nostr_gossip::prelude::*;
use nostr_gossip_memory::prelude::*;

/// Public key used by several upstream fixtures below.
const FIXTURE_PK: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";

/// NIP-65 relay list with four relays that carry no read/write marker, so they
/// are read *and* write relays.
const NIP65_UNMARKED: &str = r#"{"id":"0a49bed4a1eb0973a68a0d43b7ca62781ffd4e052b91bbadef09e5cf756f6e68","pubkey":"68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272","created_at":1759351841,"kind":10002,"tags":[["alt","Relay list to discover the user's content"],["r","wss://relay.damus.io/"],["r","wss://nostr.wine/"],["r","wss://nostr.oxtr.dev/"],["r","wss://relay.nostr.wirednet.jp/"]],"content":"","sig":"f5bc6c18b0013214588d018c9086358fb76a529aa10867d4d02a75feb239412ae1c94ac7c7917f6e6e2303d72f00dc4e9b03b168ef98f3c3c0dec9a457ce0304"}"#;

/// NIP-65 relay list with one read-only and one write-only relay.
const NIP65_MARKED: &str = r#"{"id":"0000000000000000000000000000000000000000000000000000000000000000","pubkey":"68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272","created_at":1704644581,"kind":10002,"tags":[["r","wss://read.relay.io","read"],["r","wss://write.relay.io","write"]],"content":"","sig":"f5bc6c18b0013214588d018c9086358fb76a529aa10867d4d02a75feb239412ae1c94ac7c7917f6e6e2303d72f00dc4e9b03b168ef98f3c3c0dec9a457ce0304"}"#;

/// NIP-17 inbox (private message) relay list with three relays.
const NIP17_INBOX: &str = r#"{"id":"8d9b40907f80bd7d5014bdc6a2541227b92f4ae20cbff59792b4746a713da81e","pubkey":"68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272","created_at":1756718818,"kind":10050,"tags":[["relay","wss://auth.nostr1.com/"],["relay","wss://nostr.oxtr.dev/"],["relay","wss://nip17.com"]],"content":"","sig":"05611df32f5c4e55bb8d74ab2840378b7707ad162f785a78f8bdaecee5b872667e4e43bcbbf3c6c638335c637f001155b48b7a7040ce2695660467be62f142d5"}"#;

/// Text note whose p tag carries wss://hint.relay.io as relay hint.
const HINT_EVENT: &str = r#"{"id":"0000000000000000000000000000000000000000000000000000000000000001","pubkey":"bb4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4","created_at":1704644581,"kind":1,"tags":[["p","68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272","wss://hint.relay.io"]],"content":"Hint","sig":"f5bc6c18b0013214588d018c9086358fb76a529aa10867d4d02a75feb239412ae1c94ac7c7917f6e6e2303d72f00dc4e9b03b168ef98f3c3c0dec9a457ce0304"}"#;

/// Text note that was received from wss://received.relay.io.
const RECEIVED_EVENT: &str = r#"{"id":"0000000000000000000000000000000000000000000000000000000000000002","pubkey":"68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272","created_at":1704644581,"kind":1,"tags":[],"content":"Received","sig":"f5bc6c18b0013214588d018c9086358fb76a529aa10867d4d02a75feb239412ae1c94ac7c7917f6e6e2303d72f00dc4e9b03b168ef98f3c3c0dec9a457ce0304"}"#;

/// NIP-65 relay list mixing a TLS relay, a local network relay and an onion
/// relay, used to exercise `GossipAllowedRelays`.
const NIP65_ALLOWED: &str = r#"{"id":"0a49bed4a1eb0973a68a0d43b7ca62781ffd4e052b91bbadef09e5cf756f6e68","pubkey":"68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272","created_at":1759351841,"kind":10002,"tags":[["alt","Relay list to discover the user's content"],["r","wss://relay.damus.io/"],["r","ws://192.168.1.11:7777"],["r","ws://oxtrdevav64z64yb7x6rjg4ntzqjhedm5b5zjqulugknhzr46ny2qbad.onion"]],"content":"","sig":"f5bc6c18b0013214588d018c9086358fb76a529aa10867d4d02a75feb239412ae1c94ac7c7917f6e6e2303d72f00dc4e9b03b168ef98f3c3c0dec9a457ce0304"}"#;

fn text_note(keys: &Keys, content: &str) -> Event {
    EventBuilder::new(Kind::TextNote, content)
        .finalize(keys)
        .expect("failed to finalize text note")
}

fn parse(json: &str) -> Event {
    Event::from_json(json).expect("invalid event fixture")
}

fn url(url: &str) -> RelayUrl {
    RelayUrl::parse(url).expect("invalid relay url")
}

/// Shorthand for `get_best_relays()`, since every check below needs it.
async fn best_relays(
    store: &NostrGossipMemory,
    public_key: &PublicKey,
    selection: BestRelaySelection,
    allowed: GossipAllowedRelays,
) -> HashSet<RelayUrl> {
    store
        .get_best_relays(public_key, selection, allowed)
        .await
        .expect("get_best_relays() failed")
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let allowed = GossipAllowedRelays::default();

    // ------------------------------------------------------------------
    // A fresh store knows nothing about anybody.
    // ------------------------------------------------------------------
    let store = NostrGossipMemory::unbounded();
    let unknown =
        PublicKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
            .unwrap();

    assert!(
        best_relays(
            &store,
            &unknown,
            BestRelaySelection::Read { limit: 10 },
            allowed
        )
        .await
        .is_empty(),
        "a fresh store returned relays for an unknown public key"
    );
    assert!(
        store
            .status(&unknown, GossipListKind::Nip65)
            .await
            .unwrap()
            .is_missing(),
        "NIP-65 status of an unknown public key is not Missing"
    );
    assert!(
        store
            .status(&unknown, GossipListKind::Nip17)
            .await
            .unwrap()
            .is_missing(),
        "NIP-17 status of an unknown public key is not Missing"
    );

    // ------------------------------------------------------------------
    // process() accepts events, and processing the same event twice is fine.
    // ------------------------------------------------------------------
    let keys = Keys::generate();
    let note = text_note(&keys, "hello from the Debian autopkgtest");
    store.process(&note, None).await.expect("process() failed");
    store
        .process(&note, None)
        .await
        .expect("re-processing the same event failed");

    // ------------------------------------------------------------------
    // NIP-65: relays without markers are both read and write relays, and the
    // limit is honoured.
    // ------------------------------------------------------------------
    let store = NostrGossipMemory::unbounded();
    let event = parse(NIP65_UNMARKED);
    store.process(&event, None).await.expect("process() failed");
    let public_key = event.pubkey;

    let read = best_relays(
        &store,
        &public_key,
        BestRelaySelection::Read { limit: 2 },
        allowed,
    )
    .await;
    assert_eq!(read.len(), 2, "Read selection did not honour the limit");
    let write = best_relays(
        &store,
        &public_key,
        BestRelaySelection::Write { limit: 2 },
        allowed,
    )
    .await;
    assert_eq!(write.len(), 2, "Write selection did not honour the limit");
    assert_eq!(
        read, write,
        "unmarked NIP-65 relays are not usable for both reading and writing"
    );

    let expected: HashSet<RelayUrl> = [
        "wss://relay.damus.io/",
        "wss://nostr.wine/",
        "wss://nostr.oxtr.dev/",
        "wss://relay.nostr.wirednet.jp/",
    ]
    .iter()
    .map(|u| url(u))
    .collect();
    assert!(
        read.is_subset(&expected),
        "Read selection returned relays that are not in the NIP-65 list"
    );

    // 10002 relays must not leak into the NIP-17 selection.
    assert!(
        best_relays(
            &store,
            &public_key,
            BestRelaySelection::PrivateMessage { limit: 10 },
            allowed
        )
        .await
        .is_empty(),
        "NIP-65 relays showed up in the NIP-17 selection"
    );

    // Read-only and write-only markers are kept apart.
    let store = NostrGossipMemory::unbounded();
    let event = parse(NIP65_MARKED);
    store.process(&event, None).await.expect("process() failed");
    let read = best_relays(
        &store,
        &event.pubkey,
        BestRelaySelection::Read { limit: 10 },
        allowed,
    )
    .await;
    let write = best_relays(
        &store,
        &event.pubkey,
        BestRelaySelection::Write { limit: 10 },
        allowed,
    )
    .await;
    assert_eq!(
        read,
        HashSet::from([url("wss://read.relay.io")]),
        "the read marker of a NIP-65 relay was ignored"
    );
    assert_eq!(
        write,
        HashSet::from([url("wss://write.relay.io")]),
        "the write marker of a NIP-65 relay was ignored"
    );

    // ------------------------------------------------------------------
    // NIP-17: inbox relays are selectable for private messages.
    // ------------------------------------------------------------------
    let store = NostrGossipMemory::unbounded();
    let event = parse(NIP17_INBOX);
    store.process(&event, None).await.expect("process() failed");
    let private_message = best_relays(
        &store,
        &event.pubkey,
        BestRelaySelection::PrivateMessage { limit: 4 },
        allowed,
    )
    .await;
    assert_eq!(
        private_message.len(),
        3,
        "PrivateMessage selection did not return the NIP-17 inbox relays"
    );
    assert!(
        best_relays(
            &store,
            &event.pubkey,
            BestRelaySelection::Read { limit: 10 },
            allowed
        )
        .await
        .is_empty(),
        "NIP-17 inbox relays showed up as NIP-65 read relays"
    );

    // ------------------------------------------------------------------
    // Relay hints from p tags.
    // ------------------------------------------------------------------
    let store = NostrGossipMemory::unbounded();
    let hinted = PublicKey::parse("npub1drvpzev3syqt0kjrls50050uzf25gehpz9vgdw08hvex7e0vgfeq0eseet")
        .unwrap();
    let hint_url = url("wss://hint.relay.io");
    let event = EventBuilder::new(Kind::TextNote, "test")
        .tag(Tag::from(Nip01Tag::PublicKey {
            public_key: hinted,
            relay_hint: Some(hint_url.clone()),
        }))
        .finalize(&keys)
        .unwrap();
    store.process(&event, None).await.expect("process() failed");
    let hints = best_relays(
        &store,
        &hinted,
        BestRelaySelection::Hints { limit: 5 },
        allowed,
    )
    .await;
    assert_eq!(hints, HashSet::from([hint_url]), "p tag relay hint was lost");

    // ------------------------------------------------------------------
    // Relays that delivered the most events win.
    // ------------------------------------------------------------------
    let store = NostrGossipMemory::unbounded();
    let busy = url("wss://busy.relay.io");
    let quiet = url("wss://quiet.relay.io");
    for i in 0..5 {
        let event = text_note(&keys, &format!("Test {i}"));
        store
            .process(&event, Some(&busy))
            .await
            .expect("process() failed");
    }
    let event = text_note(&keys, "only one");
    store
        .process(&event, Some(&quiet))
        .await
        .expect("process() failed");

    let most_received = best_relays(
        &store,
        &keys.public_key(),
        BestRelaySelection::MostReceived { limit: 1 },
        allowed,
    )
    .await;
    assert_eq!(
        most_received,
        HashSet::from([busy.clone()]),
        "MostReceived did not pick the relay that delivered the most events"
    );
    assert_eq!(
        best_relays(
            &store,
            &keys.public_key(),
            BestRelaySelection::MostReceived { limit: 10 },
            allowed
        )
        .await,
        HashSet::from([busy, quiet]),
        "MostReceived did not track every relay an event came from"
    );

    // ------------------------------------------------------------------
    // `All` merges the read, write, hint and most-received selections.
    // ------------------------------------------------------------------
    let store = NostrGossipMemory::unbounded();
    let public_key = PublicKey::from_hex(FIXTURE_PK).unwrap();
    let received_from = url("wss://received.relay.io");
    for event in [parse(NIP65_MARKED), parse(HINT_EVENT)] {
        store.process(&event, None).await.expect("process() failed");
    }
    store
        .process(&parse(RECEIVED_EVENT), Some(&received_from))
        .await
        .expect("process() failed");

    let all = best_relays(
        &store,
        &public_key,
        BestRelaySelection::All {
            read: 5,
            write: 5,
            hints: 5,
            most_received: 5,
        },
        allowed,
    )
    .await;
    for expected in [
        "wss://read.relay.io",
        "wss://write.relay.io",
        "wss://hint.relay.io",
        "wss://received.relay.io",
    ] {
        let expected = url(expected);
        assert!(
            all.contains(&expected),
            "All selection is missing {expected}"
        );
    }

    // ------------------------------------------------------------------
    // GossipAllowedRelays filters local, onion and plaintext relays.
    // ------------------------------------------------------------------
    let store = NostrGossipMemory::unbounded();
    let event = parse(NIP65_ALLOWED);
    store.process(&event, None).await.expect("process() failed");
    let public_key = event.pubkey;
    let damus = url("wss://relay.damus.io");
    let local = url("ws://192.168.1.11:7777");
    let onion = url("ws://oxtrdevav64z64yb7x6rjg4ntzqjhedm5b5zjqulugknhzr46ny2qbad.onion");

    let every_relay = GossipAllowedRelays {
        onion: true,
        local: true,
        without_tls: true,
    };
    let tls_only = GossipAllowedRelays {
        onion: true,
        local: true,
        without_tls: false,
    };

    assert_eq!(
        best_relays(
            &store,
            &public_key,
            BestRelaySelection::Read { limit: u8::MAX },
            every_relay
        )
        .await,
        HashSet::from([damus.clone(), local.clone(), onion.clone()]),
        "the NIP-65 relay list was not stored completely"
    );
    assert_eq!(
        best_relays(
            &store,
            &public_key,
            BestRelaySelection::Read { limit: u8::MAX },
            GossipAllowedRelays {
                onion: true,
                local: false,
                without_tls: true,
            }
        )
        .await,
        HashSet::from([damus.clone(), onion]),
        "local relays are not filtered out"
    );
    assert_eq!(
        best_relays(
            &store,
            &public_key,
            BestRelaySelection::Read { limit: u8::MAX },
            GossipAllowedRelays {
                onion: false,
                local: true,
                without_tls: true,
            }
        )
        .await,
        HashSet::from([damus.clone(), local]),
        "onion relays are not filtered out"
    );
    assert_eq!(
        best_relays(
            &store,
            &public_key,
            BestRelaySelection::Read { limit: u8::MAX },
            tls_only
        )
        .await,
        HashSet::from([damus]),
        "relays without TLS are not filtered out"
    );

    // ------------------------------------------------------------------
    // Fetch attempts and the outdated-public-key work queue.
    // ------------------------------------------------------------------
    let store = NostrGossipMemory::unbounded();
    let public_key = PublicKey::from_hex(FIXTURE_PK).unwrap();
    store
        .process(&parse(NIP65_UNMARKED), None)
        .await
        .expect("process() failed");

    // Receiving a relay list does not count as a fetch attempt.
    assert!(
        store
            .status(&public_key, GossipListKind::Nip65)
            .await
            .unwrap()
            .is_missing(),
        "processing a NIP-65 list was counted as a fetch attempt"
    );

    store
        .update_fetch_attempt(&public_key, GossipListKind::Nip65)
        .await
        .expect("update_fetch_attempt() failed");
    assert!(
        store
            .status(&public_key, GossipListKind::Nip65)
            .await
            .unwrap()
            .is_updated(),
        "NIP-65 status is not Updated after a fetch attempt"
    );
    assert!(
        store
            .status(&public_key, GossipListKind::Nip17)
            .await
            .unwrap()
            .is_missing(),
        "the NIP-65 fetch attempt leaked into the NIP-17 status"
    );
    assert!(
        store
            .outdated_public_keys(GossipListKind::Nip65, NonZeroUsize::new(10).unwrap())
            .await
            .unwrap()
            .is_empty(),
        "a public key fetched just now is reported as outdated"
    );

    // ------------------------------------------------------------------
    // A bounded store keeps the most recently used public keys only.
    // ------------------------------------------------------------------
    let store = NostrGossipMemory::bounded(NonZeroUsize::new(1).unwrap());
    let first_event = parse(NIP65_UNMARKED);
    let evicted = first_event.pubkey;
    store
        .process(&first_event, None)
        .await
        .expect("process() failed");

    let newcomer = url("wss://newcomer.relay.io");
    let event = RelayList::new([(newcomer.clone(), None)])
        .finalize(&Keys::generate())
        .expect("failed to finalize relay list");
    store.process(&event, None).await.expect("process() failed");

    assert!(
        best_relays(
            &store,
            &evicted,
            BestRelaySelection::Read { limit: 10 },
            allowed
        )
        .await
        .is_empty(),
        "bounded store did not evict the least recently used public key"
    );
    assert_eq!(
        best_relays(
            &store,
            &event.pubkey,
            BestRelaySelection::Read { limit: 10 },
            allowed
        )
        .await,
        HashSet::from([newcomer]),
        "bounded store lost the most recently used public key"
    );

    println!("nostr-gossip-memory smoke test: all checks passed");
}
SOURCE

export CARGO_HOME="$work/cargo-home" CARGO_TARGET_DIR="$work/target"
export CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0

/usr/bin/cargo run --offline --quiet --manifest-path "$work/consumer/Cargo.toml"
