Files
kvitt/server/crates/kvitt-server/tests/scheduler_daily/scheduler.rs
T
jacobh2 8cb42f85a9 Server overhaul: one test harness in tests/common for every test binary
core_api/common.rs becomes tests/common/mod.rs and sync_api/m4.rs tests/common/sync_fixtures.rs; each binary
includes them through #[path] (the binaries of SPEC §14.7 stay as they are). TempDir's Default moves into the
harness instead of three copies; the skeleton helpers take ids, tokens and TempDir from it. Milestone wording in
test docs and the server README replaced by what the code does; README seams and test layout updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 22:43:54 +02:00

303 lines
10 KiB
Rust

use std::sync::Arc;
use std::time::Duration;
use chrono::Duration as ChronoDuration;
use kvitt_server::tasks::scheduler::{
GENERATION_TIME, PRUNE_TIME, generation_job, next_run, prune_job, run_daily,
};
use kvitt_server::time::{FixedClock, format_ts, parse_ts};
use tokio::sync::mpsc;
use crate::common::*;
use crate::sync_fixtures;
fn ts(text: &str) -> chrono::DateTime<chrono::Utc> {
parse_ts(text).unwrap()
}
#[test]
fn next_06_00_is_local_time_across_both_dst_changes() {
// Summer time (CEST, UTC+2): 06:00 is 04:00Z.
assert_eq!(
next_run(ts("2026-10-17T03:59:59.999Z"), GENERATION_TIME).unwrap(),
ts("2026-10-17T04:00:00.000Z")
);
// Strictly after: at 06:00 the next run is tomorrow.
assert_eq!(
next_run(ts("2026-10-17T04:00:00.000Z"), GENERATION_TIME).unwrap(),
ts("2026-10-18T04:00:00.000Z")
);
// Autumn: summer time ends 25 Oct 2026 at 03:00 CEST; 06:00 that day is CET, 05:00Z (a 25-hour day).
assert_eq!(
next_run(ts("2026-10-24T04:00:00.000Z"), GENERATION_TIME).unwrap(),
ts("2026-10-25T05:00:00.000Z")
);
assert_eq!(
next_run(ts("2026-10-25T05:00:00.000Z"), GENERATION_TIME).unwrap(),
ts("2026-10-26T05:00:00.000Z")
);
// Spring: summer time starts 28 Mar 2027 at 02:00 CET; 06:00 that day is CEST, 04:00Z (a 23-hour day).
assert_eq!(
next_run(ts("2027-03-27T05:00:00.000Z"), GENERATION_TIME).unwrap(),
ts("2027-03-28T04:00:00.000Z")
);
// Pruning at 04:00 on both DST days.
assert_eq!(
next_run(ts("2026-10-24T02:00:00.000Z"), PRUNE_TIME).unwrap(),
ts("2026-10-25T03:00:00.000Z")
);
assert_eq!(
next_run(ts("2027-03-27T03:00:00.000Z"), PRUNE_TIME).unwrap(),
ts("2027-03-28T02:00:00.000Z")
);
}
/// Starts `run_daily` at 06:00 on `clock` with a 2 ms poll; every run sends the clock's time.
fn daily(clock: &FixedClock, run_at_start: bool) -> mpsc::UnboundedReceiver<String> {
let (tx, rx) = mpsc::unbounded_channel();
let clock2 = clock.clone();
let shared: Arc<dyn kvitt_server::time::Clock> = Arc::new(clock.clone());
tokio::spawn(async move {
run_daily(
shared,
GENERATION_TIME,
run_at_start,
Duration::from_millis(2),
|| {
let tx = tx.clone();
let now = kvitt_server::time::Clock::now_ts(&clock2);
async move {
let _ = tx.send(now);
}
},
)
.await;
});
rx
}
async fn next(rx: &mut mpsc::UnboundedReceiver<String>) -> Option<String> {
tokio::time::timeout(Duration::from_millis(200), rx.recv())
.await
.ok()
.flatten()
}
#[tokio::test]
async fn runs_at_start_then_at_06_00_and_survives_clock_jumps() {
let clock = FixedClock::new(ts("2026-10-17T03:00:00.000Z")); // 05:00 CEST
let mut rx = daily(&clock, true);
assert_eq!(
next(&mut rx).await.as_deref(),
Some("2026-10-17T03:00:00.000Z"),
"run at start"
);
assert_eq!(next(&mut rx).await, None, "not before 06:00");
clock.set(ts("2026-10-17T03:59:59.999Z"));
assert_eq!(next(&mut rx).await, None);
clock.set(ts("2026-10-17T04:00:00.000Z"));
assert_eq!(
next(&mut rx).await.as_deref(),
Some("2026-10-17T04:00:00.000Z")
);
assert_eq!(next(&mut rx).await, None, "once per day");
// Forward jump over three days (downtime, NTP after boot): one run, catch-up is the job's business.
clock.set(ts("2026-10-20T12:00:00.000Z"));
assert_eq!(
next(&mut rx).await.as_deref(),
Some("2026-10-20T12:00:00.000Z")
);
assert_eq!(next(&mut rx).await, None);
// Backward jump by a week: the next run is re-planned for the new 06:00, not the old one.
clock.set(ts("2026-10-13T03:00:00.000Z"));
assert_eq!(next(&mut rx).await, None);
clock.set(ts("2026-10-13T04:00:00.000Z"));
assert_eq!(
next(&mut rx).await.as_deref(),
Some("2026-10-13T04:00:00.000Z")
);
}
#[tokio::test]
async fn without_run_at_start_the_first_run_is_the_next_local_time() {
let clock = FixedClock::new(ts("2026-10-17T05:00:00.000Z"));
let mut rx = daily(&clock, false);
assert_eq!(next(&mut rx).await, None);
clock.set(ts("2026-10-18T04:00:00.000Z"));
assert_eq!(
next(&mut rx).await.as_deref(),
Some("2026-10-18T04:00:00.000Z")
);
}
async fn insert_template(
app: &TestApp,
id: &str,
day: i64,
start: &str,
last: Option<&str>,
paused: bool,
deleted: bool,
) {
let version = app.next_version().await;
sqlx::query(
"INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, notes, payer_user_id, \
split_mode, day_of_month, start_period, end_period, paused, last_generated_period, created_by_user_id, \
updated_by_user_id, created_at, updated_at, deleted_at, server_version) \
VALUES (?, ?, ?, 1250000, 'Hyra', '', ?, 'default', ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(ACCOUNT_1)
.bind(HOME)
.bind(ADMIN_ID)
.bind(day)
.bind(start)
.bind(i64::from(paused))
.bind(last)
.bind(ADMIN_ID)
.bind(ADMIN_ID)
.bind("2026-06-01T00:00:00.000Z")
.bind("2026-06-01T00:00:00.000Z")
.bind(deleted.then_some("2026-06-02T00:00:00.000Z"))
.bind(version)
.execute(&app.state.db)
.await
.unwrap();
}
async fn periods(app: &TestApp, template: &str) -> Vec<String> {
sqlx::query_scalar("SELECT recurring_period FROM expenses WHERE recurring_template_id = ? ORDER BY recurring_period")
.bind(template)
.fetch_all(&app.state.db)
.await
.unwrap()
}
#[tokio::test]
async fn start_runs_generation_catching_up_after_downtime_and_skips_paused_and_deleted() {
let mut app = sync_fixtures::app().await;
let active = sync_fixtures::new_id();
let paused = sync_fixtures::new_id();
let deleted = sync_fixtures::new_id();
// Last generated July; the server was down since. Now is 17 October 14:00.
insert_template(&app, &active, 15, "2026-06", Some("2026-07"), false, false).await;
insert_template(&app, &paused, 1, "2026-06", None, true, false).await;
insert_template(&app, &deleted, 1, "2026-06", None, false, true).await;
// Missing credentials in the test config: push disabled, the scheduler still runs.
let state = kvitt_server::tasks::start(app.state.clone());
app.state = state;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while periods(&app, &active).await.len() < 3 && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(
periods(&app, &active).await,
["2026-08", "2026-09", "2026-10"]
);
assert!(periods(&app, &paused).await.is_empty());
assert!(periods(&app, &deleted).await.is_empty());
let audit: i64 = app
.scalar_i64("SELECT count(*) FROM audit_log WHERE source = 'recurring'")
.await;
assert_eq!(audit, 3);
}
#[tokio::test]
async fn the_06_00_tick_generates_the_period_due_that_morning() {
let app = sync_fixtures::app().await;
let id = sync_fixtures::new_id();
insert_template(&app, &id, 15, "2026-11", None, false, false).await;
// 15 November 2026, 05:59 CET: not yet.
app.set_now("2026-11-15T04:59:00.000Z");
generation_job(&app.state).await;
assert!(periods(&app, &id).await.is_empty());
let state = app.state.clone();
let shared = state.clock.clone();
tokio::spawn(async move {
run_daily(
shared,
GENERATION_TIME,
false,
Duration::from_millis(2),
|| generation_job(&state),
)
.await;
});
tokio::time::sleep(Duration::from_millis(30)).await;
assert!(periods(&app, &id).await.is_empty());
app.set_now("2026-11-15T05:00:00.000Z");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while periods(&app, &id).await.is_empty() && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(periods(&app, &id).await, ["2026-11"]);
let date: String = sqlx::query_scalar("SELECT expense_date FROM expenses")
.fetch_one(&app.state.db)
.await
.unwrap();
assert_eq!(date, "2026-11-15");
}
async fn insert_mutation(app: &TestApp, received_at: &str) -> String {
let id = sync_fixtures::new_id();
sqlx::query(
"INSERT INTO sync_mutations (mutation_id, device_id, entity_type, entity_id, result, received_at) \
VALUES (?, ?, 'expense', ?, '{}', ?)",
)
.bind(&id)
.bind(ADMIN_DEVICE_ID)
.bind(sync_fixtures::new_id())
.bind(received_at)
.execute(&app.state.db)
.await
.unwrap();
id
}
#[tokio::test]
async fn pruning_deletes_rows_older_than_90_days_and_keeps_the_rest() {
let app = sync_fixtures::app().await;
let now = ts(NOW);
insert_mutation(
&app,
&format_ts(now - ChronoDuration::days(90) - ChronoDuration::milliseconds(1)),
)
.await;
insert_mutation(&app, "2026-01-01T00:00:00.000Z").await;
let boundary = insert_mutation(&app, &format_ts(now - ChronoDuration::days(90))).await;
let recent = insert_mutation(&app, &format_ts(now - ChronoDuration::days(89))).await;
prune_job(&app.state).await;
let left: Vec<String> =
sqlx::query_scalar("SELECT mutation_id FROM sync_mutations ORDER BY received_at")
.fetch_all(&app.state.db)
.await
.unwrap();
assert_eq!(left, [boundary, recent]);
}
#[tokio::test]
async fn pruning_runs_at_04_00_local() {
let app = sync_fixtures::app().await;
// 03:59 CEST on 18 October.
app.set_now("2026-10-18T01:59:00.000Z");
insert_mutation(&app, "2026-07-01T00:00:00.000Z").await;
let state = app.state.clone();
let shared = state.clock.clone();
tokio::spawn(async move {
run_daily(shared, PRUNE_TIME, false, Duration::from_millis(2), || {
prune_job(&state)
})
.await;
});
tokio::time::sleep(Duration::from_millis(30)).await;
assert_eq!(sync_fixtures::count(&app, "sync_mutations").await, 1);
app.set_now("2026-10-18T02:00:00.000Z");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while sync_fixtures::count(&app, "sync_mutations").await > 0
&& std::time::Instant::now() < deadline
{
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert_eq!(sync_fixtures::count(&app, "sync_mutations").await, 0);
}