weavesignal/crates/model/tests/retention.rs
The WeaveSignal contributors 466b32ab6a WeaveSignal 1.0.0 — release one
The first public release. What it does: https://weavesignal.top; what changed: CHANGELOG.md.
2026-11-04 12:15:42 +00:00

1362 lines
43 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 The WeaveSignal contributors
//! Retention: the fourteen presets against recce 27's table, and (against a real PostgreSQL,
//! ignored unless asked for, `just test-db`) the sweeper, holds, consent linked to recordings,
//! and consent read back after a restart.
use std::time::{SystemTime, UNIX_EPOCH};
use weavesignal_model::retention::{
self, CUSTOM, HoldRecord, HoldTarget, Kind, NO_RETENTION, NewHold, NewRecording, PRESETS,
Period, RecordingKind, Setting,
};
use weavesignal_model::{ConsentRecord, Station, StationId, ensure_station, record_consent};
// ---- the presets, transcribed a second time from recce 27's "Proposed presets" table ----
/// One row of recce 27's table: logger, per-play, chat ("A" or 0), moderation, consent
/// ("R+42" or with the call), raw access logs; aggregate stats are "no limit" in every row.
struct Row {
key: &'static str,
logger: u32,
per_play: u32,
chat_with_archive: bool,
moderation: u32,
consent_r42: bool,
access: u32,
/// The floor the rule column names for the logger, if any.
logger_floor: Option<u32>,
complaint_hold_days: Option<u32>,
}
const TABLE: [Row; 13] = [
Row {
key: "none",
logger: 0,
per_play: 0,
chat_with_archive: false,
moderation: 0,
consent_r42: false,
access: 0,
logger_floor: None,
complaint_hold_days: None,
},
Row {
key: "uk_ofcom",
logger: 42,
per_play: 1096,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: Some(42),
complaint_hold_days: None,
},
Row {
key: "uk_internet",
logger: 42,
per_play: 1096,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: None,
complaint_hold_days: None,
},
Row {
key: "us_webcaster",
logger: 42,
per_play: 1096,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: None,
complaint_hold_days: None,
},
Row {
key: "us_nce",
logger: 42,
per_play: 1461,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 1461,
logger_floor: None,
complaint_hold_days: None,
},
Row {
key: "eu_gdpr",
logger: 42,
per_play: 1096,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: None,
complaint_hold_days: None,
},
Row {
key: "ie_licensed",
logger: 90,
per_play: 2192,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: Some(90),
complaint_hold_days: None,
},
Row {
key: "ie_internet",
logger: 42,
per_play: 2192,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: None,
complaint_hold_days: None,
},
Row {
key: "de",
logger: 90,
per_play: 1096,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 7,
logger_floor: Some(90),
complaint_hold_days: None,
},
Row {
key: "ca_licensed",
logger: 28,
per_play: 2192,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: Some(28),
complaint_hold_days: Some(56),
},
Row {
key: "ca_internet",
logger: 28,
per_play: 2192,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: None,
complaint_hold_days: None,
},
Row {
key: "au_licensed",
logger: 42,
per_play: 1096,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: Some(42),
complaint_hold_days: Some(60),
},
Row {
key: "au_internet",
logger: 42,
per_play: 1096,
chat_with_archive: true,
moderation: 365,
consent_r42: true,
access: 30,
logger_floor: None,
complaint_hold_days: None,
},
];
#[test]
fn each_preset_has_recce_27s_values_and_a_source_for_each() {
let names = [
"No retention",
"UK, Ofcom-licensed",
"UK, internet-only",
"US webcaster (statutory licence)",
"US noncommercial educational webcaster",
"EU, GDPR only",
"Ireland, licensed",
"Ireland, internet-only (audio)",
"Germany",
"Canada, licensed",
"Canada, internet-only",
"Australia, licensed",
"Australia, internet-only",
"Custom",
];
assert_eq!(
PRESETS.iter().map(|p| p.name).collect::<Vec<_>>(),
names,
"ADR-0114's fourteen, in order"
);
for row in &TABLE {
let p = retention::preset(row.key).unwrap_or_else(|| panic!("{}", row.key));
let got = p.periods();
let days = Period::days;
assert_eq!(
got.programme_recordings,
days(row.logger),
"{} logger",
row.key
);
assert_eq!(got.per_play_log, days(row.per_play), "{} per-play", row.key);
let chat = if row.chat_with_archive {
Period::WithArchive
} else {
days(0)
};
assert_eq!(got.chat_messages, chat, "{} chat", row.key);
assert_eq!(
got.moderation_records,
days(row.moderation),
"{} moderation",
row.key
);
let consent = if row.consent_r42 {
Period::AfterRecordings { days: 42 }
} else {
Period::WithCall
};
assert_eq!(got.consent, consent, "{} consent", row.key);
assert_eq!(
got.call_recordings,
days(0),
"{}: off-air call audio is 0 everywhere",
row.key
);
assert_eq!(
got.listener_statistics,
Period::NoLimit,
"{}: counts stay (ADR-0115)",
row.key
);
assert_eq!(got.access_logs, days(row.access), "{} access logs", row.key);
assert_eq!(
p.cell(Kind::ProgrammeRecordings).floor.map(|f| f.days),
row.logger_floor,
"{} logger floor",
row.key
);
assert_eq!(
p.complaint_hold.and_then(|h| h.days),
row.complaint_hold_days,
"{} complaint hold",
row.key
);
for k in Kind::ALL {
assert!(p.cell(k).source.len() > 10, "{} {k}: a source", row.key);
}
}
// The statutory floors recce 27 names, where a preset carries them.
let floor = |key: &str, k: Kind| {
retention::preset(key)
.unwrap()
.cell(k)
.floor
.map(|f| f.days)
};
assert_eq!(floor("us_webcaster", Kind::PerPlayLog), Some(1096));
assert_eq!(floor("us_nce", Kind::AccessLogs), Some(1461));
assert_eq!(floor("ca_licensed", Kind::PerPlayLog), Some(2192));
assert_eq!(floor("uk_internet", Kind::ProgrammeRecordings), None);
}
// ---- against PostgreSQL ----
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
const DAY: u64 = 24 * 3600 * 1000;
async fn station(name: &str) -> (sqlx::PgPool, StationId) {
let url =
std::env::var("WEAVESIGNAL_TEST_DATABASE_URL").expect("WEAVESIGNAL_TEST_DATABASE_URL");
let pool = sqlx::PgPool::connect(&url).await.expect("connect");
let s = ensure_station(&pool, &Station::new(StationId::new_random(), name).unwrap())
.await
.unwrap();
(pool, s.id)
}
fn preset_setting(key: &str) -> Setting {
Setting {
preset: key.into(),
..Setting::default()
}
}
async fn recording(
pool: &sqlx::PgPool,
st: StationId,
dir: &std::path::Path,
kind: RecordingKind,
name: &str,
started_ago_days: u64,
ended_ago_days: Option<u64>,
) -> i64 {
std::fs::write(dir.join(name), b"audio").unwrap();
let now = now_ms();
retention::add_recording(
pool,
st,
&NewRecording {
kind,
label: name.into(),
file: Some(name.into()),
started_at_ms: now - started_ago_days * DAY - 3_600_000,
ended_at_ms: ended_ago_days.map(|d| now - d * DAY),
},
)
.await
.unwrap()
}
async fn deleted(pool: &sqlx::PgPool, id: i64) -> bool {
let row: Option<(Option<f64>,)> = sqlx::query_as(
"SELECT extract(epoch FROM deleted_at)::double precision FROM recording WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await
.unwrap();
// A row purged after deletion counts as deleted.
row.is_none_or(|(at,)| at.is_some())
}
fn tmpdir(what: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!(
"weavesignal-retention-{what}-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&d).unwrap();
d
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn the_sweeper_deletes_only_expired_unheld_records() {
let (pool, st) = station("Kettle FM").await;
let dir = tmpdir("sweep");
let setting = retention::save_setting(&pool, st, &preset_setting("uk_internet"), "Morag Ross")
.await
.unwrap();
let p = RecordingKind::Programme;
let expired = recording(&pool, st, &dir, p, "expired.opus", 50, Some(50)).await;
let recent = recording(&pool, st, &dir, p, "recent.opus", 10, Some(10)).await;
let open = recording(&pool, st, &dir, p, "open.opus", 60, None).await;
let named = recording(&pool, st, &dir, p, "named.opus", 50, Some(50)).await;
let windowed = recording(&pool, st, &dir, p, "windowed.opus", 45, Some(45)).await;
let offair = recording(
&pool,
st,
&dir,
RecordingKind::Call,
"offair.opus",
0,
Some(0),
)
.await;
// Another station's expired recording is not this sweep's to touch.
let (_, other) = station("Other FM").await;
let theirs = recording(&pool, other, &dir, p, "theirs.opus", 50, Some(50)).await;
// A hold naming one record, and one covering the 45-days-ago broadcast.
let now = now_ms();
let h1 = retention::place_hold(
&pool,
st,
&NewHold {
reason: "Complaint about the 14:00 phone-in".into(),
by: "Morag Ross".into(),
from_ms: None,
to_ms: None,
records: vec![HoldRecord {
kind: HoldTarget::Recording,
id: named,
}],
},
)
.await
.unwrap();
assert_eq!(h1.recordings, 1);
let h2 = retention::place_hold(
&pool,
st,
&NewHold {
reason: "Ofcom fairness complaint".into(),
by: "Morag Ross".into(),
from_ms: Some(now - 45 * DAY - 1_800_000),
to_ms: Some(now - 45 * DAY - 1_200_000),
records: vec![],
},
)
.await
.unwrap();
// The window covers the 45-days-ago recording, and the open one still recording then.
assert_eq!(h2.recordings, 2);
// A dry run says what would go and changes nothing.
let dry = retention::sweep(&pool, st, &setting, Some(&dir), true)
.await
.unwrap();
assert_eq!((dry.recordings_deleted, dry.held), (2, 2), "{dry:?}");
assert!(!deleted(&pool, expired).await);
let r = retention::sweep(&pool, st, &setting, Some(&dir), false)
.await
.unwrap();
assert_eq!(r.recordings_deleted, 2, "{r:?}");
assert_eq!(r.held, 2, "{r:?}");
assert!(r.errors.is_empty(), "{r:?}");
assert!(deleted(&pool, expired).await && !dir.join("expired.opus").exists());
assert!(
deleted(&pool, offair).await,
"off-air call audio is kept 0 days"
);
for (id, f) in [
(recent, "recent.opus"),
(open, "open.opus"),
(named, "named.opus"),
(windowed, "windowed.opus"),
] {
assert!(!deleted(&pool, id).await, "{f} is kept");
assert!(dir.join(f).exists(), "{f}'s file is kept");
}
assert!(!deleted(&pool, theirs).await && dir.join("theirs.opus").exists());
let last = retention::last_sweep(&pool, st).await.unwrap().unwrap();
assert_eq!((last.recordings_deleted, last.held), (2, 2));
// Released, the normal period resumes: both go at the next sweep.
let released = retention::release_hold(&pool, st, h1.id, "Morag Ross", "Complaint closed")
.await
.unwrap();
assert!(released.released_at_ms.is_some());
assert!(matches!(
retention::release_hold(&pool, st, h1.id, "Morag Ross", "again").await,
Err(retention::HoldError::Released(_))
));
retention::release_hold(&pool, st, h2.id, "Iain Gunn", "Ofcom closed the case")
.await
.unwrap();
let r = retention::sweep(&pool, st, &setting, Some(&dir), false)
.await
.unwrap();
assert_eq!((r.recordings_deleted, r.held), (2, 0), "{r:?}");
assert!(deleted(&pool, named).await && deleted(&pool, windowed).await);
// The log keeps both holds, with who released them and why.
let holds = retention::holds(&pool, st).await.unwrap();
assert_eq!(holds.len(), 2);
assert!(
holds
.iter()
.all(|h| h.released_by.is_some() && h.release_reason.is_some())
);
// Another station cannot see or release them.
assert!(matches!(
retention::hold(&pool, other, h1.id).await,
Err(retention::HoldError::NotFound(_))
));
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn a_missing_file_is_counted_and_a_file_outside_the_directory_is_refused() {
let (pool, st) = station("Kettle FM").await;
let dir = tmpdir("missing");
let setting = preset_setting(NO_RETENTION);
let id = recording(
&pool,
st,
&dir,
RecordingKind::Programme,
"gone.opus",
1,
Some(1),
)
.await;
std::fs::remove_file(dir.join("gone.opus")).unwrap();
let r = retention::sweep(&pool, st, &setting, Some(&dir), false)
.await
.unwrap();
assert_eq!((r.recordings_deleted, r.files_missing), (1, 1), "{r:?}");
assert!(deleted(&pool, id).await);
// Without a directory, a recording with a file is kept and reported.
let kept = recording(
&pool,
st,
&dir,
RecordingKind::Programme,
"kept.opus",
1,
Some(1),
)
.await;
let r = retention::sweep(&pool, st, &setting, None, false)
.await
.unwrap();
assert_eq!(r.recordings_deleted, 0);
assert_eq!(r.errors.len(), 1, "{r:?}");
assert!(!deleted(&pool, kept).await);
// The database refuses a path out of the directory, as the model does.
let bad = retention::add_recording(
&pool,
st,
&NewRecording {
kind: RecordingKind::Programme,
label: String::new(),
file: Some("../../etc/passwd".into()),
started_at_ms: now_ms() - DAY,
ended_at_ms: Some(now_ms()),
},
)
.await;
assert!(bad.is_err());
let raw = sqlx::query(
"INSERT INTO recording (station_id, kind, file, started_at) VALUES ($1, 'programme', '/etc/passwd', now())",
)
.bind(st.as_uuid())
.execute(&pool)
.await;
assert!(raw.is_err(), "the CHECK holds");
std::fs::remove_dir_all(&dir).ok();
}
/// A stretch on air, `from` to `to` after the consent was given.
async fn on_air_between(pool: &sqlx::PgPool, consent_id: i64, from: &str, to: &str) {
sqlx::query(
"INSERT INTO consent_on_air (consent_id, from_at, to_at)
SELECT id, accepted_at + $2::interval, accepted_at + $3::interval FROM caller_consent WHERE id = $1",
)
.bind(consent_id)
.bind(from)
.bind(to)
.execute(pool)
.await
.unwrap();
}
async fn linked_to(pool: &sqlx::PgPool, consent_id: i64, recording_id: i64) -> bool {
let (n,): (i64,) = sqlx::query_as(
"SELECT count(*) FROM consent_recording WHERE consent_id = $1 AND recording_id = $2",
)
.bind(consent_id)
.bind(recording_id)
.fetch_one(pool)
.await
.unwrap();
n == 1
}
fn consent(identity: &str, accepted_ms: u64) -> ConsentRecord {
ConsentRecord {
identity: identity.into(),
name: "Jessie Brown".into(),
topic: "No bus after 23:10".into(),
given_by: "caller",
words: "Your call may be broadcast live and recorded.".into(),
words_version: "0123456789abcdef".into(),
accepted_at_ms: accepted_ms,
}
}
async fn consent_exists(pool: &sqlx::PgPool, id: i64) -> bool {
let (n,): (i64,) = sqlx::query_as("SELECT count(*) FROM caller_consent WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await
.unwrap();
n == 1
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn consent_is_kept_as_long_as_its_recordings_plus_42_days() {
let (pool, st) = station("Kettle FM").await;
let dir = tmpdir("consent");
let setting = retention::save_setting(&pool, st, &preset_setting("uk_ofcom"), "Morag Ross")
.await
.unwrap();
let now = now_ms();
// A call 50 days ago, which the 50-days-ago programme recording covers.
let linked = record_consent(
&pool,
st,
&consent("caller-aaaaaaaaaaaa", now - 50 * DAY - 1_800_000),
)
.await
.unwrap();
sqlx::query("UPDATE caller_consent SET call_ended_at = accepted_at + interval '10 minutes' WHERE id = $1")
.bind(linked)
.execute(&pool)
.await
.unwrap();
// They were put on air for five minutes of it (ADR-0153).
on_air_between(&pool, linked, "2 minutes", "7 minutes").await;
let rec = recording(
&pool,
st,
&dir,
RecordingKind::Programme,
"hour.opus",
50,
Some(50),
)
.await;
let (links,): (i64,) = sqlx::query_as(
"SELECT count(*) FROM consent_recording WHERE consent_id = $1 AND recording_id = $2",
)
.bind(linked)
.bind(rec)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
links, 1,
"the recording took the consent of the caller who was on air during it"
);
// An unlinked consent follows the logger's period from the call: 50 days ago goes, 10 stays.
let old = record_consent(&pool, st, &consent("caller-bbbbbbbbbbbb", now - 50 * DAY))
.await
.unwrap();
let young = record_consent(&pool, st, &consent("caller-cccccccccccc", now - 10 * DAY))
.await
.unwrap();
sqlx::query("UPDATE caller_consent SET call_ended_at = accepted_at + interval '5 minutes' WHERE id = ANY($1)")
.bind(vec![old, young])
.execute(&pool)
.await
.unwrap();
let r = retention::sweep(&pool, st, &setting, Some(&dir), false)
.await
.unwrap();
assert!(deleted(&pool, rec).await, "the 42-day logger expired");
assert!(
consent_exists(&pool, linked).await,
"kept 42 days after its last recording went"
);
assert!(!consent_exists(&pool, old).await && consent_exists(&pool, young).await);
assert_eq!(r.consents_deleted, 1, "{r:?}");
// 43 days after the recording went, the consent goes too, and the recording's row with it.
sqlx::query("UPDATE recording SET deleted_at = now() - interval '43 days' WHERE id = $1")
.bind(rec)
.execute(&pool)
.await
.unwrap();
// But not while a hold names it.
let h = retention::place_hold(
&pool,
st,
&NewHold {
reason: "Caller disputes consent".into(),
by: "Iain Gunn".into(),
from_ms: None,
to_ms: None,
records: vec![HoldRecord {
kind: HoldTarget::Consent,
id: linked,
}],
},
)
.await
.unwrap();
let r = retention::sweep(&pool, st, &setting, Some(&dir), false)
.await
.unwrap();
assert_eq!((r.consents_deleted, r.held), (0, 1), "{r:?}");
assert!(consent_exists(&pool, linked).await);
retention::release_hold(&pool, st, h.id, "Iain Gunn", "Resolved")
.await
.unwrap();
let r = retention::sweep(&pool, st, &setting, Some(&dir), false)
.await
.unwrap();
assert_eq!(r.consents_deleted, 1, "{r:?}");
assert!(!consent_exists(&pool, linked).await);
let (rows,): (i64,) = sqlx::query_as("SELECT count(*) FROM recording WHERE id = $1")
.bind(rec)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
rows, 0,
"the deleted recording's row goes once no consent needs it"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn under_no_retention_consent_goes_with_the_call() {
let (pool, st) = station("Kettle FM").await;
let setting = retention::load_setting(&pool, st).await.unwrap();
assert_eq!(
setting.preset, NO_RETENTION,
"a new station starts on No retention"
);
let now = now_ms();
let waiting = record_consent(&pool, st, &consent("caller-dddddddddddd", now - 60_000))
.await
.unwrap();
let ended = record_consent(&pool, st, &consent("caller-eeeeeeeeeeee", now - 600_000))
.await
.unwrap();
assert_eq!(
retention::mark_call_ended(&pool, st, "caller-eeeeeeeeeeee")
.await
.unwrap(),
1
);
let r = retention::sweep(&pool, st, &setting, None, false)
.await
.unwrap();
assert_eq!(r.consents_deleted, 1, "{r:?}");
assert!(
consent_exists(&pool, waiting).await,
"a call still going keeps its consent"
);
assert!(!consent_exists(&pool, ended).await);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn consent_survives_a_restart_until_the_call_ends() {
let (pool, st) = station("Kettle FM").await;
let now = now_ms();
record_consent(&pool, st, &consent("caller-ffffffffffff", now - 60_000))
.await
.unwrap();
record_consent(&pool, st, &consent("caller-000000000000", now - 2 * DAY))
.await
.unwrap();
// What a restarted control plane reads back: calls that may still be going.
let open = retention::open_consents(&pool, st).await.unwrap();
assert_eq!(open.len(), 1);
assert_eq!(open[0].identity, "caller-ffffffffffff");
assert_eq!(open[0].given_by, "caller");
assert!(open[0].accepted_at_ms.abs_diff(now - 60_000) < 5);
let one = retention::open_consent_of(&pool, st, "caller-ffffffffffff")
.await
.unwrap();
assert_eq!(
one.as_ref().map(|c| c.words_version.as_str()),
Some("0123456789abcdef")
);
retention::mark_call_ended(&pool, st, "caller-ffffffffffff")
.await
.unwrap();
assert!(
retention::open_consents(&pool, st)
.await
.unwrap()
.is_empty()
);
assert!(
retention::open_consent_of(&pool, st, "caller-ffffffffffff")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn the_setting_is_kept_with_a_log_of_who_changed_it() {
let (pool, st) = station("Kettle FM").await;
let mut periods = retention::preset("ca_licensed").unwrap().periods();
periods.programme_recordings = Period::days(14);
let custom = Setting {
preset: CUSTOM.into(),
based_on: Some("ca_licensed".into()),
custom: Some(periods),
broadcast_licence: Some(true),
..Setting::default()
};
let saved = retention::save_setting(&pool, st, &custom, "Morag Ross")
.await
.unwrap();
assert_eq!(saved.preset, CUSTOM);
assert_eq!(saved.custom, Some(periods));
assert_eq!(saved.updated_by.as_deref(), Some("Morag Ross"));
assert_eq!(
saved.warnings().len(),
1,
"14 days is under Canada's 28: a warning, not a refusal"
);
retention::save_setting(&pool, st, &preset_setting("uk_ofcom"), "Iain Gunn")
.await
.unwrap();
let back = retention::load_setting(&pool, st).await.unwrap();
assert_eq!((back.preset.as_str(), back.custom), ("uk_ofcom", None));
let (n,): (i64,) =
sqlx::query_as("SELECT count(*) FROM retention_change WHERE station_id = $1")
.bind(st.as_uuid())
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(n, 2);
assert!(
retention::save_setting(&pool, st, &preset_setting("atlantis"), "x")
.await
.is_err()
);
assert!(
retention::save_setting(&pool, st, &preset_setting("uk_ofcom"), " ")
.await
.is_err()
);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn an_unreadable_custom_setting_deletes_nothing() {
let (pool, st) = station("Kettle FM").await;
let dir = tmpdir("unreadable");
let id = recording(
&pool,
st,
&dir,
RecordingKind::Programme,
"old.opus",
400,
Some(400),
)
.await;
// A Custom row whose periods this release cannot read (a newer release's, say).
sqlx::query(
"INSERT INTO retention_setting (station_id, preset, periods, updated_by) VALUES ($1, 'custom', '{}'::jsonb, 'x')",
)
.bind(st.as_uuid())
.execute(&pool)
.await
.unwrap();
let setting = retention::load_setting(&pool, st).await.unwrap();
assert!(setting.check().is_err());
let r = retention::sweep(&pool, st, &setting, Some(&dir), false)
.await
.unwrap();
assert_eq!((r.recordings_deleted, r.consents_deleted), (0, 0), "{r:?}");
assert_eq!(r.errors.len(), 1, "{r:?}");
assert!(!deleted(&pool, id).await && dir.join("old.opus").exists());
assert!(
!retention::last_sweep(&pool, st)
.await
.unwrap()
.unwrap()
.errors
.is_empty()
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn an_erased_consent_keeps_its_date_and_terms_and_loses_the_name() {
use weavesignal_model::retention::{ERASED, erase_consent, find_consents};
let url =
std::env::var("WEAVESIGNAL_TEST_DATABASE_URL").expect("WEAVESIGNAL_TEST_DATABASE_URL");
let pool = sqlx::PgPool::connect(&url).await.expect("connect");
let station = weavesignal_model::ensure_station(
&pool,
&weavesignal_model::Station::new(weavesignal_model::StationId::new_random(), "Kettle FM")
.unwrap(),
)
.await
.unwrap()
.id;
for (identity, name) in [
("caller-aa11", "Jessie Brown"),
("caller-bb22", "Niall Anderson"),
] {
sqlx::query(
"INSERT INTO caller_consent (station_id, identity, name, topic, given_by, words, words_version, accepted_at)
VALUES ($1, $2, $3, 'Buses after the late shift', 'caller', 'Your call may be broadcast and recorded.', '0123456789abcdef', now())",
)
.bind(station.as_uuid())
.bind(identity)
.bind(name)
.execute(&pool)
.await
.unwrap();
}
let found = find_consents(&pool, station, "jessie").await.unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].name, "Jessie Brown");
assert_eq!(
erase_consent(&pool, station, found[0].id, "Elspeth Gordon")
.await
.unwrap(),
1
);
let (name, topic, words, version, by, erased): (String, String, String, String, String, bool) =
sqlx::query_as(
"SELECT name, topic, words, words_version, erased_by, erased_at IS NOT NULL
FROM caller_consent WHERE id = $1",
)
.bind(found[0].id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!((name.as_str(), topic.as_str()), (ERASED, ""));
assert_eq!(
words, "Your call may be broadcast and recorded.",
"the terms stay as evidence"
);
assert_eq!(version, "0123456789abcdef");
assert_eq!(by, "Elspeth Gordon");
assert!(erased);
assert!(
find_consents(&pool, station, "jessie")
.await
.unwrap()
.is_empty()
);
assert_eq!(
find_consents(&pool, station, "niall").await.unwrap().len(),
1,
"others untouched"
);
assert_eq!(
erase_consent(&pool, station, found[0].id, "x")
.await
.unwrap(),
0,
"erased once"
);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn a_recording_keeps_the_consent_only_of_callers_who_went_on_air_during_it() {
let (pool, st) = station("Kettle FM").await;
let dir = tmpdir("on-air-link");
let setting = retention::save_setting(&pool, st, &preset_setting("uk_ofcom"), "Morag Ross")
.await
.unwrap();
let now = now_ms();
// Four calls in the same hour, 50 days ago, each ten minutes long.
let at = now - 50 * DAY;
let mut ids = Vec::new();
for (n, identity) in [
"caller-aaaaaaaaaaaa",
"caller-bbbbbbbbbbbb",
"caller-cccccccccccc",
"caller-dddddddddddd",
]
.iter()
.enumerate()
{
let id = record_consent(&pool, st, &consent(identity, at + n as u64 * 1000))
.await
.unwrap();
sqlx::query("UPDATE caller_consent SET call_ended_at = accepted_at + interval '10 minutes' WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.unwrap();
ids.push(id);
}
let [aired, waited, aired_before, aired_after] = ids[..] else {
unreachable!()
};
// `aired` was on air two minutes in; `waited` never was (lobby or screening only).
on_air_between(&pool, aired, "2 minutes", "7 minutes").await;
// The recording runs from half an hour before the calls to half an hour after. One caller
// came off air before it began and one went on air after it ended.
on_air_between(&pool, aired_before, "-2 hours", "-1 hour").await;
on_air_between(&pool, aired_after, "5 hours", "6 hours").await;
std::fs::write(dir.join("hour.opus"), b"audio").unwrap();
let rec = retention::add_recording(
&pool,
st,
&NewRecording {
kind: RecordingKind::Programme,
label: "hour".into(),
file: Some("hour.opus".into()),
started_at_ms: at - 1_800_000,
ended_at_ms: Some(at + 1_800_000),
},
)
.await
.unwrap();
assert!(linked_to(&pool, aired, rec).await, "on air during it");
for (id, why) in [
(waited, "never on air"),
(aired_before, "off air before it began"),
(aired_after, "on air after it ended"),
] {
assert!(!linked_to(&pool, id, rec).await, "{why}");
}
// The three unlinked consents follow the logger period from the call's end (42 days on
// this preset): 50 days ago, so they go at once; the linked one stays while its recording
// is kept, and for 42 days after.
let r = retention::sweep(&pool, st, &setting, Some(&dir), false)
.await
.unwrap();
assert_eq!(r.consents_deleted, 3, "{r:?}");
assert!(consent_exists(&pool, aired).await);
for id in [waited, aired_before, aired_after] {
assert!(!consent_exists(&pool, id).await);
}
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn an_open_recording_takes_a_caller_on_air_when_it_ends_and_one_still_on_air_counts() {
let (pool, st) = station("Kettle FM").await;
let dir = tmpdir("on-air-open");
let now = now_ms();
let on_air = record_consent(&pool, st, &consent("caller-eeeeeeeeeeee", now - 600_000))
.await
.unwrap();
let screened = record_consent(&pool, st, &consent("caller-ffffffffffff", now - 600_000))
.await
.unwrap();
// The recording is open and begins before the call; nobody had gone on air yet.
let rec = recording(
&pool,
st,
&dir,
RecordingKind::Programme,
"open.opus",
0,
None,
)
.await;
assert!(!linked_to(&pool, on_air, rec).await);
// They go on air, still on air as the recording ends: an open stretch overlaps it.
let synced = retention::sync_on_air(&pool, st, &["caller-eeeeeeeeeeee".to_owned()], 0)
.await
.unwrap();
assert_eq!((synced.opened, synced.closed), (1, 0));
assert!(
retention::end_recording(&pool, st, rec, now_ms())
.await
.unwrap()
);
assert!(linked_to(&pool, on_air, rec).await);
assert!(!linked_to(&pool, screened, rec).await);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn the_record_of_who_is_on_air_follows_the_desk_and_writing_it_twice_changes_nothing() {
let (pool, st) = station("Kettle FM").await;
let now = now_ms();
let a = "caller-111111111111";
let b = "caller-222222222222";
let gone = "caller-333333333333";
let ida = record_consent(&pool, st, &consent(a, now - 600_000))
.await
.unwrap();
let idb = record_consent(&pool, st, &consent(b, now - 600_000))
.await
.unwrap();
let open_stretches = |id: i64| {
let pool = pool.clone();
async move {
let (n,): (i64,) = sqlx::query_as(
"SELECT count(*) FROM consent_on_air WHERE consent_id = $1 AND to_at IS NULL",
)
.bind(id)
.fetch_one(&pool)
.await
.unwrap();
n
}
};
let both = [a.to_owned(), b.to_owned(), gone.to_owned()];
let done = retention::sync_on_air(&pool, st, &both, 0).await.unwrap();
assert_eq!((done.opened, done.closed), (2, 0));
assert_eq!(
done.no_consent,
vec![gone.to_owned()],
"no consent on file to record it against"
);
let again = retention::sync_on_air(&pool, st, &both, 0).await.unwrap();
assert_eq!(
(again.opened, again.closed),
(0, 0),
"the same state twice changes nothing"
);
assert_eq!(
(open_stretches(ida).await, open_stretches(idb).await),
(1, 1)
);
// `a` comes off; `b` stays on; `a` goes on again later: a second stretch.
let done = retention::sync_on_air(&pool, st, &[b.to_owned()], 0)
.await
.unwrap();
assert_eq!((done.opened, done.closed), (0, 1));
assert_eq!(open_stretches(ida).await, 0);
retention::sync_on_air(&pool, st, &[a.to_owned(), b.to_owned()], 0)
.await
.unwrap();
let (stretches,): (i64,) =
sqlx::query_as("SELECT count(*) FROM consent_on_air WHERE consent_id = $1")
.bind(ida)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stretches, 2);
// A call that ends closes its stretch, and the caller cannot go on air for it again.
retention::mark_call_ended(&pool, st, b).await.unwrap();
assert_eq!(open_stretches(idb).await, 0);
let done = retention::sync_on_air(&pool, st, &[a.to_owned(), b.to_owned()], 0)
.await
.unwrap();
assert_eq!(done.opened, 0);
assert_eq!(done.no_consent, vec![b.to_owned()]);
assert_eq!(open_stretches(idb).await, 0);
// The desk saying nobody is on air ends the rest.
let done = retention::sync_on_air(&pool, st, &[], 0).await.unwrap();
assert_eq!(done.closed, 1);
assert_eq!(open_stretches(ida).await, 0);
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn a_command_opens_a_stretch_a_poll_backdates_one_and_a_late_close_is_bounded() {
let (pool, st) = station("Kettle FM").await;
let now = now_ms();
let a = "caller-444444444444";
let b = "caller-555555555555";
let ida = record_consent(&pool, st, &consent(a, now - 600_000))
.await
.unwrap();
// `b` gave consent three days ago and the call never ended in the database (a crash).
let idb = record_consent(&pool, st, &consent(b, now - 3 * DAY))
.await
.unwrap();
let stretch = |id: i64| {
let pool = pool.clone();
async move {
let r: Vec<(f64, Option<f64>, f64)> = sqlx::query_as(
"SELECT extract(epoch FROM a.from_at)::double precision * 1000,
extract(epoch FROM a.to_at)::double precision * 1000,
extract(epoch FROM cc.accepted_at)::double precision * 1000
FROM consent_on_air a JOIN caller_consent cc ON cc.id = a.consent_id
WHERE a.consent_id = $1 ORDER BY a.id",
)
.bind(id)
.fetch_all(&pool)
.await
.unwrap();
r
}
};
// The command's write: once, then nothing more while it is open; no call, nothing.
assert!(retention::open_on_air(&pool, st, a).await.unwrap());
assert!(
!retention::open_on_air(&pool, st, a).await.unwrap(),
"already open"
);
assert!(
!retention::open_on_air(&pool, st, "caller-nobody")
.await
.unwrap()
);
// The poll that follows sees them on air: nothing new.
let done = retention::sync_on_air(&pool, st, &[a.to_owned()], 250)
.await
.unwrap();
assert_eq!(done.opened, 0);
assert_eq!(stretch(ida).await.len(), 1);
// A stretch the poll opens begins one poll earlier than the poll that saw it.
let before = now_ms() as f64;
retention::sync_on_air(&pool, st, &[a.to_owned(), b.to_owned()], 5_000)
.await
.unwrap();
let opened = stretch(idb).await;
assert_eq!(opened.len(), 1);
assert!(
opened[0].0 <= before - 4_000.0,
"backdated by the poll: {} against {before}",
opened[0].0
);
// A crash left `b`'s stretch open; it began two days ago. Closed now, it ends at the day
// after consent, not three days on.
sqlx::query("UPDATE consent_on_air SET from_at = now() - interval '2 days 23 hours' WHERE consent_id = $1")
.bind(idb)
.execute(&pool)
.await
.unwrap();
retention::sync_on_air(&pool, st, &[a.to_owned()], 250)
.await
.unwrap();
let closed = stretch(idb).await;
let (from, to, accepted) = (closed[0].0, closed[0].1.unwrap(), closed[0].2);
assert!(
(to - (accepted + DAY as f64)).abs() < 1_000.0,
"bounded by a day after consent"
);
assert!(to >= from);
// One that began after that bound ends where it began, never before.
sqlx::query("UPDATE consent_on_air SET to_at = NULL, from_at = now() - interval '1 hour' WHERE consent_id = $1")
.bind(idb)
.execute(&pool)
.await
.unwrap();
retention::sync_on_air(&pool, st, &[a.to_owned()], 250)
.await
.unwrap();
let late = stretch(idb).await;
assert_eq!(late[0].1, Some(late[0].0));
}
#[tokio::test]
#[ignore = "needs WEAVESIGNAL_TEST_DATABASE_URL"]
async fn a_hold_can_be_extended_once_and_again_but_never_to_a_date_not_later() {
let (pool, st) = station("Kettle FM").await;
let rule = retention::preset("ca_licensed").unwrap().complaint_hold;
let now = now_ms();
let held = retention::place_hold(
&pool,
st,
&NewHold {
reason: "CRTC complaint about the 14:00 phone-in".into(),
by: "Iain Gunn".into(),
from_ms: Some(now - 100 * DAY),
to_ms: Some(now - 100 * DAY + 3_600_000),
records: vec![],
},
)
.await
.unwrap();
let ext = |until_ms: u64| retention::Extension {
until_ms,
by: "Morag Ross".into(),
reason: "The regulator asked for the tapes again".into(),
};
// Past its 56-day date: flagged until it is extended.
assert!(held.past_date(rule.as_ref(), now));
assert_eq!(held.extended_until_ms, None);
// Not later than the date it has (44 days ago), not in the future, or past any date the
// database keeps: refused, with a sentence and not a database error.
for bad in [now - 60 * DAY, now - 1000, 200 * 366 * DAY + now, u64::MAX] {
assert!(matches!(
retention::extend_hold(&pool, st, held.id, &ext(bad), rule.as_ref(), now).await,
Err(retention::HoldError::Invalid(_))
));
}
let until = now + 30 * DAY;
let h = retention::extend_hold(&pool, st, held.id, &ext(until), rule.as_ref(), now)
.await
.unwrap();
assert!(h.extended_until_ms.is_some_and(|d| d.abs_diff(until) < 5));
assert_eq!(h.extended_by.as_deref(), Some("Morag Ross"));
assert!(h.extended_at_ms.is_some() && h.extend_reason.is_some());
assert!(
!h.past_date(rule.as_ref(), now),
"extending clears the flag"
);
assert!(
h.past_date(rule.as_ref(), now + 31 * DAY),
"and it returns after the new date"
);
// The hold is the same: still open, same reason, nothing released.
assert_eq!((h.id, h.reason.as_str()), (held.id, held.reason.as_str()));
assert!(h.released_at_ms.is_none());
// The flag listing agrees.
let setting = Setting {
preset: "ca_licensed".into(),
..Setting::default()
};
assert!(
retention::holds_past_date(&pool, st, &setting, now)
.await
.unwrap()
.is_empty()
);
assert_eq!(
retention::holds_past_date(&pool, st, &setting, now + 31 * DAY)
.await
.unwrap()
.len(),
1
);
// Again, later; not to the same date or earlier.
assert!(matches!(
retention::extend_hold(&pool, st, held.id, &ext(until), rule.as_ref(), now).await,
Err(retention::HoldError::Invalid(_))
));
let h = retention::extend_hold(
&pool,
st,
held.id,
&ext(until + 30 * DAY),
rule.as_ref(),
now,
)
.await
.unwrap();
assert!(
h.extended_until_ms
.is_some_and(|d| d.abs_diff(until + 30 * DAY) < 5)
);
// Empty words are refused; a released hold cannot be extended; neither can another
// station's, nor one that is not there.
let blank = retention::Extension {
by: " ".into(),
..ext(until + 60 * DAY)
};
assert!(matches!(
retention::extend_hold(&pool, st, held.id, &blank, rule.as_ref(), now).await,
Err(retention::HoldError::Invalid(_))
));
let (_, other) = station("Other FM").await;
assert!(matches!(
retention::extend_hold(
&pool,
other,
held.id,
&ext(until + 60 * DAY),
rule.as_ref(),
now
)
.await,
Err(retention::HoldError::NotFound(_))
));
retention::release_hold(&pool, st, held.id, "Morag Ross", "Complaint closed")
.await
.unwrap();
assert!(matches!(
retention::extend_hold(
&pool,
st,
held.id,
&ext(until + 60 * DAY),
rule.as_ref(),
now
)
.await,
Err(retention::HoldError::Released(_))
));
}