use std::collections::HashMap; use std::fs; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::Path; use serde::Deserialize; use crate::state::{ AppOptions, AppState, Comment, Installation, IssueContent, ManifestConversion, OwnerType, Project, ProjectItem, PullRequest, Release, Repository, StatusOption, TokenInfo, WebhookOptions, }; #[derive(Debug, Clone, Deserialize, Default)] pub struct FixtureState { #[serde(default)] pub apps: Vec, #[serde(default)] pub installations: Vec, #[serde(default)] pub repositories: Vec, #[serde(default)] pub pull_requests: Vec, #[serde(default)] pub active_tokens: Vec, #[serde(default)] pub projects: Vec, #[serde(default)] pub releases: Vec, #[serde(default)] pub manifest_conversions: Vec, #[serde(default)] pub comments: Vec, #[serde(default)] pub webhook_config: FixtureWebhookOptions, pub next_installation_id: Option, pub next_pr_number: Option, pub viewer_id: Option, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureApp { pub app_id: String, pub slug: String, pub owner_login: String, pub public: bool, pub private_key_pem: String, pub webhook_secret: Option, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureInstallation { pub id: u64, pub app_id: String, pub owner: String, #[serde(default)] pub repositories: Vec, #[serde(default)] pub suspended: bool, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureRepository { pub owner: String, pub name: String, #[serde(default)] pub branches: Vec, pub default_branch: Option, #[serde(default)] pub private: bool, } #[derive(Debug, Clone, Deserialize)] pub struct FixturePullRequest { pub owner: String, pub repo: String, #[serde(flatten)] pub pull_request: PullRequest, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureActiveToken { pub token: String, pub app_id: String, pub installation_id: u64, #[serde(default)] pub repositories: Vec, #[serde(default = "empty_json_object")] pub permissions: serde_json::Value, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureProject { pub node_id: String, pub number: u64, pub owner: String, pub owner_type: FixtureOwnerType, pub status_field_id: String, #[serde(default)] pub status_options: Vec, #[serde(default)] pub items: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "lowercase")] pub enum FixtureOwnerType { Organization, User, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureStatusOption { pub id: String, pub name: String, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureProjectItem { pub id: String, pub status: String, pub content: FixtureIssueContent, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureIssueContent { pub id: String, pub number: u64, pub title: String, pub body: String, pub url: String, pub created_at: String, pub updated_at: String, #[serde(default)] pub assignee_ids: Vec, #[serde(default)] pub labels: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureRelease { pub owner: String, pub repo: String, pub tag_name: String, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureManifestConversion { pub code: String, pub app_id: i64, pub slug: String, pub client_id: String, pub client_secret: String, pub webhook_secret: Option, pub pem: String, } #[derive(Debug, Clone, Deserialize)] pub struct FixtureComment { pub issue_node_id: String, pub body: String, } #[derive(Debug, Clone, Deserialize, Default)] pub struct FixtureWebhookOptions { pub url: Option, pub content_type: Option, } impl FixtureState { pub fn load_from_path(path: impl AsRef) -> Result { let path = path.as_ref(); #[expect( clippy::disallowed_methods, reason = "twin test harness: sync fixture load from disk; not on a Tokio hot path" )] let contents = fs::read_to_string(path) .map_err(|err| format!("failed to read fixture {}: {err}", path.display()))?; serde_json::from_str(&contents) .map_err(|err| format!("failed to parse fixture {}: {err}", path.display())) } pub fn into_app_state(self) -> Result { let mut state = AppState::new(); for app in self.apps { let app_id = app.app_id.clone(); let config = AppOptions { app_id: app.app_id, slug: app.slug, owner_login: app.owner_login, public: app.public, private_key_pem: app.private_key_pem, webhook_secret: app.webhook_secret, }; catch_unwind(AssertUnwindSafe(|| state.register_app(config))) .map_err(|_| format!("invalid RSA private key PEM for fixture app {app_id}"))?; } state.installations = self .installations .into_iter() .map(|installation| Installation { id: installation.id, app_id: installation.app_id, owner: installation.owner, repositories: installation.repositories, suspended: installation.suspended, }) .collect(); state.repositories = self .repositories .into_iter() .map(|repository| Repository { owner: repository.owner, name: repository.name, branches: repository.branches, default_branch: repository .default_branch .unwrap_or_else(|| "main".to_string()), private: repository.private, git_dir: None, }) .collect(); for pull_request in self.pull_requests { state .pull_requests .entry((pull_request.owner, pull_request.repo)) .or_default() .push(pull_request.pull_request); } state.active_tokens = self .active_tokens .into_iter() .map(|token| { (token.token, TokenInfo { app_id: token.app_id, installation_id: token.installation_id, repositories: token.repositories, permissions: token.permissions, }) }) .collect(); state.projects = self .projects .into_iter() .map(|project| Project { node_id: project.node_id, number: project.number, owner: project.owner, owner_type: match project.owner_type { FixtureOwnerType::Organization => OwnerType::Organization, FixtureOwnerType::User => OwnerType::User, }, status_field_id: project.status_field_id, status_options: project .status_options .into_iter() .map(|option| StatusOption { id: option.id, name: option.name, }) .collect(), items: project .items .into_iter() .map(|item| ProjectItem { id: item.id, status: item.status, content: IssueContent { id: item.content.id, number: item.content.number, title: item.content.title, body: item.content.body, url: item.content.url, created_at: item.content.created_at, updated_at: item.content.updated_at, assignee_ids: item.content.assignee_ids, labels: item.content.labels, }, }) .collect(), }) .collect(); state.releases = self .releases .into_iter() .map(|release| { ((release.owner, release.repo), Release { tag_name: release.tag_name, }) }) .collect::>(); state.manifest_conversions = self .manifest_conversions .into_iter() .map(|conversion| { let code = conversion.code.clone(); (code, ManifestConversion { code: conversion.code, app_id: conversion.app_id, slug: conversion.slug, client_id: conversion.client_id, client_secret: conversion.client_secret, webhook_secret: conversion.webhook_secret, pem: conversion.pem, }) }) .collect::>(); state.comments = self .comments .into_iter() .map(|comment| Comment { issue_node_id: comment.issue_node_id, body: comment.body, }) .collect(); state.webhook_config = WebhookOptions { url: self.webhook_config.url, content_type: self.webhook_config.content_type, }; state.next_installation_id = self .next_installation_id .unwrap_or_else(|| next_installation_id(&state.installations)); state.next_pr_number = self .next_pr_number .unwrap_or_else(|| next_pr_number(&state.pull_requests)); if let Some(viewer_id) = self.viewer_id { state.viewer_id = viewer_id; } Ok(state) } } fn empty_json_object() -> serde_json::Value { serde_json::json!({}) } fn next_installation_id(installations: &[Installation]) -> u64 { installations .iter() .map(|installation| installation.id) .max() .unwrap_or(0) + 1 } fn next_pr_number(pull_requests: &HashMap<(String, String), Vec>) -> u64 { pull_requests .values() .flat_map(|prs| prs.iter().map(|pr| pr.number)) .max() .unwrap_or(0) + 1 } #[cfg(test)] impl FixtureState { fn single_app_fixture_for_test() -> Self { Self { apps: vec![FixtureApp { app_id: "100".to_string(), slug: "fixture-app".to_string(), owner_login: "acme".to_string(), public: true, private_key_pem: test_rsa_key().to_string(), webhook_secret: Some("whsec".to_string()), }], ..Self::default() } } } #[cfg(test)] fn test_rsa_key() -> &'static str { crate::test_support::test_rsa_private_key() } #[cfg(test)] mod tests { use super::*; #[test] fn fixture_state_loads_full_server_state() { let private_key_pem = test_rsa_key().replace('\n', "\\n"); let fixture_json = r#"{ "apps": [{ "app_id": "100", "slug": "ops-app", "owner_login": "acme", "public": true, "private_key_pem": "__PRIVATE_KEY__", "webhook_secret": "whsec" }], "installations": [{ "id": 7, "app_id": "100", "owner": "acme", "repositories": ["widgets"], "suspended": false }], "repositories": [{ "owner": "acme", "name": "widgets", "branches": ["main", "feature"], "default_branch": "main" }], "pull_requests": [{ "owner": "acme", "repo": "widgets", "number": 1, "node_id": "PR_1", "title": "Ship it", "body": "Ready", "state": "open", "draft": false, "mergeable": true, "additions": 10, "deletions": 5, "changed_files": 2, "html_url": "https://github.com/acme/widgets/pull/1", "user_login": "bot", "head_ref": "feature", "base_ref": "main", "created_at": "2026-03-27T00:00:00Z", "updated_at": "2026-03-27T00:00:00Z", "auto_merge": null }], "active_tokens": [{ "token": "ghs_exampletoken", "app_id": "100", "installation_id": 7, "repositories": ["widgets"], "permissions": {"contents": "write"} }], "projects": [{ "node_id": "PVT_kwDOB", "number": 3, "owner": "acme", "owner_type": "organization", "status_field_id": "PVTSSF_1", "status_options": [{ "id": "opt_todo", "name": "Todo" }], "items": [{ "id": "PVTI_1", "status": "Todo", "content": { "id": "ISSUE_1", "number": 42, "title": "Track launch", "body": "Ship it", "url": "https://github.com/acme/widgets/issues/42", "created_at": "2026-03-27T00:00:00Z", "updated_at": "2026-03-27T00:00:00Z", "assignee_ids": ["U_1"], "labels": ["priority"] } }] }], "releases": [{ "owner": "acme", "repo": "widgets", "tag_name": "v1.2.3" }], "manifest_conversions": [{ "code": "manifest-code", "app_id": 100, "slug": "ops-app-dev", "client_id": "Iv1.123", "client_secret": "manifest-secret", "webhook_secret": "manifest-whsec", "pem": "manifest pem" }], "comments": [{ "issue_node_id": "ISSUE_1", "body": "Looks good" }], "webhook_config": { "url": "https://example.com/webhooks/github", "content_type": "json" }, "next_installation_id": 99, "next_pr_number": 77, "viewer_id": "U_seeded" }"# .replace("__PRIVATE_KEY__", &private_key_pem); let fixture: FixtureState = serde_json::from_str(&fixture_json).unwrap(); let state = fixture.into_app_state().unwrap(); assert_eq!(state.apps["100"].config.slug, "ops-app"); assert_eq!(state.installations.len(), 1); assert_eq!(state.repositories.len(), 1); assert_eq!( state.pull_requests[&("acme".into(), "widgets".into())].len(), 1 ); assert_eq!(state.active_tokens.len(), 1); assert_eq!(state.projects.len(), 1); assert_eq!( state.releases[&("acme".into(), "widgets".into())].tag_name, "v1.2.3" ); assert_eq!( state.manifest_conversions["manifest-code"].client_secret, "manifest-secret" ); assert_eq!(state.comments.len(), 1); assert_eq!( state.webhook_config.url.as_deref(), Some("https://example.com/webhooks/github") ); assert_eq!(state.webhook_config.content_type.as_deref(), Some("json")); assert_eq!(state.next_installation_id, 99); assert_eq!(state.next_pr_number, 77); assert_eq!(state.viewer_id, "U_seeded"); } #[test] fn fixture_state_derives_public_keys_for_registered_apps() { let fixture = FixtureState::single_app_fixture_for_test(); let state = fixture.into_app_state().unwrap(); assert!(state.apps["100"].public_key_pem.contains("PUBLIC KEY")); } }