feat: add many things

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-08 23:00:03 +01:00
parent 45353089c2
commit 5a5f9a3003
104 changed files with 23417 additions and 2027 deletions

View File

@@ -582,7 +582,7 @@ async fn projects_list_non_member_returns_403() {
}
#[tokio::test]
async fn projects_list_platform_unavailable_degrades_gracefully() {
async fn projects_list_platform_unavailable_returns_500() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_projects_result: Some(Err(PlatformError::Unavailable(
"connection refused".into(),
@@ -603,12 +603,13 @@ async fn projects_list_platform_unavailable_degrades_gracefully() {
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No projects yet"));
assert!(html.contains("Something went wrong"));
assert!(html.contains("connection refused"));
}
// ─── Project detail ─────────────────────────────────────────────────
@@ -634,9 +635,10 @@ async fn project_detail_returns_200_with_artifacts() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("my-api"));
assert!(html.contains("Deploy v1.0"));
assert!(html.contains("my-api-abc123"));
// The timeline is now rendered by a Svelte web component
assert!(html.contains("release-timeline"));
assert!(html.contains("org=\"testorg\""));
assert!(html.contains("project=\"my-api\""));
}
#[tokio::test]
@@ -664,7 +666,9 @@ async fn project_detail_empty_artifacts_shows_empty_state() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No releases yet"));
// Empty state is now rendered client-side by the Svelte component
assert!(html.contains("release-timeline"));
assert!(html.contains("project=\"my-api\""));
}
#[tokio::test]
@@ -698,6 +702,7 @@ async fn project_detail_shows_enriched_artifact_data() {
type_organisation: None,
type_name: None,
type_version: None,
status: None,
}],
created_at: "2026-03-07T12:00:00Z".into(),
}])),
@@ -722,10 +727,79 @@ async fn project_detail_shows_enriched_artifact_data() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("v2.0.0"));
assert!(html.contains("main"));
assert!(html.contains("abc1234"));
assert!(html.contains("production"));
// Enriched data is now rendered client-side by the Svelte component
assert!(html.contains("release-timeline"));
assert!(html.contains("project=\"my-api\""));
}
#[tokio::test]
async fn timeline_api_returns_json_with_artifacts() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/orgs/testorg/projects/my-api/timeline")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["timeline"].is_array());
assert!(json["lanes"].is_array());
// Should have at least one timeline item from the mock data
assert!(!json["timeline"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn org_timeline_api_returns_json() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/orgs/testorg/timeline")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["timeline"].is_array());
assert!(json["lanes"].is_array());
}
#[tokio::test]
async fn timeline_api_requires_auth() {
let (state, _sessions) = test_state();
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/api/orgs/testorg/projects/my-api/timeline")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// Should redirect to login (302) when not authenticated
assert_eq!(response.status(), StatusCode::SEE_OTHER);
}
// ─── Artifact detail ────────────────────────────────────────────────
@@ -787,6 +861,7 @@ async fn artifact_detail_shows_enriched_data() {
type_organisation: None,
type_name: None,
type_version: None,
status: None,
},
ArtifactDestination {
name: "staging".into(),
@@ -794,6 +869,7 @@ async fn artifact_detail_shows_enriched_data() {
type_organisation: None,
type_name: None,
type_version: None,
status: None,
},
],
created_at: "2026-03-07T12:00:00Z".into(),
@@ -1081,7 +1157,7 @@ async fn destinations_page_shows_empty_state() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No destinations yet"));
assert!(html.contains("No environments yet"));
}
// ─── Releases ────────────────────────────────────────────────────────
@@ -1169,5 +1245,288 @@ async fn releases_page_shows_empty_state() {
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No releases yet"));
// Empty state is now rendered client-side by the Svelte component
assert!(html.contains("release-timeline"));
assert!(html.contains("org=\"testorg\""));
}
// ─── User profile ──────────────────────────────────────────────────
#[tokio::test]
async fn user_profile_shows_username() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/users/testuser")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("testuser"));
assert!(html.contains("Member since"));
}
// ─── Triggers (auto-release) ────────────────────────────────────────
#[tokio::test]
async fn triggers_page_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Triggers"));
}
#[tokio::test]
async fn triggers_page_shows_existing_triggers() {
use forage_core::platform::Trigger;
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_triggers_result: Some(Ok(vec![Trigger {
id: "t1".into(),
name: "deploy-main".into(),
enabled: true,
branch_pattern: Some("main".into()),
title_pattern: None,
author_pattern: None,
commit_message_pattern: None,
source_type_pattern: None,
target_environments: vec!["staging".into()],
target_destinations: vec![],
force_release: false,
use_pipeline: false,
created_at: "2026-03-08T00:00:00Z".into(),
updated_at: "2026-03-08T00:00:00Z".into(),
}])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("deploy-main"));
assert!(html.contains("staging"));
}
#[tokio::test]
async fn create_trigger_requires_admin() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf&name=test-trigger"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_trigger_requires_csrf() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=wrong-token&name=test-trigger"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_trigger_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf&name=deploy-main&branch_pattern=main&target_environments=staging")
)
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/projects/my-api/triggers"
);
}
#[tokio::test]
async fn toggle_trigger_requires_admin() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers/deploy-main/toggle")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_trigger_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/triggers/deploy-main/delete")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/projects/my-api/triggers"
);
}
// ─── Deployment Policies ────────────────────────────────────────────
#[tokio::test]
async fn policies_page_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/policies")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Deployment Policies"));
}
#[tokio::test]
async fn create_policy_requires_admin() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/policies")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=test-csrf&name=test-policy&policy_type=soak_time"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_policy_requires_csrf() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/projects/my-api/policies")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("csrf_token=wrong-token&name=test-policy&policy_type=soak_time"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}