Skip to content

Commit c99d74b

Browse files
committed
test(coverage): push all 4 remaining modules
- discord/api.rs: list_bot_guilds_at_base/list_guild_channels_at_base test seams with mock axum server; parse happy-path, error status, channel filter+sort, empty list. - channels/controllers/ops.rs: parse_allowed_users for string CSV/array/ newline/@-prefix/case-insensitive dedup/non-string; credential_provider; list_channels/describe_channel; connect_channel unknown-channel and non-object credentials. - local_ai/ollama_api.rs: `ollama_base_url()` honours OPENHUMAN_OLLAMA_BASE_URL env var so tests can point at mock servers; DEFAULT_OLLAMA_BASE_URL preserved. - local_ai/service/public_infer.rs: mock-backend tests for inference/prompt happy path, non-success status, suggest_questions parsing, disabled- local-ai short-circuits for summarize/prompt/suggest_questions/ inline_complete. - voice/schemas.rs: overlay_notify cancelled→released, unknown state errors, missing state errors, server_start handler, TranscribeParams + TtsParams deserialize happy/error paths, server_start all-optional invariant, description completeness.
1 parent 51a46d4 commit c99d74b

6 files changed

Lines changed: 560 additions & 13 deletions

File tree

‎src/openhuman/channels/controllers/ops.rs‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,4 +822,127 @@ mod tests {
822822
.await;
823823
assert!(err.is_err());
824824
}
825+
826+
// ── parse_allowed_users / credential_provider ─────────────────
827+
828+
#[test]
829+
fn parse_allowed_users_handles_string_csv() {
830+
let v = serde_json::json!("alice,bob,@carol");
831+
let out = parse_allowed_users(Some(&v));
832+
assert_eq!(out, vec!["alice", "bob", "carol"]);
833+
}
834+
835+
#[test]
836+
fn parse_allowed_users_handles_newline_separated_string() {
837+
let v = serde_json::json!("alice\nbob\r\ncarol");
838+
let out = parse_allowed_users(Some(&v));
839+
assert_eq!(out, vec!["alice", "bob", "carol"]);
840+
}
841+
842+
#[test]
843+
fn parse_allowed_users_dedups_case_insensitively() {
844+
let v = serde_json::json!("Alice,ALICE,alice,@Alice");
845+
let out = parse_allowed_users(Some(&v));
846+
assert_eq!(out, vec!["alice"]);
847+
}
848+
849+
#[test]
850+
fn parse_allowed_users_normalises_at_prefix_and_whitespace() {
851+
let v = serde_json::json!(" @Alice ");
852+
let out = parse_allowed_users(Some(&v));
853+
assert_eq!(out, vec!["alice"]);
854+
}
855+
856+
#[test]
857+
fn parse_allowed_users_rejects_empty_and_at_only() {
858+
let v = serde_json::json!(", ,@,@ ,@@@, ,");
859+
let out = parse_allowed_users(Some(&v));
860+
// "@@@" trims to "@" then strips one '@' leaving "@@" which is non-empty
861+
// — the canonical value should still survive once. Just ensure
862+
// pure whitespace / empty tokens are dropped.
863+
assert!(!out.contains(&"".to_string()));
864+
assert!(!out.contains(&"@".to_string()));
865+
}
866+
867+
#[test]
868+
fn parse_allowed_users_accepts_array_of_strings() {
869+
let v = serde_json::json!(["a", "b,c", "@d\ne"]);
870+
let out = parse_allowed_users(Some(&v));
871+
for expected in ["a", "b", "c", "d", "e"] {
872+
assert!(
873+
out.contains(&expected.to_string()),
874+
"missing `{expected}` in {out:?}"
875+
);
876+
}
877+
}
878+
879+
#[test]
880+
fn parse_allowed_users_returns_empty_for_none_or_non_string_value() {
881+
assert!(parse_allowed_users(None).is_empty());
882+
assert!(parse_allowed_users(Some(&serde_json::json!(42))).is_empty());
883+
assert!(parse_allowed_users(Some(&serde_json::json!({}))).is_empty());
884+
assert!(parse_allowed_users(Some(&serde_json::Value::Null)).is_empty());
885+
}
886+
887+
#[test]
888+
fn credential_provider_combines_channel_id_and_mode() {
889+
let out = credential_provider("telegram", ChannelAuthMode::BotToken);
890+
assert!(out.starts_with("channel:telegram:"));
891+
let out = credential_provider("discord", ChannelAuthMode::OAuth);
892+
assert!(out.starts_with("channel:discord:"));
893+
}
894+
895+
// ── describe_channel / list_channels ───────────────────────────
896+
897+
#[tokio::test]
898+
async fn list_channels_returns_nonempty_catalog() {
899+
let out = list_channels().await.unwrap();
900+
assert!(!out.value.is_empty());
901+
let slugs: Vec<&str> = out.value.iter().map(|d| d.id).collect();
902+
assert!(slugs.contains(&"telegram"));
903+
}
904+
905+
#[tokio::test]
906+
async fn describe_channel_returns_known_channel() {
907+
let out = describe_channel("telegram").await.unwrap();
908+
assert_eq!(out.value.id, "telegram");
909+
}
910+
911+
#[tokio::test]
912+
async fn describe_channel_errors_for_unknown_channel() {
913+
let err = describe_channel("__definitely_unknown__")
914+
.await
915+
.unwrap_err();
916+
assert!(err.contains("unknown channel"));
917+
}
918+
919+
// ── connect_channel validation ─────────────────────────────────
920+
921+
#[tokio::test]
922+
async fn connect_channel_errors_for_unknown_channel() {
923+
let config = Config::default();
924+
let err = connect_channel(
925+
&config,
926+
"__unknown__",
927+
ChannelAuthMode::BotToken,
928+
serde_json::json!({}),
929+
)
930+
.await
931+
.unwrap_err();
932+
assert!(err.contains("unknown channel"));
933+
}
934+
935+
#[tokio::test]
936+
async fn connect_channel_rejects_non_object_credentials_for_credential_modes() {
937+
let config = Config::default();
938+
let err = connect_channel(
939+
&config,
940+
"telegram",
941+
ChannelAuthMode::BotToken,
942+
serde_json::json!("not an object"),
943+
)
944+
.await
945+
.unwrap_err();
946+
assert!(err.contains("credentials must be a JSON object"));
947+
}
825948
}

