fabrica

hanna/fabrica

test: cover profile queries, avatars, explore, and settings auth

99168ec · hanna committed on 2026-07-25

Add a store round-trip test for update_profile / set_avatar_mime and web
tests for the profile page, identicon fallback, the anonymous Explore
listing (no in-content sign-in button), and the settings auth redirect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
2 files changed · +118 −0UnifiedSplit
crates/store/src/tests.rs +57 −0
@@ -87,6 +87,63 @@ async fn create_and_fetch_user() {
8787 }
8888
8989 #[tokio::test]
90+async fn update_profile_and_avatar_round_trip() {
91+ for fx in sqlite_fixtures().await {
92+ let user = fx
93+ .store
94+ .create_user(sample_user("prof", "prof@example.com"))
95+ .await
96+ .unwrap();
97+
98+ let ok = fx
99+ .store
100+ .update_profile(
101+ &user.id,
102+ crate::ProfileUpdate {
103+ display_name: Some("Professor".to_string()),
104+ pronouns: Some("they/them".to_string()),
105+ bio: Some("Hello there.".to_string()),
106+ website: Some("https://example.com".to_string()),
107+ location: Some("Everywhere".to_string()),
108+ social_github: Some("prof".to_string()),
109+ social_mastodon: Some("@prof@example.social".to_string()),
110+ },
111+ )
112+ .await
113+ .unwrap();
114+ assert!(ok);
115+
116+ let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
117+ assert_eq!(got.pronouns.as_deref(), Some("they/them"));
118+ assert_eq!(got.bio.as_deref(), Some("Hello there."));
119+ assert_eq!(got.social_mastodon.as_deref(), Some("@prof@example.social"));
120+ assert!(got.avatar_mime.is_none(), "no avatar yet");
121+
122+ // Setting then clearing the avatar content type round-trips.
123+ assert!(
124+ fx.store
125+ .set_avatar_mime(&user.id, Some("image/png"))
126+ .await
127+ .unwrap()
128+ );
129+ let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
130+ assert_eq!(got.avatar_mime.as_deref(), Some("image/png"));
131+
132+ assert!(fx.store.set_avatar_mime(&user.id, None).await.unwrap());
133+ let got = fx.store.user_by_id(&user.id).await.unwrap().unwrap();
134+ assert!(got.avatar_mime.is_none(), "avatar cleared");
135+
136+ // A missing user reports no update.
137+ assert!(
138+ !fx.store
139+ .update_profile("nope", crate::ProfileUpdate::default())
140+ .await
141+ .unwrap()
142+ );
143+ }
144+}
145+
146+#[tokio::test]
90147 async fn duplicate_username_conflicts_case_insensitively() {
91148 for fx in sqlite_fixtures().await {
92149 fx.store
crates/web/src/tests.rs +61 −0
@@ -266,3 +266,64 @@ async fn unknown_owner_is_404() {
266266 let res = app.oneshot(get("/nobody")).await.unwrap();
267267 assert_eq!(res.status(), StatusCode::NOT_FOUND);
268268 }
269+
270+#[tokio::test]
271+async fn profile_page_renders_with_identity() {
272+ let (_state, app) = app().await;
273+ let res = app.oneshot(get("/ada")).await.unwrap();
274+ assert_eq!(res.status(), StatusCode::OK);
275+ let html = body_string(res).await;
276+ // Display name in the heading and the "User · username" subtitle.
277+ assert!(html.contains("Ada"), "display name shown");
278+ assert!(html.contains("User · ada"), "profile subtitle shown");
279+ assert!(html.contains("/avatar/ada"), "avatar image referenced");
280+}
281+
282+#[tokio::test]
283+async fn avatar_falls_back_to_identicon_svg() {
284+ let (_state, app) = app().await;
285+ let res = app.oneshot(get("/avatar/ada")).await.unwrap();
286+ assert_eq!(res.status(), StatusCode::OK);
287+ assert_eq!(
288+ res.headers().get(header::CONTENT_TYPE).unwrap(),
289+ "image/svg+xml; charset=utf-8"
290+ );
291+ let body = body_string(res).await;
292+ assert!(body.starts_with("<svg"), "identicon is an SVG");
293+}
294+
295+#[tokio::test]
296+async fn explore_lists_public_repos_for_anonymous() {
297+ let (state, app) = app().await;
298+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
299+ state
300+ .store
301+ .create_repo(NewRepo {
302+ owner_id: ada.id,
303+ group_id: None,
304+ name: "open".to_string(),
305+ path: "open".to_string(),
306+ description: None,
307+ is_private: false,
308+ default_branch: "main".to_string(),
309+ })
310+ .await
311+ .unwrap();
312+
313+ let res = app.oneshot(get("/explore")).await.unwrap();
314+ assert_eq!(res.status(), StatusCode::OK);
315+ let html = body_string(res).await;
316+ assert!(html.contains("Public repositories"), "explore heading");
317+ assert!(html.contains("ada/open"), "public repo listed");
318+ // The anonymous landing must not offer an in-content sign-in button.
319+ assert!(!html.contains("btn-primary"), "no content sign-in button");
320+}
321+
322+#[tokio::test]
323+async fn settings_requires_authentication() {
324+ let (_state, app) = app().await;
325+ let res = app.oneshot(get("/settings")).await.unwrap();
326+ // RequireUser redirects anonymous visitors to /login.
327+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
328+ assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login");
329+}