Skip to content

Commit b63d5f2

Browse files
h4x0rclaude
andcommitted
test: add E2E coverage with API integration, WebSocket, and Playwright tests
Add 13 backend API integration tests covering metrics, sync, templates, auth, task lifecycle, and iTerm2 session endpoints. Add 5 WebSocket integration tests verifying the full event pipeline (connect, create, delete, multi-client broadcast, graceful close). Rewrite Playwright E2E from 8 shallow smoke tests to 57 proper tests with data-testid usage and Playwright best practices across 4 spec files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8812b03 commit b63d5f2

8 files changed

Lines changed: 1286 additions & 53 deletions

File tree

Cargo.lock

Lines changed: 32 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/shepherd-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ tempfile = "3"
2828
serde_json = { workspace = true }
2929
tokio = { workspace = true }
3030
shepherd-core = { workspace = true }
31+
tokio-tungstenite = "0.29.0"

crates/shepherd-server/tests/server_test.rs

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,260 @@ async fn test_logogen_export_invalid_base64() {
368368
.unwrap()
369369
.contains("Icon export failed"));
370370
}
371+
372+
// ── Metrics endpoints ────────────────────────────────────────────────
373+
374+
#[tokio::test]
375+
async fn test_metrics_summary_returns_empty_when_no_tasks() {
376+
let (url, _handle) = start_test_server().await;
377+
let client = Client::new();
378+
let resp = client
379+
.get(format!("{url}/api/metrics"))
380+
.send()
381+
.await
382+
.unwrap();
383+
assert_eq!(resp.status(), 200);
384+
let body: Value = resp.json().await.unwrap();
385+
assert_eq!(body["total_cost_usd"], 0.0);
386+
assert_eq!(body["total_tokens"], 0);
387+
assert_eq!(body["total_tasks"], 0);
388+
assert_eq!(body["total_llm_calls"], 0);
389+
assert!(body["by_agent"].as_array().unwrap().is_empty());
390+
assert!(body["by_model"].as_array().unwrap().is_empty());
391+
}
392+
393+
#[tokio::test]
394+
async fn test_metrics_task_not_found() {
395+
let (url, _handle) = start_test_server().await;
396+
let client = Client::new();
397+
let resp = client
398+
.get(format!("{url}/api/metrics/99999"))
399+
.send()
400+
.await
401+
.unwrap();
402+
assert_eq!(resp.status(), 404);
403+
let body: Value = resp.json().await.unwrap();
404+
assert!(body["error"]
405+
.as_str()
406+
.unwrap()
407+
.contains("No metrics found for task 99999"));
408+
}
409+
410+
// ── Sync endpoints (503 when cloud_client: None) ─────────────────────
411+
412+
#[tokio::test]
413+
async fn test_sync_push_no_cloud_returns_503() {
414+
let (url, _handle) = start_test_server().await;
415+
let client = Client::new();
416+
let resp = client
417+
.post(format!("{url}/api/sync/push"))
418+
.json(&json!({}))
419+
.send()
420+
.await
421+
.unwrap();
422+
assert_eq!(resp.status(), 503);
423+
let body: Value = resp.json().await.unwrap();
424+
assert!(body["error"].as_str().unwrap().contains("not available"));
425+
}
426+
427+
#[tokio::test]
428+
async fn test_sync_pull_no_cloud_returns_503() {
429+
let (url, _handle) = start_test_server().await;
430+
let client = Client::new();
431+
let resp = client
432+
.post(format!("{url}/api/sync/pull"))
433+
.json(&json!({}))
434+
.send()
435+
.await
436+
.unwrap();
437+
assert_eq!(resp.status(), 503);
438+
let body: Value = resp.json().await.unwrap();
439+
assert!(body["error"].as_str().unwrap().contains("not available"));
440+
}
441+
442+
#[tokio::test]
443+
async fn test_sync_now_no_cloud_returns_503() {
444+
let (url, _handle) = start_test_server().await;
445+
let client = Client::new();
446+
let resp = client
447+
.post(format!("{url}/api/sync/now"))
448+
.send()
449+
.await
450+
.unwrap();
451+
assert_eq!(resp.status(), 503);
452+
let body: Value = resp.json().await.unwrap();
453+
assert!(body["error"].as_str().unwrap().contains("not available"));
454+
}
455+
456+
// ── Templates endpoint ───────────────────────────────────────────────
457+
458+
#[tokio::test]
459+
async fn test_templates_no_cloud_returns_503() {
460+
let (url, _handle) = start_test_server().await;
461+
let client = Client::new();
462+
let resp = client
463+
.get(format!("{url}/api/templates"))
464+
.send()
465+
.await
466+
.unwrap();
467+
assert_eq!(resp.status(), 503);
468+
let body: Value = resp.json().await.unwrap();
469+
assert!(body["error"].as_str().unwrap().contains("not available"));
470+
}
471+
472+
// ── Auth endpoints ───────────────────────────────────────────────────
473+
474+
#[tokio::test]
475+
async fn test_auth_login_no_cloud_returns_503() {
476+
let (url, _handle) = start_test_server().await;
477+
let client = Client::new();
478+
let resp = client
479+
.post(format!("{url}/api/auth/login"))
480+
.send()
481+
.await
482+
.unwrap();
483+
assert_eq!(resp.status(), 503);
484+
let body: Value = resp.json().await.unwrap();
485+
assert!(body["error"].as_str().unwrap().contains("not available"));
486+
}
487+
488+
#[tokio::test]
489+
async fn test_auth_profile_unauthenticated_returns_401() {
490+
let (url, _handle) = start_test_server().await;
491+
let client = Client::new();
492+
let resp = client
493+
.get(format!("{url}/api/auth/profile"))
494+
.send()
495+
.await
496+
.unwrap();
497+
assert_eq!(resp.status(), 401);
498+
let body: Value = resp.json().await.unwrap();
499+
assert!(body["error"]
500+
.as_str()
501+
.unwrap()
502+
.contains("Not authenticated"));
503+
}
504+
505+
#[tokio::test]
506+
async fn test_auth_logout_returns_success() {
507+
let (url, _handle) = start_test_server().await;
508+
let client = Client::new();
509+
let resp = client
510+
.post(format!("{url}/api/auth/logout"))
511+
.send()
512+
.await
513+
.unwrap();
514+
assert_eq!(resp.status(), 200);
515+
let body: Value = resp.json().await.unwrap();
516+
assert_eq!(body["success"], true);
517+
}
518+
519+
// ── Task lifecycle (multi-step) ──────────────────────────────────────
520+
521+
#[tokio::test]
522+
async fn test_task_full_lifecycle() {
523+
let (url, _handle) = start_test_server().await;
524+
let client = Client::new();
525+
526+
// Step 1: Create a task
527+
let resp = client
528+
.post(format!("{url}/api/tasks"))
529+
.json(&json!({
530+
"title": "Lifecycle test",
531+
"agent_id": "claude-code"
532+
}))
533+
.send()
534+
.await
535+
.unwrap();
536+
assert_eq!(resp.status(), 201);
537+
let task: Value = resp.json().await.unwrap();
538+
let id = task["id"].as_i64().unwrap();
539+
assert_eq!(task["title"], "Lifecycle test");
540+
assert_eq!(task["status"], "queued");
541+
542+
// Step 2: List tasks — should have exactly 1
543+
let tasks: Vec<Value> = client
544+
.get(format!("{url}/api/tasks"))
545+
.send()
546+
.await
547+
.unwrap()
548+
.json()
549+
.await
550+
.unwrap();
551+
assert_eq!(tasks.len(), 1);
552+
assert_eq!(tasks[0]["id"], id);
553+
554+
// Step 3: Approve the task
555+
let resp = client
556+
.post(format!("{}/api/tasks/{}/approve", url, id))
557+
.send()
558+
.await
559+
.unwrap();
560+
assert_eq!(resp.status(), 200);
561+
let body: Value = resp.json().await.unwrap();
562+
assert_eq!(body["id"], id);
563+
assert_eq!(body["status"], "running");
564+
565+
// Step 4: Delete the task
566+
let resp = client
567+
.delete(format!("{}/api/tasks/{}", url, id))
568+
.send()
569+
.await
570+
.unwrap();
571+
assert_eq!(resp.status(), 200);
572+
let body: Value = resp.json().await.unwrap();
573+
assert_eq!(body["deleted"], id);
574+
575+
// Step 5: List tasks — should be empty
576+
let tasks: Vec<Value> = client
577+
.get(format!("{url}/api/tasks"))
578+
.send()
579+
.await
580+
.unwrap()
581+
.json()
582+
.await
583+
.unwrap();
584+
assert!(tasks.is_empty());
585+
}
586+
587+
// ── iTerm2 session endpoints ─────────────────────────────────────────
588+
589+
#[tokio::test]
590+
async fn test_iterm2_sessions_task_not_found() {
591+
let (url, _handle) = start_test_server().await;
592+
let client = Client::new();
593+
let resp = client
594+
.get(format!("{url}/api/sessions/99999/claude-sessions"))
595+
.send()
596+
.await
597+
.unwrap();
598+
assert_eq!(resp.status(), 404);
599+
}
600+
601+
#[tokio::test]
602+
async fn test_iterm2_resume_task_not_found() {
603+
let (url, _handle) = start_test_server().await;
604+
let client = Client::new();
605+
let resp = client
606+
.post(format!("{url}/api/sessions/99999/resume"))
607+
.json(&json!({}))
608+
.send()
609+
.await
610+
.unwrap();
611+
assert_eq!(resp.status(), 404);
612+
}
613+
614+
// ── WebSocket connectivity ───────────────────────────────────────────
615+
616+
#[tokio::test]
617+
async fn test_websocket_upgrade_succeeds() {
618+
let (url, _handle) = start_test_server().await;
619+
let client = Client::new();
620+
// A plain GET to /ws without the upgrade headers should not return 404.
621+
// The WebSocket handler requires an upgrade, so it should return 400 or similar.
622+
let resp = client.get(format!("{url}/ws")).send().await.unwrap();
623+
// The endpoint exists — it should NOT be 404 or 405.
624+
// Without proper WebSocket upgrade headers, Axum returns 400.
625+
assert_ne!(resp.status(), 404);
626+
assert_ne!(resp.status(), 405);
627+
}

0 commit comments

Comments
 (0)