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::prelude::*;
11use headless_lms_credit_registration::{
12    CreditRegistrationPhase, PhaseContext, PhaseSkipReason, PhaseTick, Runner, registry_health,
13    run_phase_once,
14};
15use headless_lms_models::credit_registrations::RegistrationScope;
16use headless_lms_models::library::credit_registration::enrolment_check_schedule::{
17    EnrolmentCheckGroup, EnrolmentCheckSource,
18};
19use headless_lms_utils::services::suotar::SuotarClient;
20use sqlx::PgPool;
21
22use super::commands;
23
24#[derive(Debug, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct RunTickQuery {
27    /// Optional so a missing phase answers our typed error listing the valid names, not actix's 400.
28    pub phase: Option<String>,
29    pub course_id: Option<Uuid>,
30    /// Resolved here so a scenario's returned owner object doubles as the tick scope.
31    pub course_slug: Option<String>,
32    pub user_id: Option<Uuid>,
33    pub user_email: Option<String>,
34    /// Comma-separated ledger row ids, for a spec that already knows them.
35    pub credit_registration_ids: Option<String>,
36    /// Overrides the deployment's account-linking switch for this tick, so a spec can run a phase
37    /// the way a deployment with linking off would. On, it keeps the deployment's cutoff, or mails
38    /// every enrolment if the deployment has none.
39    pub account_linking_enabled: Option<bool>,
40}
41
42#[derive(Debug, Serialize, Deserialize, PartialEq)]
43#[serde(rename_all = "camelCase")]
44pub struct UnresolvedScope {
45    pub status: String,
46    pub half: String,
47    pub value: String,
48}
49
50#[derive(Debug, Serialize, Deserialize, PartialEq)]
51#[serde(rename_all = "camelCase", tag = "status")]
52pub enum PhaseTickResult {
53    #[serde(rename_all = "camelCase")]
54    Ran {
55        phase: String,
56        items_processed: i32,
57        items_failed: i32,
58        /// Set when the iteration ran but failed; already scrubbed by the phase.
59        error: Option<String>,
60    },
61    /// The phase is paused, or the circuit breaker for this scope is open. Not a failure.
62    Skipped { phase: String, reason: String },
63    /// The scope names something this phase's claim query cannot narrow on.
64    ScopeNotSupported { phase: String },
65    #[serde(rename_all = "camelCase")]
66    UnknownPhase {
67        phase: Option<String>,
68        known_phases: Vec<String>,
69    },
70}
71
72impl PhaseTickResult {
73    fn of(phase: CreditRegistrationPhase, tick: PhaseTick) -> Self {
74        match tick {
75            PhaseTick::Ran(outcome) => Self::Ran {
76                phase: phase.as_str().to_string(),
77                items_processed: outcome.items_processed,
78                items_failed: outcome.items_failed,
79                error: outcome.error,
80            },
81            PhaseTick::Skipped(reason) => Self::Skipped {
82                phase: phase.as_str().to_string(),
83                reason: match reason {
84                    PhaseSkipReason::Paused => "paused".to_string(),
85                    PhaseSkipReason::CircuitBreakerOpen => "circuitBreakerOpen".to_string(),
86                    PhaseSkipReason::AccountLinkingDisabled => "accountLinkingDisabled".to_string(),
87                    PhaseSkipReason::SisuDayGap => "sisuDayGap".to_string(),
88                },
89            },
90            PhaseTick::ScopeNotSupported => Self::ScopeNotSupported {
91                phase: phase.as_str().to_string(),
92            },
93        }
94    }
95}
96
97/// One entry per phase in the sequence, in the order they ran.
98#[derive(Debug, Serialize, Deserialize, PartialEq)]
99#[serde(rename_all = "camelCase")]
100pub struct RegistrarTickResult {
101    pub phases: Vec<PhaseTickResult>,
102}
103
104/// Runs one iteration of one pipeline phase: 200 when it ran, 400 for `unknownPhase` or a scope the
105/// phase cannot narrow on. An optional scope narrows the rows the iteration may claim; absent is
106/// unscoped, which is what production does.
107async fn run_tick(
108    app_conf: web::Data<ApplicationConfiguration>,
109    pool: web::Data<PgPool>,
110    suotar_client: web::Data<SuotarClient>,
111    query: web::Query<RunTickQuery>,
112) -> ControllerResult<HttpResponse> {
113    super::assert_enabled(&app_conf);
114    let token = skip_authorize();
115
116    let Some(phase) = query
117        .phase
118        .as_deref()
119        .and_then(CreditRegistrationPhase::from_phase_name)
120    else {
121        return token.authorized_ok(HttpResponse::BadRequest().json(
122            PhaseTickResult::UnknownPhase {
123                phase: query.phase.clone(),
124                known_phases: known_phase_names(),
125            },
126        ));
127    };
128
129    let scope = match resolve_scope(&pool, &query).await? {
130        Ok(scope) => scope,
131        // Never a silent fall-through to sweeping everything.
132        Err(unresolved) => {
133            return token.authorized_ok(HttpResponse::BadRequest().json(unresolved));
134        }
135    };
136
137    let ctx = PhaseContext {
138        account_linking_since: match query.account_linking_enabled {
139            Some(true) => Some(
140                app_conf
141                    .suotar_configuration
142                    .account_linking_since
143                    .unwrap_or(DateTime::UNIX_EPOCH),
144            ),
145            Some(false) => None,
146            None => app_conf.suotar_configuration.account_linking_since,
147        },
148        ..tick_context(&app_conf, &pool, &suotar_client)
149    };
150    debug!(phase = phase.as_str(), ?scope, "run-tick requested");
151    let result = PhaseTickResult::of(phase, run_phase_once(&ctx, phase, &scope).await?);
152    debug!(phase = phase.as_str(), ?result, "run-tick finished");
153    token.authorized_ok(match &result {
154        PhaseTickResult::Ran { .. } | PhaseTickResult::Skipped { .. } => {
155            HttpResponse::Ok().json(&result)
156        }
157        PhaseTickResult::ScopeNotSupported { .. } | PhaseTickResult::UnknownPhase { .. } => {
158            HttpResponse::BadRequest().json(&result)
159        }
160    })
161}
162
163/// The phases `run-registrar-tick` runs, in pipeline order. Not every `credit-registrar` phase:
164/// `legacy-mirror` and `student-notifications` are driven explicitly by specs that need them.
165const REGISTRAR_TICK_SEQUENCE: [CreditRegistrationPhase; 5] = [
166    CreditRegistrationPhase::Materialize,
167    CreditRegistrationPhase::Preconditions,
168    CreditRegistrationPhase::ResolveEnrolments,
169    CreditRegistrationPhase::Import,
170    CreditRegistrationPhase::Verify,
171];
172
173/// The combined form of `run-tick`, walking the sequence in pipeline order. Always 200; each phase
174/// reports its own status. Takes no scope on purpose: a suite that only ever ticks scoped never
175/// exercises the sweep-everything behaviour production has.
176async fn run_registrar_tick(
177    app_conf: web::Data<ApplicationConfiguration>,
178    pool: web::Data<PgPool>,
179    suotar_client: web::Data<SuotarClient>,
180) -> ControllerResult<HttpResponse> {
181    super::assert_enabled(&app_conf);
182    let token = skip_authorize();
183
184    let scope = RegistrationScope::default();
185    let ctx = tick_context(&app_conf, &pool, &suotar_client);
186    debug!("run-registrar-tick requested");
187    let mut phases = Vec::new();
188    for phase in REGISTRAR_TICK_SEQUENCE {
189        phases.push(PhaseTickResult::of(
190            phase,
191            run_phase_once(&ctx, phase, &scope).await?,
192        ));
193    }
194    token.authorized_ok(HttpResponse::Ok().json(RegistrarTickResult { phases }))
195}
196
197#[derive(Debug, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct RegradeCompletionPayload {
200    pub credit_registration_id: Uuid,
201    /// `null` puts the completion on the pass/fail scale, which is how a spec crosses grade scales.
202    pub grade: Option<i32>,
203    /// Absent leaves it alone.
204    pub passed: Option<bool>,
205}
206
207#[derive(Debug, Serialize, Deserialize, PartialEq)]
208#[serde(rename_all = "camelCase")]
209pub struct RegradeCompletionResult {
210    pub course_module_completion_id: Uuid,
211    pub grade: Option<i32>,
212}
213
214/// Rewrites the grade of the completion behind one ledger row.
215///
216/// A test hook, not a product path: the manual completion flow writes a new completion row, so
217/// nothing else in the product edits a completion's grade, and the grade-improvement statement is
218/// about exactly that edit.
219async fn regrade_completion(
220    app_conf: web::Data<ApplicationConfiguration>,
221    pool: web::Data<PgPool>,
222    payload: web::Json<RegradeCompletionPayload>,
223) -> ControllerResult<HttpResponse> {
224    super::assert_enabled(&app_conf);
225    let token = skip_authorize();
226
227    let mut conn = pool.acquire().await?;
228    let registration =
229        models::credit_registrations::get_by_id(&mut conn, payload.credit_registration_id).await?;
230    models::course_module_completions::set_grade_for_testing(
231        &mut conn,
232        registration.course_module_completion_id,
233        payload.grade,
234        payload.passed,
235    )
236    .await?;
237    token.authorized_ok(HttpResponse::Ok().json(RegradeCompletionResult {
238        course_module_completion_id: registration.course_module_completion_id,
239        grade: payload.grade,
240    }))
241}
242
243#[derive(Debug, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct SetTestExclusiveHoldPayload {
246    pub user_email: String,
247    /// Absent holds every course of the user; set to narrow to one.
248    pub course_id: Option<Uuid>,
249    pub hold_secs: i64,
250}
251
252#[derive(Debug, Serialize, Deserialize, PartialEq)]
253#[serde(rename_all = "camelCase")]
254pub struct SetTestExclusiveHoldResult {
255    pub held_until: DateTime<Utc>,
256}
257
258/// Comfortably above Playwright's own 100 s per-test timeout (`playwright.config.ts`'s `timeout`),
259/// so no legitimate hold is ever rejected; a value anywhere near this is a spec that mistyped a
260/// unit, not one that needs the identity held that long.
261const MAX_TEST_EXCLUSIVE_HOLD_SECS: i64 = 120;
262
263/// Excuses a user's rows from the live background worker's unscoped sweeps — see
264/// `credit_registrations::testing::set_test_exclusive_hold_for_testing`. Keyed on identity rather
265/// than a row id, so a spec can hold before materialize creates the row it means to protect.
266async fn set_test_exclusive_hold(
267    app_conf: web::Data<ApplicationConfiguration>,
268    pool: web::Data<PgPool>,
269    payload: web::Json<SetTestExclusiveHoldPayload>,
270) -> ControllerResult<HttpResponse> {
271    super::assert_enabled(&app_conf);
272    let token = skip_authorize();
273
274    if !(0..=MAX_TEST_EXCLUSIVE_HOLD_SECS).contains(&payload.hold_secs) {
275        return token.authorized_ok(HttpResponse::BadRequest().json(format!(
276            "holdSecs must be between 0 and {MAX_TEST_EXCLUSIVE_HOLD_SECS}"
277        )));
278    }
279
280    let mut conn = pool.acquire().await?;
281    let Some(user_id) = models::user_details::get_active_user_id_by_email_case_insensitive(
282        &mut conn,
283        &payload.user_email,
284    )
285    .await?
286    else {
287        return token.authorized_ok(HttpResponse::BadRequest().json(UnresolvedScope {
288            status: "unresolvedScope".to_string(),
289            half: "userEmail".to_string(),
290            value: payload.user_email.clone(),
291        }));
292    };
293
294    let held_until = Utc::now() + Duration::seconds(payload.hold_secs);
295    models::credit_registrations::testing::set_test_exclusive_hold_for_testing(
296        &mut conn,
297        user_id,
298        payload.course_id,
299        held_until,
300    )
301    .await?;
302
303    token.authorized_ok(HttpResponse::Ok().json(SetTestExclusiveHoldResult { held_until }))
304}
305
306#[derive(Debug, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct ExpireEnrolmentRecheckAllowancePayload {
309    pub credit_registration_id: Uuid,
310    /// Also forgets the day's schedule restarts, so a spec can go on restarting.
311    #[serde(default)]
312    pub clear_restarts: bool,
313}
314
315/// Lets a spec press a recheck button right after the last check or request, or on a new row,
316/// instead of waiting out the limit on asking or the student's first-hour wait.
317async fn expire_enrolment_recheck_allowance(
318    app_conf: web::Data<ApplicationConfiguration>,
319    pool: web::Data<PgPool>,
320    payload: web::Json<ExpireEnrolmentRecheckAllowancePayload>,
321) -> ControllerResult<HttpResponse> {
322    super::assert_enabled(&app_conf);
323    let token = skip_authorize();
324
325    let mut conn = pool.acquire().await?;
326    models::credit_registrations::testing::expire_enrolment_recheck_allowance_for_testing(
327        &mut conn,
328        payload.credit_registration_id,
329        payload.clear_restarts,
330    )
331    .await?;
332    token.authorized_ok(HttpResponse::Ok().json(()))
333}
334
335#[derive(Debug, Serialize)]
336#[serde(rename_all = "camelCase")]
337pub struct MakeEnrolmentChecksDueResult {
338    pub made_due_count: u64,
339}
340
341/// Brings the next enrolment check of every waiting row in the scope forward to now, so a spec can
342/// drive a row past its first rung without waiting a day. Takes the tick's scope; the phase is
343/// ignored.
344async fn make_enrolment_checks_due(
345    app_conf: web::Data<ApplicationConfiguration>,
346    pool: web::Data<PgPool>,
347    query: web::Query<RunTickQuery>,
348) -> ControllerResult<HttpResponse> {
349    super::assert_enabled(&app_conf);
350    let token = skip_authorize();
351
352    let scope = match resolve_scope(&pool, &query).await? {
353        Ok(scope) if !scope.is_unscoped() => scope,
354        Ok(_) => {
355            return token.authorized_ok(
356                HttpResponse::BadRequest().json("A scope is required, or every test's rows move."),
357            );
358        }
359        Err(unresolved) => {
360            return token.authorized_ok(HttpResponse::BadRequest().json(unresolved));
361        }
362    };
363    let mut conn = pool.acquire().await?;
364    let made_due_count =
365        models::credit_registrations::testing::make_enrolment_checks_due_for_testing(
366            &mut conn, &scope,
367        )
368        .await?;
369    token.authorized_ok(HttpResponse::Ok().json(MakeEnrolmentChecksDueResult { made_due_count }))
370}
371
372#[derive(Debug, Serialize)]
373#[serde(rename_all = "camelCase")]
374pub struct MakeRosterListingsDueResult {
375    pub made_due_count: u64,
376}
377
378/// Makes the roster listings of the scope's course due now and refills the listing limiter of the
379/// scope, so a spec can list its codes again without waiting for their next fetch, a backoff or
380/// the rate. Takes the tick's scope, which must name a course; the phase is ignored.
381async fn make_roster_listings_due(
382    app_conf: web::Data<ApplicationConfiguration>,
383    pool: web::Data<PgPool>,
384    query: web::Query<RunTickQuery>,
385) -> ControllerResult<HttpResponse> {
386    super::assert_enabled(&app_conf);
387    let token = skip_authorize();
388
389    let scope = match resolve_scope(&pool, &query).await? {
390        Ok(scope) => scope,
391        Err(unresolved) => {
392            return token.authorized_ok(HttpResponse::BadRequest().json(unresolved));
393        }
394    };
395    let Some(course_id) = scope.course_id else {
396        return token.authorized_ok(
397            HttpResponse::BadRequest().json("A course is required, or every test's codes move."),
398        );
399    };
400    let mut conn = pool.acquire().await?;
401    let made_due_count =
402        models::credit_registration_roster_schedules::testing::make_listings_due_for_testing(
403            &mut conn, course_id,
404        )
405        .await?;
406    registry_health::reset_rate_limits(&scope);
407    token.authorized_ok(HttpResponse::Ok().json(MakeRosterListingsDueResult { made_due_count }))
408}
409
410#[derive(Debug, Deserialize)]
411#[serde(rename_all = "camelCase")]
412pub struct EnrolmentCheckScheduleQuery {
413    pub credit_registration_id: Uuid,
414}
415
416/// A row's enrolment check schedule, which no product surface shows whole.
417#[derive(Debug, Serialize)]
418#[serde(rename_all = "camelCase")]
419pub struct EnrolmentCheckSchedule {
420    pub state: models::credit_registrations::CreditRegistrationState,
421    pub group: EnrolmentCheckGroup,
422    pub anchor_at: Option<DateTime<Utc>>,
423    pub step: Option<i32>,
424    pub due_at: Option<DateTime<Utc>>,
425    pub next_attempt_at: DateTime<Utc>,
426    pub is_batched: bool,
427    pub source: EnrolmentCheckSource,
428    pub stopped_at: Option<DateTime<Utc>>,
429    pub requested_at: Option<DateTime<Utc>>,
430    pub restart_count: i32,
431    pub checked_at: Option<DateTime<Utc>>,
432    pub first_failed_at: Option<DateTime<Utc>>,
433    pub submit_retry_count: i32,
434    pub error_code: Option<models::credit_registrations::CreditRegistrationErrorCode>,
435    pub seen_enrolment_ids: Option<Vec<String>>,
436    pub claimed_until: Option<DateTime<Utc>>,
437}
438
439async fn enrolment_check_schedule(
440    app_conf: web::Data<ApplicationConfiguration>,
441    pool: web::Data<PgPool>,
442    query: web::Query<EnrolmentCheckScheduleQuery>,
443) -> ControllerResult<HttpResponse> {
444    super::assert_enabled(&app_conf);
445    let token = skip_authorize();
446
447    let mut conn = pool.acquire().await?;
448    let row =
449        models::credit_registrations::get_by_id(&mut conn, query.credit_registration_id).await?;
450    token.authorized_ok(HttpResponse::Ok().json(EnrolmentCheckSchedule {
451        state: row.state,
452        group: row.enrolment_check_group,
453        anchor_at: row.enrolment_check_anchor_at,
454        step: row.enrolment_check_step,
455        due_at: row.enrolment_check_due_at,
456        next_attempt_at: row.next_attempt_at,
457        is_batched: row.is_enrolment_check_batched,
458        source: row.enrolment_check_source,
459        stopped_at: row.enrolment_checks_stopped_at,
460        requested_at: row.enrolment_check_requested_at,
461        restart_count: row.enrolment_check_restart_count,
462        checked_at: row.enrolment_checked_at,
463        first_failed_at: row.first_failed_at,
464        submit_retry_count: row.submit_retry_count,
465        error_code: row.error_code,
466        seen_enrolment_ids: row.seen_enrolment_ids,
467        claimed_until: row.enrolment_check_claimed_until,
468    }))
469}
470
471#[derive(Debug, Deserialize)]
472#[serde(rename_all = "camelCase")]
473pub struct QueuedEmailsQuery {
474    pub user_email: String,
475}
476
477#[derive(Debug, Serialize, Deserialize, PartialEq)]
478#[serde(rename_all = "camelCase")]
479pub struct QueuedEmail {
480    pub template_type: String,
481    pub placeholders: serde_json::Value,
482}
483
484/// How many of an account's mails one read looks back over. Generous: the shared test database has a
485/// live pipeline queueing mail for everybody, and a short window would turn "exactly one" into a
486/// false pass.
487const QUEUED_EMAIL_SCAN: i64 = 200;
488
489/// The mails queued to one account, newest first. There is no mail capture in this repo, so a spec
490/// asserting a message was composed reads the send queue instead of an inbox.
491async fn queued_emails(
492    app_conf: web::Data<ApplicationConfiguration>,
493    pool: web::Data<PgPool>,
494    query: web::Query<QueuedEmailsQuery>,
495) -> ControllerResult<HttpResponse> {
496    super::assert_enabled(&app_conf);
497    let token = skip_authorize();
498
499    let mut conn = pool.acquire().await?;
500    let Some(user_id) = models::user_details::get_active_user_id_by_email_case_insensitive(
501        &mut conn,
502        &query.user_email,
503    )
504    .await?
505    else {
506        return token.authorized_ok(HttpResponse::BadRequest().json(UnresolvedScope {
507            status: "unresolvedScope".to_string(),
508            half: "userEmail".to_string(),
509            value: query.user_email.clone(),
510        }));
511    };
512    let queued = models::email_deliveries::get_recent_template_types_for_user_for_testing(
513        &mut conn,
514        user_id,
515        QUEUED_EMAIL_SCAN,
516    )
517    .await?
518    .into_iter()
519    .map(|(template_type, placeholders)| QueuedEmail {
520        template_type: serde_json::to_value(template_type)
521            .ok()
522            .and_then(|value| value.as_str().map(str::to_string))
523            .unwrap_or_default(),
524        placeholders,
525    })
526    .collect::<Vec<_>>();
527
528    token.authorized_ok(HttpResponse::Ok().json(queued))
529}
530
531/// Attributed to the tick rather than to a worker, so the audit log says which traffic a test made.
532fn tick_context<'a>(
533    app_conf: &'a ApplicationConfiguration,
534    pool: &'a PgPool,
535    suotar_client: &'a SuotarClient,
536) -> PhaseContext<'a> {
537    PhaseContext::from_app(pool, suotar_client, app_conf, Runner::Other("run-tick"))
538}
539
540/// The outer error is a real failure; the inner one is a scope half that names nothing.
541async fn resolve_scope(
542    pool: &PgPool,
543    query: &RunTickQuery,
544) -> anyhow::Result<Result<RegistrationScope, UnresolvedScope>> {
545    let mut scope = RegistrationScope {
546        course_id: query.course_id,
547        user_id: query.user_id,
548        credit_registration_ids: Vec::new(),
549    };
550    if let Some(slug) = &query.course_slug {
551        let mut conn = pool.acquire().await?;
552        let found = models::courses::get_active_course_id_by_slug(&mut conn, slug).await?;
553        match found {
554            Some(id) => scope.course_id = Some(id),
555            None => {
556                return Ok(Err(UnresolvedScope {
557                    status: "unresolvedScope".to_string(),
558                    half: "courseSlug".to_string(),
559                    value: slug.clone(),
560                }));
561            }
562        }
563    }
564    if let Some(email) = &query.user_email {
565        let mut conn = pool.acquire().await?;
566        let found =
567            models::user_details::get_active_user_id_by_email_case_insensitive(&mut conn, email)
568                .await?;
569        match found {
570            Some(id) => scope.user_id = Some(id),
571            None => {
572                return Ok(Err(UnresolvedScope {
573                    status: "unresolvedScope".to_string(),
574                    half: "userEmail".to_string(),
575                    value: email.clone(),
576                }));
577            }
578        }
579    }
580    if let Some(raw) = &query.credit_registration_ids {
581        for part in raw.split(',').filter(|part| !part.trim().is_empty()) {
582            match Uuid::parse_str(part.trim()) {
583                Ok(id) => scope.credit_registration_ids.push(id),
584                Err(_) => {
585                    return Ok(Err(UnresolvedScope {
586                        status: "unresolvedScope".to_string(),
587                        half: "creditRegistrationIds".to_string(),
588                        value: part.trim().to_string(),
589                    }));
590                }
591            }
592        }
593    }
594    Ok(Ok(scope))
595}
596
597fn known_phase_names() -> Vec<String> {
598    CreditRegistrationPhase::ALL
599        .iter()
600        .map(|phase| phase.as_str().to_string())
601        .collect()
602}
603
604pub fn _add_routes(cfg: &mut ServiceConfig) {
605    cfg.route("/run-tick", web::post().to(run_tick))
606        .route("/run-registrar-tick", web::post().to(run_registrar_tick))
607        .route("/regrade-completion", web::post().to(regrade_completion))
608        .route(
609            "/test-exclusive-hold",
610            web::post().to(set_test_exclusive_hold),
611        )
612        .route(
613            "/expire-enrolment-recheck-allowance",
614            web::post().to(expire_enrolment_recheck_allowance),
615        )
616        .route(
617            "/enrolment-check-schedule",
618            web::get().to(enrolment_check_schedule),
619        )
620        .route(
621            "/make-enrolment-checks-due",
622            web::post().to(make_enrolment_checks_due),
623        )
624        .route(
625            "/make-roster-listings-due",
626            web::post().to(make_roster_listings_due),
627        )
628        .route("/queued-emails", web::get().to(queued_emails))
629        .configure(commands::_add_routes);
630}
631
632#[cfg(test)]
633mod tests {
634    use actix_web::{App, http::StatusCode, test, web::Data};
635
636    use super::*;
637    use crate::controllers::configure_controllers;
638
639    /// The mock config is present either way; `test_suotar` alone decides whether the routes exist.
640    fn app_conf(test_suotar: bool) -> ApplicationConfiguration {
641        ApplicationConfiguration {
642            test_suotar,
643            ..ApplicationConfiguration::mock_conf()
644                .expect("the mock configuration is built from constants")
645        }
646    }
647
648    /// Registers the real controller tree so the test sees the same gate production does. The pool is
649    /// never connected, so only phases that answer before they would need one can be driven here.
650    async fn call_run_tick(
651        test_suotar: bool,
652        query: &str,
653    ) -> actix_web::dev::ServiceResponse<actix_web::body::BoxBody> {
654        let app_conf = Data::new(app_conf(test_suotar));
655        let pool = Data::new(
656            PgPool::connect_lazy("postgres://headless-lms@localhost:54328/headless_lms_dev")
657                .expect("a lazy pool only parses the url"),
658        );
659        let service = test::init_service(
660            App::new()
661                .app_data(pool)
662                .app_data(Data::new(SuotarClient::mock_for_test()))
663                .app_data(app_conf.clone())
664                .service(
665                    web::scope("/api/v0")
666                        .configure(|cfg| configure_controllers(cfg, app_conf.clone())),
667                ),
668        )
669        .await;
670        let req = test::TestRequest::post()
671            .uri(&format!("/api/v0/mock-suotar/control/run-tick{query}"))
672            .to_request();
673        test::call_service(&service, req).await
674    }
675
676    /// Without the flag the routes must be absent, not merely refuse.
677    #[actix_web::test]
678    async fn run_tick_is_absent_when_the_mock_is_disabled() {
679        let res = call_run_tick(false, "?phase=verify").await;
680        assert_eq!(res.status(), StatusCode::NOT_FOUND);
681    }
682
683    /// A caller that asked to be narrowed and cannot be must be told, not quietly run wide over a
684    /// shared database.
685    #[actix_web::test]
686    async fn a_scope_a_phase_cannot_apply_is_refused() {
687        let res = call_run_tick(
688            true,
689            &format!("?phase=retention-sweep&courseId={}", Uuid::new_v4()),
690        )
691        .await;
692        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
693        let body: PhaseTickResult = test::read_body_json(res).await;
694        assert_eq!(
695            body,
696            PhaseTickResult::ScopeNotSupported {
697                phase: "retention-sweep".to_string()
698            }
699        );
700    }
701
702    #[actix_web::test]
703    async fn an_invented_phase_name_is_rejected() {
704        let res = call_run_tick(true, "?phase=materialise").await;
705        assert_eq!(res.status(), StatusCode::BAD_REQUEST);
706        let body: PhaseTickResult = test::read_body_json(res).await;
707        assert_eq!(
708            body,
709            PhaseTickResult::UnknownPhase {
710                phase: Some("materialise".to_string()),
711                known_phases: known_phase_names(),
712            }
713        );
714    }
715}