From a8664f066f790e372c181200ee5f8b22f44c2157 Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Thu, 16 Apr 2026 05:01:51 +0530 Subject: [PATCH] =?UTF-8?q?test(coverage):=20batch=201=E2=80=934=20?= =?UTF-8?q?=E2=80=94=20Rust=20unit=20tests=20toward=2080%=20for=20critical?= =?UTF-8?q?=20modules=20(#530)=20(#581)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(coverage): phase 1 — webhooks/types, workspace/ops, webhooks/schemas Lines: webhooks/types 0% → 100%; workspace/ops 0% → 96.98%; webhooks/schemas 35.40% → 79.18%. - webhooks/types.rs: serde round-trip tests for WebhookRequest / WebhookResponseData / TunnelRegistration (including the default_webhook_target_kind fallback) / WebhookActivityEntry / WebhookDebugLogEntry / the three debug result wrappers / and WebhookDebugEvent, exercising every `#[serde(default)]` and camel-case rename. - workspace/ops.rs: ensure_workspace_file covers create / leave / force / write-error branches; BOOTSTRAP_FILES contract test locks in SOUL and IDENTITY presence; init_workspace covers fresh-install, idempotent second-call, and forced-overwrite paths against a temp OPENHUMAN_WORKSPACE (serialised via the shared TEST_ENV_LOCK). - webhooks/schemas.rs: catalog integrity (length / namespace / uniqueness / schema↔handler parity), per-function required-field assertions for all 11 RPC methods plus the unknown fallback, and deserialize_params / json_output / to_json coverage. Also fixes a pre-existing test bug in composio/ops.rs discovered while running llvm-cov: fetch_connected_integrations_via_mock_aggregates_tools was not mocking /composio/toolkits, so list_toolkits failed and the expected aggregation never happened. Adds the missing route so the test now observes the 2-integration result it asserts. * test(coverage): phase 2 — webhooks/bus, webhooks/ops Lines: webhooks/bus 0% → 100%; webhooks/ops 14.34% → 97.96%. - webhooks/bus.rs: base64_encode / error_body helpers; Default vs new equivalence; EventHandler name and domain filter; handle() on a non-webhook variant (early return) and on a WebhookIncomingRequest without a registered socket manager (graceful fallthrough). - webhooks/ops.rs: require_token for missing / whitespace / valid stored sessions; the four stub RPC ops (list_registrations, list_logs including ignored-limit, clear_logs, register/unregister_echo) lock in their current payload + log shape; build_echo_response decodes back to the expected echo body and sets the echo-target header; trimmed-input validation for create_tunnel (empty/whitespace name) and the id-bearing ops (get/update/delete); and full mock-backend round-trips for list_tunnels, create_tunnel (trim + drop-whitespace description), get_tunnel, update_tunnel, delete_tunnel, and get_bandwidth, plus a guard asserting authed HTTP calls fail fast without a session token. * test(coverage): phase 3 — voice/postprocess, voice/text_input Lines: voice/postprocess 58.94% → 92.06%; voice/text_input 18.83% → 51.18%. voice/postprocess.rs - Convert existing short-circuit tests to #[tokio::test] so the async function is awaited directly instead of via a freshly-constructed runtime per case. - Add `enabled_but_llm_not_ready_returns_raw_text` for the "cleanup is on but the local LLM hasn't reached ready/degraded yet" branch. - Add five LLM-ready tests that spin up a mock Ollama behind the OPENHUMAN_OLLAMA_BASE_URL override: happy-path cleanup + trim, whitespace-only response fallback, HTTP 500 fallback, conversation context embedded in the prompt, and whitespace-only context ignored. Assertions are permissive about "LLM called → cleaned" vs "LLM short-circuited → raw" because ~30 sibling tests touch the shared `local_ai::global()` singleton without LOCAL_AI_TEST_MUTEX and can race our `state = "ready"` setup. Either branch is documented as acceptable; the function must always return a deterministic String and never panic. Full end-to-end correctness of the cleanup output is pinned by the deterministic short-circuit tests above. voice/text_input.rs - Add `\\t` / `\\n`-only input case and verify the OpenWhispr timing constants (PASTE_DELAY 120ms, CLIPBOARD_RESTORE_DELAY 450ms) so nobody silently shortens them and breaks paste reliability. - macOS: escape_applescript_string backslash/quote/idempotence cases and restore_focus_to_app error path against a bogus app name. - Ceiling note: the clipboard/enigo body of insert_text is not testable in a headless environment (Clipboard::new / Enigo::new fail without a display). Pushing past ~51% here requires dependency-injecting those traits — a production refactor, not a test addition. * test(coverage): batch 4.1 — skills/bus, text_input/{types,ops} Lines: skills/bus 0% → 100%; text_input/types 0% → 100%; text_input/ops 0% → 57.67% (accessibility-gated ceiling). - skills/bus.rs: idempotent no-op for the legacy register_skill_cleanup_subscriber() hook. - text_input/types.rs: FieldBounds ↔ accessibility::ElementBounds round-trip, ReadFieldParams default/omitted-key serde, and round-trips for every request/result struct (InsertText, ShowGhostText, DismissGhostText, AcceptGhostText). - text_input/ops.rs: empty-text guard assertions for insert_text, show_ghost, and accept_ghost; dismiss_ghost idempotent-success contract; plus a deterministic-shape check that the accessibility failure path wraps the error into InsertTextResult rather than panicking. Anything past the guard reaches `accessibility::*`, which requires a live focused field on an OS display and is therefore not reachable in a headless unit-test environment — lifting the ceiling past ~58% requires dependency-injecting the accessibility surface, a production refactor. * test(coverage): batch 4.2 — service/bus, migration/ops, referral/ops Lines: service/bus 0% → 85.25%; migration/ops 0% → 89.71%; referral/ops 0% → 94.63%. - service/bus.rs: RestartSubscriber name and domain metadata, plus two handle() branches that are safe to exercise — non-restart event (early return) and duplicate-suppression (gate already set). Deliberately does NOT cover the success path of a real SystemRestartRequested — it spawns a tokio task that calls std::process::exit(0) and would terminate the test runner. register_restart_subscriber idempotency test confirms the OnceLock guard skips re-registration. - migration/ops.rs: dry-run against an empty temp workspace returns the canonical "migration completed" log; missing source-workspace path exercises the Err-propagation branch. - referral/ops.rs: require_token for missing / whitespace / valid sessions; get_stats + claim_referral guard fast-fail without a session; claim_referral round-trip against a mock backend asserting (a) the referral code is trimmed, (b) a whitespace-only deviceFingerprint is dropped from the outgoing body, and (c) a non-empty fingerprint is trimmed and forwarded. * test(coverage): batch 4.3 — security/ops, service/{bus,ops}, update/{ops,scheduler} Adds unit tests covering: - security/ops: security_policy_info payload shape + default values - service/ops: daemon_host_get/set happy path and error branches - service/bus: fix register_restart_subscriber test to use #[tokio::test] so it has a runtime under `cargo llvm-cov` - update/ops: validate_asset_name and validate_download_url edge cases, update_apply short-circuit guards - update/scheduler: min-interval invariant, disabled-run short-circuit, tick resilience when the event bus is uninitialised All 3583 lib tests pass under `cargo llvm-cov`. Refs #530. * style: cargo fmt cleanup in coverage test modules Auto-fixes from `cargo fmt` on test code added in batches 4.1–4.3. No behavioral changes. * test(coverage): batch 5.1 — cron/{ops,schemas} - cron/ops.rs: 74.73% → 91.20% (+add_once_at, pause/resume, async cron_list/update/remove/runs, enabled/disabled and empty-id branches) - cron/schemas.rs: 29.11% → 77.00% (all schemas() branches, registry helpers, read_required/read_optional_u64/type_name) 30 new deterministic tests. Refs #530. * test(coverage): batch 5.2 — memory/{rpc_models,schemas} - memory/rpc_models.rs: 70.91% (targets resolved_limit priority across QueryNamespace/RecallContext/RecallMemories, deny_unknown_fields enforcement, ApiError/ApiMeta/ApiEnvelope round-trips) - memory/schemas.rs: 45.67% (all 31 controller schemas present, registry parity with handlers, parse_params success + error paths, unknown function placeholder) 19 new deterministic tests. Refs #530. * test(coverage): batch 5.3 — socket/manager webhook router paths - socket/manager.rs: 67.54% (adds set_webhook_router populates/overwrites paths and emit-after-disconnect guard) 3 new deterministic tests. Refs #530. * test(coverage): batch 5.4 — config/{schemas, schema/observability} - config/schemas.rs: 52.37% (adds required_string / optional_string / optional_bool field builder coverage, exercises deserialize_params across ModelSettings / MemorySettings / WorkspaceOnboarding / SetBrowserAllowAll / OnboardingCompleted params, pins DEFAULT_ONBOARDING_FLAG_NAME constant) - config/schema/observability.rs: 66.67% (default-values invariant, serde defaults for optional fields, explicit analytics flag, round-trip) 16 new deterministic tests. Refs #530. * test(coverage): batch 5.5 — local_ai/{core,gif_decision} - local_ai/core.rs: 33.33% (pins model_artifact_path structure: models/local-ai dir, `.ollama` suffix, colon→dash normalisation for Windows-safe filenames; Arc sharing across global() calls) - local_ai/gif_decision.rs: 44.04% (trim whitespace in parse, length/word-count boundary cases, tenor_search empty-query guard, local_ai_should_send_gif empty-message early return) 10 new deterministic tests. Refs #530. * test(coverage): batch 5.6 — local_ai/sentiment, migration/schemas, referral/schemas - local_ai/sentiment.rs: 68.03% (negative-confidence clamp to zero, unknown valence fallback, all documented emotion/valence labels accepted, neutral() constructor invariants, empty-message early return) - migration/schemas.rs: 36.73% (controller registry parity, openclaw input shape, MigrateOpenClawParams defaults + round-trip, unknown function placeholder, to_json wrapping) - referral/schemas.rs: 37.18% (controller registry parity, claim input required/optional mapping, ReferralClaimParams camelCase alias + missing-code rejection, json_output + to_json helpers) 25 new deterministic tests. Refs #530. * test(coverage): batch 5.7 — tools/traits, local_ai/device - tools/traits.rs: 76.77% (Tool default-method values for permission/scope/category, PermissionLevel total order + default + Display + serde round-trip, ToolCategory default + Display + snake_case serde, ToolScope variant distinctness) - local_ai/device.rs: 63.16% (total_ram_gb truncation for sub-GB and partial-GB, detect_gpu branches for Apple-brand / ARM-on-mac / Intel-Mac, DeviceProfile serde round-trip) 17 new deterministic tests. Refs #530. * test(coverage): address PR review — tighten assertions, fix flaky test, add env-var RAII guard Inline review fixes: - migration/ops.rs: migrate_openclaw_returns_error_for_missing_source_workspace now requires Err() (the underlying helper bails when the source dir doesn't exist) and asserts a non-empty error message. - text_input/ops.rs: replace tautological `!inserted || inserted` in insert_text_surfaces_accessibility_failure_as_inserted_false with the real contract: require Ok(..) and pin `inserted`↔`error` mutual exclusion. Headless runs legitimately see inserted=false, so the assertion still holds while catching regressions in either branch. - update/scheduler.rs: remove tick_runs_without_panicking_when_event_bus_is_uninitialised — it hit real api.github.com HTTPS and was flaky under offline CI / rate limits. Replace with a comment documenting the decision and the integration-test path for exercising tick(). Nitpick fixes: - security/ops.rs: extend security_policy_info_matches_default_policy_values with assertions for autonomy and allowed_commands so the full default shape is pinned. - voice/postprocess.rs: tighten ready_llm_with_whitespace_only_context_never_embeds_header from `assert_eq!(result.trim(), "raw text")` to exact equality (cleanup_transcription trims internally). The pre-existing doc comment on with_ready_llm plus the in-module block comment at lines 255-268 already document the LOCAL_AI_TEST_MUTEX contract and the deliberate non-use in permissive tests — no new docs needed. - webhooks/ops.rs: list_tunnels_hits_webhooks_core_endpoint_and_returns_payload now asserts the inbound Authorization header equals `Bearer test-session-token`; get_tunnel_encodes_id_in_path uses an id full of reserved URL chars so the test actually verifies percent-encoding rather than just trimming. - workspace/ops.rs: introduce a WorkspaceEnvGuard RAII helper; replace all six unsafe set_var/remove_var pairs in the init_workspace tests so OPENHUMAN_WORKSPACE is cleared on panic too. Contract is documented inline: guard requires the caller to hold ENV_LOCK. Refs #530. * test(coverage): tighten sentry_dsn round-trip assertion to exact value round_trip_preserves_all_fields previously only checked `back.sentry_dsn.is_some()`, which would still pass if serde dropped or corrupted the DSN string. Compare the full decoded value instead so any regression in the Option path is caught. Refs #530. --- src/openhuman/config/schema/observability.rs | 63 +++ src/openhuman/config/schemas.rs | 113 ++++++ src/openhuman/cron/ops.rs | 142 +++++++ src/openhuman/cron/schemas.rs | 169 ++++++++ src/openhuman/local_ai/core.rs | 51 +++ src/openhuman/local_ai/device.rs | 71 ++++ src/openhuman/local_ai/gif_decision.rs | 55 +++ src/openhuman/local_ai/sentiment.rs | 49 +++ src/openhuman/memory/rpc_models.rs | 206 +++++++++- src/openhuman/memory/schemas.rs | 121 ++++++ src/openhuman/migration/ops.rs | 56 +++ src/openhuman/migration/schemas.rs | 68 ++++ src/openhuman/referral/ops.rs | 166 ++++++++ src/openhuman/referral/schemas.rs | 95 +++++ src/openhuman/security/ops.rs | 57 +++ src/openhuman/service/bus.rs | 63 +++ src/openhuman/service/ops.rs | 88 +++++ src/openhuman/skills/bus.rs | 14 + src/openhuman/socket/manager.rs | 32 ++ src/openhuman/text_input/ops.rs | 125 ++++++ src/openhuman/text_input/types.rs | 177 +++++++++ src/openhuman/tools/traits.rs | 97 +++++ src/openhuman/update/ops.rs | 113 ++++++ src/openhuman/update/scheduler.rs | 39 ++ src/openhuman/voice/postprocess.rs | 272 ++++++++++++- src/openhuman/voice/text_input.rs | 65 +++- src/openhuman/webhooks/bus.rs | 92 +++++ src/openhuman/webhooks/ops.rs | 381 +++++++++++++++++++ src/openhuman/webhooks/schemas.rs | 257 +++++++++++++ src/openhuman/webhooks/types.rs | 234 ++++++++++++ src/openhuman/workspace/ops.rs | 212 +++++++++++ 31 files changed, 3721 insertions(+), 22 deletions(-) diff --git a/src/openhuman/config/schema/observability.rs b/src/openhuman/config/schema/observability.rs index 0f3e1823a..64073af83 100644 --- a/src/openhuman/config/schema/observability.rs +++ b/src/openhuman/config/schema/observability.rs @@ -41,3 +41,66 @@ impl Default for ObservabilityConfig { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn default_disables_backend_and_enables_analytics() { + let cfg = ObservabilityConfig::default(); + assert_eq!(cfg.backend, "none"); + assert!(cfg.otel_endpoint.is_none()); + assert!(cfg.otel_service_name.is_none()); + assert!(cfg.sentry_dsn.is_none()); + assert!(cfg.analytics_enabled); + } + + #[test] + fn default_analytics_enabled_helper_returns_true() { + assert!(default_analytics_enabled()); + } + + #[test] + fn deserialize_missing_optional_fields_uses_defaults() { + let cfg: ObservabilityConfig = serde_json::from_value(json!({ + "backend": "log" + })) + .unwrap(); + assert_eq!(cfg.backend, "log"); + assert!(cfg.otel_endpoint.is_none()); + assert!(cfg.analytics_enabled, "analytics default must be true"); + } + + #[test] + fn deserialize_respects_explicit_analytics_flag() { + let cfg: ObservabilityConfig = serde_json::from_value(json!({ + "backend": "otel", + "analytics_enabled": false + })) + .unwrap(); + assert!(!cfg.analytics_enabled); + } + + #[test] + fn round_trip_preserves_all_fields() { + let original = ObservabilityConfig { + backend: "otel".into(), + otel_endpoint: Some("http://localhost:4318".into()), + otel_service_name: Some("openhuman-test".into()), + sentry_dsn: Some("https://token@sentry.io/1".into()), + analytics_enabled: false, + }; + let s = serde_json::to_string(&original).unwrap(); + let back: ObservabilityConfig = serde_json::from_str(&s).unwrap(); + assert_eq!(back.backend, "otel"); + assert_eq!(back.otel_endpoint.as_deref(), Some("http://localhost:4318")); + assert_eq!(back.otel_service_name.as_deref(), Some("openhuman-test")); + assert_eq!( + back.sentry_dsn.as_deref(), + Some("https://token@sentry.io/1") + ); + assert!(!back.analytics_enabled); + } +} diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index c1da70fb3..722f0fae9 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -870,4 +870,117 @@ mod tests { .expect("serialize"); assert!(v.get("logs").is_some() || v.get("result").is_some()); } + + // ── Field builder helpers ──────────────────────────────────── + + #[test] + fn required_string_builds_required_string_field() { + let f = required_string("api_key", "Auth key"); + assert_eq!(f.name, "api_key"); + assert_eq!(f.comment, "Auth key"); + assert!(f.required); + assert!(matches!(f.ty, TypeSchema::String)); + } + + #[test] + fn optional_string_builds_option_string_field() { + let f = optional_string("model", "model name"); + assert!(!f.required); + match &f.ty { + TypeSchema::Option(inner) => assert!(matches!(**inner, TypeSchema::String)), + other => panic!("expected Option, got {other:?}"), + } + } + + #[test] + fn optional_bool_builds_option_bool_field() { + let f = optional_bool("enabled", "Whether enabled"); + assert!(!f.required); + match &f.ty { + TypeSchema::Option(inner) => assert!(matches!(**inner, TypeSchema::Bool)), + other => panic!("expected Option, got {other:?}"), + } + } + + // ── deserialize_params helper ──────────────────────────────── + + #[test] + fn deserialize_params_parses_model_settings_update() { + let mut m = Map::new(); + m.insert("api_key".into(), Value::String("sk-123".into())); + m.insert( + "default_temperature".into(), + Value::Number(serde_json::Number::from_f64(0.7).unwrap()), + ); + let out: ModelSettingsUpdate = deserialize_params(m).unwrap(); + assert_eq!(out.api_key.as_deref(), Some("sk-123")); + assert_eq!(out.default_temperature, Some(0.7)); + assert!(out.api_url.is_none()); + assert!(out.default_model.is_none()); + } + + #[test] + fn deserialize_params_parses_memory_settings_update() { + let mut m = Map::new(); + m.insert("backend".into(), Value::String("sqlite".into())); + m.insert("auto_save".into(), Value::Bool(true)); + m.insert( + "embedding_dimensions".into(), + Value::Number(serde_json::Number::from(1536)), + ); + let out: MemorySettingsUpdate = deserialize_params(m).unwrap(); + assert_eq!(out.backend.as_deref(), Some("sqlite")); + assert_eq!(out.auto_save, Some(true)); + assert_eq!(out.embedding_dimensions, Some(1536)); + } + + #[test] + fn deserialize_params_parses_workspace_onboarding_flag_params() { + let out: WorkspaceOnboardingFlagParams = deserialize_params(Map::new()).unwrap(); + assert!(out.flag_name.is_none()); + + let mut m = Map::new(); + m.insert("flag_name".into(), Value::String(".custom_marker".into())); + let out: WorkspaceOnboardingFlagParams = deserialize_params(m).unwrap(); + assert_eq!(out.flag_name.as_deref(), Some(".custom_marker")); + } + + #[test] + fn deserialize_params_parses_workspace_onboarding_flag_set_params() { + let mut m = Map::new(); + m.insert("value".into(), Value::Bool(true)); + let out: WorkspaceOnboardingFlagSetParams = deserialize_params(m).unwrap(); + assert_eq!(out.value, true); + assert!(out.flag_name.is_none()); + } + + #[test] + fn deserialize_params_rejects_wrong_types_with_invalid_params_prefix() { + let mut m = Map::new(); + m.insert( + "default_temperature".into(), + Value::String("not-a-number".into()), + ); + let err = deserialize_params::(m).unwrap_err(); + assert!(err.starts_with("invalid params")); + } + + #[test] + fn deserialize_params_requires_value_on_set_onboarding() { + let err = deserialize_params::(Map::new()).unwrap_err(); + assert!(err.contains("invalid params")); + } + + #[test] + fn deserialize_params_rejects_missing_required_for_set_browser_allow_all() { + let err = deserialize_params::(Map::new()).unwrap_err(); + assert!(err.contains("invalid params")); + } + + #[test] + fn default_onboarding_flag_constant_points_to_hidden_marker() { + // Keeps the constant's observable value pinned so tool behavior + // stays stable across refactors. + assert_eq!(DEFAULT_ONBOARDING_FLAG_NAME, ".skip_onboarding"); + } } diff --git a/src/openhuman/cron/ops.rs b/src/openhuman/cron/ops.rs index ab7d8c09a..090aeee21 100644 --- a/src/openhuman/cron/ops.rs +++ b/src/openhuman/cron/ops.rs @@ -448,4 +448,146 @@ mod tests { assert!(add_once(&config, "", "cmd").is_err()); assert!(add_once(&config, "5x", "cmd").is_err()); } + + // ── add_once_at ───────────────────────────────────────────────── + + #[test] + fn add_once_at_stores_exact_timestamp() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let when = chrono::Utc::now() + chrono::Duration::hours(1); + let job = add_once_at(&config, when, "echo hi").unwrap(); + match job.schedule { + Schedule::At { at } => assert_eq!(at, when), + other => panic!("expected At schedule, got {other:?}"), + } + } + + // ── pause_job / resume_job ────────────────────────────────────── + + #[test] + fn pause_and_resume_toggle_enabled_flag() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let job = make_job(&config, "*/5 * * * *", None, "echo test"); + assert!(job.enabled); + + let paused = pause_job(&config, &job.id).unwrap(); + assert!(!paused.enabled); + + let resumed = resume_job(&config, &job.id).unwrap(); + assert!(resumed.enabled); + } + + // ── cron_list / cron_update / cron_remove / cron_runs ─────────── + + fn disabled_cron_config(tmp: &TempDir) -> Config { + let mut config = test_config(tmp); + config.cron.enabled = false; + config + } + + #[tokio::test] + async fn cron_list_errors_when_cron_disabled() { + let tmp = TempDir::new().unwrap(); + let config = disabled_cron_config(&tmp); + let err = cron_list(&config).await.unwrap_err(); + assert!(err.contains("cron is disabled")); + } + + #[tokio::test] + async fn cron_list_returns_jobs_when_enabled() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let job = make_job(&config, "*/5 * * * *", None, "echo test"); + let out = cron_list(&config).await.unwrap(); + assert!(out.value.iter().any(|j| j.id == job.id)); + assert!(out.logs.iter().any(|l| l.contains("cron jobs listed"))); + } + + #[tokio::test] + async fn cron_update_rejects_empty_job_id() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let err = cron_update(&config, " ", CronJobPatch::default()) + .await + .unwrap_err(); + assert!(err.contains("Missing 'job_id'")); + } + + #[tokio::test] + async fn cron_update_errors_when_cron_disabled() { + let tmp = TempDir::new().unwrap(); + let config = disabled_cron_config(&tmp); + let err = cron_update(&config, "some-id", CronJobPatch::default()) + .await + .unwrap_err(); + assert!(err.contains("cron is disabled")); + } + + #[tokio::test] + async fn cron_update_mutates_existing_job() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let job = make_job(&config, "*/5 * * * *", None, "echo test"); + let patch = CronJobPatch { + name: Some("renamed".to_string()), + ..CronJobPatch::default() + }; + let out = cron_update(&config, &job.id, patch).await.unwrap(); + assert_eq!(out.value.name.as_deref(), Some("renamed")); + assert!(out.logs.iter().any(|l| l.contains("cron job updated"))); + } + + #[tokio::test] + async fn cron_remove_rejects_empty_job_id() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let err = cron_remove(&config, "").await.unwrap_err(); + assert!(err.contains("Missing 'job_id'")); + } + + #[tokio::test] + async fn cron_remove_errors_when_cron_disabled() { + let tmp = TempDir::new().unwrap(); + let config = disabled_cron_config(&tmp); + let err = cron_remove(&config, "abc").await.unwrap_err(); + assert!(err.contains("cron is disabled")); + } + + #[tokio::test] + async fn cron_remove_returns_removed_true_on_success() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let job = make_job(&config, "*/5 * * * *", None, "echo test"); + let out = cron_remove(&config, &job.id).await.unwrap(); + assert_eq!(out.value["job_id"], json!(job.id)); + assert_eq!(out.value["removed"], json!(true)); + } + + #[tokio::test] + async fn cron_runs_rejects_empty_job_id() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let err = cron_runs(&config, "", None).await.unwrap_err(); + assert!(err.contains("Missing 'job_id'")); + } + + #[tokio::test] + async fn cron_runs_errors_when_cron_disabled() { + let tmp = TempDir::new().unwrap(); + let config = disabled_cron_config(&tmp); + let err = cron_runs(&config, "abc", Some(5)).await.unwrap_err(); + assert!(err.contains("cron is disabled")); + } + + #[tokio::test] + async fn cron_runs_returns_empty_history_for_new_job() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let job = make_job(&config, "*/5 * * * *", None, "echo test"); + let out = cron_runs(&config, &job.id, Some(10)).await.unwrap(); + assert!(out.value.is_empty()); + assert!(out.logs.iter().any(|l| l.contains("cron run history"))); + } } diff --git a/src/openhuman/cron/schemas.rs b/src/openhuman/cron/schemas.rs index a7b0a6e42..fe87402c0 100644 --- a/src/openhuman/cron/schemas.rs +++ b/src/openhuman/cron/schemas.rs @@ -275,3 +275,172 @@ fn type_name(value: &Value) -> &'static str { Value::Object(_) => "object", } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ── schemas() branch coverage ─────────────────────────────────── + + #[test] + fn schemas_list_has_no_inputs_and_jobs_output() { + let s = schemas("list"); + assert_eq!(s.namespace, "cron"); + assert_eq!(s.function, "list"); + assert!(s.inputs.is_empty()); + assert_eq!(s.outputs.len(), 1); + assert_eq!(s.outputs[0].name, "jobs"); + } + + #[test] + fn schemas_update_requires_job_id_and_patch() { + let s = schemas("update"); + let names: Vec<_> = s.inputs.iter().map(|f| f.name).collect(); + assert!(names.contains(&"job_id")); + assert!(names.contains(&"patch")); + assert!(s.inputs.iter().all(|f| f.required)); + } + + #[test] + fn schemas_remove_has_job_id_input_and_result_output() { + let s = schemas("remove"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "job_id"); + assert_eq!(s.outputs[0].name, "result"); + } + + #[test] + fn schemas_run_result_contains_status_and_duration_fields() { + let s = schemas("run"); + // Status is an enum with ok/error — clients rely on this shape. + if let TypeSchema::Object { fields } = &s.outputs[0].ty { + let names: Vec<_> = fields.iter().map(|f| f.name).collect(); + assert!(names.contains(&"status")); + assert!(names.contains(&"duration_ms")); + assert!(names.contains(&"output")); + assert!(names.contains(&"job_id")); + } else { + panic!("expected object output type"); + } + } + + #[test] + fn schemas_runs_limit_is_optional() { + let s = schemas("runs"); + let limit = s.inputs.iter().find(|f| f.name == "limit").unwrap(); + assert!(!limit.required); + } + + #[test] + fn schemas_unknown_function_returns_placeholder_with_error_output() { + // The `_other` branch is used when a caller requests a schema + // for a function that does not exist — it should not panic. + let s = schemas("does-not-exist"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.outputs[0].name, "error"); + } + + // ── registry helpers ──────────────────────────────────────────── + + #[test] + fn all_controller_schemas_covers_every_supported_function() { + let names: Vec<_> = all_controller_schemas() + .into_iter() + .map(|s| s.function) + .collect(); + assert_eq!(names, vec!["list", "update", "remove", "run", "runs"]); + } + + #[test] + fn all_registered_controllers_has_handler_per_schema() { + let controllers = all_registered_controllers(); + assert_eq!(controllers.len(), 5); + let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect(); + assert_eq!(names, vec!["list", "update", "remove", "run", "runs"]); + } + + // ── read_required ─────────────────────────────────────────────── + + #[test] + fn read_required_returns_value_for_present_key() { + let mut params = Map::new(); + params.insert("job_id".into(), json!("abc")); + let got: String = read_required(¶ms, "job_id").unwrap(); + assert_eq!(got, "abc"); + } + + #[test] + fn read_required_errors_when_key_missing() { + let params = Map::new(); + let err = read_required::(¶ms, "job_id").unwrap_err(); + assert!(err.contains("missing required param 'job_id'")); + } + + #[test] + fn read_required_errors_when_deserialization_fails() { + let mut params = Map::new(); + params.insert("job_id".into(), json!(42)); + let err = read_required::(¶ms, "job_id").unwrap_err(); + assert!(err.contains("invalid 'job_id'")); + } + + // ── read_optional_u64 ─────────────────────────────────────────── + + #[test] + fn read_optional_u64_absent_key_is_none() { + assert_eq!(read_optional_u64(&Map::new(), "limit").unwrap(), None); + } + + #[test] + fn read_optional_u64_explicit_null_is_none() { + let mut params = Map::new(); + params.insert("limit".into(), Value::Null); + assert_eq!(read_optional_u64(¶ms, "limit").unwrap(), None); + } + + #[test] + fn read_optional_u64_accepts_unsigned_integer() { + let mut params = Map::new(); + params.insert("limit".into(), json!(42)); + assert_eq!(read_optional_u64(¶ms, "limit").unwrap(), Some(42)); + } + + #[test] + fn read_optional_u64_rejects_negative_number() { + let mut params = Map::new(); + params.insert("limit".into(), json!(-1)); + let err = read_optional_u64(¶ms, "limit").unwrap_err(); + assert!(err.contains("expected unsigned integer")); + } + + #[test] + fn read_optional_u64_rejects_non_number_types() { + for (tag, v) in [ + ("string", json!("ten")), + ("bool", json!(true)), + ("array", json!([1, 2])), + ("object", json!({"k": 1})), + ] { + let mut params = Map::new(); + params.insert("limit".into(), v); + let err = read_optional_u64(¶ms, "limit").unwrap_err(); + assert!( + err.contains("expected unsigned integer"), + "tag={tag} err={err}" + ); + } + } + + // ── type_name ─────────────────────────────────────────────────── + + #[test] + fn type_name_reports_each_json_variant() { + assert_eq!(type_name(&Value::Null), "null"); + assert_eq!(type_name(&json!(true)), "bool"); + assert_eq!(type_name(&json!(1)), "number"); + assert_eq!(type_name(&json!("s")), "string"); + assert_eq!(type_name(&json!([])), "array"); + assert_eq!(type_name(&json!({})), "object"); + } +} diff --git a/src/openhuman/local_ai/core.rs b/src/openhuman/local_ai/core.rs index cec637583..6331f75f8 100644 --- a/src/openhuman/local_ai/core.rs +++ b/src/openhuman/local_ai/core.rs @@ -26,3 +26,54 @@ pub fn model_artifact_path(config: &Config) -> PathBuf { .join("local-ai") .join(effective_chat_model_id(config).replace(':', "-") + ".ollama") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_artifact_path_includes_models_local_ai_subdirs() { + let config = Config::default(); + let path = model_artifact_path(&config); + let path_str = path.to_string_lossy(); + assert!( + path_str.contains("models"), + "expected `models` in path: {path_str}" + ); + assert!( + path_str.contains("local-ai"), + "expected `local-ai` subdir in path: {path_str}" + ); + } + + #[test] + fn model_artifact_path_ends_with_ollama_suffix() { + let config = Config::default(); + let path = model_artifact_path(&config); + assert_eq!( + path.extension().and_then(|s| s.to_str()), + Some("ollama"), + "model artifact must have `.ollama` extension: {}", + path.display() + ); + } + + #[test] + fn model_artifact_path_replaces_colon_in_model_id_with_dash() { + // Model IDs commonly look like `qwen2:1.5b`; colons are illegal on + // Windows path components, so we normalise to `-`. This test pins + // that mapping. + let config = Config::default(); + let path = model_artifact_path(&config); + let file = path.file_name().unwrap().to_string_lossy().to_string(); + assert!(!file.contains(':'), "filename must not contain `:`: {file}"); + } + + #[test] + fn global_returns_same_arc_across_calls() { + let config = Config::default(); + let a = global(&config); + let b = global(&config); + assert!(Arc::ptr_eq(&a, &b), "global() must return a shared Arc"); + } +} diff --git a/src/openhuman/local_ai/device.rs b/src/openhuman/local_ai/device.rs index 709a56394..f1f2db489 100644 --- a/src/openhuman/local_ai/device.rs +++ b/src/openhuman/local_ai/device.rs @@ -148,4 +148,75 @@ mod tests { }; assert_eq!(profile.total_ram_gb(), 16); } + + #[test] + fn total_ram_gb_reports_zero_for_sub_gb_systems() { + let profile = DeviceProfile { + total_ram_bytes: 500_000_000, + cpu_count: 1, + cpu_brand: "x".into(), + os_name: "x".into(), + os_version: "1".into(), + has_gpu: false, + gpu_description: None, + }; + assert_eq!(profile.total_ram_gb(), 0); + } + + #[test] + fn total_ram_gb_truncates_partial_gigabyte() { + // 1 GiB + 512 MiB should round down to 1 GiB. + let profile = DeviceProfile { + total_ram_bytes: (1024 * 1024 * 1024) + (512 * 1024 * 1024), + cpu_count: 1, + cpu_brand: "x".into(), + os_name: "x".into(), + os_version: "1".into(), + has_gpu: false, + gpu_description: None, + }; + assert_eq!(profile.total_ram_gb(), 1); + } + + #[test] + fn detect_gpu_reports_apple_silicon_from_brand() { + let (has, desc) = detect_gpu("Apple M2 Pro", "Darwin"); + assert!(has); + assert_eq!(desc.as_deref(), Some("Apple Silicon (Metal)")); + } + + #[test] + fn detect_gpu_reports_apple_silicon_from_arm_on_mac() { + // macOS + ARM CPU but brand lacks the literal "apple" string — + // the arm+mac heuristic must still flag this as Apple Silicon. + let (has, desc) = detect_gpu("arm based", "macOS"); + assert!(has); + assert_eq!(desc.as_deref(), Some("Apple Silicon (Metal)")); + } + + #[test] + fn detect_gpu_reports_no_gpu_on_intel_mac() { + let (has, desc) = detect_gpu("Intel Core i7", "macOS"); + assert!(!has); + assert_eq!(desc.as_deref(), Some("Intel Mac (no Metal GPU)")); + } + + #[test] + fn device_profile_serde_round_trip() { + let original = DeviceProfile { + total_ram_bytes: 8 * 1024 * 1024 * 1024, + cpu_count: 4, + cpu_brand: "CPU".into(), + os_name: "OS".into(), + os_version: "1.2.3".into(), + has_gpu: true, + gpu_description: Some("GPU".into()), + }; + let s = serde_json::to_string(&original).unwrap(); + let back: DeviceProfile = serde_json::from_str(&s).unwrap(); + assert_eq!(back.total_ram_bytes, original.total_ram_bytes); + assert_eq!(back.cpu_count, original.cpu_count); + assert_eq!(back.has_gpu, original.has_gpu); + assert_eq!(back.gpu_description, original.gpu_description); + } } diff --git a/src/openhuman/local_ai/gif_decision.rs b/src/openhuman/local_ai/gif_decision.rs index 5b6e58a2c..249682afc 100644 --- a/src/openhuman/local_ai/gif_decision.rs +++ b/src/openhuman/local_ai/gif_decision.rs @@ -259,4 +259,59 @@ mod tests { let d = parse_gif_response("no gif"); assert!(!d.should_send_gif); } + + #[test] + fn parse_trims_surrounding_whitespace() { + let d = parse_gif_response(" NONE "); + assert!(!d.should_send_gif); + + let d = parse_gif_response(" hello wave "); + assert!(d.should_send_gif); + assert_eq!(d.search_query.as_deref(), Some("hello wave")); + } + + #[test] + fn parse_reject_over_eighty_chars_even_if_word_count_small() { + // 8 words but ≥ 80 chars is still rejected — protects against + // words that are URL-like or extremely long. + let long_word = "x".repeat(90); + let d = parse_gif_response(&long_word); + assert!(!d.should_send_gif); + } + + #[test] + fn parse_reject_more_than_eight_words() { + let nine_words = "one two three four five six seven eight nine"; + let d = parse_gif_response(nine_words); + assert!(!d.should_send_gif); + } + + #[test] + fn parse_accepts_boundary_eight_words() { + // Exactly 8 words: accepted. + let eight = "one two three four five six seven eight"; + let d = parse_gif_response(eight); + assert!(d.should_send_gif); + } + + // ── tenor_search guard paths ───────────────────────────────── + + #[tokio::test] + async fn tenor_search_rejects_empty_query() { + let config = crate::openhuman::config::Config::default(); + let err = tenor_search(&config, " ", Some(5)).await.unwrap_err(); + assert!(err.contains("query is required")); + } + + // ── local_ai_should_send_gif early-returns ────────────────── + + #[tokio::test] + async fn should_send_gif_returns_false_for_empty_message() { + let config = crate::openhuman::config::Config::default(); + let outcome = local_ai_should_send_gif(&config, " ", "slack") + .await + .unwrap(); + assert!(!outcome.value.should_send_gif); + assert!(outcome.logs.iter().any(|l| l.contains("empty message"))); + } } diff --git a/src/openhuman/local_ai/sentiment.rs b/src/openhuman/local_ai/sentiment.rs index 4c3531b31..c94bd8411 100644 --- a/src/openhuman/local_ai/sentiment.rs +++ b/src/openhuman/local_ai/sentiment.rs @@ -197,4 +197,53 @@ mod tests { assert_eq!(r.emotion, "neutral"); assert_eq!(r.valence, "neutral"); } + + #[test] + fn parse_clamps_negative_confidence_to_zero() { + let r = parse_sentiment_response("joy positive -0.5"); + assert!(r.confidence >= 0.0 && r.confidence <= 1.0); + assert!((r.confidence - 0.0).abs() < 0.01); + } + + #[test] + fn parse_unknown_valence_falls_back_to_neutral() { + let r = parse_sentiment_response("joy mixed 0.8"); + assert_eq!(r.emotion, "joy"); + assert_eq!(r.valence, "neutral"); + } + + #[test] + fn parse_accepts_all_documented_emotions() { + for e in [ + "joy", "sadness", "anger", "surprise", "fear", "disgust", "neutral", + ] { + let r = parse_sentiment_response(&format!("{e} positive 0.5")); + assert_eq!(r.emotion, e, "emotion `{e}` should be accepted verbatim"); + } + } + + #[test] + fn parse_accepts_all_documented_valences() { + for v in ["positive", "negative", "neutral"] { + let r = parse_sentiment_response(&format!("joy {v} 0.5")); + assert_eq!(r.valence, v, "valence `{v}` should be accepted verbatim"); + } + } + + #[test] + fn neutral_constructor_returns_documented_defaults() { + let r = SentimentResult::neutral(); + assert_eq!(r.emotion, "neutral"); + assert_eq!(r.valence, "neutral"); + assert!((r.confidence - 1.0).abs() < 0.01); + } + + #[tokio::test] + async fn local_ai_analyze_sentiment_returns_neutral_for_empty_message() { + let config = Config::default(); + let outcome = local_ai_analyze_sentiment(&config, " ").await.unwrap(); + assert_eq!(outcome.value.emotion, "neutral"); + assert_eq!(outcome.value.valence, "neutral"); + assert!(outcome.logs.iter().any(|l| l.contains("empty message"))); + } } diff --git a/src/openhuman/memory/rpc_models.rs b/src/openhuman/memory/rpc_models.rs index 69eff36c5..2a0b18ae3 100644 --- a/src/openhuman/memory/rpc_models.rs +++ b/src/openhuman/memory/rpc_models.rs @@ -543,7 +543,7 @@ fn default_memory_relative_dir() -> String { #[cfg(test)] mod tests { - use super::RecallMemoriesRequest; + use super::*; use serde_json::json; #[test] @@ -574,4 +574,208 @@ mod tests { assert_eq!(request.resolved_limit(), 3); } + + // ── resolved_limit priorities ───────────────────────────────── + + #[test] + fn recall_memories_resolved_limit_prefers_top_k_over_max_chunks_and_limit() { + let req = RecallMemoriesRequest { + namespace: "n".into(), + min_retention: None, + as_of: None, + limit: Some(5), + max_chunks: Some(7), + top_k: Some(9), + }; + assert_eq!(req.resolved_limit(), 9); + } + + #[test] + fn recall_memories_resolved_limit_falls_back_to_max_chunks_then_limit_then_default() { + let without_top_k = RecallMemoriesRequest { + namespace: "n".into(), + min_retention: None, + as_of: None, + limit: Some(5), + max_chunks: Some(7), + top_k: None, + }; + assert_eq!(without_top_k.resolved_limit(), 7); + + let limit_only = RecallMemoriesRequest { + namespace: "n".into(), + min_retention: None, + as_of: None, + limit: Some(5), + max_chunks: None, + top_k: None, + }; + assert_eq!(limit_only.resolved_limit(), 5); + + let none = RecallMemoriesRequest { + namespace: "n".into(), + min_retention: None, + as_of: None, + limit: None, + max_chunks: None, + top_k: None, + }; + assert_eq!(none.resolved_limit(), 10); + } + + #[test] + fn query_namespace_resolved_limit_prefers_max_chunks_then_limit_then_default() { + let req = QueryNamespaceRequest { + namespace: "n".into(), + query: "q".into(), + include_references: None, + document_ids: None, + limit: Some(3), + max_chunks: Some(9), + }; + assert_eq!(req.resolved_limit(), 9); + + let req_limit_only = QueryNamespaceRequest { + namespace: "n".into(), + query: "q".into(), + include_references: None, + document_ids: None, + limit: Some(3), + max_chunks: None, + }; + assert_eq!(req_limit_only.resolved_limit(), 3); + + let req_none = QueryNamespaceRequest { + namespace: "n".into(), + query: "q".into(), + include_references: None, + document_ids: None, + limit: None, + max_chunks: None, + }; + assert_eq!(req_none.resolved_limit(), 10); + } + + #[test] + fn recall_context_resolved_limit_prefers_max_chunks_then_limit_then_default() { + let req = RecallContextRequest { + namespace: "n".into(), + include_references: None, + limit: Some(3), + max_chunks: Some(9), + }; + assert_eq!(req.resolved_limit(), 9); + + let req_limit_only = RecallContextRequest { + namespace: "n".into(), + include_references: None, + limit: Some(3), + max_chunks: None, + }; + assert_eq!(req_limit_only.resolved_limit(), 3); + + let req_none = RecallContextRequest { + namespace: "n".into(), + include_references: None, + limit: None, + max_chunks: None, + }; + assert_eq!(req_none.resolved_limit(), 10); + } + + // ── deny_unknown_fields enforcement ─────────────────────────── + + #[test] + fn query_namespace_request_rejects_unknown_fields() { + let err = serde_json::from_value::(json!({ + "namespace": "n", + "query": "q", + "bogus": 1 + })) + .unwrap_err(); + assert!(err.to_string().contains("bogus")); + } + + #[test] + fn recall_context_request_rejects_unknown_fields() { + let err = serde_json::from_value::(json!({ + "namespace": "n", + "bogus": true + })) + .unwrap_err(); + assert!(err.to_string().contains("bogus")); + } + + #[test] + fn empty_request_rejects_any_field() { + let err = serde_json::from_value::(json!({"x": 1})).unwrap_err(); + assert!(err.to_string().contains("x")); + serde_json::from_value::(json!({})).unwrap(); + } + + // ── MemoryInitRequest tolerates backwards-compatible jwt_token ──── + + #[test] + fn memory_init_request_jwt_token_is_optional_and_ignored() { + let without: MemoryInitRequest = serde_json::from_value(json!({})).unwrap(); + assert_eq!(without.jwt_token, None); + let with: MemoryInitRequest = serde_json::from_value(json!({"jwt_token": "abc"})).unwrap(); + assert_eq!(with.jwt_token.as_deref(), Some("abc")); + } + + // ── ApiError / ApiMeta / ApiEnvelope round-trip ────────────── + + #[test] + fn api_error_round_trips_with_optional_details() { + let err = ApiError { + code: "E".into(), + message: "boom".into(), + details: Some(json!({"why": "reason"})), + }; + let s = serde_json::to_string(&err).unwrap(); + let back: ApiError = serde_json::from_str(&s).unwrap(); + assert_eq!(back.code, "E"); + assert_eq!(back.message, "boom"); + assert!(back.details.is_some()); + } + + #[test] + fn api_error_without_details_omits_field_when_serialized() { + let err = ApiError { + code: "E".into(), + message: "boom".into(), + details: None, + }; + let s = serde_json::to_string(&err).unwrap(); + assert!(!s.contains("details"), "got: {s}"); + } + + #[test] + fn api_envelope_round_trip_preserves_data_and_meta() { + let env = ApiEnvelope:: { + data: Some(42), + error: None, + meta: ApiMeta { + request_id: "r1".into(), + latency_seconds: Some(0.5), + cached: Some(false), + counts: None, + pagination: Some(PaginationMeta { + limit: 10, + offset: 0, + count: 1, + }), + }, + }; + let s = serde_json::to_string(&env).unwrap(); + let back: ApiEnvelope = serde_json::from_str(&s).unwrap(); + assert_eq!(back.data, Some(42)); + assert!(back.error.is_none()); + assert_eq!(back.meta.pagination.unwrap().count, 1); + } + + #[test] + fn default_memory_relative_dir_is_memory() { + assert_eq!(default_memory_relative_dir(), "memory"); + } } diff --git a/src/openhuman/memory/schemas.rs b/src/openhuman/memory/schemas.rs index 4a293b710..297104ede 100644 --- a/src/openhuman/memory/schemas.rs +++ b/src/openhuman/memory/schemas.rs @@ -1357,3 +1357,124 @@ fn parse_params(params: Map) -> Result(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const ALL_FUNCTIONS: &[&str] = &[ + "init", + "list_documents", + "list_namespaces", + "delete_document", + "query_namespace", + "recall_context", + "recall_memories", + "list_files", + "read_file", + "write_file", + "threads_list", + "thread_upsert", + "messages_list", + "message_append", + "message_update", + "thread_delete", + "threads_purge", + "namespace_list", + "doc_put", + "doc_ingest", + "doc_list", + "doc_delete", + "context_query", + "context_recall", + "kv_set", + "kv_get", + "kv_delete", + "kv_list_namespace", + "graph_upsert", + "graph_query", + "clear_namespace", + ]; + + #[test] + fn all_controller_schemas_has_entry_per_supported_function() { + let names: Vec<_> = all_controller_schemas() + .into_iter() + .map(|s| s.function) + .collect(); + assert_eq!(names.len(), ALL_FUNCTIONS.len()); + for expected in ALL_FUNCTIONS { + assert!(names.contains(expected), "missing schema for {expected}"); + } + } + + #[test] + fn all_registered_controllers_has_handler_per_schema() { + let controllers = all_registered_controllers(); + assert_eq!(controllers.len(), ALL_FUNCTIONS.len()); + let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect(); + for expected in ALL_FUNCTIONS { + assert!(names.contains(expected), "missing handler for {expected}"); + } + } + + #[test] + fn every_schema_uses_memory_namespace() { + for s in all_controller_schemas() { + assert_eq!( + s.namespace, "memory", + "schema {} must use the memory namespace", + s.function + ); + } + } + + #[test] + fn every_schema_has_a_non_empty_description() { + for s in all_controller_schemas() { + assert!( + !s.description.is_empty(), + "schema {} has empty description", + s.function + ); + } + } + + #[test] + fn schemas_unknown_function_returns_unknown_placeholder() { + let s = schemas("not-a-real-function"); + assert_eq!(s.namespace, "memory"); + assert_eq!(s.function, "unknown"); + } + + // ── parse_params helper ────────────────────────────────────── + + #[test] + fn parse_params_deserializes_simple_struct() { + #[derive(serde::Deserialize, Debug)] + struct Simple { + name: String, + count: u32, + } + let mut m = Map::new(); + m.insert("name".into(), json!("hi")); + m.insert("count".into(), json!(7)); + let out: Simple = parse_params(m).unwrap(); + assert_eq!(out.name, "hi"); + assert_eq!(out.count, 7); + } + + #[test] + fn parse_params_surfaces_deserialization_errors_with_context() { + #[derive(serde::Deserialize, Debug)] + struct Strict { + #[allow(dead_code)] + count: u32, + } + let mut m = Map::new(); + m.insert("count".into(), json!("not-a-number")); + let err = parse_params::(m).unwrap_err(); + assert!(err.contains("invalid params")); + } +} diff --git a/src/openhuman/migration/ops.rs b/src/openhuman/migration/ops.rs index c2eae39f5..af245c1e4 100644 --- a/src/openhuman/migration/ops.rs +++ b/src/openhuman/migration/ops.rs @@ -16,3 +16,59 @@ pub async fn migrate_openclaw( .map_err(|e| e.to_string())?; Ok(RpcOutcome::single_log(report, "migration completed")) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_config(tmp: &TempDir) -> Config { + Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + } + } + + #[tokio::test] + async fn migrate_openclaw_dry_run_on_empty_source_returns_report() { + // A fresh temp workspace contains nothing to migrate. The + // underlying migration helper should still return a report + // rather than erroring, and the wrapper should attach the + // canonical completion log. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let result = migrate_openclaw(&config, Some(tmp.path().to_path_buf()), true).await; + match result { + Ok(outcome) => { + assert!( + outcome + .logs + .iter() + .any(|l| l.contains("migration completed")), + "expected 'migration completed' log, got logs: {:?}", + outcome.logs + ); + } + Err(e) => panic!("dry_run on empty source should not error: {e}"), + } + } + + #[tokio::test] + async fn migrate_openclaw_returns_error_for_missing_source_workspace() { + // Pointing at a non-existent source directory must surface as + // an Err from the wrapper (the underlying `migrate_openclaw_memory` + // bails with "OpenClaw workspace not found at ..."), so the + // JSON-RPC adapter can return the error to the caller. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let missing = tmp.path().join("does-not-exist").join("nested"); + let err = migrate_openclaw(&config, Some(missing), false) + .await + .expect_err("missing source workspace must surface as Err"); + assert!( + !err.is_empty(), + "error string must be non-empty so the RPC caller sees a reason" + ); + } +} diff --git a/src/openhuman/migration/schemas.rs b/src/openhuman/migration/schemas.rs index 2d552e6f0..77fc3bc1f 100644 --- a/src/openhuman/migration/schemas.rs +++ b/src/openhuman/migration/schemas.rs @@ -85,3 +85,71 @@ fn handle_migrate_openclaw(params: Map) -> ControllerFuture { fn to_json(outcome: RpcOutcome) -> Result { outcome.into_cli_compatible_json() } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn all_controller_schemas_advertises_openclaw_only() { + let names: Vec<_> = all_controller_schemas() + .into_iter() + .map(|s| s.function) + .collect(); + assert_eq!(names, vec!["openclaw"]); + } + + #[test] + fn all_registered_controllers_has_one_handler() { + let ctrl = all_registered_controllers(); + assert_eq!(ctrl.len(), 1); + assert_eq!(ctrl[0].schema.function, "openclaw"); + } + + #[test] + fn openclaw_schema_describes_optional_source_and_dry_run() { + let s = schemas("openclaw"); + assert_eq!(s.namespace, "migrate"); + assert_eq!(s.function, "openclaw"); + let names: Vec<_> = s.inputs.iter().map(|f| f.name).collect(); + assert!(names.contains(&"source_workspace")); + assert!(names.contains(&"dry_run")); + for f in &s.inputs { + assert!(!f.required, "input `{}` must be optional", f.name); + } + assert_eq!(s.outputs[0].name, "report"); + } + + #[test] + fn unknown_function_returns_unknown_placeholder() { + let s = schemas("bogus"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "migrate"); + assert_eq!(s.outputs[0].name, "error"); + } + + #[test] + fn migrate_openclaw_params_tolerates_empty_object() { + let params: MigrateOpenClawParams = serde_json::from_value(json!({})).unwrap(); + assert!(params.source_workspace.is_none()); + assert!(params.dry_run.is_none()); + } + + #[test] + fn migrate_openclaw_params_parses_both_fields() { + let params: MigrateOpenClawParams = serde_json::from_value(json!({ + "source_workspace": "/tmp/old", + "dry_run": false + })) + .unwrap(); + assert_eq!(params.source_workspace.as_deref(), Some("/tmp/old")); + assert_eq!(params.dry_run, Some(false)); + } + + #[test] + fn to_json_wraps_rpc_outcome_result_envelope() { + let v = to_json(RpcOutcome::single_log(json!({"done": true}), "done")).unwrap(); + assert!(v.get("logs").is_some() || v.get("result").is_some()); + } +} diff --git a/src/openhuman/referral/ops.rs b/src/openhuman/referral/ops.rs index 4acea7a8d..c169cb2bf 100644 --- a/src/openhuman/referral/ops.rs +++ b/src/openhuman/referral/ops.rs @@ -69,3 +69,169 @@ pub async fn claim_referral( "referral claim accepted by backend POST /referral/claim", )) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::credentials::{ + AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, + }; + use axum::{ + routing::{get, post}, + Json, Router, + }; + use tempfile::TempDir; + + fn test_config(tmp: &TempDir) -> Config { + Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + } + } + + fn store_session_token(config: &Config, token: &str) { + AuthService::from_config(config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + token, + std::collections::HashMap::new(), + true, + ) + .expect("store token"); + } + + async fn spawn_mock(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut backoff = std::time::Duration::from_millis(2); + loop { + if tokio::net::TcpStream::connect(addr).await.is_ok() { + break; + } + if std::time::Instant::now() >= deadline { + panic!("mock backend at {addr} did not become ready"); + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(std::time::Duration::from_millis(50)); + } + format!("http://127.0.0.1:{}", addr.port()) + } + + fn config_with_backend(tmp: &TempDir, base: String) -> Config { + let mut c = test_config(tmp); + c.api_url = Some(base); + store_session_token(&c, "test-session-token"); + c + } + + // ── require_token (private helper) ──────────────────────────── + + #[test] + fn require_token_errors_without_stored_session() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let err = require_token(&config).unwrap_err(); + assert!(err.contains("no backend session token")); + } + + #[test] + fn require_token_trims_stored_value() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + store_session_token(&config, " tok "); + assert_eq!(require_token(&config).unwrap(), "tok"); + } + + #[test] + fn require_token_rejects_whitespace_only_stored_token() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + store_session_token(&config, " "); + assert!(require_token(&config) + .unwrap_err() + .contains("no backend session token")); + } + + // ── get_stats ──────────────────────────────────────────────── + + #[tokio::test] + async fn get_stats_errors_without_session() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let err = get_stats(&config).await.unwrap_err(); + assert!(err.contains("no backend session token")); + } + + #[tokio::test] + async fn get_stats_returns_backend_payload_with_log() { + let app = Router::new().route( + "/referral/stats", + get(|| async { Json(json!({"referrals": 3, "earned_cents": 1500})) }), + ); + let base = spawn_mock(app).await; + let tmp = TempDir::new().unwrap(); + let config = config_with_backend(&tmp, base); + let out = get_stats(&config).await.unwrap(); + assert_eq!(out.value["referrals"], json!(3)); + assert!(out + .logs + .iter() + .any(|l| l.contains("referral stats fetched"))); + } + + // ── claim_referral ─────────────────────────────────────────── + + #[tokio::test] + async fn claim_referral_errors_without_session() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let err = claim_referral(&config, "ABC", None).await.unwrap_err(); + assert!(err.contains("no backend session token")); + } + + #[tokio::test] + async fn claim_referral_posts_trimmed_code_and_drops_whitespace_fingerprint() { + let app = Router::new().route( + "/referral/claim", + post(|Json(body): Json| async move { Json(json!({ "echoed": body })) }), + ); + let base = spawn_mock(app).await; + let tmp = TempDir::new().unwrap(); + let config = config_with_backend(&tmp, base); + + // Code is trimmed; whitespace-only fingerprint must be dropped. + let out = claim_referral(&config, " ABC-123 ", Some(" ")) + .await + .unwrap(); + assert_eq!(out.value["echoed"]["code"], json!("ABC-123")); + assert!( + out.value["echoed"].get("deviceFingerprint").is_none(), + "whitespace-only fingerprint must be dropped" + ); + assert!(out + .logs + .iter() + .any(|l| l.contains("referral claim accepted"))); + } + + #[tokio::test] + async fn claim_referral_forwards_non_empty_device_fingerprint_trimmed() { + let app = Router::new().route( + "/referral/claim", + post(|Json(body): Json| async move { Json(json!({ "echoed": body })) }), + ); + let base = spawn_mock(app).await; + let tmp = TempDir::new().unwrap(); + let config = config_with_backend(&tmp, base); + let out = claim_referral(&config, "CODE", Some(" fp-1 ")) + .await + .unwrap(); + assert_eq!(out.value["echoed"]["deviceFingerprint"], json!("fp-1")); + } +} diff --git a/src/openhuman/referral/schemas.rs b/src/openhuman/referral/schemas.rs index 4e8ea08b8..02b2640f8 100644 --- a/src/openhuman/referral/schemas.rs +++ b/src/openhuman/referral/schemas.rs @@ -123,3 +123,98 @@ fn json_output(name: &'static str, comment: &'static str) -> FieldSchema { required: true, } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn all_referral_controller_schemas_advertises_stats_and_claim() { + let names: Vec<_> = all_referral_controller_schemas() + .into_iter() + .map(|s| s.function) + .collect(); + assert_eq!(names, vec!["get_stats", "claim"]); + } + + #[test] + fn all_referral_registered_controllers_matches_schema_count() { + assert_eq!( + all_referral_registered_controllers().len(), + all_referral_controller_schemas().len() + ); + } + + #[test] + fn get_stats_schema_has_no_inputs_and_required_output() { + let s = referral_schemas("referral_get_stats"); + assert_eq!(s.namespace, "referral"); + assert!(s.inputs.is_empty()); + assert!(s.outputs.iter().all(|f| f.required)); + } + + #[test] + fn claim_schema_requires_code_and_has_optional_fingerprint() { + let s = referral_schemas("referral_claim"); + let code = s.inputs.iter().find(|f| f.name == "code").unwrap(); + assert!(code.required); + let fp = s + .inputs + .iter() + .find(|f| f.name == "deviceFingerprint") + .unwrap(); + assert!(!fp.required); + } + + #[test] + fn unknown_function_returns_unknown_placeholder() { + let s = referral_schemas("no_such"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "referral"); + } + + #[test] + fn claim_params_parse_camel_case_device_fingerprint() { + let p: ReferralClaimParams = serde_json::from_value(json!({ + "code": "ABC123", + "deviceFingerprint": "fp-xyz" + })) + .unwrap(); + assert_eq!(p.code, "ABC123"); + assert_eq!(p.device_fingerprint.as_deref(), Some("fp-xyz")); + } + + #[test] + fn claim_params_tolerate_missing_device_fingerprint() { + let p: ReferralClaimParams = serde_json::from_value(json!({"code": "ABC"})).unwrap(); + assert!(p.device_fingerprint.is_none()); + } + + #[test] + fn claim_params_require_code() { + let err = serde_json::from_value::(json!({})).unwrap_err(); + assert!(err.to_string().contains("code")); + } + + #[test] + fn deserialize_params_reports_invalid_params_prefix_on_bad_types() { + let mut m = Map::new(); + m.insert("code".into(), json!(42)); + let err = deserialize_params::(m).unwrap_err(); + assert!(err.starts_with("invalid params")); + } + + #[test] + fn json_output_builds_required_json_field() { + let f = json_output("x", "c"); + assert!(f.required); + assert!(matches!(f.ty, TypeSchema::Json)); + } + + #[test] + fn to_json_wraps_result_and_logs() { + let v = to_json(RpcOutcome::single_log(json!({"ok": true}), "log")).unwrap(); + assert!(v.get("result").is_some() || v.get("logs").is_some()); + } +} diff --git a/src/openhuman/security/ops.rs b/src/openhuman/security/ops.rs index c02d91e36..fc05d36a3 100644 --- a/src/openhuman/security/ops.rs +++ b/src/openhuman/security/ops.rs @@ -17,3 +17,60 @@ pub fn security_policy_info() -> RpcOutcome { }); RpcOutcome::single_log(payload, "security_policy_info computed") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn security_policy_info_returns_all_documented_fields() { + // Locks in the JSON shape the JSON-RPC clients depend on — + // any rename / removal of a field would break the UI. + let outcome = security_policy_info(); + for key in [ + "autonomy", + "workspace_only", + "allowed_commands", + "max_actions_per_hour", + "require_approval_for_medium_risk", + "block_high_risk_commands", + ] { + assert!( + outcome.value.get(key).is_some(), + "missing `{key}` in security_policy_info payload: {}", + outcome.value + ); + } + assert!(outcome + .logs + .iter() + .any(|l| l.contains("security_policy_info computed"))); + } + + #[test] + fn security_policy_info_matches_default_policy_values() { + let outcome = security_policy_info(); + let default = SecurityPolicy::default(); + assert_eq!(outcome.value["autonomy"], json!(default.autonomy)); + assert_eq!( + outcome.value["allowed_commands"], + json!(default.allowed_commands) + ); + assert_eq!( + outcome.value["max_actions_per_hour"], + json!(default.max_actions_per_hour) + ); + assert_eq!( + outcome.value["workspace_only"], + json!(default.workspace_only) + ); + assert_eq!( + outcome.value["block_high_risk_commands"], + json!(default.block_high_risk_commands) + ); + assert_eq!( + outcome.value["require_approval_for_medium_risk"], + json!(default.require_approval_for_medium_risk) + ); + } +} diff --git a/src/openhuman/service/bus.rs b/src/openhuman/service/bus.rs index 4b103b2d4..4a5e52b72 100644 --- a/src/openhuman/service/bus.rs +++ b/src/openhuman/service/bus.rs @@ -100,3 +100,66 @@ impl EventHandler for RestartSubscriber { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // NOTE: We deliberately do NOT test the success path of `handle()` + // for `SystemRestartRequested` — it spawns a tokio task that calls + // `std::process::exit(0)` after 150ms and would terminate the test + // runner. We exercise the observable metadata plus the two quick + // early-return branches instead. + + #[test] + fn restart_subscriber_name_is_namespaced() { + assert_eq!(RestartSubscriber.name(), "service::restart"); + } + + #[test] + fn restart_subscriber_domain_filter_is_system() { + assert_eq!(RestartSubscriber.domains(), Some(&["system"][..])); + } + + #[tokio::test] + async fn handle_returns_early_on_non_restart_event() { + // A domain event from a different module must be ignored — + // `handle()` checks the variant and returns without touching + // RESTART_IN_PROGRESS or spawning a restart. + RestartSubscriber + .handle(&DomainEvent::AgentTurnStarted { + session_id: "s".into(), + channel: "web".into(), + }) + .await; + } + + #[tokio::test] + async fn handle_ignores_duplicate_restart_when_gate_is_set() { + // Simulate "a restart is already underway" by flipping the + // global gate manually. `handle()` must notice this, log, and + // return without calling into `trigger_self_restart_now` + // (which would spawn a replacement process). + let previous = RESTART_IN_PROGRESS.swap(true, Ordering::SeqCst); + RestartSubscriber + .handle(&DomainEvent::SystemRestartRequested { + source: "test".into(), + reason: "duplicate-suppression".into(), + }) + .await; + // Restore the prior gate value so other tests in the same + // binary aren't skewed by this one. + RESTART_IN_PROGRESS.store(previous, Ordering::SeqCst); + } + + #[tokio::test] + async fn register_restart_subscriber_is_idempotent_and_safe_without_bus() { + // `subscribe_global` reaches into a tokio broadcast channel, so a + // runtime must be present — hence `#[tokio::test]`. When the event + // bus isn't initialised in the test process the first call logs a + // warning and returns; subsequent calls must also be no-ops rather + // than registering duplicates. + register_restart_subscriber(); + register_restart_subscriber(); + } +} diff --git a/src/openhuman/service/ops.rs b/src/openhuman/service/ops.rs index e66ec9abd..5e106f7e9 100644 --- a/src/openhuman/service/ops.rs +++ b/src/openhuman/service/ops.rs @@ -69,3 +69,91 @@ pub async fn daemon_host_set( daemon_host::save_for_config_dir(config_dir, &next).await?; Ok(RpcOutcome::single_log(next, "daemon host config saved")) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_config(tmp: &TempDir) -> Config { + Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + } + } + + // NOTE: `service_install`, `service_start`, `service_stop`, + // `service_status`, `service_uninstall`, and `service_restart` + // mutate real OS state (launchctl / systemd) or terminate the + // process. They are not safe to exercise from unit tests; the + // RPC adapter tests live in tests/json_rpc_e2e.rs. + + // ── daemon_host_get / set ──────────────────────────────────── + + #[tokio::test] + async fn daemon_host_get_returns_default_when_no_file_present() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + // Ensure the config dir exists so `load_for_config_dir` can + // operate (most loaders treat a missing dir as "use default"). + std::fs::create_dir_all(tmp.path()).unwrap(); + let out = daemon_host_get(&config).await.unwrap(); + // No assertion on `show_tray` value — defaults vary by build. + // The contract under test is that the function returns Ok with + // the canonical log line and a deterministic struct shape. + assert!(out + .logs + .iter() + .any(|l| l.contains("daemon host config loaded"))); + let _ = out.value.show_tray; + } + + #[tokio::test] + async fn daemon_host_set_persists_value_visible_to_subsequent_get() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + std::fs::create_dir_all(tmp.path()).unwrap(); + + // Write `show_tray = false`, then read it back. + let saved = daemon_host_set(&config, false).await.unwrap(); + assert!(!saved.value.show_tray); + assert!(saved + .logs + .iter() + .any(|l| l.contains("daemon host config saved"))); + + let loaded = daemon_host_get(&config).await.unwrap(); + assert!( + !loaded.value.show_tray, + "set→get round-trip must observe the persisted value" + ); + + // Flip it back and confirm the toggle round-trips too. + let saved = daemon_host_set(&config, true).await.unwrap(); + assert!(saved.value.show_tray); + let loaded = daemon_host_get(&config).await.unwrap(); + assert!(loaded.value.show_tray); + } + + #[tokio::test] + async fn daemon_host_get_errors_when_config_path_has_no_parent() { + // A config_path of just a filename (no parent directory) trips + // the "failed to resolve config directory" guard. + let mut config = Config::default(); + config.config_path = std::path::PathBuf::from(""); + let err = daemon_host_get(&config).await.unwrap_err(); + assert!( + err.contains("failed to resolve config directory"), + "expected config-dir error, got: {err}" + ); + } + + #[tokio::test] + async fn daemon_host_set_errors_when_config_path_has_no_parent() { + let mut config = Config::default(); + config.config_path = std::path::PathBuf::from(""); + let err = daemon_host_set(&config, true).await.unwrap_err(); + assert!(err.contains("failed to resolve config directory")); + } +} diff --git a/src/openhuman/skills/bus.rs b/src/openhuman/skills/bus.rs index a7705d147..07fb965e2 100644 --- a/src/openhuman/skills/bus.rs +++ b/src/openhuman/skills/bus.rs @@ -1,3 +1,17 @@ //! Legacy no-op event bus hooks retained while call-sites are cleaned up. pub fn register_skill_cleanup_subscriber() {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_skill_cleanup_subscriber_is_a_safe_noop() { + // The function is intentionally empty while call-sites migrate + // off the legacy bus hook — calling it repeatedly must remain + // side-effect free. + register_skill_cleanup_subscriber(); + register_skill_cleanup_subscriber(); + } +} diff --git a/src/openhuman/socket/manager.rs b/src/openhuman/socket/manager.rs index 1c66a343f..7ca9c4ecf 100644 --- a/src/openhuman/socket/manager.rs +++ b/src/openhuman/socket/manager.rs @@ -280,4 +280,36 @@ mod tests { emit_server_event(&shared, "any.event", json!({})); assert_eq!(*shared.status.read(), ConnectionStatus::Connected); } + + #[test] + fn set_webhook_router_populates_the_shared_slot() { + let mgr = SocketManager::new(); + assert!(mgr.shared.webhook_router.read().is_none()); + let router = Arc::new(WebhookRouter::new(None)); + mgr.set_webhook_router(router); + assert!(mgr.shared.webhook_router.read().is_some()); + } + + #[test] + fn set_webhook_router_overwrites_previous_router() { + // Replacing the router is allowed so callers can hot-swap during + // reconfiguration — this test nails that observable behaviour down. + let mgr = SocketManager::new(); + mgr.set_webhook_router(Arc::new(WebhookRouter::new(None))); + let second = Arc::new(WebhookRouter::new(None)); + let second_ptr = Arc::as_ptr(&second); + mgr.set_webhook_router(Arc::clone(&second)); + let stored = mgr.shared.webhook_router.read().clone().unwrap(); + assert!(std::ptr::eq(Arc::as_ptr(&stored), second_ptr)); + } + + #[tokio::test] + async fn emit_after_disconnect_errors_not_connected() { + // Even without ever calling connect(), the disconnect() call path + // leaves the emit channel torn down — and emit() must reject. + let mgr = SocketManager::new(); + mgr.disconnect().await.unwrap(); + let err = mgr.emit("x", json!({})).await.unwrap_err(); + assert_eq!(err, "Not connected"); + } } diff --git a/src/openhuman/text_input/ops.rs b/src/openhuman/text_input/ops.rs index 585f0e77f..c5d63dd1a 100644 --- a/src/openhuman/text_input/ops.rs +++ b/src/openhuman/text_input/ops.rs @@ -188,3 +188,128 @@ pub async fn accept_ghost( )), } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── Guard-clause branches ──────────────────────────────────── + // + // The post-guard paths below these entry-points call into + // `accessibility::*`, which requires a focused text field on a + // live OS display — not reproducible in a headless unit-test + // environment. These tests pin the pure validation logic that + // every RPC call must hit before any platform work runs. + + #[tokio::test] + async fn insert_text_rejects_empty_text() { + let err = insert_text(InsertTextParams { + text: String::new(), + validate_focus: None, + expected_app: None, + expected_role: None, + }) + .await + .unwrap_err(); + assert!( + err.contains("text must not be empty"), + "expected empty-text error, got: {err}" + ); + } + + #[tokio::test] + async fn show_ghost_rejects_empty_text() { + let err = show_ghost(ShowGhostTextParams { + text: String::new(), + ttl_ms: None, + bounds: None, + }) + .await + .unwrap_err(); + assert!( + err.contains("ghost text must not be empty"), + "expected empty-ghost error, got: {err}" + ); + } + + #[tokio::test] + async fn accept_ghost_rejects_empty_text() { + let err = accept_ghost(AcceptGhostTextParams { + text: String::new(), + validate_focus: None, + expected_app: None, + expected_role: None, + }) + .await + .unwrap_err(); + assert!( + err.contains("text must not be empty"), + "expected empty-text error, got: {err}" + ); + } + + // ── dismiss_ghost always succeeds ──────────────────────────── + + #[tokio::test] + async fn dismiss_ghost_always_reports_success_even_without_overlay() { + // The implementation discards any hide_overlay() error, so + // every call must yield `dismissed: true` — callers rely on + // this idempotent contract. + let out = dismiss_ghost().await.unwrap(); + assert!(out.value.dismissed); + assert!(out.logs.iter().any(|l| l.contains("dismiss_ghost: ok"))); + } + + // ── Post-guard paths surface accessibility errors ─────────── + // + // Without a focused text field, `accessibility::*` returns an + // Err which the RPC wrappers convert into an `InsertTextResult + // { inserted: false, error: Some(..) }` (for insert/accept) or + // bubble up as Err for `read_field` / `show_ghost` (when reading + // bounds fails). We assert only that these paths do not panic + // and return a deterministic shape — the specific error string + // depends on the host OS. + + #[tokio::test] + async fn insert_text_surfaces_accessibility_failure_as_inserted_false() { + // A non-empty payload bypasses the guard and reaches the + // `accessibility::apply_text_to_focused_field` call. The contract + // of `insert_text` is: any platform failure is wrapped in + // `InsertTextResult { inserted: false, error: Some(..) }` and + // returned as `Ok` — never propagated as `Err` — so the JSON-RPC + // caller always gets a structured result. We pin that contract. + // + // On a host with a focused text field `inserted` can legitimately + // be `true`; in a headless CI runner it will be `false`. Either + // way, `inserted` and `error` must be mutually exclusive. + let r = insert_text(InsertTextParams { + text: "hello".into(), + // Keep validation flags off so the test only exercises the + // `apply_text_to_focused_field` path; turning them on would + // route through `validate_focused_target` first which has its + // own OS-specific behaviour. + validate_focus: None, + expected_app: None, + expected_role: None, + }) + .await + .expect("insert_text must wrap platform failures as Ok(inserted=false)"); + + if r.value.inserted { + assert!( + r.value.error.is_none(), + "inserted=true must not carry an error: {:?}", + r.value.error + ); + assert!(r.logs.iter().any(|l| l.contains("insert_text: ok"))); + } else { + let err = r + .value + .error + .as_deref() + .expect("inserted=false must carry an error message"); + assert!(!err.is_empty(), "error message must be non-empty"); + assert!(r.logs.iter().any(|l| l.contains("insert_text: failed"))); + } + } +} diff --git a/src/openhuman/text_input/types.rs b/src/openhuman/text_input/types.rs index c260f8864..4907ee726 100644 --- a/src/openhuman/text_input/types.rs +++ b/src/openhuman/text_input/types.rs @@ -116,3 +116,180 @@ pub struct AcceptGhostTextResult { pub inserted: bool, pub error: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::accessibility::ElementBounds; + use serde_json::json; + + // ── FieldBounds ↔ ElementBounds ────────────────────────────── + + #[test] + fn field_bounds_from_element_copies_all_fields() { + let e = ElementBounds { + x: 10, + y: 20, + width: 300, + height: 40, + }; + let b = FieldBounds::from_element(&e); + assert_eq!((b.x, b.y, b.width, b.height), (10, 20, 300, 40)); + } + + #[test] + fn field_bounds_round_trips_through_element_bounds() { + let original = FieldBounds { + x: -5, + y: 7, + width: 123, + height: 456, + }; + let roundtripped = FieldBounds::from_element(&original.to_element()); + assert_eq!( + ( + roundtripped.x, + roundtripped.y, + roundtripped.width, + roundtripped.height + ), + (-5, 7, 123, 456) + ); + } + + // ── ReadFieldParams ────────────────────────────────────────── + + #[test] + fn read_field_params_default_has_no_include_bounds() { + let p = ReadFieldParams::default(); + assert!(p.include_bounds.is_none()); + } + + #[test] + fn read_field_params_omits_include_bounds_in_wire_json_when_none() { + // `Option` with `#[serde(default)]` must accept JSON that + // omits the field entirely (so existing callers without the + // key keep working) and preserve the None round-trip. + let parsed: ReadFieldParams = serde_json::from_value(json!({})).unwrap(); + assert!(parsed.include_bounds.is_none()); + let parsed: ReadFieldParams = + serde_json::from_value(json!({"include_bounds": true})).unwrap(); + assert_eq!(parsed.include_bounds, Some(true)); + } + + // ── ReadFieldResult ────────────────────────────────────────── + + #[test] + fn read_field_result_round_trips_all_optional_fields() { + let r = ReadFieldResult { + app_name: Some("Editor".into()), + role: Some("TextField".into()), + text: "hello".into(), + selected_text: Some("ell".into()), + bounds: Some(FieldBounds { + x: 1, + y: 2, + width: 3, + height: 4, + }), + is_terminal: false, + }; + let s = serde_json::to_string(&r).unwrap(); + let back: ReadFieldResult = serde_json::from_str(&s).unwrap(); + assert_eq!(back.app_name.as_deref(), Some("Editor")); + assert_eq!(back.text, "hello"); + assert_eq!(back.bounds.as_ref().map(|b| b.width), Some(3)); + assert!(!back.is_terminal); + } + + // ── InsertTextParams / Result ──────────────────────────────── + + #[test] + fn insert_text_params_defaults_validate_focus_when_absent() { + let parsed: InsertTextParams = serde_json::from_value(json!({"text": "hi"})).unwrap(); + assert_eq!(parsed.text, "hi"); + assert!(parsed.validate_focus.is_none()); + assert!(parsed.expected_app.is_none()); + assert!(parsed.expected_role.is_none()); + } + + #[test] + fn insert_text_result_round_trips_error_field() { + let r = InsertTextResult { + inserted: false, + error: Some("no focus".into()), + }; + let back: InsertTextResult = + serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap(); + assert!(!back.inserted); + assert_eq!(back.error.as_deref(), Some("no focus")); + } + + // ── Ghost text ─────────────────────────────────────────────── + + #[test] + fn show_ghost_text_params_round_trip_includes_bounds_and_ttl() { + let p = ShowGhostTextParams { + text: "suggestion".into(), + ttl_ms: Some(5000), + bounds: Some(FieldBounds { + x: 0, + y: 0, + width: 100, + height: 20, + }), + }; + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["ttl_ms"], json!(5000)); + let back: ShowGhostTextParams = serde_json::from_value(v).unwrap(); + assert_eq!(back.text, "suggestion"); + assert_eq!(back.ttl_ms, Some(5000)); + assert_eq!(back.bounds.unwrap().width, 100); + } + + #[test] + fn show_ghost_text_result_shown_and_error_round_trip() { + let r = ShowGhostTextResult { + shown: true, + error: None, + }; + let back: ShowGhostTextResult = + serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap(); + assert!(back.shown); + assert!(back.error.is_none()); + } + + #[test] + fn dismiss_ghost_text_result_round_trips() { + let r = DismissGhostTextResult { dismissed: true }; + let back: DismissGhostTextResult = + serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap(); + assert!(back.dismissed); + } + + #[test] + fn accept_ghost_text_params_round_trip() { + let parsed: AcceptGhostTextParams = serde_json::from_value(json!({ + "text": "go", + "validate_focus": true, + "expected_app": "Editor", + "expected_role": "TextField" + })) + .unwrap(); + assert_eq!(parsed.text, "go"); + assert_eq!(parsed.validate_focus, Some(true)); + assert_eq!(parsed.expected_app.as_deref(), Some("Editor")); + assert_eq!(parsed.expected_role.as_deref(), Some("TextField")); + } + + #[test] + fn accept_ghost_text_result_round_trips() { + let r = AcceptGhostTextResult { + inserted: true, + error: None, + }; + let back: AcceptGhostTextResult = + serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap(); + assert!(back.inserted); + } +} diff --git a/src/openhuman/tools/traits.rs b/src/openhuman/tools/traits.rs index ddca4014a..17b6b52a7 100644 --- a/src/openhuman/tools/traits.rs +++ b/src/openhuman/tools/traits.rs @@ -203,4 +203,101 @@ mod tests { assert!(parsed.is_error); assert_eq!(parsed.output(), "boom"); } + + // ── Default trait-method values ──────────────────────────────── + + #[test] + fn default_permission_level_is_read_only() { + let tool = DummyTool; + assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); + } + + #[test] + fn default_scope_is_all() { + let tool = DummyTool; + assert_eq!(tool.scope(), ToolScope::All); + } + + #[test] + fn default_category_is_system() { + let tool = DummyTool; + assert_eq!(tool.category(), ToolCategory::System); + } + + // ── PermissionLevel ordering ─────────────────────────────────── + + #[test] + fn permission_level_is_totally_ordered_from_none_to_dangerous() { + // The runtime compares PermissionLevel as `<` to reject tools whose + // required level exceeds the channel max, so the ordering is a + // load-bearing invariant. + assert!(PermissionLevel::None < PermissionLevel::ReadOnly); + assert!(PermissionLevel::ReadOnly < PermissionLevel::Write); + assert!(PermissionLevel::Write < PermissionLevel::Execute); + assert!(PermissionLevel::Execute < PermissionLevel::Dangerous); + } + + #[test] + fn permission_level_default_is_read_only() { + assert_eq!(PermissionLevel::default(), PermissionLevel::ReadOnly); + } + + #[test] + fn permission_level_display_matches_variant_name() { + assert_eq!(PermissionLevel::None.to_string(), "None"); + assert_eq!(PermissionLevel::ReadOnly.to_string(), "ReadOnly"); + assert_eq!(PermissionLevel::Write.to_string(), "Write"); + assert_eq!(PermissionLevel::Execute.to_string(), "Execute"); + assert_eq!(PermissionLevel::Dangerous.to_string(), "Dangerous"); + } + + #[test] + fn permission_level_round_trips_as_json_number() { + for level in [ + PermissionLevel::None, + PermissionLevel::ReadOnly, + PermissionLevel::Write, + PermissionLevel::Execute, + PermissionLevel::Dangerous, + ] { + let s = serde_json::to_string(&level).unwrap(); + let back: PermissionLevel = serde_json::from_str(&s).unwrap(); + assert_eq!(back, level); + } + } + + // ── ToolCategory ─────────────────────────────────────────────── + + #[test] + fn tool_category_default_is_system() { + assert_eq!(ToolCategory::default(), ToolCategory::System); + } + + #[test] + fn tool_category_display_is_lowercase() { + assert_eq!(ToolCategory::System.to_string(), "system"); + assert_eq!(ToolCategory::Skill.to_string(), "skill"); + } + + #[test] + fn tool_category_serde_uses_snake_case() { + // The runtime relies on snake_case JSON for `category` in agent + // definitions — catch any rename that would break user-facing + // definition files. + let s = serde_json::to_string(&ToolCategory::System).unwrap(); + assert_eq!(s, "\"system\""); + let s = serde_json::to_string(&ToolCategory::Skill).unwrap(); + assert_eq!(s, "\"skill\""); + let back: ToolCategory = serde_json::from_str("\"skill\"").unwrap(); + assert_eq!(back, ToolCategory::Skill); + } + + // ── ToolScope ────────────────────────────────────────────────── + + #[test] + fn tool_scope_variants_are_distinct() { + assert_ne!(ToolScope::All, ToolScope::AgentOnly); + assert_ne!(ToolScope::All, ToolScope::CliRpcOnly); + assert_ne!(ToolScope::AgentOnly, ToolScope::CliRpcOnly); + } } diff --git a/src/openhuman/update/ops.rs b/src/openhuman/update/ops.rs index d9bda8c60..4e6fd7fe3 100644 --- a/src/openhuman/update/ops.rs +++ b/src/openhuman/update/ops.rs @@ -116,3 +116,116 @@ pub async fn update_apply( } } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── validate_download_url ───────────────────────────────────── + + #[test] + fn validate_download_url_accepts_github_https_hosts() { + for url in [ + "https://github.com/owner/repo/releases/download/v1/asset.tar.gz", + "https://api.github.com/repos/owner/repo/releases/assets/1", + "https://objects.githubusercontent.com/release-asset/123", + ] { + validate_download_url(url).unwrap_or_else(|e| panic!("`{url}` rejected: {e}")); + } + } + + #[test] + fn validate_download_url_rejects_non_github_hosts() { + let err = validate_download_url("https://evil.example.com/asset.tar.gz").unwrap_err(); + assert!(err.contains("must be a GitHub domain"), "got: {err}"); + } + + #[test] + fn validate_download_url_rejects_non_https_schemes() { + let err = validate_download_url("http://github.com/owner/repo/releases/download/v1/x") + .unwrap_err(); + assert!(err.contains("must use HTTPS"), "got: {err}"); + } + + #[test] + fn validate_download_url_rejects_malformed_url() { + let err = validate_download_url("not a url").unwrap_err(); + assert!(err.contains("invalid download URL"), "got: {err}"); + } + + // ── validate_asset_name ─────────────────────────────────────── + + #[test] + fn validate_asset_name_accepts_well_formed_core_asset() { + validate_asset_name("openhuman-core-aarch64-apple-darwin.tar.gz") + .expect("canonical asset name should be accepted"); + } + + #[test] + fn validate_asset_name_rejects_empty_string() { + let err = validate_asset_name("").unwrap_err(); + assert!(err.contains("must not be empty")); + } + + #[test] + fn validate_asset_name_rejects_path_separators_and_traversal() { + for bad in [ + "openhuman-core-../etc/passwd", + "../openhuman-core-x86.tar.gz", + "openhuman-core/x86.tar.gz", + "openhuman-core\\x86.tar.gz", + ] { + let err = validate_asset_name(bad).unwrap_err(); + assert!( + err.contains("path separators") || err.contains("'..'"), + "input `{bad}` produced unexpected error: {err}" + ); + } + } + + #[test] + fn validate_asset_name_rejects_unprefixed_asset() { + let err = validate_asset_name("malicious-binary.tar.gz").unwrap_err(); + assert!( + err.contains("must start with 'openhuman-core-'"), + "got: {err}" + ); + } + + // ── update_apply rejection paths ────────────────────────────── + + #[tokio::test] + async fn update_apply_rejects_non_github_url_before_network_call() { + let outcome = update_apply( + "https://evil.example.com/asset".to_string(), + "openhuman-core-x86_64.tar.gz".to_string(), + None, + ) + .await; + assert!(outcome.value.get("error").is_some()); + assert!(outcome + .logs + .iter() + .any(|l| l.contains("update_apply rejected"))); + } + + #[tokio::test] + async fn update_apply_rejects_unsafe_asset_name() { + let outcome = update_apply( + "https://github.com/owner/repo/releases/download/v1/x".to_string(), + "../etc/passwd".to_string(), + None, + ) + .await; + assert!(outcome.value.get("error").is_some()); + assert!(outcome + .logs + .iter() + .any(|l| l.contains("update_apply rejected"))); + } + + // NOTE: `update_check` and the success path of `update_apply` + // hit GitHub's REST API and stage real binaries on disk — they + // are deferred to the integration test suite (tests/) where a + // real network fixture or recorded cassette is available. +} diff --git a/src/openhuman/update/scheduler.rs b/src/openhuman/update/scheduler.rs index 838b4af42..88a8b5c27 100644 --- a/src/openhuman/update/scheduler.rs +++ b/src/openhuman/update/scheduler.rs @@ -79,3 +79,42 @@ async fn tick() { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn min_interval_is_at_least_ten_minutes() { + // GitHub's API rate-limits unauthenticated callers — anything + // shorter than ~10 minutes will trip the rate limit on a busy + // machine. Lock in the floor so a future "let users tick every + // minute" change doesn't silently break update visibility. + assert!(MIN_INTERVAL_MINUTES >= 10); + } + + #[tokio::test] + async fn run_returns_immediately_when_disabled() { + // Even with `interval_minutes = 0` the disabled config must + // short-circuit before the loop. Using tokio's pause/advance + // would also work, but a direct .await is enough — if the + // function doesn't return promptly the test will hang and + // surface the regression. + let cfg = UpdateConfig { + enabled: false, + interval_minutes: 0, + }; + run(cfg).await; + } + + // NOTE: We deliberately do NOT unit-test `tick()` directly. It calls + // `update_core::check_available()` which performs a real HTTPS request + // to api.github.com — running that from the unit suite makes the test + // flaky (offline CI runners, rate limits, DNS hiccups). Coverage of + // the HTTP + JSON-parse path is better handled via an integration test + // that uses an HTTP mock (e.g. `httpmock`) around a refactored + // `check_available_with_url(base_url)`. For now the surrounding + // properties are locked down by: + // - `min_interval_is_at_least_ten_minutes` (rate-limit floor) + // - `run_returns_immediately_when_disabled` (disabled short-circuit) +} diff --git a/src/openhuman/voice/postprocess.rs b/src/openhuman/voice/postprocess.rs index 71d1dbadd..5f582f119 100644 --- a/src/openhuman/voice/postprocess.rs +++ b/src/openhuman/voice/postprocess.rs @@ -151,36 +151,270 @@ pub async fn cleanup_transcription( #[cfg(test)] mod tests { use super::*; + use axum::{routing::post, Json, Router}; + use serde_json::json; - #[test] - fn empty_text_returns_unchanged() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let config = Config::default(); - let result = rt.block_on(cleanup_transcription(&config, "", None)); - assert_eq!(result, ""); + // ── Helpers ────────────────────────────────────────────────── + + async fn spawn_mock(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut backoff = std::time::Duration::from_millis(2); + loop { + if tokio::net::TcpStream::connect(addr).await.is_ok() { + break; + } + if std::time::Instant::now() >= deadline { + panic!("mock ollama at {addr} did not become ready"); + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(std::time::Duration::from_millis(50)); + } + format!("http://127.0.0.1:{}", addr.port()) } - #[test] - fn whitespace_only_returns_unchanged() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let config = Config::default(); - let result = rt.block_on(cleanup_transcription(&config, " ", None)); - assert_eq!(result, " "); + /// Parks the global `local_ai::global(&config)` service state at + /// "ready", runs the async test with `body`, then restores the prior + /// state and clears `OPENHUMAN_OLLAMA_BASE_URL`. Returns whatever + /// `body` returned so the caller can assert on it. + /// + /// The [`LOCAL_AI_TEST_MUTEX`] serialises every test in this module + /// — and sibling modules — that touches the global service state or + /// the shared env var. + async fn with_ready_llm(base: String, config: &Config, body: F) -> R + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + { + let service = local_ai::global(config); + let previous = service.status.lock().state.clone(); + service.status.lock().state = "ready".into(); + unsafe { + std::env::set_var("OPENHUMAN_OLLAMA_BASE_URL", &base); + } + let out = body().await; + unsafe { + std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL"); + } + service.status.lock().state = previous; + out } - #[test] - fn disabled_cleanup_returns_raw_text() { - let rt = tokio::runtime::Runtime::new().unwrap(); + // ── Short-circuit paths (no LLM call) ──────────────────────── + + #[tokio::test] + async fn empty_text_returns_unchanged() { + let config = Config::default(); + assert_eq!(cleanup_transcription(&config, "", None).await, ""); + } + + #[tokio::test] + async fn whitespace_only_returns_unchanged() { + let config = Config::default(); + assert_eq!(cleanup_transcription(&config, " ", None).await, " "); + } + + #[tokio::test] + async fn disabled_cleanup_returns_raw_text() { + let _g = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX + .lock() + .unwrap_or_else(|p| p.into_inner()); let mut config = Config::default(); config.local_ai.voice_llm_cleanup_enabled = false; - let _guard = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX - .lock() - .expect("local ai test mutex poisoned"); let service = local_ai::global(&config); let previous = service.status.lock().state.clone(); service.status.lock().state = "not_ready".into(); - let result = rt.block_on(cleanup_transcription(&config, "um hello uh world", None)); + let result = cleanup_transcription(&config, "um hello uh world", None).await; service.status.lock().state = previous; assert_eq!(result, "um hello uh world"); } + + #[tokio::test] + async fn enabled_but_llm_not_ready_returns_raw_text() { + // Covers the branch where cleanup is enabled in config but the + // local LLM hasn't reached the ready/degraded state yet — + // cleanup must gracefully fall back to the raw Whisper output. + let _g = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX + .lock() + .unwrap_or_else(|p| p.into_inner()); + let config = Config::default(); // voice_llm_cleanup_enabled = true by default + let service = local_ai::global(&config); + let previous = service.status.lock().state.clone(); + service.status.lock().state = "not_ready".into(); + let result = cleanup_transcription(&config, "raw whisper output", None).await; + service.status.lock().state = previous; + assert_eq!(result, "raw whisper output"); + } + + // ── LLM-ready paths (mocked Ollama) ────────────────────────── + // + // These exercise the "LLM ready → actually call Ollama" branch, but + // assert only on *either* the cleaned response or the raw-text + // fallback. The reason is structural: + // + // `cleanup_transcription` resolves the `LocalAiService` via + // `local_ai::global(config)` — a process-wide `OnceCell` singleton. + // ~30 sibling tests across the crate touch that singleton's state + // without holding `LOCAL_AI_TEST_MUTEX`, so even when we set the + // state to `"ready"` here, another test can flip it back to + // `"idle"` mid-run. We still want to exercise the full code path + // for coverage, so the assertions are deliberately permissive — + // we pin the contract that the function returns a deterministic + // String in either case and never panics. Tight end-to-end + // correctness of the cleanup output is covered in the + // deterministic short-circuit tests above and in an integration + // test that controls the full process state. + + /// `result` must equal either the cleaned `expected` or the raw + /// `fallback`, never anything else. Returns the matched variant for + /// callers that want to assert coverage of both branches over time. + fn assert_cleaned_or_raw(result: &str, expected: &str, fallback: &str) { + assert!( + result == expected || result == fallback, + "unexpected cleanup result: got `{result}`, expected `{expected}` or raw fallback `{fallback}`" + ); + } + + #[tokio::test] + async fn ready_llm_returns_trimmed_cleanup_or_falls_back() { + let _g = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX + .lock() + .unwrap_or_else(|p| p.into_inner()); + let app = Router::new().route( + "/api/generate", + post(|| async { + Json(json!({ + "model": "test", + "response": " Hello, world. ", + "done": true + })) + }), + ); + let base = spawn_mock(app).await; + let config = Config::default(); + let raw = "um hello world"; + let result = with_ready_llm(base, &config, || async { + cleanup_transcription(&config, raw, None).await + }) + .await; + assert_cleaned_or_raw(&result, "Hello, world.", raw); + } + + #[tokio::test] + async fn ready_llm_empty_response_falls_back_to_raw_text() { + let _g = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX + .lock() + .unwrap_or_else(|p| p.into_inner()); + let app = Router::new().route( + "/api/generate", + post(|| async { Json(json!({"model":"test","response":" ","done": true})) }), + ); + let base = spawn_mock(app).await; + let config = Config::default(); + let result = with_ready_llm(base, &config, || async { + cleanup_transcription(&config, "keep me", None).await + }) + .await; + // Both "LLM saw the empty response and fell back" and "LLM was + // not ready so short-circuited" produce the same result here. + assert_eq!(result, "keep me"); + } + + #[tokio::test] + async fn ready_llm_error_response_falls_back_to_raw_text() { + let _g = crate::openhuman::local_ai::LOCAL_AI_TEST_MUTEX + .lock() + .unwrap_or_else(|p| p.into_inner()); + let app = Router::new().route( + "/api/generate", + post(|| async { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "boom".to_string(), + ) + }), + ); + let base = spawn_mock(app).await; + let config = Config::default(); + let result = with_ready_llm(base, &config, || async { + cleanup_transcription(&config, "raw text", None).await + }) + .await; + // Err fallback or short-circuit both return raw text. + assert_eq!(result, "raw text"); + } + + #[tokio::test] + async fn ready_llm_with_conversation_context_uses_context_or_raw_fallback() { + // Echo the received prompt so we can assert the caller actually + // glued the conversation context in front of the raw text when + // the LLM ran. If the global state raced away from "ready" the + // call short-circuits to raw — still valid, just the other branch. + #[derive(serde::Deserialize)] + struct Body { + prompt: String, + } + let app = Router::new().route( + "/api/generate", + post(|Json(body): Json| async move { + Json(json!({ + "model": "test", + "response": body.prompt, + "done": true + })) + }), + ); + let base = spawn_mock(app).await; + let config = Config::default(); + let raw = "raw text"; + let result = with_ready_llm(base, &config, || async { + cleanup_transcription(&config, raw, Some("previous turn: check the oven")).await + }) + .await; + if result.contains("Conversation context:") { + assert!(result.contains("previous turn: check the oven")); + assert!(result.contains("Transcribed text to clean up:")); + assert!(result.contains(raw)); + } else { + assert_eq!(result, raw); + } + } + + #[tokio::test] + async fn ready_llm_with_whitespace_only_context_never_embeds_header() { + // A Some(ctx) that is pure whitespace must NOT embed the + // "Conversation context:" header regardless of which branch + // runs — the LLM path uses the raw-text-only prompt, and the + // short-circuit path never builds a prompt at all. + #[derive(serde::Deserialize)] + struct Body { + prompt: String, + } + let app = Router::new().route( + "/api/generate", + post(|Json(body): Json| async move { + Json(json!({ + "model": "test", + "response": body.prompt, + "done": true + })) + }), + ); + let base = spawn_mock(app).await; + let config = Config::default(); + let result = with_ready_llm(base, &config, || async { + cleanup_transcription(&config, "raw text", Some(" ")).await + }) + .await; + assert!( + !result.contains("Conversation context:"), + "whitespace-only context must NOT be forwarded, got: {result}" + ); + // Exact equality: `cleanup_transcription` trims the LLM response, + // so either branch (LLM echo of the raw-only prompt, or the + // short-circuit fallback) must return exactly "raw text". + assert_eq!(result, "raw text"); + } } diff --git a/src/openhuman/voice/text_input.rs b/src/openhuman/voice/text_input.rs index b30d87e92..67c94bacd 100644 --- a/src/openhuman/voice/text_input.rs +++ b/src/openhuman/voice/text_input.rs @@ -170,16 +170,32 @@ fn paste_modifier_key() -> Key { mod tests { use super::*; + // ── Guard clause: empty / whitespace input short-circuits ──── + // + // The post-guard code (clipboard / enigo / AppleScript) needs a + // display and a real system event loop, so coverage of those paths + // below `insert_text`'s trim-guard is only achievable in an + // end-to-end integration environment. Units here pin the logic + // that IS deterministic in a headless test process. + #[test] - fn empty_text_is_noop() { + fn empty_text_is_noop_and_succeeds() { assert!(insert_text("", None).is_ok()); } #[test] - fn whitespace_only_skips_insertion() { + fn whitespace_only_skips_insertion_and_succeeds() { assert!(insert_text(" ", None).is_ok()); } + #[test] + fn newlines_and_tabs_only_also_treated_as_empty() { + // `trim()` strips any Unicode whitespace — the skip branch must + // fire for pure `\t` and `\n` buffers too, not just spaces. + assert!(insert_text("\n\n", None).is_ok()); + assert!(insert_text("\t \n", Some("any-app")).is_ok()); + } + #[test] fn paste_modifier_is_platform_correct() { let key = paste_modifier_key(); @@ -189,4 +205,49 @@ mod tests { assert!(matches!(key, Key::Control)); } } + + #[test] + fn constants_match_openwhispr_timings() { + // Lock in the OpenWhispr-derived delays so nobody silently + // shortens them (would race the target app's paste handler). + assert_eq!(PASTE_DELAY, Duration::from_millis(120)); + assert_eq!(CLIPBOARD_RESTORE_DELAY, Duration::from_millis(450)); + } + + // ── AppleScript string escaping (macOS-only) ───────────────── + + #[cfg(target_os = "macos")] + #[test] + fn escape_applescript_string_escapes_backslash_and_quote() { + assert_eq!(escape_applescript_string("plain"), "plain"); + assert_eq!(escape_applescript_string(r#"a"b"#), r#"a\"b"#); + assert_eq!(escape_applescript_string(r"a\b"), r"a\\b"); + // Backslash must be escaped BEFORE quotes so the order of + // substitutions doesn't double-escape already-escaped quotes. + assert_eq!(escape_applescript_string(r#"\"mix"#), r#"\\\"mix"#); + } + + #[cfg(target_os = "macos")] + #[test] + fn escape_applescript_string_is_idempotent_on_benign_input() { + for s in ["", "App Name", "Safari", "Sub-App 2", "123"] { + assert_eq!(escape_applescript_string(s), s); + } + } + + // ── Focus-restore error path (macOS-only) ──────────────────── + + #[cfg(target_os = "macos")] + #[test] + fn restore_focus_to_app_errors_on_bogus_app_name() { + // `osascript` returns a non-zero exit when the target app + // cannot be activated, so we expect the helper to surface + // that as an Err. This exercises the error-formatting branch. + let err = restore_focus_to_app("__definitely_no_such_app_abcxyz__") + .expect_err("bogus app should not activate"); + assert!( + err.contains("failed to restore focus"), + "expected focus-restore prefix in error, got: {err}" + ); + } } diff --git a/src/openhuman/webhooks/bus.rs b/src/openhuman/webhooks/bus.rs index d5d283f1b..409c69bf1 100644 --- a/src/openhuman/webhooks/bus.rs +++ b/src/openhuman/webhooks/bus.rs @@ -134,3 +134,95 @@ impl EventHandler for WebhookRequestSubscriber { ); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::webhooks::WebhookRequest; + use base64::Engine; + use std::collections::HashMap; + + // ── Local helpers ───────────────────────────────────────────── + + #[test] + fn base64_encode_matches_standard_engine_output() { + assert_eq!(base64_encode("hello"), "aGVsbG8="); + assert_eq!(base64_encode(""), ""); + } + + #[test] + fn error_body_is_base64_of_json_envelope() { + let encoded = error_body("boom"); + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded.as_bytes()) + .expect("valid base64"); + let json: serde_json::Value = serde_json::from_slice(&decoded).expect("valid json"); + assert_eq!(json["error"].as_str(), Some("boom")); + } + + // ── Constructor + EventHandler metadata ─────────────────────── + + #[test] + fn default_equals_new_and_is_zero_sized() { + // Both constructors produce the same unit-variant struct. + let _a = WebhookRequestSubscriber::default(); + let _b = WebhookRequestSubscriber::new(); + // Zero-sized type — just asserting both compile and construct. + assert_eq!(std::mem::size_of::(), 0); + } + + #[test] + fn event_handler_name_is_namespaced() { + let s = WebhookRequestSubscriber::new(); + assert_eq!(s.name(), "webhook::request_handler"); + } + + #[test] + fn event_handler_domain_filter_is_webhook() { + let s = WebhookRequestSubscriber::new(); + assert_eq!(s.domains(), Some(&["webhook"][..])); + } + + // ── handle() behaviour ──────────────────────────────────────── + + #[tokio::test] + async fn handle_returns_early_on_non_webhook_event() { + // A domain event for a different module must be ignored — + // `handle()` checks the variant and returns without touching + // the socket manager or publishing anything. + let subscriber = WebhookRequestSubscriber::new(); + let event = DomainEvent::AgentTurnStarted { + session_id: "s1".into(), + channel: "web".into(), + }; + // Must not panic, must not block — even without any singletons + // initialised in the test process. + subscriber.handle(&event).await; + } + + #[tokio::test] + async fn handle_processes_incoming_webhook_without_socket_manager() { + // When the socket-manager singleton isn't initialised, the + // handler should log "no socket manager available" and return + // cleanly rather than panicking. We exercise the full routing + // path (currently the "skill runtime removed" branch) to lock + // in that contract for future refactors. + let subscriber = WebhookRequestSubscriber::new(); + let request = WebhookRequest { + correlation_id: "wh_test_1".into(), + tunnel_id: "tid-1".into(), + tunnel_uuid: "uuid-1".into(), + tunnel_name: "my-hook".into(), + method: "POST".into(), + path: "/hook".into(), + headers: HashMap::new(), + query: HashMap::new(), + body: String::new(), + }; + let event = DomainEvent::WebhookIncomingRequest { + request, + raw_data: serde_json::json!({}), + }; + subscriber.handle(&event).await; + } +} diff --git a/src/openhuman/webhooks/ops.rs b/src/openhuman/webhooks/ops.rs index 67f181093..7e73e6618 100644 --- a/src/openhuman/webhooks/ops.rs +++ b/src/openhuman/webhooks/ops.rs @@ -211,3 +211,384 @@ pub async fn get_bandwidth(config: &Config) -> Result, String> let data = get_authed_value(config, Method::GET, "/webhooks/core/bandwidth", None).await?; Ok(RpcOutcome::single_log(data, "webhook bandwidth fetched")) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::credentials::{ + AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, + }; + use axum::{ + extract::Path, + http::HeaderMap, + routing::{delete, get, patch, post}, + Json, Router, + }; + use serde_json::json; + use tempfile::TempDir; + + fn test_config(tmp: &TempDir) -> Config { + Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + } + } + + fn store_session_token(config: &Config, token: &str) { + let service = AuthService::from_config(config); + service + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + token, + std::collections::HashMap::new(), + true, + ) + .expect("store session token"); + } + + async fn spawn_mock_backend(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + // Poll for readiness so the accept loop is live before the + // first authed HTTP call — same pattern used by composio/ops. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut backoff = std::time::Duration::from_millis(2); + loop { + if tokio::net::TcpStream::connect(addr).await.is_ok() { + break; + } + if std::time::Instant::now() >= deadline { + panic!("mock backend at {addr} did not become ready"); + } + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(std::time::Duration::from_millis(50)); + } + format!("http://127.0.0.1:{}", addr.port()) + } + + fn config_with_backend(tmp: &TempDir, base: String) -> Config { + let mut c = test_config(tmp); + c.api_url = Some(base); + store_session_token(&c, "test-session-token"); + c + } + + // ── require_token ───────────────────────────────────────────── + + #[test] + fn require_token_errors_when_no_session_stored() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + let err = require_token(&config).unwrap_err(); + assert!( + err.contains("no backend session token"), + "expected 'no backend session token', got: {err}" + ); + } + + #[test] + fn require_token_returns_stored_token_trimmed() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + store_session_token(&config, " tok-123 "); + let got = require_token(&config).expect("token"); + assert_eq!(got, "tok-123"); + } + + #[test] + fn require_token_rejects_whitespace_only_stored_token() { + // A token that exists in the store but is just whitespace must + // be treated as absent — otherwise downstream HTTP calls would + // send an empty `Authorization: Bearer` header. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + store_session_token(&config, " "); + let err = require_token(&config).unwrap_err(); + assert!(err.contains("no backend session token")); + } + + // ── Stub ops (list/clear/register/unregister) ──────────────── + + #[tokio::test] + async fn list_registrations_returns_empty_payload_with_count_log() { + let out = list_registrations().await.unwrap(); + assert!(out.value.registrations.is_empty()); + assert!(out.logs.iter().any(|l| l.contains("returned 0"))); + } + + #[tokio::test] + async fn list_logs_returns_empty_and_ignores_limit() { + let out = list_logs(Some(50)).await.unwrap(); + assert!(out.value.logs.is_empty()); + assert!(out.logs.iter().any(|l| l.contains("returned 0"))); + + // `None` limit path. + let out2 = list_logs(None).await.unwrap(); + assert!(out2.value.logs.is_empty()); + } + + #[tokio::test] + async fn clear_logs_reports_zero_cleared() { + let out = clear_logs().await.unwrap(); + assert_eq!(out.value.cleared, 0); + assert!(out.logs.iter().any(|l| l.contains("removed 0"))); + } + + #[tokio::test] + async fn register_echo_surfaces_tunnel_uuid_in_log() { + let out = register_echo("uuid-1", Some("name".into()), Some("btid-1".into())) + .await + .unwrap(); + assert!(out.value.registrations.is_empty()); + assert!(out + .logs + .iter() + .any(|l| l.contains("registered tunnel uuid-1"))); + } + + #[tokio::test] + async fn unregister_echo_surfaces_tunnel_uuid_in_log() { + let out = unregister_echo("uuid-1").await.unwrap(); + assert!(out.value.registrations.is_empty()); + assert!(out.logs.iter().any(|l| l.contains("removed tunnel uuid-1"))); + } + + // ── build_echo_response ─────────────────────────────────────── + + #[test] + fn build_echo_response_encodes_request_fields_and_sets_headers() { + let mut query = std::collections::HashMap::new(); + query.insert("q".to_string(), "1".to_string()); + let mut headers = std::collections::HashMap::new(); + headers.insert("X-Foo".to_string(), json!("bar")); + let req = WebhookRequest { + correlation_id: "c-1".into(), + tunnel_id: "tid-1".into(), + tunnel_uuid: "uuid-1".into(), + tunnel_name: "hook".into(), + method: "POST".into(), + path: "/p".into(), + headers, + query, + body: "cGF5bG9hZA==".into(), // base64 of "payload" + }; + let resp = build_echo_response(&req); + + assert_eq!(resp.correlation_id, "c-1"); + assert_eq!(resp.status_code, 200); + assert_eq!( + resp.headers.get("content-type").map(String::as_str), + Some("application/json") + ); + assert_eq!( + resp.headers + .get("x-openhuman-webhook-target") + .map(String::as_str), + Some("echo") + ); + // Decode the body and check the echoed fields survived the round-trip. + let decoded = base64::engine::general_purpose::STANDARD + .decode(resp.body.as_bytes()) + .expect("base64 body"); + let v: serde_json::Value = serde_json::from_slice(&decoded).expect("json body"); + assert_eq!(v["ok"], json!(true)); + assert_eq!(v["echo"]["correlationId"], json!("c-1")); + assert_eq!(v["echo"]["method"], json!("POST")); + assert_eq!(v["echo"]["path"], json!("/p")); + assert_eq!(v["echo"]["bodyBase64"], json!("cGF5bG9hZA==")); + } + + // ── Validation on trimmed inputs ────────────────────────────── + + #[tokio::test] + async fn create_tunnel_rejects_empty_or_whitespace_name() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + for name in ["", " "] { + let err = create_tunnel(&config, name, None).await.unwrap_err(); + assert!( + err.contains("name is required"), + "expected 'name is required' for `{name:?}`, got: {err}" + ); + } + } + + #[tokio::test] + async fn id_bearing_tunnel_ops_reject_empty_or_whitespace_id() { + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + for id in ["", " "] { + assert!(get_tunnel(&config, id) + .await + .unwrap_err() + .contains("id is required")); + assert!(delete_tunnel(&config, id) + .await + .unwrap_err() + .contains("id is required")); + assert!(update_tunnel(&config, id, json!({})) + .await + .unwrap_err() + .contains("id is required")); + } + } + + // ── Authed HTTP round-trips via a mock backend ─────────────── + + #[tokio::test] + async fn list_tunnels_hits_webhooks_core_endpoint_and_returns_payload() { + // Inspect the inbound Authorization header so we catch regressions + // where the JWT stops being forwarded (or is sent with the wrong + // scheme). `config_with_backend` stores `test-session-token`, so + // the header must be `Bearer test-session-token`. + let app = Router::new().route( + "/webhooks/core", + get(|headers: HeaderMap| async move { + let auth = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + assert_eq!( + auth, "Bearer test-session-token", + "authorization header must forward the stored session token" + ); + Json(json!({"tunnels": [{"id": "t-1"}]})) + }), + ); + let base = spawn_mock_backend(app).await; + let tmp = TempDir::new().unwrap(); + let config = config_with_backend(&tmp, base); + let out = list_tunnels(&config).await.unwrap(); + assert_eq!(out.value["tunnels"][0]["id"], json!("t-1")); + assert!(out + .logs + .iter() + .any(|l| l.contains("webhook tunnels fetched"))); + } + + #[tokio::test] + async fn create_tunnel_posts_name_and_optional_description() { + let app = Router::new().route( + "/webhooks/core", + post(|Json(body): Json| async move { + // Echo back the received body so the test can verify + // trimming and optional-description handling. + Json(json!({ "echoed": body })) + }), + ); + let base = spawn_mock_backend(app).await; + let tmp = TempDir::new().unwrap(); + let config = config_with_backend(&tmp, base); + + // Description with surrounding whitespace must be trimmed into + // the outgoing payload; empty description must be dropped. + let out = create_tunnel(&config, " my-hook ", Some(" desc ".into())) + .await + .unwrap(); + assert_eq!(out.value["echoed"]["name"], json!("my-hook")); + assert_eq!(out.value["echoed"]["description"], json!("desc")); + + let out2 = create_tunnel(&config, "nodesc", Some(" ".into())) + .await + .unwrap(); + assert_eq!(out2.value["echoed"]["name"], json!("nodesc")); + assert!( + out2.value["echoed"].get("description").is_none(), + "whitespace-only description must not be forwarded" + ); + } + + #[tokio::test] + async fn get_tunnel_encodes_id_in_path() { + // Use an id full of reserved URL characters so we actually verify + // percent-encoding on the outbound path. axum's `Path` extractor + // decodes before handing us the string, so the server must see + // the trimmed, *decoded* form of the id. + let app = Router::new().route( + "/webhooks/core/{id}", + get(|Path(id): Path| async move { Json(json!({ "id": id })) }), + ); + let base = spawn_mock_backend(app).await; + let tmp = TempDir::new().unwrap(); + let config = config_with_backend(&tmp, base); + let raw_id = " abc:/?#[ ]@!$&'()*+,;=% "; + let trimmed = raw_id.trim(); + let out = get_tunnel(&config, raw_id).await.unwrap(); + assert_eq!( + out.value["id"], + json!(trimmed), + "server should receive the trimmed, decoded id — proves the client \ + percent-encoded reserved chars instead of sending them raw" + ); + } + + #[tokio::test] + async fn update_tunnel_patches_id_with_body() { + let app = Router::new().route( + "/webhooks/core/{id}", + patch( + |Path(id): Path, Json(body): Json| async move { + Json(json!({ "id": id, "patched": body })) + }, + ), + ); + let base = spawn_mock_backend(app).await; + let tmp = TempDir::new().unwrap(); + let config = config_with_backend(&tmp, base); + let out = update_tunnel(&config, "t-1", json!({"name":"renamed","isActive":true})) + .await + .unwrap(); + assert_eq!(out.value["id"], json!("t-1")); + assert_eq!(out.value["patched"]["name"], json!("renamed")); + assert_eq!(out.value["patched"]["isActive"], json!(true)); + } + + #[tokio::test] + async fn delete_tunnel_deletes_by_id() { + let app = Router::new().route( + "/webhooks/core/{id}", + delete(|Path(id): Path| async move { Json(json!({"deleted": id})) }), + ); + let base = spawn_mock_backend(app).await; + let tmp = TempDir::new().unwrap(); + let config = config_with_backend(&tmp, base); + let out = delete_tunnel(&config, "t-42").await.unwrap(); + assert_eq!(out.value["deleted"], json!("t-42")); + } + + #[tokio::test] + async fn get_bandwidth_fetches_the_bandwidth_endpoint() { + let app = Router::new().route( + "/webhooks/core/bandwidth", + get(|| async { Json(json!({"remaining": 1024})) }), + ); + let base = spawn_mock_backend(app).await; + let tmp = TempDir::new().unwrap(); + let config = config_with_backend(&tmp, base); + let out = get_bandwidth(&config).await.unwrap(); + assert_eq!(out.value["remaining"], json!(1024)); + } + + #[tokio::test] + async fn authed_http_calls_surface_require_token_error_without_session() { + // No token stored → all authed endpoints should error with the + // shared "no backend session token" message before any network + // call is made. + let tmp = TempDir::new().unwrap(); + let config = test_config(&tmp); + assert!(list_tunnels(&config) + .await + .unwrap_err() + .contains("no backend session token")); + assert!(get_bandwidth(&config) + .await + .unwrap_err() + .contains("no backend session token")); + } +} diff --git a/src/openhuman/webhooks/schemas.rs b/src/openhuman/webhooks/schemas.rs index 15c264732..af2c3b615 100644 --- a/src/openhuman/webhooks/schemas.rs +++ b/src/openhuman/webhooks/schemas.rs @@ -399,3 +399,260 @@ fn json_output(name: &'static str, comment: &'static str) -> FieldSchema { required: true, } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ── Catalog integrity ───────────────────────────────────────── + + const EXPECTED_FUNCTIONS: &[&str] = &[ + "list_registrations", + "list_logs", + "clear_logs", + "register_echo", + "unregister_echo", + "list_tunnels", + "create_tunnel", + "get_tunnel", + "update_tunnel", + "delete_tunnel", + "get_bandwidth", + ]; + + #[test] + fn all_controller_schemas_matches_expected_function_set() { + let schemas_list = all_controller_schemas(); + assert_eq!(schemas_list.len(), EXPECTED_FUNCTIONS.len()); + let names: Vec<&str> = schemas_list.iter().map(|s| s.function).collect(); + for expected in EXPECTED_FUNCTIONS { + assert!( + names.contains(expected), + "catalog missing `{expected}`; got {names:?}" + ); + } + } + + #[test] + fn all_controller_schemas_entries_are_all_under_webhooks_namespace() { + for s in all_controller_schemas() { + assert_eq!( + s.namespace, "webhooks", + "schema `{}` has wrong namespace", + s.function + ); + assert!( + !s.description.trim().is_empty(), + "schema `{}` must have a description", + s.function + ); + } + } + + #[test] + fn all_registered_controllers_parallels_the_schema_list() { + let schemas_list = all_controller_schemas(); + let handlers = all_registered_controllers(); + assert_eq!(schemas_list.len(), handlers.len()); + + // Every registered controller's schema must resolve back to the + // same ControllerSchema produced by `schemas()` — proves the two + // lists are kept in lock-step and no handler is mis-wired. + for rc in &handlers { + let resolved = schemas(rc.schema.function); + assert_eq!(resolved.function, rc.schema.function); + assert_eq!(resolved.namespace, rc.schema.namespace); + } + } + + #[test] + fn all_registered_controller_function_names_are_unique() { + let handlers = all_registered_controllers(); + let mut names: Vec<&str> = handlers.iter().map(|rc| rc.schema.function).collect(); + names.sort_unstable(); + let unique_count = { + let mut clone = names.clone(); + clone.dedup(); + clone.len() + }; + assert_eq!( + unique_count, + names.len(), + "duplicate function names: {names:?}" + ); + } + + // ── schemas(function) per-arm coverage ─────────────────────── + + fn required_input_names(s: &ControllerSchema) -> Vec<&'static str> { + s.inputs + .iter() + .filter(|f| f.required) + .map(|f| f.name) + .collect() + } + + #[test] + fn list_registrations_has_no_inputs_and_json_output() { + let s = schemas("list_registrations"); + assert!(s.inputs.is_empty()); + assert_eq!(s.outputs.len(), 1); + assert_eq!(s.outputs[0].name, "result"); + assert!(matches!(s.outputs[0].ty, TypeSchema::Json)); + } + + #[test] + fn list_logs_limit_is_optional_u64() { + let s = schemas("list_logs"); + assert_eq!(s.inputs.len(), 1); + assert_eq!(s.inputs[0].name, "limit"); + assert!(!s.inputs[0].required); + match &s.inputs[0].ty { + TypeSchema::Option(inner) => assert!(matches!(**inner, TypeSchema::U64)), + other => panic!("limit must be Option, got {other:?}"), + } + } + + #[test] + fn clear_logs_has_no_inputs() { + assert!(schemas("clear_logs").inputs.is_empty()); + } + + #[test] + fn register_echo_requires_tunnel_uuid_only() { + let s = schemas("register_echo"); + assert_eq!(required_input_names(&s), vec!["tunnel_uuid"]); + // The two optional fields must exist and be Option. + for optional in ["tunnel_name", "backend_tunnel_id"] { + let f = s + .inputs + .iter() + .find(|f| f.name == optional) + .unwrap_or_else(|| panic!("missing optional `{optional}`")); + assert!(!f.required); + assert!( + matches!(&f.ty, TypeSchema::Option(inner) if matches!(**inner, TypeSchema::String)) + ); + } + } + + #[test] + fn unregister_echo_requires_tunnel_uuid_only() { + let s = schemas("unregister_echo"); + assert_eq!(required_input_names(&s), vec!["tunnel_uuid"]); + } + + #[test] + fn list_tunnels_has_no_inputs() { + assert!(schemas("list_tunnels").inputs.is_empty()); + } + + #[test] + fn create_tunnel_requires_name_and_allows_optional_description() { + let s = schemas("create_tunnel"); + assert_eq!(required_input_names(&s), vec!["name"]); + assert!(s + .inputs + .iter() + .any(|f| f.name == "description" && !f.required)); + } + + #[test] + fn get_and_delete_tunnel_require_id_only() { + for fn_name in ["get_tunnel", "delete_tunnel"] { + let s = schemas(fn_name); + assert_eq!( + required_input_names(&s), + vec!["id"], + "`{fn_name}` must require only `id`" + ); + } + } + + #[test] + fn update_tunnel_requires_id_and_allows_optional_name_description_is_active() { + let s = schemas("update_tunnel"); + assert_eq!(required_input_names(&s), vec!["id"]); + for optional in ["name", "description", "isActive"] { + assert!( + s.inputs.iter().any(|f| f.name == optional && !f.required), + "`update_tunnel` must accept optional `{optional}`" + ); + } + } + + #[test] + fn get_bandwidth_has_no_inputs() { + assert!(schemas("get_bandwidth").inputs.is_empty()); + } + + #[test] + fn unknown_function_returns_error_fallback_schema() { + let s = schemas("no_such_fn"); + assert_eq!(s.function, "unknown"); + assert_eq!(s.namespace, "webhooks"); + assert_eq!(s.outputs.len(), 1); + assert_eq!(s.outputs[0].name, "error"); + assert!(matches!(s.outputs[0].ty, TypeSchema::String)); + assert!(s.outputs[0].required); + } + + // ── deserialize_params ──────────────────────────────────────── + + #[test] + fn deserialize_params_returns_typed_struct_for_valid_input() { + let mut params = Map::new(); + params.insert("tunnel_uuid".to_string(), Value::String("u-1".into())); + params.insert("tunnel_name".to_string(), Value::String("n".into())); + params.insert("backend_tunnel_id".to_string(), Value::Null); + let parsed = deserialize_params::(params).unwrap(); + assert_eq!(parsed.tunnel_uuid, "u-1"); + assert_eq!(parsed.tunnel_name.as_deref(), Some("n")); + assert!(parsed.backend_tunnel_id.is_none()); + } + + #[test] + fn deserialize_params_reports_invalid_params_errors() { + // Missing required `tunnel_uuid` for WebhookUnregisterEchoParams. + let err = deserialize_params::(Map::new()).unwrap_err(); + assert!( + err.contains("invalid params"), + "expected 'invalid params' prefix, got: {err}" + ); + } + + #[test] + fn deserialize_params_honours_camel_case_rename_for_update_tunnel() { + // `WebhookUpdateTunnelParams` uses `#[serde(rename_all = "camelCase")]`, + // so the JSON key is `isActive` even though the Rust field is + // `is_active`. This test locks in that contract. + let mut params = Map::new(); + params.insert("id".to_string(), Value::String("t-1".into())); + params.insert("isActive".to_string(), Value::Bool(true)); + let parsed = deserialize_params::(params).unwrap(); + assert_eq!(parsed.id, "t-1"); + assert_eq!(parsed.is_active, Some(true)); + } + + // ── json_output / to_json ───────────────────────────────────── + + #[test] + fn json_output_builds_required_json_field() { + let f = json_output("result", "stuff"); + assert_eq!(f.name, "result"); + assert_eq!(f.comment, "stuff"); + assert!(f.required); + assert!(matches!(f.ty, TypeSchema::Json)); + } + + #[test] + fn to_json_renders_rpc_outcome_in_cli_compatible_shape() { + // `to_json` is a thin wrapper over `RpcOutcome::into_cli_compatible_json`. + // We exercise it here so coverage follows the real shape the + // adapters produce, rather than asserting on implementation details. + let outcome: RpcOutcome = RpcOutcome::new(json!({"ok": true}), vec![]); + let value = to_json(outcome).unwrap(); + assert!(value.is_object()); + } +} diff --git a/src/openhuman/webhooks/types.rs b/src/openhuman/webhooks/types.rs index 155b6f5eb..66e163c27 100644 --- a/src/openhuman/webhooks/types.rs +++ b/src/openhuman/webhooks/types.rs @@ -157,3 +157,237 @@ pub struct WebhookDebugEvent { pub correlation_id: Option, pub tunnel_uuid: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ── WebhookRequest ───────────────────────────────────────────── + + #[test] + fn webhook_request_deserializes_camel_case_ids_and_defaults_body() { + // Body is `#[serde(default)]` — missing body must deserialise + // to the empty string rather than erroring. + let payload = json!({ + "correlationId": "wh_abc_123", + "tunnelId": "tid-1", + "tunnelUuid": "uuid-1", + "tunnelName": "my-hook", + "method": "POST", + "path": "/x", + "headers": {"X-Foo": "bar"}, + "query": {"q": "1"} + }); + let req: WebhookRequest = serde_json::from_value(payload).unwrap(); + assert_eq!(req.correlation_id, "wh_abc_123"); + assert_eq!(req.tunnel_id, "tid-1"); + assert_eq!(req.tunnel_uuid, "uuid-1"); + assert_eq!(req.tunnel_name, "my-hook"); + assert_eq!(req.method, "POST"); + assert_eq!(req.path, "/x"); + assert_eq!(req.headers.get("X-Foo"), Some(&json!("bar"))); + assert_eq!(req.query.get("q").map(String::as_str), Some("1")); + assert_eq!(req.body, ""); + } + + #[test] + fn webhook_request_serializes_back_to_camel_case_keys() { + let req = WebhookRequest { + correlation_id: "c".into(), + tunnel_id: "t".into(), + tunnel_uuid: "u".into(), + tunnel_name: "n".into(), + method: "GET".into(), + path: "/".into(), + headers: HashMap::new(), + query: HashMap::new(), + body: "aGVsbG8=".into(), + }; + let v = serde_json::to_value(&req).unwrap(); + assert!(v.get("correlationId").is_some()); + assert!(v.get("tunnelId").is_some()); + assert!(v.get("tunnelUuid").is_some()); + assert!(v.get("tunnelName").is_some()); + assert_eq!(v.get("body").and_then(|b| b.as_str()), Some("aGVsbG8=")); + } + + // ── WebhookResponseData ──────────────────────────────────────── + + #[test] + fn webhook_response_data_defaults_headers_and_body() { + let payload = json!({ + "correlationId": "c", + "statusCode": 204 + }); + let resp: WebhookResponseData = serde_json::from_value(payload).unwrap(); + assert_eq!(resp.correlation_id, "c"); + assert_eq!(resp.status_code, 204); + assert!(resp.headers.is_empty()); + assert_eq!(resp.body, ""); + } + + #[test] + fn webhook_response_data_round_trips() { + let resp = WebhookResponseData { + correlation_id: "c".into(), + status_code: 200, + headers: [("Content-Type".to_string(), "text/plain".to_string())] + .into_iter() + .collect(), + body: "Zm9v".into(), + }; + let s = serde_json::to_string(&resp).unwrap(); + let back: WebhookResponseData = serde_json::from_str(&s).unwrap(); + assert_eq!(back.status_code, 200); + assert_eq!( + back.headers.get("Content-Type").map(String::as_str), + Some("text/plain") + ); + assert_eq!(back.body, "Zm9v"); + } + + // ── TunnelRegistration + default_webhook_target_kind ────────── + + #[test] + fn default_webhook_target_kind_is_skill() { + assert_eq!(default_webhook_target_kind(), "skill"); + } + + #[test] + fn tunnel_registration_defaults_target_kind_to_skill() { + // Omitting `target_kind` must fall back to "skill" via the + // `#[serde(default = "default_webhook_target_kind")]` attribute. + let payload = json!({ + "tunnel_uuid": "u-1", + "skill_id": "gmail" + }); + let reg: TunnelRegistration = serde_json::from_value(payload).unwrap(); + assert_eq!(reg.tunnel_uuid, "u-1"); + assert_eq!(reg.target_kind, "skill"); + assert_eq!(reg.skill_id, "gmail"); + assert!(reg.tunnel_name.is_none()); + assert!(reg.backend_tunnel_id.is_none()); + } + + #[test] + fn tunnel_registration_honours_explicit_target_kind() { + let payload = json!({ + "tunnel_uuid": "u-1", + "target_kind": "echo", + "skill_id": "echo", + "tunnel_name": "my", + "backend_tunnel_id": "b-1" + }); + let reg: TunnelRegistration = serde_json::from_value(payload).unwrap(); + assert_eq!(reg.target_kind, "echo"); + assert_eq!(reg.tunnel_name.as_deref(), Some("my")); + assert_eq!(reg.backend_tunnel_id.as_deref(), Some("b-1")); + } + + // ── WebhookActivityEntry ────────────────────────────────────── + + #[test] + fn webhook_activity_entry_round_trips_optional_fields() { + let entry = WebhookActivityEntry { + correlation_id: "c".into(), + tunnel_name: "t".into(), + method: "POST".into(), + path: "/p".into(), + status_code: Some(200), + skill_id: Some("gmail".into()), + timestamp: 1_700_000_000_000, + }; + let s = serde_json::to_string(&entry).unwrap(); + let back: WebhookActivityEntry = serde_json::from_str(&s).unwrap(); + assert_eq!(back.status_code, Some(200)); + assert_eq!(back.skill_id.as_deref(), Some("gmail")); + + let unrouted = WebhookActivityEntry { + status_code: None, + skill_id: None, + ..entry + }; + let s2 = serde_json::to_string(&unrouted).unwrap(); + let back2: WebhookActivityEntry = serde_json::from_str(&s2).unwrap(); + assert!(back2.status_code.is_none()); + assert!(back2.skill_id.is_none()); + } + + // ── WebhookDebugLogEntry ────────────────────────────────────── + + #[test] + fn webhook_debug_log_entry_defaults_request_response_payloads() { + // Five `#[serde(default)]` fields — omit them all in the JSON + // and confirm they come back as empty collections / strings. + let payload = json!({ + "correlation_id": "c", + "tunnel_id": "t", + "tunnel_uuid": "u", + "tunnel_name": "n", + "method": "GET", + "path": "/", + "skill_id": null, + "status_code": null, + "timestamp": 1, + "updated_at": 2, + "stage": "received", + "error_message": null, + "raw_payload": null + }); + let entry: WebhookDebugLogEntry = serde_json::from_value(payload).unwrap(); + assert!(entry.request_headers.is_empty()); + assert!(entry.request_query.is_empty()); + assert_eq!(entry.request_body, ""); + assert!(entry.response_headers.is_empty()); + assert_eq!(entry.response_body, ""); + assert_eq!(entry.timestamp, 1); + assert_eq!(entry.updated_at, 2); + } + + // ── Debug* result wrappers ──────────────────────────────────── + + #[test] + fn debug_result_wrappers_round_trip() { + let regs = WebhookDebugRegistrationsResult { + registrations: vec![TunnelRegistration { + tunnel_uuid: "u".into(), + target_kind: "skill".into(), + skill_id: "s".into(), + tunnel_name: None, + backend_tunnel_id: None, + }], + }; + let back: WebhookDebugRegistrationsResult = + serde_json::from_str(&serde_json::to_string(®s).unwrap()).unwrap(); + assert_eq!(back.registrations.len(), 1); + + let logs = WebhookDebugLogListResult { logs: vec![] }; + let back: WebhookDebugLogListResult = + serde_json::from_str(&serde_json::to_string(&logs).unwrap()).unwrap(); + assert!(back.logs.is_empty()); + + let cleared = WebhookDebugLogsClearedResult { cleared: 7 }; + let back: WebhookDebugLogsClearedResult = + serde_json::from_str(&serde_json::to_string(&cleared).unwrap()).unwrap(); + assert_eq!(back.cleared, 7); + } + + // ── WebhookDebugEvent ───────────────────────────────────────── + + #[test] + fn webhook_debug_event_round_trips_optional_correlation_fields() { + let ev = WebhookDebugEvent { + event_type: "request".into(), + timestamp: 123, + correlation_id: Some("c".into()), + tunnel_uuid: Some("u".into()), + }; + let s = serde_json::to_string(&ev).unwrap(); + let back: WebhookDebugEvent = serde_json::from_str(&s).unwrap(); + assert_eq!(back.event_type, "request"); + assert_eq!(back.timestamp, 123); + assert_eq!(back.correlation_id.as_deref(), Some("c")); + assert_eq!(back.tunnel_uuid.as_deref(), Some("u")); + } +} diff --git a/src/openhuman/workspace/ops.rs b/src/openhuman/workspace/ops.rs index bd633a10a..5f0652d63 100644 --- a/src/openhuman/workspace/ops.rs +++ b/src/openhuman/workspace/ops.rs @@ -96,3 +96,215 @@ pub async fn init_workspace(force: bool) -> Result { ] })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::config::TEST_ENV_LOCK as ENV_LOCK; + use tempfile::tempdir; + + /// RAII guard for `OPENHUMAN_WORKSPACE`. Sets the env var on + /// construction and clears it on drop so a panicking test doesn't + /// leak the override into sibling tests. Must be constructed while + /// holding `ENV_LOCK` — mutating process env vars concurrently is + /// unsafe and the lock serialises every test in this module. + struct WorkspaceEnvGuard; + + impl WorkspaceEnvGuard { + fn set(path: &std::path::Path) -> Self { + // SAFETY: Caller holds `ENV_LOCK`, so no other thread in + // this process is reading or mutating this env var. + unsafe { + std::env::set_var("OPENHUMAN_WORKSPACE", path); + } + Self + } + } + + impl Drop for WorkspaceEnvGuard { + fn drop(&mut self) { + // SAFETY: Same contract as `set()` — `ENV_LOCK` is held for + // the whole test, so no concurrent env access is possible. + unsafe { + std::env::remove_var("OPENHUMAN_WORKSPACE"); + } + } + } + + // ── ensure_workspace_file ────────────────────────────────────── + + #[test] + fn ensure_workspace_file_creates_missing_file() { + let tmp = tempdir().unwrap(); + let status = + ensure_workspace_file(tmp.path(), "A.md", "hello", false).expect("should create"); + assert_eq!(status, "created"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("A.md")).unwrap(), + "hello" + ); + } + + #[test] + fn ensure_workspace_file_leaves_existing_file_untouched_without_force() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("B.md"), "original").unwrap(); + let status = ensure_workspace_file(tmp.path(), "B.md", "new contents", false).expect("ok"); + assert_eq!(status, "existing"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("B.md")).unwrap(), + "original", + "file must not be overwritten when force=false" + ); + } + + #[test] + fn ensure_workspace_file_overwrites_when_forced() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("C.md"), "original").unwrap(); + let status = ensure_workspace_file(tmp.path(), "C.md", "new contents", true).expect("ok"); + assert_eq!(status, "overwritten"); + assert_eq!( + std::fs::read_to_string(tmp.path().join("C.md")).unwrap(), + "new contents" + ); + } + + #[test] + fn ensure_workspace_file_errors_when_directory_missing() { + let tmp = tempdir().unwrap(); + let missing = tmp.path().join("does/not/exist"); + let err = ensure_workspace_file(&missing, "x.md", "y", false).unwrap_err(); + assert!( + err.contains("failed to write"), + "expected write-failure error, got: {err}" + ); + } + + #[test] + fn bootstrap_files_contain_soul_and_identity() { + // Lock in the contract so `init_workspace` doesn't silently stop + // shipping a required prompt. These are the canonical prompt + // files the agent harness expects in every fresh workspace. + let names: Vec<&str> = BOOTSTRAP_FILES.iter().map(|(n, _)| *n).collect(); + assert!(names.contains(&"SOUL.md")); + assert!(names.contains(&"IDENTITY.md")); + assert_eq!(BOOTSTRAP_FILES.len(), 2); + // Bundled contents must be non-empty — a packaging regression + // that empties one would otherwise silently ship a broken agent. + for (_, contents) in BOOTSTRAP_FILES { + assert!(!contents.trim().is_empty()); + } + } + + // ── init_workspace ──────────────────────────────────────────── + + #[tokio::test] + async fn init_workspace_creates_dirs_and_files_in_fresh_workspace() { + let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let tmp = tempdir().unwrap(); + let _env = WorkspaceEnvGuard::set(tmp.path()); + + let value = init_workspace(false) + .await + .expect("init_workspace on empty temp should succeed"); + + let workspace_dir = value["result"]["workspace_dir"] + .as_str() + .expect("workspace_dir string"); + let workspace_dir = std::path::PathBuf::from(workspace_dir); + for rel in ["memory", "sessions", "state", "cron"] { + assert!( + workspace_dir.join(rel).is_dir(), + "expected {rel} directory under {}", + workspace_dir.display() + ); + } + assert!(workspace_dir.join("SOUL.md").is_file()); + assert!(workspace_dir.join("IDENTITY.md").is_file()); + assert!(workspace_dir.join("HEARTBEAT.md").is_file()); + + let created: Vec<&str> = value["result"]["files"]["created"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(created.iter().any(|s| s.ends_with("SOUL.md"))); + assert!(created.iter().any(|s| s.ends_with("IDENTITY.md"))); + + let logs = value["logs"].as_array().expect("logs array"); + assert!(logs.iter().any(|l| l + .as_str() + .unwrap_or("") + .contains("workspace initialization completed"))); + } + + #[tokio::test] + async fn init_workspace_reports_existing_entries_on_second_call_without_force() { + let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let tmp = tempdir().unwrap(); + let _env = WorkspaceEnvGuard::set(tmp.path()); + + // First call populates the workspace. + init_workspace(false).await.expect("first init ok"); + // Second call without force should report everything as existing + // and nothing as created / overwritten. + let value = init_workspace(false).await.expect("second init ok"); + + let created = value["result"]["files"]["created"].as_array().unwrap(); + let overwritten = value["result"]["files"]["overwritten"].as_array().unwrap(); + let existing = value["result"]["files"]["existing"].as_array().unwrap(); + assert!(created.is_empty(), "no files should be re-created"); + assert!(overwritten.is_empty(), "no files should be overwritten"); + assert!( + existing + .iter() + .any(|v| v.as_str().unwrap_or("").ends_with("SOUL.md")), + "SOUL.md should appear in the existing list" + ); + + let created_dirs = value["result"]["directories"]["created"] + .as_array() + .unwrap(); + let existing_dirs = value["result"]["directories"]["existing"] + .as_array() + .unwrap(); + assert!(created_dirs.is_empty()); + assert!(!existing_dirs.is_empty()); + } + + #[tokio::test] + async fn init_workspace_with_force_overwrites_existing_bootstrap_files() { + let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let tmp = tempdir().unwrap(); + let _env = WorkspaceEnvGuard::set(tmp.path()); + + let first = init_workspace(false).await.expect("initial init"); + // The config loader may place the workspace at a subpath of the + // env override (e.g. `{tmp}/workspace`), so discover the real + // location from the first result rather than assuming it is + // `tmp.path()` itself. + let workspace_dir = std::path::PathBuf::from( + first["result"]["workspace_dir"] + .as_str() + .expect("workspace_dir string"), + ); + let soul = workspace_dir.join("SOUL.md"); + std::fs::write(&soul, "corrupted").unwrap(); + + let value = init_workspace(true).await.expect("forced init"); + + let overwritten: Vec<&str> = value["result"]["files"]["overwritten"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(overwritten.iter().any(|s| s.ends_with("SOUL.md"))); + // And the on-disk contents must no longer be "corrupted". + let restored = std::fs::read_to_string(&soul).unwrap(); + assert_ne!(restored, "corrupted"); + assert!(!restored.trim().is_empty()); + } +}