Skip to main content

headless_lms_server/controllers/mock_suotar/
control.rs

1//! The mock Suotar control surface: test-only routes that are not part of Suotar's API.
2//!
3//! A tick runs one iteration of one phase synchronously, because the real loops are long-running
4//! intervals in their own Deployments that a Playwright spec cannot wait out. Gated on
5//! `test_mode && test_suotar`, so no token; the client is hand-written in
6//! `system-tests/src/utils/suotarControl.ts`.
7
8use chrono::Duration;
9
10use crate::domain::credit_registration_phases::{
11    CreditRegistrationPhase, PhaseContext, PhaseScope, PhaseSkipReason, PhaseTick, run_phase_once,
12};
13use crate::prelude::*;
14use headless_lms_utils::services::suotar::SuotarClient;
15use sqlx::PgPool;
16
17use super::commands;
18
19#[derive(Debug, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct RunTickQuery {
22    /// Optional so a missing phase answers our typed error listing the valid names, not actix's 400.
23    pub phase: Option<String>,
24    pub course_id: Option<Uuid>,
25    /// Resolved here so a scenario's returned owner object doubles as the tick scope.
26    pub course_slug: Option<String>,
27    pub user_id: Option<Uuid>,
28    pub user_email: Option<String>,
29    /// Comma-separated ledger row ids, for a spec that already knows them.
30    pub credit_registration_ids: Option<String>,
31}
32
33#[derive(Debug, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "camelCase")]
35pub struct UnresolvedScope {
36    pub status: String,
37    pub half: String,
38    pub value: String,
39}
40
41#[derive(Debug, Serialize, Deserialize, PartialEq)]
42#[serde(rename_all = "camelCase", tag = "status")]
43pub enum PhaseTickResult {
44    #[serde(rename_all = "camelCase")]
45    Ran {
46        phase: String,
47        items_processed: i32,
48        items_failed: i32,
49        /// Set when the iteration ran but failed; already scrubbed by the phase.
50        error: Option<String>,
51    },
52    /// The phase is paused, or the circuit breaker for this scope is open. Not a failure.
53    Skipped { phase: String, reason: String },
54    /// The scope names something this phase's claim query cannot narrow on.
55    ScopeNotSupported { phase: String },
56    #[serde(rename_all = "camelCase")]
57    UnknownPhase {
58        phase: Option<String>,
59        known_phases: Vec<String>,
60    },
61}
62
63impl PhaseTickResult {
64    fn of(phase: CreditRegistrationPhase, tick: PhaseTick) -> Self {
65        match tick {
66            PhaseTick::Ran(outcome) => Self::Ran {
67                phase: phase.as_str().to_string(),
68                items_processed: outcome.items_processed,
69                items_failed: outcome.items_failed,
70                error: outcome.error,
71            },
72            PhaseTick::Skipped(reason) => Self::Skipped {
73                phase: phase.as_str().to_string(),
74                reason: match reason {
75                    PhaseSkipReason::Paused => "paused".to_string(),
76                    PhaseSkipReason::CircuitBreakerOpen => "circuitBreakerOpen".to_string(),
77                },
78            },
79            PhaseTick::ScopeNotSupported => Self::ScopeNotSupported {
80                phase: phase.as_str().to_string(),
81            },
82        }
83    }
84}
85
86/// One entry per phase in the sequence, in the order they ran.
87#[derive(Debug, Serialize, Deserialize, PartialEq)]
88#[serde(rename_all = "camelCase")]
89pub struct RegistrarTickResult {
90    pub phases: Vec<PhaseTickResult>,
91}
92
93/// Runs one iteration of one pipeline phase: 200 when it ran, 400 for `unknownPhase` or a scope the
94/// phase cannot narrow on. An optional scope narrows the rows the iteration may claim; absent is
95/// unscoped, which is what production does.
96async fn run_tick(
97    app_conf: web::Data<ApplicationConfiguration>,
98    pool: web::Data<PgPool>,
99    suotar_client: web::Data<SuotarClient>,
100    query: web::Query<RunTickQuery>,
101) -> ControllerResult<HttpResponse> {
102    super::assert_enabled(&app_conf);
103    let token = skip_authorize();
104
105    let Some(phase) = query
106        .phase
107        .as_deref()
108        .and_then(CreditRegistrationPhase::from_phase_name)
109    else {
110        return token.authorized_ok(HttpResponse::BadRequest().json(
111            PhaseTickResult::UnknownPhase {
112                phase: query.phase.clone(),
113                known_phases: known_phase_names(),
114            },
115        ));
116    };
117
118    let scope = match resolve_scope(&pool, &query).await? {
119        Ok(scope) => scope,
120        // Never a silent fall-through to sweeping everything.
121        Err(unresolved) => {
122            return token.authorized_ok(HttpResponse::BadRequest().json(unresolved));
123        }
124    };
125
126    let ctx = tick_context(&app_conf, &pool, &suotar_client);
127    let result = PhaseTickResult::of(phase, run_phase_once(&ctx, phase, &scope).await?);
128    token.authorized_ok(match &result {
129        PhaseTickResult::Ran { .. } | PhaseTickResult::Skipped { .. } => {
130            HttpResponse::Ok().json(&result)
131        }
132        PhaseTickResult::ScopeNotSupported { .. } | PhaseTickResult::UnknownPhase { .. } => {
133            HttpResponse::BadRequest().json(&result)
134        }
135    })
136}
137
138/// The combined form of `run-tick`, walking the sequence in pipeline order. Always 200; each phase
139/// reports its own status. Takes no scope on purpose: a suite that only ever ticks scoped never
140/// exercises the sweep-everything behaviour production has.
141async fn run_registrar_tick(
142    app_conf: web::Data<ApplicationConfiguration>,
143    pool: web::Data<PgPool>,
144    suotar_client: web::Data<SuotarClient>,
145) -> ControllerResult<HttpResponse> {
146    super::assert_enabled(&app_conf);
147    let token = skip_authorize();
148
149    let scope = PhaseScope::default();
150    let ctx = tick_context(&app_conf, &pool, &suotar_client);
151    let mut phases = Vec::new();
152    for phase in CreditRegistrationPhase::REGISTRAR_TICK_SEQUENCE {
153        phases.push(PhaseTickResult::of(
154            phase,
155            run_phase_once(&ctx, phase, &scope).await?,
156        ));
157    }
158    token.authorized_ok(HttpResponse::Ok().json(RegistrarTickResult { phases }))
159}
160
161#[derive(Debug, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct RegradeCompletionPayload {
164    pub credit_registration_id: Uuid,
165    /// `null` puts the completion on the pass/fail scale, which is how a spec crosses grade scales.
166    pub grade: Option<i32>,
167    /// Absent leaves it alone.
168    pub passed: Option<bool>,
169}
170
171#[derive(Debug, Serialize, Deserialize, PartialEq)]
172#[serde(rename_all = "camelCase")]
173pub struct RegradeCompletionResult {
174    pub course_module_completion_id: Uuid,
175    pub grade: Option<i32>,
176}
177
178/// Rewrites the grade of the completion behind one ledger row.
179///
180/// A test hook, not a product path: the manual completion flow writes a new completion row, so
181/// nothing else in the product edits a completion's grade, and the grade-improvement statement is
182/// about exactly that edit.
183async fn regrade_completion(
184    app_conf: web::Data<ApplicationConfiguration>,
185    pool: web::Data<PgPool>,
186    payload: web::Json<RegradeCompletionPayload>,
187) -> ControllerResult<HttpResponse> {
188    super::assert_enabled(&app_conf);
189    let token = skip_authorize();
190
191    let mut conn = pool.acquire().await?;
192    let registration =
193        models::credit_registrations::get_by_id(&mut conn, payload.credit_registration_id).await?;
194    models::course_module_completions::set_grade_for_testing(
195        &mut conn,
196        registration.course_module_completion_id,
197        payload.grade,
198        payload.passed,
199    )
200    .await?;
201    token.authorized_ok(HttpResponse::Ok().json(RegradeCompletionResult {
202        course_module_completion_id: registration.course_module_completion_id,
203        grade: payload.grade,
204    }))
205}
206
207#[derive(Debug, Deserialize)]
208#[serde(rename_all = "camelCase")]
209pub struct SetTestExclusiveHoldPayload {
210    pub user_email: String,
211    /// Absent holds every course of the user; set to narrow to one.
212    pub course_id: Option<Uuid>,
213    pub hold_secs: i64,
214}
215
216#[derive(Debug, Serialize, Deserialize, PartialEq)]
217#[serde(rename_all = "camelCase")]
218pub struct SetTestExclusiveHoldResult {
219    pub held_until: DateTime<Utc>,
220}
221
222/// Comfortably above Playwright's own 100 s per-test timeout (`playwright.config.ts`'s `timeout`),
223/// so no legitimate hold is ever rejected; a value anywhere near this is a spec that mistyped a
224/// unit, not one that needs the identity held that long.
225const MAX_TEST_EXCLUSIVE_HOLD_SECS: i64 = 120;
226
227/// Excuses a user's rows from the live background worker's unscoped sweeps — see
228/// `credit_registrations::set_test_exclusive_hold_for_testing`. Keyed on identity rather than a row
229/// id, so a spec can hold before materialize creates the row it means to protect.
230async fn set_test_exclusive_hold(
231    app_conf: web::Data<ApplicationConfiguration>,
232    pool: web::Data<PgPool>,
233    payload: web::Json<SetTestExclusiveHoldPayload>,
234) -> ControllerResult<HttpResponse> {
235    super::assert_enabled(&app_conf);
236    let token = skip_authorize();
237
238    if !(0..=MAX_TEST_EXCLUSIVE_HOLD_SECS).contains(&payload.hold_secs) {
239        return token.authorized_ok(HttpResponse::BadRequest().json(format!(
240            "holdSecs must be between 0 and {MAX_TEST_EXCLUSIVE_HOLD_SECS}"
241        )));
242    }
243
244    let mut conn = pool.acquire().await?;
245    let Some(user_id) = models::user_details::get_active_user_id_by_email_case_insensitive(
246        &mut conn,
247        &payload.user_email,
248    )
249    .await?
250    else {
251        return token.authorized_ok(HttpResponse::BadRequest().json(UnresolvedScope {
252            status: "unresolvedScope".to_string(),
253            half: "userEmail".to_string(),
254            value: payload.user_email.clone(),
255        }));
256    };
257
258    let held_until = Utc::now() + Duration::seconds(payload.hold_secs);
259    models::credit_registrations::set_test_exclusive_hold_for_testing(
260        &mut conn,
261        user_id,
262        payload.course_id,
263        held_until,
264    )
265    .await?;
266
267    token.authorized_ok(HttpResponse::Ok().json(SetTestExclusiveHoldResult { held_until }))
268}
269
270#[derive(Debug, Deserialize)]
271#[serde(rename_all = "camelCase")]
272pub struct QueuedEmailsQuery {
273    pub user_email: String,
274}
275
276#[derive(Debug, Serialize, Deserialize, PartialEq)]
277#[serde(rename_all = "camelCase")]
278pub struct QueuedEmail {
279    pub template_type: String,
280    pub placeholders: serde_json::Value,
281}
282
283/// How many of an account's mails one read looks back over. Generous: the shared test database has a
284/// live pipeline queueing mail for everybody, and a short window would turn "exactly one" into a
285/// false pass.
286const QUEUED_EMAIL_SCAN: i64 = 200;
287
288/// The mails queued to one account, newest first. There is no mail capture in this repo, so a spec
289/// asserting a message was composed reads the send queue instead of an inbox.
290async fn queued_emails(
291    app_conf: web::Data<ApplicationConfiguration>,
292    pool: web::Data<PgPool>,
293    query: web::Query<QueuedEmailsQuery>,
294) -> ControllerResult<HttpResponse> {
295    super::assert_enabled(&app_conf);
296    let token = skip_authorize();
297
298    let mut conn = pool.acquire().await?;
299    let Some(user_id) = models::user_details::get_active_user_id_by_email_case_insensitive(
300        &mut conn,
301        &query.user_email,
302    )
303    .await?
304    else {
305        return token.authorized_ok(HttpResponse::BadRequest().json(UnresolvedScope {
306            status: "unresolvedScope".to_string(),
307            half: "userEmail".to_string(),
308            value: query.user_email.clone(),
309        }));
310    };
311    let queued = models::email_deliveries::get_recent_template_types_for_user_for_testing(
312        &mut conn,
313        user_id,
314        QUEUED_EMAIL_SCAN,
315    )
316    .await?
317    .into_iter()
318    .map(|(template_type, placeholders)| QueuedEmail {
319        template_type: serde_json::to_value(template_type)
320            .ok()
321            .and_then(|value| value.as_str().map(str::to_string))
322            .unwrap_or_default(),
323        placeholders,
324    })
325    .collect::<Vec<_>>();
326
327    token.authorized_ok(HttpResponse::Ok().json(queued))
328}
329
330/// Attributed to the tick rather than to a worker, so the audit log says which traffic a test made.
331fn tick_context<'a>(
332    app_conf: &'a ApplicationConfiguration,
333    pool: &'a PgPool,
334    suotar_client: &'a SuotarClient,
335) -> PhaseContext<'a> {
336    PhaseContext::from_app(pool, suotar_client, app_conf, "run-tick")
337}
338
339/// The outer error is a real failure; the inner one is a scope half that names nothing.
340async fn resolve_scope(
341    pool: &PgPool,
342    query: &RunTickQuery,
343) -> anyhow::Result<Result<PhaseScope, UnresolvedScope>> {
344    let mut scope = PhaseScope {
345        course_id: query.course_id,
346        user_id: query.user_id,
347        credit_registration_ids: Vec::new(),
348    };
349    if let Some(slug) = &query.course_slug {
350        let mut conn = pool.acquire().await?;
351        let found = models::courses::get_active_course_id_by_slug(&mut conn, slug).await?;
352        match found {
353            Some(id) => scope.course_id = Some(id),
354            None => {
355                return Ok(Err(UnresolvedScope {
356                    status: "unresolvedScope".to_string(),
357                    half: "courseSlug".to_string(),
358                    value: slug.clone(),
359                }));
360            }
361        }
362    }
363    if let Some(email) = &query.user_email {
364        let mut conn = pool.acquire().await?;
365        let found =
366            models::user_details::get_active_user_id_by_email_case_insensitive(&mut conn, email)
367                .await?;
368        match found {
369            Some(id) => scope.user_id = Some(id),
370            None => {
371                return Ok(Err(UnresolvedScope {
372                    status: "unresolvedScope".to_string(),
373                    half: "userEmail".to_string(),
374                    value: email.clone(),
375                }));
376            }
377        }
378    }
379    if let Some(raw) = &query.credit_registration_ids {
380        for part in raw.split(',').filter(|part| !part.trim().is_empty()) {
381            match Uuid::parse_str(part.trim()) {
382                Ok(id) => scope.credit_registration_ids.push(id),
383                Err(_) => {
384                    return Ok(Err(UnresolvedScope {
385                        status: "unresolvedScope".to_string(),
386                        half: "creditRegistrationIds".to_string(),
387                        value: part.trim().to_string(),
388                    }));
389                }
390            }
391        }
392    }
393    Ok(Ok(scope))
394}
395
396fn known_phase_names() -> Vec<String> {
397    CreditRegistrationPhase::ALL
398        .iter()
399        .map(|phase| phase.as_str().to_string())
400        .collect()
401}
402
403pub fn _add_routes(cfg: &mut ServiceConfig) {
404    cfg.route("/run-tick", web::post().to(run_tick))
405        .route("/run-registrar-tick", web::post().to(run_registrar_tick))
406        .route("/regrade-completion", web::post().to(regrade_completion))
407        .route(
408            "/test-exclusive-hold",
409            web::post().to(set_test_exclusive_hold),
410        )
411        .route("/queued-emails", web::get().to(queued_emails))
412        .configure(commands::_add_routes);
413}
414
415#[cfg(test)]
416mod tests {
417    use actix_web::{App, http::StatusCode, test, web::Data};
418
419    use super::*;
420    use crate::controllers::configure_controllers;
421
422    /// The mock config is present either way; `test_suotar` alone decides whether the routes exist.
423    fn app_conf(test_suotar: bool) -> ApplicationConfiguration {
424        ApplicationConfiguration {
425            test_suotar,
426            ..ApplicationConfiguration::mock_conf()
427                .expect("the mock configuration is built from constants")
428        }
429    }
430
431    /// Registers the real controller tree so the test sees the same gate production does. The pool is
432    /// never connected, so only phases that answer before they would need one can be driven here.
433    async fn call_run_tick(
434        test_suotar: bool,
435        query: &str,
436    ) -> actix_web::dev::ServiceResponse<actix_web::body::BoxBody> {
437        let app_conf = Data::new(app_conf(test_suotar));
438        let pool = Data::new(
439            PgPool::connect_lazy("postgres://headless-lms@localhost:54328/headless_lms_dev")
440                .expect("a lazy pool only parses the url"),
441        );
442        let service = test::init_service(
443            App::new()
444                .app_data(pool)
445                .app_data(Data::new(SuotarClient::mock_for_test()))
446                .app_data(app_conf.clone())
447                .service(
448                    web::scope("/api/v0")
449                        .configure(|cfg| configure_controllers(cfg, app_conf.clone())),
450                ),
451        )
452        .await;
453        let req = test::TestRequest::post()
454            .uri(&format!("/api/v0/mock-suotar/control/run-tick{query}"))
455            .to_request();
456        test::call_service(&service, req).await
457    }
458
459    /// Without the flag the routes must be absent, not merely refuse.
460    #[actix_web::test]
461    async fn run_tick_is_absent_when_the_mock_is_disabled() {
462        let res = call_run_tick(false, "?phase=verify").await;
463        assert_eq!(res.status(), StatusCode::NOT_FOUND);
464    }
465
466    /// A caller that asked to be narrowed and cannot be must be told, not quietly run wide over a
467    /// shared database.
468    #[actix_web::test]
469    async fn a_scope_a_phase_cannot_apply_is_refused() {
470        let res = call_run_tick(
471            true,
472            &format!("?phase=retention-sweep&courseId={}", Uuid::new_v4()),
473        )
474        .await;
475        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
476        let body: PhaseTickResult = test::read_body_json(res).await;
477        assert_eq!(
478            body,
479            PhaseTickResult::ScopeNotSupported {
480                phase: "retention-sweep".to_string()
481            }
482        );
483    }
484
485    #[actix_web::test]
486    async fn an_invented_phase_name_is_rejected() {
487        let res = call_run_tick(true, "?phase=materialise").await;
488        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
489        let body: PhaseTickResult = test::read_body_json(res).await;
490        assert_eq!(
491            body,
492            PhaseTickResult::UnknownPhase {
493                phase: Some("materialise".to_string()),
494                known_phases: known_phase_names(),
495            }
496        );
497    }
498}