OXYGENHUB
◆ v0.1.0 spectra / crates / spectra-core / tests / conversation.rs
7.7 KiB · Rust · last changed Channels, threads, messages, the inbox and full-text search, shared by every interface (#6) 2 days ago source blame history raw
1
//! Conversation (spec §10, §13.1): who may post where, threads, mentions
2
//! and direct messages into the inbox, duplicates, limits, redaction, and
3
//! search limited to what the reader may see.
4
5
mod common;
6
7
use common::World;
8
use spectra_core::conversation::{self as conv, Post, Posted};
9
use spectra_core::ids::now_ms;
10
11
fn post(body: &str) -> Post {
12
    Post { body: body.into(), ..Default::default() }
13
}
14
15
fn id(p: Posted) -> String {
16
    match p {
17
        Posted::Message { id, .. } => id,
18
        Posted::Duplicate { id } => panic!("dropped as a duplicate of {id}"),
19
    }
20
}
21
22
#[tokio::test]
23
async fn agents_post_where_they_belong_and_nowhere_else() {
24
    let mut w = World::new(&["oxygen", "oxygenhub"]).await;
25
    let claude = w.agent("claude-oxygen", "oxygen").await;
26
    let a = w.author(&claude).await;
27
    let at = conv::post(&w.ledger, &a, post("default goes to my project"), now_ms()).await.unwrap();
28
    assert!(matches!(&at, Posted::Message { channel, .. } if channel == "#oxygen"), "{at:?}");
29
    for c in ["general", "humans", "#oxygen"] {
30
        conv::post(&w.ledger, &a, Post { channel: Some(c.into()), ..post(&format!("hello {c}")) }, now_ms()).await.unwrap();
31
    }
32
    for (c, why) in [("oxygenhub", "another project"), ("system", "Spectra's")] {
33
        let err = conv::post(&w.ledger, &a, Post { channel: Some(c.into()), ..post("nope") }, now_ms()).await.unwrap_err();
34
        assert!(err.to_string().contains(why), "{c}: {err}");
35
    }
36
    let owner_pair = w.owner.clone();
37
    let owner = w.author(&owner_pair).await;
38
    conv::post(&w.ledger, &owner, Post { channel: Some("oxygenhub".into()), ..post("the owner posts anywhere but #system") }, now_ms())
39
        .await
40
        .unwrap();
41
    w.verify_clean().await;
42
}
43
44
#[tokio::test]
45
async fn threads_mentions_and_direct_messages_reach_the_inbox() {
46
    let mut w = World::new(&["oxygen", "oxygenhub"]).await;
47
    let claude = w.agent("claude-oxygen", "oxygen").await;
48
    let kimi = w.agent("kimi-hub", "oxygenhub").await;
49
    let (c, k) = (w.author(&claude).await, w.author(&kimi).await);
50
    let root = id(conv::post(
51
        &w.ledger,
52
        &c,
53
        Post { channel: Some("general".into()), ..post("@kimi-hub the wire protocol grew a field; see oxy:change:abcdef12") },
54
        now_ms(),
55
    )
56
    .await
57
    .unwrap());
58
    let items = conv::inbox(&w.ledger, "agent:kimi-hub", Some(&k)).await.unwrap();
59
    assert_eq!(items.len(), 1);
60
    assert_eq!((items[0].kind.as_str(), items[0].from.as_str(), items[0].channel.as_str()), ("mention", "agent:claude-oxygen", "#general"));
61
    let reply = id(conv::post(&w.ledger, &k, Post { reply_to: Some(root.clone()), ..post("@andy should I bump the pin?") }, now_ms())
62
        .await
63
        .unwrap());
64
    let nested =
65
        id(conv::post(&w.ledger, &c, Post { reply_to: Some(reply.clone()), ..post("yes, after the release") }, now_ms()).await.unwrap());
66
    let (_, thread) = conv::read(&w.ledger, &c.principal, None, Some(&nested), None, 50).await.unwrap();
67
    assert_eq!(
68
        thread.iter().map(|m| m.id.clone()).collect::<Vec<_>>(),
69
        [root.clone(), reply.clone(), nested],
70
        "a reply to a reply joins the root's thread"
71
    );
72
    let (_, roots) = conv::read(&w.ledger, &c.principal, Some("general"), None, None, 50).await.unwrap();
73
    assert_eq!(roots.len(), 1);
74
    assert_eq!(roots[0].replies, 2);
75
    assert_eq!(conv::unread(&w.ledger, "human").await.unwrap(), 1, "@andy is the owner");
76
77
    assert_eq!(conv::ack(&w.ledger, &k, std::slice::from_ref(&root), Some("ack")).await.unwrap(), 1);
78
    assert!(conv::inbox(&w.ledger, "agent:kimi-hub", None).await.unwrap().is_empty());
79
80
    let dm = conv::ensure_direct(&w.ledger, &c.principal, "kimi-hub").await.unwrap();
81
    assert_eq!(dm.display(), "@claude-oxygen+kimi-hub");
82
    conv::post(&w.ledger, &c, Post { channel: Some(dm.name.clone()), ..post("just between us") }, now_ms()).await.unwrap();
83
    assert_eq!(conv::inbox(&w.ledger, "agent:kimi-hub", None).await.unwrap()[0].kind, "dm");
84
    let third = w.agent("third", "oxygen").await;
85
    let t = w.author(&third).await;
86
    assert!(conv::read(&w.ledger, &t.principal, Some(&dm.name), None, None, 10).await.is_err(), "not a member");
87
    assert!(conv::post(&w.ledger, &t, Post { channel: Some(dm.name.clone()), ..post("let me in") }, now_ms()).await.is_err());
88
    w.verify_clean().await;
89
}
90
91
#[tokio::test]
92
async fn duplicates_limits_and_redaction() {
93
    let mut w = World::new(&["oxygen"]).await;
94
    let claude = w.agent("claude-oxygen", "oxygen").await;
95
    let c = w.author(&claude).await;
96
    let first = id(conv::post(&w.ledger, &c, post("build failed on main"), now_ms()).await.unwrap());
97
    assert!(
98
        matches!(conv::post(&w.ledger, &c, post("build failed on main"), now_ms()).await.unwrap(), Posted::Duplicate { id } if id == first)
99
    );
100
    assert!(conv::post(&w.ledger, &c, post(&"x".repeat(conv::MAX_BODY + 1)), now_ms()).await.is_err());
101
    assert!(conv::post(&w.ledger, &c, post("   "), now_ms()).await.is_err());
102
    assert!(conv::post(&w.ledger, &c, Post { kind: Some("shout".into()), ..post("x") }, now_ms()).await.is_err());
103
104
    let secret = id(conv::post(&w.ledger, &c, post("the token is hunter2"), now_ms()).await.unwrap());
105
    assert_eq!(conv::search(&w.ledger, &c.principal, "hunter2", None, None, None, 10).await.unwrap().len(), 1);
106
    let kimi = w.agent("kimi", "oxygen").await;
107
    let k = w.author(&kimi).await;
108
    assert!(conv::redact(&w.ledger, &k, &secret, "not mine").await.is_err(), "only its author or the owner");
109
    conv::redact(&w.ledger, &c, &secret, "a secret pasted by mistake").await.unwrap();
110
    let (_, msgs) = conv::read(&w.ledger, &c.principal, None, None, None, 10).await.unwrap();
111
    let shown = msgs.iter().find(|m| m.id == secret).unwrap();
112
    assert_eq!(shown.body, "[redacted: a secret pasted by mistake]");
113
    assert!(conv::search(&w.ledger, &c.principal, "hunter2", None, None, None, 10).await.unwrap().is_empty(), "gone from search");
114
    w.verify_clean().await;
115
}
116
117
#[tokio::test]
118
async fn search_finds_stemmed_words_and_respects_who_may_read() {
119
    let mut w = World::new(&["oxygen"]).await;
120
    let claude = w.agent("claude-oxygen", "oxygen").await;
121
    let kimi = w.agent("kimi", "oxygen").await;
122
    let (c, k) = (w.author(&claude).await, w.author(&kimi).await);
123
    conv::post(
124
        &w.ledger,
125
        &c,
126
        Post { channel: Some("general".into()), ..post("We decided to bump the oxygen pin after releasing 0.11") },
127
        now_ms(),
128
    )
129
    .await
130
    .unwrap();
131
    conv::post(&w.ledger, &c, post("Unrelated chatter about lunch"), now_ms()).await.unwrap();
132
    let dm = conv::ensure_direct(&w.ledger, &c.principal, "human").await.unwrap();
133
    conv::post(&w.ledger, &c, Post { channel: Some(dm.name.clone()), ..post("privately: the pin bump worries me") }, now_ms())
134
        .await
135
        .unwrap();
136
137
    let hits = conv::search(&w.ledger, &k.principal, "bumping pins", None, None, None, 20).await.unwrap();
138
    assert_eq!(hits.len(), 1, "stemmed, and the direct message is not kimi's to see: {hits:?}");
139
    assert!(hits[0].snippet.contains("[bump]"), "{}", hits[0].snippet);
140
    assert_eq!(hits[0].channel, "#general");
141
    let owner_pair = w.owner.clone();
142
    let owner = w.author(&owner_pair).await;
143
    assert_eq!(
144
        conv::search(&w.ledger, &owner.principal, "pin", None, None, None, 20).await.unwrap().len(),
145
        2,
146
        "the owner reads everything"
147
    );
148
    assert_eq!(
149
        conv::search(&w.ledger, &k.principal, "\"oxygen pin\"", Some("general"), Some("claude-oxygen"), None, 20).await.unwrap().len(),
150
        1
151
    );
152
    assert!(conv::search(&w.ledger, &k.principal, "lunch", Some("general"), None, None, 20).await.unwrap().is_empty());
153
    let err = conv::search(&w.ledger, &k.principal, "AND OR (", None, None, None, 20).await.unwrap_err();
154
    assert!(err.to_string().contains("does not parse"), "{err}");
155
    w.verify_clean().await;
156
}