| 920 |
920 |
|
.unwrap();
|
| 921 |
921 |
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
| 922 |
922 |
|
}
|
|
923 |
+ |
|
|
924 |
+ |
// ---- /retry handler (the route itself, distinct from the release-view
|
|
925 |
+ |
// aggregation a retry produces above) ----
|
|
926 |
+ |
|
|
927 |
+ |
fn retry_req(json: &str) -> Request<Body> {
|
|
928 |
+ |
Request::builder()
|
|
929 |
+ |
.method("POST")
|
|
930 |
+ |
.uri("/retry")
|
|
931 |
+ |
.header("content-type", "application/json")
|
|
932 |
+ |
.body(Body::from(json.to_string()))
|
|
933 |
+ |
.unwrap()
|
|
934 |
+ |
}
|
|
935 |
+ |
|
|
936 |
+ |
#[tokio::test]
|
|
937 |
+ |
async fn retry_accepts_a_single_shipped_target() {
|
|
938 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
939 |
+ |
let app = router(test_state(tmp.path()).await);
|
|
940 |
+ |
// Explicit version so the handler doesn't read a (nonexistent) version
|
|
941 |
+ |
// file; the point here is the accept path, not version resolution.
|
|
942 |
+ |
let resp = app
|
|
943 |
+ |
.oneshot(retry_req(
|
|
944 |
+ |
r#"{"app":"goingson","target":"linux/x86_64","version":"0.4.1"}"#,
|
|
945 |
+ |
))
|
|
946 |
+ |
.await
|
|
947 |
+ |
.unwrap();
|
|
948 |
+ |
assert_eq!(resp.status(), StatusCode::OK);
|
|
949 |
+ |
let v: serde_json::Value = serde_json::from_str(&body_string(resp).await).unwrap();
|
|
950 |
+ |
assert_eq!(v["accepted"], true);
|
|
951 |
+ |
assert_eq!(v["target"], "linux/x86_64", "echoes the retried target");
|
|
952 |
+ |
assert!(
|
|
953 |
+ |
v["build_id"].as_i64().is_some(),
|
|
954 |
+ |
"a fresh single-target build id is returned"
|
|
955 |
+ |
);
|
|
956 |
+ |
}
|
|
957 |
+ |
|
|
958 |
+ |
#[tokio::test]
|
|
959 |
+ |
async fn retry_rejects_an_unshipped_target_as_bad_request() {
|
|
960 |
+ |
// goingson does not ship windows; a retry of it is a 400, not a 500.
|
|
961 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
962 |
+ |
let app = router(test_state(tmp.path()).await);
|
|
963 |
+ |
let resp = app
|
|
964 |
+ |
.oneshot(retry_req(
|
|
965 |
+ |
r#"{"app":"goingson","target":"windows/x86_64","version":"0.4.1"}"#,
|
|
966 |
+ |
))
|
|
967 |
+ |
.await
|
|
968 |
+ |
.unwrap();
|
|
969 |
+ |
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
970 |
+ |
assert!(body_string(resp).await.contains("does not ship target"));
|
|
971 |
+ |
}
|
|
972 |
+ |
|
|
973 |
+ |
#[tokio::test]
|
|
974 |
+ |
async fn retry_rejects_a_malformed_target_as_bad_request() {
|
|
975 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
976 |
+ |
let app = router(test_state(tmp.path()).await);
|
|
977 |
+ |
let resp = app
|
|
978 |
+ |
.oneshot(retry_req(
|
|
979 |
+ |
r#"{"app":"goingson","target":"not-a-target","version":"0.4.1"}"#,
|
|
980 |
+ |
))
|
|
981 |
+ |
.await
|
|
982 |
+ |
.unwrap();
|
|
983 |
+ |
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
984 |
+ |
}
|
|
985 |
+ |
|
|
986 |
+ |
#[tokio::test]
|
|
987 |
+ |
async fn retry_rejects_an_unknown_app_as_bad_request() {
|
|
988 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
989 |
+ |
let app = router(test_state(tmp.path()).await);
|
|
990 |
+ |
let resp = app
|
|
991 |
+ |
.oneshot(retry_req(r#"{"app":"nope","target":"linux/x86_64"}"#))
|
|
992 |
+ |
.await
|
|
993 |
+ |
.unwrap();
|
|
994 |
+ |
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
995 |
+ |
assert!(body_string(resp).await.contains("unknown app"));
|
|
996 |
+ |
}
|
|
997 |
+ |
|
|
998 |
+ |
// ---- /events websocket: the HTTP->WS wiring (upgrade + serialize + send).
|
|
999 |
+ |
// The channel merge and lagged framing themselves are covered in
|
|
1000 |
+ |
// `crate::events`; this asserts a real client on `/events` receives an
|
|
1001 |
+ |
// emitted event as the flat-`kind` JSON the TUI parses. ----
|
|
1002 |
+ |
|
|
1003 |
+ |
#[tokio::test]
|
|
1004 |
+ |
async fn events_ws_streams_emitted_events_as_flat_json() {
|
|
1005 |
+ |
use futures_util::StreamExt;
|
|
1006 |
+ |
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
|
1007 |
+ |
|
|
1008 |
+ |
let tmp = tempfile::tempdir().unwrap();
|
|
1009 |
+ |
let state = test_state(tmp.path()).await;
|
|
1010 |
+ |
let events = state.events.clone();
|
|
1011 |
+ |
let app = router(state);
|
|
1012 |
+ |
|
|
1013 |
+ |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
1014 |
+ |
let addr = listener.local_addr().unwrap();
|
|
1015 |
+ |
tokio::spawn(async move {
|
|
1016 |
+ |
axum::serve(listener, app).await.unwrap();
|
|
1017 |
+ |
});
|
|
1018 |
+ |
|
|
1019 |
+ |
let (mut ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/events"))
|
|
1020 |
+ |
.await
|
|
1021 |
+ |
.expect("client connects to /events");
|
|
1022 |
+ |
|
|
1023 |
+ |
// The handler subscribes to the bus inside `on_upgrade`, which can land
|
|
1024 |
+ |
// just after the handshake returns; a broadcast has no backlog for a
|
|
1025 |
+ |
// late subscriber, so re-emit until the first frame arrives rather than
|
|
1026 |
+ |
// racing that window with a single send.
|
|
1027 |
+ |
let ev = crate::events::Event::PublishFailed {
|
|
1028 |
+ |
app: AppId::new("goingson"),
|
|
1029 |
+ |
target: "macos/aarch64".parse().unwrap(),
|
|
1030 |
+ |
channel: "stable".into(),
|
|
1031 |
+ |
error: "boom".into(),
|
|
1032 |
+ |
};
|
|
1033 |
+ |
let mut frame = None;
|
|
1034 |
+ |
for _ in 0..100 {
|
|
1035 |
+ |
crate::events::emit(&events, ev.clone());
|
|
1036 |
+ |
if let Ok(Some(Ok(WsMessage::Text(t)))) =
|
|
1037 |
+ |
tokio::time::timeout(std::time::Duration::from_millis(50), ws.next()).await
|
|
1038 |
+ |
{
|
|
1039 |
+ |
frame = Some(t);
|
|
1040 |
+ |
break;
|
|
1041 |
+ |
}
|
|
1042 |
+ |
}
|
|
1043 |
+ |
let frame = frame.expect("received an event frame within the retry budget");
|
|
1044 |
+ |
let v: serde_json::Value = serde_json::from_str(&frame).unwrap();
|
|
1045 |
+ |
assert_eq!(v["kind"], "publish_failed", "flat kind-tagged wire shape");
|
|
1046 |
+ |
assert_eq!(v["channel"], "stable");
|
|
1047 |
+ |
assert!(v.get("event").is_none(), "not nested under `event`");
|
|
1048 |
+ |
}
|
| 923 |
1049 |
|
}
|