‎src/openhuman/channels/providers/discord/api.rs‎

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,14 @@ fn auth_header(token: &str) -> String {
5050

5151
/// List all guilds (servers) the bot is a member of.
5252
pub async fn list_bot_guilds(token: &str) -> anyhow::Result<Vec<DiscordGuild>> {
53-
let url = format!("{DISCORD_API_BASE}/users/@me/guilds");
53+
list_bot_guilds_at_base(DISCORD_API_BASE, token).await
54+
}
55+
56+
/// Test seam: list guilds against an arbitrary API base. Used by
57+
/// `list_bot_guilds` in production and by unit tests that drive a
58+
/// local mock Discord API.
59+
async fn list_bot_guilds_at_base(base: &str, token: &str) -> anyhow::Result<Vec<DiscordGuild>> {
60+
let url = format!("{base}/users/@me/guilds");
5461
tracing::debug!("[discord-api] listing guilds for bot");
5562

5663
let resp = build_client()
@@ -75,7 +82,16 @@ pub async fn list_guild_channels(
7582
token: &str,
7683
guild_id: &str,
7784
) -> anyhow::Result<Vec<DiscordTextChannel>> {
78-
let url = format!("{DISCORD_API_BASE}/guilds/{guild_id}/channels");
85+
list_guild_channels_at_base(DISCORD_API_BASE, token, guild_id).await
86+
}
87+
88+
/// Test seam: list guild channels against an arbitrary API base.
89+
async fn list_guild_channels_at_base(
90+
base: &str,
91+
token: &str,
92+
guild_id: &str,
93+
) -> anyhow::Result<Vec<DiscordTextChannel>> {
94+
let url = format!("{base}/guilds/{guild_id}/channels");
7995
tracing::debug!("[discord-api] listing channels for guild {guild_id}");
8096

8197
let resp = build_client()
@@ -405,4 +421,98 @@ mod tests {
405421
assert_ne!(VIEW_CHANNEL, SEND_MESSAGES);
406422
assert_ne!(SEND_MESSAGES, READ_MESSAGE_HISTORY);
407423
}
424+
425+
// ── Mock Discord server integration tests ──────────────────────
426+
427+
use axum::{extract::Path, http::StatusCode, routing::get, Json, Router};
428+
use serde_json::json;
429+
430+
async fn spawn_mock(app: Router) -> String {
431+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
432+
let addr = listener.local_addr().unwrap();
433+
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
434+
format!("http://127.0.0.1:{}", addr.port())
435+
}
436+
437+
#[tokio::test]
438+
async fn list_bot_guilds_parses_discord_response() {
439+
let app = Router::new().route(
440+
"/users/@me/guilds",
441+
get(|| async {
442+
Json(json!([
443+
{"id": "g1", "name": "Guild One", "icon": "hash1"},
444+
{"id": "g2", "name": "Guild Two", "icon": null}
445+
]))
446+
}),
447+
);
448+
let base = spawn_mock(app).await;
449+
let guilds = list_bot_guilds_at_base(&base, "test-token").await.unwrap();
450+
assert_eq!(guilds.len(), 2);
451+
assert_eq!(guilds[0].id, "g1");
452+
assert_eq!(guilds[0].name, "Guild One");
453+
assert_eq!(guilds[1].icon, None);
454+
}
455+
456+
#[tokio::test]
457+
async fn list_bot_guilds_errors_on_non_success_status() {
458+
let app = Router::new().route(
459+
"/users/@me/guilds",
460+
get(|| async { (StatusCode::UNAUTHORIZED, "bad token") }),
461+
);
462+
let base = spawn_mock(app).await;
463+
let err = list_bot_guilds_at_base(&base, "t")
464+
.await
465+
.unwrap_err()
466+
.to_string();
467+
assert!(err.contains("list guilds failed"));
468+
assert!(err.contains("401"));
469+
}
470+
471+
#[tokio::test]
472+
async fn list_guild_channels_filters_text_channels_and_sorts_by_position() {
473+
let app = Router::new().route(
474+
"/guilds/{guild_id}/channels",
475+
get(|Path(guild_id): Path<String>| async move {
476+
assert_eq!(guild_id, "g1");
477+
Json(json!([
478+
{"id": "c3", "name": "category", "type": 4, "position": 0, "parent_id": null},
479+
{"id": "c1", "name": "general", "type": 0, "position": 2, "parent_id": null},
480+
{"id": "c2", "name": "random", "type": 0, "position": 1, "parent_id": null},
481+
{"id": "c4", "name": "voice", "type": 2, "position": 3, "parent_id": null}
482+
]))
483+
}),
484+
);
485+
let base = spawn_mock(app).await;
486+
let channels = list_guild_channels_at_base(&base, "t", "g1").await.unwrap();
487+
// Only text channels (type=0) remain, sorted by position ascending.
488+
assert_eq!(channels.len(), 2);
489+
assert_eq!(channels[0].id, "c2");
490+
assert_eq!(channels[1].id, "c1");
491+
}
492+
493+
#[tokio::test]
494+
async fn list_guild_channels_errors_on_non_success_status() {
495+
let app = Router::new().route(
496+
"/guilds/{guild_id}/channels",
497+
get(|| async { (StatusCode::FORBIDDEN, "nope") }),
498+
);
499+
let base = spawn_mock(app).await;
500+
let err = list_guild_channels_at_base(&base, "t", "g1")
501+
.await
502+
.unwrap_err()
503+
.to_string();
504+
assert!(err.contains("list channels failed"));
505+
assert!(err.contains("403"));
506+
}
507+
508+
#[tokio::test]
509+
async fn list_guild_channels_empty_returns_empty_vec() {
510+
let app = Router::new().route(
511+
"/guilds/{guild_id}/channels",
512+
get(|| async { Json(json!([])) }),
513+
);
514+
let base = spawn_mock(app).await;
515+
let channels = list_guild_channels_at_base(&base, "t", "g").await.unwrap();
516+
assert!(channels.is_empty());
517+
}
408518
}

‎src/openhuman/local_ai/ollama_api.rs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,23 @@
22
33
use serde::{Deserialize, Serialize};
44

5-
pub(crate) const OLLAMA_BASE_URL: &str = "http://localhost:11434";
5+
pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434";
6+
7+
/// Returns the effective Ollama base URL, honouring the
8+
/// `OPENHUMAN_OLLAMA_BASE_URL` env override so tests can point it at
9+
/// a local mock server. In production builds this is always
10+
/// [`DEFAULT_OLLAMA_BASE_URL`] unless an operator has deliberately
11+
/// pointed the sidecar at a remote Ollama.
12+
pub(crate) fn ollama_base_url() -> String {
13+
match std::env::var("OPENHUMAN_OLLAMA_BASE_URL") {
14+
Ok(url) if !url.trim().is_empty() => url.trim().trim_end_matches('/').to_string(),
15+
_ => DEFAULT_OLLAMA_BASE_URL.to_string(),
16+
}
17+
}
18+
19+
/// Back-compat constant kept at its original value for callers that
20+
/// reference it directly. New callers should use [`ollama_base_url`].
21+
pub(crate) const OLLAMA_BASE_URL: &str = DEFAULT_OLLAMA_BASE_URL;
622

723
#[derive(Debug, Serialize)]
824
pub(crate) struct OllamaPullRequest {

0 commit comments

Comments
 (0)