mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
* 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<String> path is caught.
Refs #530.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>, 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<Bool>, 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::<ModelSettingsUpdate>(m).unwrap_err();
|
||||
assert!(err.starts_with("invalid params"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_params_requires_value_on_set_onboarding() {
|
||||
let err = deserialize_params::<OnboardingCompletedSetParams>(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::<SetBrowserAllowAllParams>(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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<String>(¶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::<String>(¶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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<QueryNamespaceRequest>(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::<RecallContextRequest>(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::<EmptyRequest>(json!({"x": 1})).unwrap_err();
|
||||
assert!(err.to_string().contains("x"));
|
||||
serde_json::from_value::<EmptyRequest>(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::<u32> {
|
||||
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<u32> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1357,3 +1357,124 @@ fn parse_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, St
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
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::<Strict>(m).unwrap_err();
|
||||
assert!(err.contains("invalid params"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,3 +85,71 @@ fn handle_migrate_openclaw(params: Map<String, Value>) -> ControllerFuture {
|
||||
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Value>| 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<Value>| 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<ReferralClaimParams>(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::<ReferralClaimParams>(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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,60 @@ pub fn security_policy_info() -> RpcOutcome<serde_json::Value> {
|
||||
});
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,3 +116,180 @@ pub struct AcceptGhostTextResult {
|
||||
pub inserted: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<bool>` 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<F, Fut, R>(base: String, config: &Config, body: F) -> R
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = R>,
|
||||
{
|
||||
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<Body>| 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<Body>| 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<WebhookRequestSubscriber>(), 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,3 +211,384 @@ pub async fn get_bandwidth(config: &Config) -> Result<RpcOutcome<Value>, 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<serde_json::Value>| 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<String>| 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<String>, Json(body): Json<serde_json::Value>| 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<String>| 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<U64>, 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<String>.
|
||||
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::<WebhookRegisterEchoParams>(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::<WebhookUnregisterEchoParams>(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::<WebhookUpdateTunnelParams>(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<serde_json::Value> = RpcOutcome::new(json!({"ok": true}), vec![]);
|
||||
let value = to_json(outcome).unwrap();
|
||||
assert!(value.is_object());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,3 +157,237 @@ pub struct WebhookDebugEvent {
|
||||
pub correlation_id: Option<String>,
|
||||
pub tunnel_uuid: Option<String>,
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,3 +96,215 @@ pub async fn init_workspace(force: bool) -> Result<serde_json::Value, String> {
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user