diff --git a/backend/src/models/mission.rs b/backend/src/models/mission.rs index 35ddecc..c4a3206 100644 --- a/backend/src/models/mission.rs +++ b/backend/src/models/mission.rs @@ -327,9 +327,13 @@ impl Mission { 87 | 88 | 89 | 90 => DoKind::Knowledge, 91 | 93 | 94 | 95 | 96 => DoKind::Knowledge, 97 | 98 | 99 => DoKind::Knowledge, - // 102/103 render a paste-value input on the frontend; server-side - // any non-empty reflection counts, same as knowledge missions. - 100 | 101 | 102 | 103 | 104 => DoKind::Knowledge, + 100 | 101 | 104 => DoKind::Knowledge, + // 102/103 render a paste-value input on the frontend: paste a + // docs link/sentence (102), or restate a "good first issue" in + // your own words (103). Split out of the Knowledge run above so + // verify_proof can require a minimum length instead of letting + // a one-character reflection clear the mission (issue #81). + 102 | 103 => DoKind::PasteValue, 106 | 107 | 108 | 109 | 110 => DoKind::Knowledge, // Action missions: @@ -411,6 +415,19 @@ mod tests { } } + /// Missions 102/103 render a paste-value reflection input and must + /// enforce a minimum length (issue #81) — they can't fall back into the + /// neighbouring 100/101/104 `Knowledge` arm, which accepts any + /// non-empty string. + #[test] + fn paste_value_missions_are_not_shadowed_by_knowledge_arms() { + assert_eq!(Mission::do_kind(102), DoKind::PasteValue); + assert_eq!(Mission::do_kind(103), DoKind::PasteValue); + for n in [100u8, 101, 104] { + assert_eq!(Mission::do_kind(n), DoKind::Knowledge, "mission {n}"); + } + } + /// The bar itself: every flight path carries at least one mission with a /// real action, except Money Basics, which is deliberately exempt /// because it is the conceptual opening path. @@ -438,6 +455,15 @@ mod tests { #[derive(Debug, Clone, Copy, PartialEq)] pub enum DoKind { Knowledge, + /// A free-text reflection (docs link, restated issue, etc.) that must + /// clear a minimum length so it isn't the same "any non-empty string + /// passes" bar as `Knowledge`. See `verify_proof`'s `PasteValue` arm. + /// + /// Covers missions 102/103 only. Mission 106 also renders a paste-value + /// input on the frontend, but intentionally stays `Knowledge` here: it's a + /// numeric naira-quote input, not a reflection, so the minimum-length floor + /// doesn't apply. + PasteValue, NostrIdentity, Invoice, Pay, diff --git a/backend/src/routes/missions.rs b/backend/src/routes/missions.rs index dd1f577..77f17ec 100644 --- a/backend/src/routes/missions.rs +++ b/backend/src/routes/missions.rs @@ -185,6 +185,15 @@ async fn complete_mission( })) } +/// Minimum character count for a `DoKind::PasteValue` reflection (missions +/// 102/103). Not tied to any protocol fact — it's a judgment call about how +/// much text rules out a "no" or a single pasted character while still +/// welcoming a short-but-real answer (a bare docs link, a one-line +/// restatement). Deliberately well under the frontend's per-mission +/// `maxLength` (300 for 102, 500 for 103), which caps length rather than +/// setting a floor. +const MIN_PASTE_VALUE_LEN: usize = 20; + /// Verify that a submitted proof matches what the server expects for the /// given mission. Returns `BadRequest` on mismatch. /// @@ -204,6 +213,22 @@ async fn verify_proof( // The proof string gets recorded in mission_completions for audit. DoKind::Knowledge => Ok(()), + // Missions 102/103: a paste-value reflection (a docs link/sentence, + // or a restated "good first issue"). We deliberately do not judge + // whether the reflection is *good* — we can't, and pretending to + // would be worse than the honest bar this replaces (issue #81). + // All we enforce is that it clears a minimum length, so a + // one-character answer can't pass it off as substance. The message + // is written as an invitation to say more, not a rejection. + DoKind::PasteValue => { + if proof.chars().count() < MIN_PASTE_VALUE_LEN { + return Err(AppError::BadRequest(format!( + "say a little more: at least {MIN_PASTE_VALUE_LEN} characters, so there's something real to reflect on" + ))); + } + Ok(()) + } + // Mission 14: Nostr identity created in browser; client submits the // npub it generated. We don't validate the bech32 here beyond // "non-empty"; the very fact that the participant possesses an npub diff --git a/backend/tests/api.rs b/backend/tests/api.rs index 1bac59f..23aa96b 100644 --- a/backend/tests/api.rs +++ b/backend/tests/api.rs @@ -20,6 +20,7 @@ //! * mission 11 (seed-words) requires 64-hex commitment //! * mission 14 (nostr-identity) requires bech32 npub //! * mission 42 (onchain-signet) requires 64-hex txid +//! * missions 102/103 (paste-value) require a minimum-length reflection //! - bearer auth enforcement: wrong/missing token → 401 //! - facilitator endpoints require X-Facilitator-Key //! - proof archive endpoint (/me/completions) @@ -685,6 +686,62 @@ fn mission_42_requires_64_hex_txid_before_any_explorer_lookup() { ); } +#[test] +fn paste_value_missions_require_more_than_a_word() { + // Issue #81: 102 and 103 render a paste-value reflection input and must + // enforce a minimum length, so a one-character (or one-word) answer + // can't clear the mission the way a Knowledge mission's "acknowledged" + // filler would. + let h = Harness::start(); + let s = create_session(&h.base, "paste-value-test"); + let j = join_session(&h.base, "kate", &s.id); + + // Walk to mission 102: prerequisites are 100, 101. + for m in [100u8, 101] { + let r = complete(&h.base, &j.auth_token, m, "acknowledged"); + assert_eq!(r.status(), 200, "mission {m} should succeed"); + } + + // Too short: rejected, and the message reads as an invitation to say + // more rather than a scolding "invalid" rejection. + let r = complete(&h.base, &j.auth_token, 102, "no"); + assert_eq!(r.status(), 400); + let v: Value = r.json().unwrap(); + let err = v["error"].as_str().unwrap(); + assert!( + err.contains("more"), + "expected a nudge to write more, got: {err}" + ); + + // The knowledge-mission filler "acknowledged" is also too short here — + // it must not silently pass the way it does on a Knowledge mission. + let r = complete(&h.base, &j.auth_token, 102, "acknowledged"); + assert_eq!(r.status(), 400); + + // Long enough: passes and advances the per-tree pointer to 103. + let r = complete( + &h.base, + &j.auth_token, + 102, + "https://bitcoindevkit.org/docs — the intro paragraph assumes I already know what a descriptor is.", + ); + assert_eq!(r.status(), 200, "{}", r.text().unwrap()); + let v: Value = r.json().unwrap(); + assert_eq!(v["next_mission"], 103); + + // Mission 103 enforces the same floor. + let r = complete(&h.base, &j.auth_token, 103, "idk"); + assert_eq!(r.status(), 400); + + let r = complete( + &h.base, + &j.auth_token, + 103, + "The issue asks for reflection answers to need substance; it's done when 102 and 103 enforce a minimum length.", + ); + assert_eq!(r.status(), 200, "{}", r.text().unwrap()); +} + #[test] fn proof_archive_lists_completions_in_order() { let h = Harness::start(); @@ -1042,16 +1099,24 @@ fn daily_streak_extends_on_consecutive_days_and_resets_after_a_gap() { #[test] fn open_source_graduation_demands_a_parseable_github_proof() { - // Missions 100..=104 are reflection missions (any non-empty proof), - // but 105 must name a GitHub account and a github.com PR URL. A bad + // Missions 100/101/104 are plain reflection missions (any non-empty + // proof); 102/103 are paste-value missions and need a proof past the + // minimum-length floor (see paste_value_missions_require_more_than_a_word + // above). 105 must name a GitHub account and a github.com PR URL. A bad // proof fails the shape check before any network call happens, so // this test stays offline. let h = Harness::start(); let s = create_session(&h.base, "oss-grad"); let j = join_session(&h.base, "grace", &s.id); - for m in [100, 101, 102, 103, 104] { - let r = complete(&h.base, &j.auth_token, m, "acknowledged"); + // Order matters: the per-tree gate requires 100..104 in sequence. + for m in [100u8, 101, 102, 103, 104] { + let proof = if m == 102 || m == 103 { + "This is a long enough reflection to clear the paste-value floor." + } else { + "acknowledged" + }; + let r = complete(&h.base, &j.auth_token, m, proof); assert_eq!(r.status(), 200, "completing mission {m}"); }