LCOV - code coverage report
Current view: top level - antares-api/src - snapshots.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 95.5 % 1410 1347
Test Date: 2026-09-21 10:31:06 Functions: 59.4 % 503 299

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! 5.16 Snapshots (optional API group; resources 6.36 /snapshots,
       3              : //! 6.37 /snapshots/{id}, 6.38 /snapshots/{id}/clone; scoping 6.3.22).
       4              : //!
       5              : //! A Snapshot freezes the results of a set of queries (5.2.41) into an
       6              : //! isolated copy on which Core + Temporal API operations run implicitly
       7              : //! local (5.5.15). Implementation shape: each snapshot owns a synthetic
       8              : //! internal tenant ("snap-…"); the 6.3.22 NGSILD-Snapshot header is resolved
       9              : //! by a middleware that rewrites the request's tenant, so every existing
      10              : //! Core/Temporal handler serves snapshot content unchanged — and, because
      11              : //! no registrations exist under the synthetic tenant, all operations are
      12              : //! naturally local.
      13              : //!
      14              : //! Snapshot metadata lives in the store (Kind::Snapshot, ADR-0012) —
      15              : //! persistent modes serve snapshots across restarts; 5.5.15 still allows
      16              : //! dropping them under resource pressure (evict_over_cap).
      17              : //! Fills follow the 5.7.2.4 distributed path and page past max_limit, up to
      18              : //! the 5.5.6 result ceiling (fill_cap) and only for as long as the snapshot
      19              : //! exists — a fill whose snapshot was deleted or evicted frees its copy
      20              : //! (fill_cancelled), which nothing else could reach.
      21              : //! Resource pressure evicts lowest-priority snapshots (evict_over_cap).
      22              : 
      23              : use crate::negotiate::{
      24              :     check_params, created, no_content, parse_accept, parse_body, respond, single_header,
      25              :     tenant_from, ApiError, ApiResult, BodyKind, CleanParams,
      26              : };
      27              : use crate::state::{now_iso, AppState};
      28              : use antares_model::{NgsiError, TenantId, API_ROOT};
      29              : use antares_store::CurrentStateDriverExt;
      30              : use antares_store::Kind;
      31              : use axum::body::Bytes;
      32              : use axum::extract::{Path, State};
      33              : use axum::http::{HeaderMap, StatusCode};
      34              : use axum::response::{IntoResponse, Response};
      35              : use serde_json::{json, Map, Value};
      36              : use std::collections::HashMap;
      37              : 
      38              : const DEFAULT_LIFETIME_SECS: i64 = 86_400; // 1 day
      39              : const MAX_LIFETIME_SECS: i64 = 604_800; // 7 days — the "configured limit"
      40              : 
      41          106 : fn bad(m: String) -> NgsiError {
      42          106 :     NgsiError::BadRequestData(m)
      43          106 : }
      44              : 
      45              : /// 5.5.6: an unexpected failure while filling a snapshot surfaces as
      46              : /// InternalError. Its detail reaches the client twice — in
      47              : /// snapshotQueriesDetails.problemDetails (5.2.41) and in the 5.3.4
      48              : /// SnapshotNotification body — so the internal error text stays in the
      49              : /// server log and the client-visible detail is generic.
      50            4 : fn opaque(what: &str, e: &dyn std::fmt::Debug) -> NgsiError {
      51            4 :     tracing::error!("snapshot {what} failed: {e:?}");
      52            4 :     NgsiError::InternalError(format!("{what} failed"))
      53            4 : }
      54              : 
      55              : /// 5.2.41: expiresAt from the suggested snapshotLifetime, bounded by the
      56              : /// system limit (5.16.1.4 "applying the configured limit").
      57          144 : fn expires_at(meta: &Map<String, Value>) -> Result<String, NgsiError> {
      58          144 :     let secs = match meta.get("snapshotLifetime").and_then(Value::as_str) {
      59            6 :         Some(d) => crate::entity_map::iso8601_secs(d)
      60            6 :             .ok_or_else(|| {
      61            0 :                 bad(format!(
      62              :                     "snapshotLifetime is not an ISO 8601 duration: {d:?}"
      63              :                 ))
      64            0 :             })?
      65              :             // A zero or negative suggestion would answer 201 with a Location
      66              :             // that is already expired, so the broker floor applies as well as
      67              :             // the ceiling — the same reasoning the EntityMap lifetime carries.
      68            6 :             .clamp(1, MAX_LIFETIME_SECS),
      69          138 :         None => DEFAULT_LIFETIME_SECS,
      70              :     };
      71          144 :     Ok((chrono::Utc::now() + chrono::Duration::seconds(secs))
      72          144 :         .to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
      73          144 : }
      74              : 
      75         1261 : fn expired(meta: &Value) -> bool {
      76         1261 :     meta.get("expiresAt")
      77         1261 :         .and_then(Value::as_str)
      78         1261 :         .and_then(|e| chrono::DateTime::parse_from_rfc3339(e).ok())
      79         1261 :         .is_some_and(|e| e < chrono::Utc::now())
      80         1261 : }
      81              : 
      82              : /// The internal tenant holding the synth-tenant -> (owner, snapshot id)
      83              : /// index docs (durable reverse lookup for 6.3.22 notification stamping).
      84          184 : fn snap_index_tenant() -> Option<TenantId> {
      85          184 :     TenantId::new_internal("snap-index").ok()
      86          184 : }
      87              : 
      88              : /// 5.2.41: a Snapshot's type is fixed to "Snapshot". The reverse-index
      89              : /// marker docs share Kind::Snapshot storage but are not Snapshots, so every
      90              : /// place that reads a document as one filters on this — a listing that does
      91              : /// not would count and select the markers as snapshots.
      92         1439 : fn is_snapshot(meta: &Value) -> bool {
      93         1439 :     meta.get("type").and_then(Value::as_str) == Some("Snapshot")
      94         1439 : }
      95              : 
      96              : /// Registry access with lazy expiry: an expired snapshot is gone to every
      97              : /// caller. Reading is a read — the meta row, its reverse-index entry and
      98              : /// the synthetic tenant's data are freed by `sweep_expired_snapshots` on
      99              : /// the sweep tick, not by the request that found the expiry. Snapshot docs
     100              : /// live in the store (Kind::Snapshot) so restarts keep them on persistent
     101              : /// store modes.
     102          922 : pub(crate) async fn snap_get(st: &AppState, tenant: &TenantId, id: &str) -> Option<Value> {
     103          922 :     st.store
     104          922 :         .get(tenant, Kind::Snapshot, id)
     105          922 :         .await
     106          922 :         .ok()
     107          922 :         .flatten()
     108          922 :         .filter(|meta| is_snapshot(meta) && !expired(meta))
     109          922 : }
     110              : 
     111              : /// 5.16.1.4: "If the NGSI-LD endpoint already knows about this Snapshot …
     112              : /// an error of type AlreadyExists shall be raised." The store's create is
     113              : /// the atomic check-and-insert, so two concurrent creates of the same id
     114              : /// cannot both succeed and overwrite each other's synthetic tenant.
     115          140 : async fn snap_insert(
     116          140 :     st: &AppState,
     117          140 :     tenant: &TenantId,
     118          140 :     id: &str,
     119          140 :     meta: &Value,
     120          140 : ) -> Result<(), NgsiError> {
     121          140 :     if !st
     122          140 :         .store
     123          140 :         .create(tenant, Kind::Snapshot, id, meta.clone())
     124          140 :         .await?
     125              :     {
     126            4 :         return Err(NgsiError::AlreadyExists(format!(
     127            4 :             "snapshot {id} already exists"
     128            4 :         )));
     129          136 :     }
     130              :     // Durable reverse index for snapshot_of_synth. The marker carries the
     131              :     // synthetic tenant as its `id` as well as its key: it is the only record
     132              :     // that names that tenant once the Snapshot document is gone, and a
     133              :     // document a listing cannot identify is one nothing can walk.
     134          136 :     if let (Some(synth), Some(idx)) = (
     135          136 :         meta.get("__tenant").and_then(Value::as_str),
     136          136 :         snap_index_tenant(),
     137              :     ) {
     138          136 :         let _ = st
     139          136 :             .store
     140          136 :             .create(
     141          136 :                 &idx,
     142          136 :                 Kind::Snapshot,
     143          136 :                 synth,
     144          136 :                 json!({"id": synth, "tenant": tenant.as_str(), "snapshot": id}),
     145          136 :             )
     146          136 :             .await;
     147            0 :     }
     148          136 :     Ok(())
     149          140 : }
     150              : 
     151              : /// Write back the output-only members (5.2.41 Table 5.2.41-2) of a snapshot
     152              : /// that already exists; a snapshot deleted meanwhile is not resurrected.
     153          142 : async fn snap_put(st: &AppState, tenant: &TenantId, meta: Value) {
     154          142 :     let id = meta
     155          142 :         .get("id")
     156          142 :         .and_then(Value::as_str)
     157          142 :         .unwrap_or_default()
     158          142 :         .to_owned();
     159          142 :     let _ = st
     160          142 :         .store
     161          142 :         .mutate(tenant, Kind::Snapshot, &id, |d| {
     162          142 :             *d = meta.clone();
     163          142 :             Ok::<_, std::convert::Infallible>(())
     164          142 :         })
     165          142 :         .await;
     166          142 : }
     167              : 
     168              : /// Remove a snapshot everywhere: doc, synth-tenant index, data purge.
     169           44 : pub(crate) async fn snap_remove(st: &AppState, tenant: &TenantId, id: &str, meta: &Value) {
     170           44 :     let _ = st.store.delete(tenant, Kind::Snapshot, id).await;
     171           44 :     purge_data_bg(st, meta);
     172           44 : }
     173              : 
     174              : /// 4.22 for snapshots: `snap_get` refuses an expired one, and this is what
     175              : /// removes it — meta, reverse-index entry and the synthetic tenant's data,
     176              : /// through the same `snap_remove` a read used to call, so an expiry
     177              : /// collected on the tick frees exactly what an expiry collected on a read
     178              : /// freed.
     179        20062 : pub(crate) async fn sweep_expired_snapshots(st: &AppState, tenant: &TenantId) -> usize {
     180        20062 :     let mut dead: Vec<(String, Value)> = Vec::new();
     181        20062 :     if crate::csource::walk_docs(st, tenant, Kind::Snapshot, |doc| {
     182          411 :         if is_snapshot(&doc) && expired(&doc) {
     183            8 :             if let Some(id) = doc.get("id").and_then(Value::as_str) {
     184            8 :                 dead.push((id.to_owned(), doc));
     185            8 :             }
     186          403 :         }
     187          411 :         Ok(())
     188          411 :     })
     189        20062 :     .await
     190        20062 :     .is_err()
     191              :     {
     192            0 :         return 0;
     193        20062 :     }
     194        20062 :     let n = dead.len();
     195        20062 :     for (id, meta) in dead {
     196            8 :         snap_remove(st, tenant, &id, &meta).await;
     197              :     }
     198        20062 :     n
     199        20062 : }
     200              : 
     201          276 : fn synth_tenant(meta: &Value) -> Option<TenantId> {
     202          276 :     meta.get("__tenant")
     203          276 :         .and_then(Value::as_str)
     204          276 :         .and_then(|t| TenantId::new_internal(t).ok())
     205          276 : }
     206              : 
     207              : /// Free the snapshot's isolated copy. 5.5.15 permits every Core and
     208              : /// Temporal operation on a snapshot, so a snapshot-scoped request can put
     209              : /// documents of ANY kind under the synthetic tenant — once the snapshot is
     210              : /// gone none of them is reachable (no client may name the internal tenant),
     211              : /// so every kind is dropped, not only the filled data.
     212              : ///
     213              : /// The tenant purge, not a per-document sweep: it covers every kind rather
     214              : /// than a list that has to be kept in step with `Kind`, and it removes the
     215              : /// tenant itself. A sweep leaves the empty tenant behind, and `/q/tenants`
     216              : /// reports what the store holds a tenant entry for — so every snapshot ever
     217              : /// deleted would leave a `snap-<uuid>` name in the inventory for the life of
     218              : /// the deployment.
     219           48 : async fn purge_synth(st: &AppState, synth: &TenantId) {
     220              :     // history lives behind its own driver seam
     221           48 :     let _ = st.temporal.purge_tenant(synth).await;
     222           48 :     let _ = st.store.purge_tenant(synth).await;
     223           48 : }
     224              : 
     225              : /// The teardown that outlives the request: the copy is dropped, and only
     226              : /// then the reverse-index entry that named the synthetic tenant. That order
     227              : /// is the recovery path — the Snapshot document is already gone, so the index
     228              : /// entry is the last thing that says which tenant holds the copy, and a
     229              : /// broker that stops mid-teardown leaves it pointing at data still there
     230              : /// rather than data nothing names.
     231           44 : fn purge_data_bg(st: &AppState, meta: &Value) {
     232           44 :     let Some(synth) = synth_tenant(meta) else {
     233            0 :         return;
     234              :     };
     235           44 :     let st = st.clone();
     236           44 :     crate::spawn(async move {
     237           44 :         purge_synth(&st, &synth).await;
     238           44 :         if let Some(idx) = snap_index_tenant() {
     239           44 :             let _ = st.store.delete(&idx, Kind::Snapshot, synth.as_str()).await;
     240            0 :         }
     241           44 :     });
     242           44 : }
     243              : 
     244              : /// The snapshot document as presented to clients (internal members hidden).
     245          170 : fn present(meta: &Value) -> Value {
     246          170 :     let mut out = meta.clone();
     247          170 :     crate::policy::strip_internal(&mut out);
     248          170 :     out
     249          170 : }
     250              : 
     251              : enum Mode {
     252              :     Create,
     253              :     Clone,
     254              :     Update,
     255              : }
     256              : 
     257              : /// 5.2.41 Table 5.2.41-1/-2 validation. Output-only members are IGNORED
     258              : /// (stripped); read-only members in the wrong mode are BadRequestData.
     259          212 : fn validate(body: &Value, mode: Mode) -> Result<Map<String, Value>, NgsiError> {
     260          212 :     let mut o = body
     261          212 :         .as_object()
     262          212 :         .cloned()
     263          212 :         .ok_or_else(|| bad("snapshot must be a JSON object".into()))?;
     264          212 :     o.remove("@context");
     265              :     // the broker's own members (the synthetic tenant, the creating subject)
     266              :     // are not the client's to send — see policy::strip_internal
     267          452 :     o.retain(|k, _| !k.starts_with("__"));
     268              :     // output-only members (Table 5.2.41-2) "shall be ignored"
     269         1484 :     for k in [
     270          212 :         "snapshotStatus",
     271          212 :         "snapshotQueriesDetails",
     272          212 :         "snapshotTemporalQueriesDetails",
     273          212 :         "createdAt",
     274          212 :         "modifiedAt",
     275          212 :         "expiresAt",
     276          212 :         "lastUsedAt",
     277         1484 :     ] {
     278         1484 :         o.remove(k);
     279         1484 :     }
     280          212 :     match mode {
     281              :         Mode::Create => {
     282          168 :             if o.get("type").and_then(Value::as_str) != Some("Snapshot") {
     283            2 :                 return Err(bad("type must be \"Snapshot\" (5.2.41)".into()));
     284          166 :             }
     285          166 :             if !o.contains_key("snapshotQueries") && !o.contains_key("snapshotTemporalQueries") {
     286           12 :                 return Err(bad(
     287           12 :                     "at least one of snapshotQueries or snapshotTemporalQueries \
     288           12 :                      shall be present (5.2.41)"
     289           12 :                         .into(),
     290           12 :                 ));
     291          154 :             }
     292              :         }
     293              :         Mode::Clone | Mode::Update => {
     294              :             // "both shall be omitted when updating the Snapshot status or
     295              :             // cloning the Snapshot" — read-only after creation
     296           44 :             if o.contains_key("snapshotQueries") || o.contains_key("snapshotTemporalQueries") {
     297            8 :                 return Err(bad(
     298            8 :                     "snapshotQueries/snapshotTemporalQueries are read-only after \
     299            8 :                      creation (5.2.41)"
     300            8 :                         .into(),
     301            8 :                 ));
     302           36 :             }
     303              :             // Table 5.2.41-1: the id "cannot be later modified in update
     304              :             // operations" — of whatever JSON type it is sent as
     305           36 :             if matches!(mode, Mode::Update) && o.contains_key("id") {
     306            8 :                 return Err(bad("the snapshot id cannot be modified (5.2.41)".into()));
     307           28 :             }
     308              :         }
     309              :     }
     310          358 :     for (key, temporal) in [
     311          182 :         ("snapshotQueries", false),
     312          182 :         ("snapshotTemporalQueries", true),
     313          182 :     ] {
     314          358 :         if let Some(qs) = o.get(key) {
     315          158 :             let arr = qs
     316          158 :                 .as_array()
     317          158 :                 .filter(|a| !a.is_empty())
     318          158 :                 .ok_or_else(|| bad(format!("{key} must be a non-empty array of Query (5.2.41)")))?;
     319          158 :             for q in arr {
     320          158 :                 let qo = q
     321          158 :                     .as_object()
     322          158 :                     .ok_or_else(|| bad(format!("{key} entries must be Query objects (5.2.23)")))?;
     323          158 :                 if qo.get("type").and_then(Value::as_str) != Some("Query") {
     324            0 :                     return Err(bad(format!("{key} entries must have type Query (5.2.23)")));
     325          158 :                 }
     326          158 :                 if temporal != qo.contains_key("temporalQ") {
     327            6 :                     return Err(bad(format!(
     328              :                         "{key} entries must {} a temporalQ element (5.2.41)",
     329            6 :                         if temporal { "carry" } else { "not carry" }
     330              :                     )));
     331          152 :                 }
     332              :             }
     333          200 :         }
     334              :     }
     335          176 :     if let Some(p) = o.get("snapshotPriority") {
     336           46 :         let ok = p.as_i64().is_some_and(|n| (1..=10).contains(&n));
     337           46 :         if !ok {
     338            2 :             return Err(bad(
     339            2 :                 "snapshotPriority must be an integer between 1 and 10 (5.2.41)".into(),
     340            2 :             ));
     341           44 :         }
     342          130 :     }
     343          174 :     if let Some(l) = o.get("snapshotLifetime") {
     344            6 :         let ok = l
     345            6 :             .as_str()
     346            6 :             .and_then(crate::entity_map::iso8601_secs)
     347            6 :             .is_some();
     348            6 :         if !ok {
     349            4 :             return Err(bad(
     350            4 :                 "snapshotLifetime must be an ISO 8601 duration (5.2.41)".into(),
     351            4 :             ));
     352            2 :         }
     353          168 :     }
     354              :     // Table 5.2.41-1: endpoint is a "Dereferenceable URI", so a string that is
     355              :     // no URI at all names nothing the 5.16.6 notification could be sent to.
     356          170 :     if let Some(e) = o.get("endpoint") {
     357           12 :         let uri = e
     358           12 :             .as_str()
     359           12 :             .ok_or_else(|| bad("endpoint must be a URI string (5.2.41)".into()))?;
     360           12 :         antares_model::EntityId::new(uri)
     361           12 :             .map_err(|_| bad(format!("endpoint is not a valid URI: {uri:?} (5.2.41)")))?;
     362          158 :     }
     363              :     // Table 5.2.41-1: receiverInfo is a KeyValuePair[] (5.2.22 — both members
     364              :     // Strings), and 5.16.6 renders each pair as one header on the
     365              :     // SnapshotNotification POST. 6.3.8 binds those to RFC 7230 for the same
     366              :     // reason it binds a Subscription's: a pair that cannot be a header leaves
     367              :     // a Snapshot whose notification can only ever fail, silently.
     368          168 :     if let Some(ri) = o.get("receiverInfo") {
     369            8 :         let pairs = ri
     370            8 :             .as_array()
     371            8 :             .ok_or_else(|| bad("receiverInfo must be a KeyValuePair array (5.2.41)".into()))?;
     372            6 :         for kv in pairs {
     373            6 :             let (k, v) = (kv["key"].as_str(), kv["value"].as_str());
     374            6 :             if !k.is_some_and(crate::negotiate::is_field_name)
     375            4 :                 || !v.is_some_and(crate::negotiate::is_field_value)
     376              :             {
     377            6 :                 return Err(bad(format!(
     378            6 :                     "receiverInfo entry {kv} is not a valid HTTP header (RFC 7230, 6.3.8)"
     379            6 :                 )));
     380            0 :             }
     381              :         }
     382          160 :     }
     383          160 :     Ok(o)
     384          212 : }
     385              : 
     386              : /// Fresh metadata for a new snapshot (create or clone) — 5.16.1.4/5.16.2.4:
     387              : /// timestamps now, status "preparing", priority default 5, bounded expiresAt.
     388          140 : fn new_meta(
     389          140 :     mut o: Map<String, Value>,
     390          140 :     tenant: &TenantId,
     391          140 :     headers: &HeaderMap,
     392          140 : ) -> Result<(String, Value), NgsiError> {
     393          140 :     let id = match o.get("id").and_then(Value::as_str) {
     394           14 :         Some(id) => {
     395           14 :             antares_model::EntityId::new(id)
     396           14 :                 .map_err(|_| bad(format!("snapshot id is not a valid URI: {id:?}")))?;
     397           14 :             id.to_owned()
     398              :         }
     399              :         None => {
     400          126 :             let id = format!("urn:ngsi-ld:Snapshot:{}", uuid::Uuid::new_v4());
     401          126 :             o.insert("id".into(), Value::String(id.clone()));
     402          126 :             id
     403              :         }
     404              :     };
     405          140 :     let ts = now_iso();
     406          140 :     o.insert("type".into(), Value::String("Snapshot".into()));
     407          140 :     o.insert("createdAt".into(), Value::String(ts.clone()));
     408          140 :     o.insert("modifiedAt".into(), Value::String(ts));
     409          140 :     o.insert("snapshotStatus".into(), Value::String("preparing".into()));
     410              :     // 5.2.41 Table 5.2.41-2: lastUsedAt "is initialized at creation time"
     411          140 :     o.insert("lastUsedAt".into(), Value::String(now_iso()));
     412          140 :     o.insert("expiresAt".into(), Value::String(expires_at(&o)?));
     413          140 :     o.entry("snapshotPriority".to_owned())
     414          140 :         .or_insert(Value::Number(5.into()));
     415          140 :     o.insert(
     416          140 :         "__tenant".into(),
     417          140 :         Value::String(format!("snap-{}", uuid::Uuid::new_v4().simple())),
     418              :     );
     419              :     // Whose snapshot this is. 5.16.1's fill runs after the request has been
     420              :     // answered, so the subject it copies under has to be recorded here or it
     421              :     // is gone; and 5.16.1/5.16.2/5.16.7 act on everything the tenant holds,
     422              :     // which is why a narrowing answer to any of them is a refusal
     423              :     // (policy::WHOLE_TENANT) rather than a smaller snapshot.
     424            6 :     if let Some(subject) =
     425          140 :         crate::policy::subject_member(&crate::policy::subject_of(tenant, headers))
     426            6 :     {
     427            6 :         o.insert(crate::policy::SUBJECT_MEMBER.into(), subject);
     428          134 :     }
     429          140 :     Ok((id, Value::Object(o)))
     430          140 : }
     431              : 
     432              : /// 5.2.41: lastUsedAt tracks "the point in time when the snapshot was most
     433              : /// recently used" — refreshed on every snapshot-scoped operation.
     434           76 : async fn snap_touch(st: &AppState, tenant: &TenantId, id: &str) {
     435           76 :     let _ = st
     436           76 :         .store
     437           76 :         .mutate(tenant, Kind::Snapshot, id, |meta| {
     438           76 :             if let Some(o) = meta.as_object_mut() {
     439           76 :                 o.insert("lastUsedAt".into(), Value::String(now_iso()));
     440           76 :             }
     441           76 :             Ok::<_, std::convert::Infallible>(())
     442           76 :         })
     443           76 :         .await;
     444           76 : }
     445              : 
     446              : /// Reverse lookup: which (owner tenant, snapshot id) does a synthetic
     447              : /// "snap-…" tenant belong to? Used to stamp NGSILD-Snapshot on
     448              : /// notifications from snapshot-scoped subscriptions (6.3.22) without
     449              : /// leaking the internal tenant.
     450         1068 : pub(crate) async fn snapshot_of_synth(st: &AppState, synth: &str) -> Option<(TenantId, String)> {
     451              :     // only synthetic tenants are indexed; every other tenant skips the lookup
     452         1068 :     if !synth.starts_with("snap-") {
     453         1064 :         return None;
     454            4 :     }
     455            4 :     let idx = snap_index_tenant()?;
     456            4 :     let doc = st
     457            4 :         .store
     458            4 :         .get(&idx, Kind::Snapshot, synth)
     459            4 :         .await
     460            4 :         .ok()
     461            4 :         .flatten()?;
     462            4 :     let owner = TenantId::new(doc.get("tenant")?.as_str()?).ok()?;
     463            4 :     Some((owner, doc.get("snapshot")?.as_str()?.to_owned()))
     464         1068 : }
     465              : 
     466              : /// The tenant an operation is judged under. 6.3.22 scopes a request to a
     467              : /// Snapshot by swapping the tenant for the snapshot's internal one, so the
     468              : /// handlers below it serve the frozen copy; the subject asking is still the
     469              : /// owner tenant's. An engine keyed by tenant — which is the shape every
     470              : /// rule set has — would otherwise be asked about an id no deployment ever
     471              : /// wrote a rule for, and every snapshot-scoped read would step out from
     472              : /// under its rules. Only a synthetic tenant costs the lookup.
     473          684 : pub(crate) async fn asking_tenant(st: &AppState, tenant: &TenantId) -> TenantId {
     474          684 :     match snapshot_of_synth(st, tenant.as_str()).await {
     475            2 :         Some((owner, _)) => owner,
     476          682 :         None => tenant.clone(),
     477              :     }
     478          684 : }
     479              : 
     480              : /// 5.5.15: "If an implementation determines that it is low on resources,
     481              : /// it may delete one or more snapshots", considering snapshotPriority
     482              : /// (lowest first; earliest expiresAt breaks ties). The resource signal is
     483              : /// the per-tenant registry cap (AppState.snapshot_cap); the just-created
     484              : /// snapshot is never the victim. Evicted snapshots with an endpoint are
     485              : /// notified with expiresAt set before notifiedAt — the 5.3.4 deletion
     486              : /// encoding.
     487          136 : async fn evict_over_cap(st: &AppState, tenant: &TenantId, keep: &str) {
     488            4 :     let victims: Vec<Value> = {
     489          136 :         let mut metas: Vec<Value> = st
     490          136 :             .store
     491          136 :             .list(tenant, Kind::Snapshot)
     492          136 :             .await
     493          136 :             .unwrap_or_default()
     494          136 :             .into_iter()
     495          136 :             .filter(is_snapshot)
     496          136 :             .collect();
     497          136 :         if metas.len() <= st.snapshot_cap {
     498          132 :             return;
     499            4 :         }
     500            4 :         let over = metas.len() - st.snapshot_cap;
     501           22 :         metas.sort_by_key(|v| {
     502           22 :             (
     503           22 :                 v.get("snapshotPriority")
     504           22 :                     .and_then(Value::as_i64)
     505           22 :                     .unwrap_or(5),
     506           22 :                 v.get("expiresAt")
     507           22 :                     .and_then(Value::as_str)
     508           22 :                     .unwrap_or("")
     509           22 :                     .to_owned(),
     510           22 :             )
     511           22 :         });
     512            4 :         metas
     513            4 :             .into_iter()
     514            4 :             .filter(|v| v.get("id").and_then(Value::as_str) != Some(keep))
     515            4 :             .take(over)
     516            4 :             .collect()
     517              :     };
     518            4 :     for mut meta in victims {
     519            4 :         if let Some(id) = meta.get("id").and_then(Value::as_str).map(str::to_owned) {
     520            4 :             snap_remove(st, tenant, &id, &meta).await;
     521            0 :         }
     522            4 :         if let Some(o) = meta.as_object_mut() {
     523            4 :             // deletion signal: expiresAt strictly before the notification
     524            4 :             o.insert("expiresAt".into(), Value::String(now_iso()));
     525            4 :         }
     526            4 :         let st2 = st.clone();
     527            4 :         crate::spawn(async move {
     528            4 :             send_notification(&st2, &meta).await;
     529            4 :         });
     530              :     }
     531          136 : }
     532              : 
     533              : // ---------- 5.16.1 Create Snapshot (POST /snapshots, 6.36.3.1) ----------
     534              : 
     535          178 : pub async fn create_snapshot(
     536          178 :     State(st): State<AppState>,
     537          178 :     CleanParams(params): CleanParams,
     538          178 :     headers: HeaderMap,
     539          178 :     body: Bytes,
     540          178 : ) -> Response {
     541          178 :     let go = async {
     542          178 :         let tenant = tenant_from(&headers)?;
     543          178 :         check_params(&params, &["local"])?;
     544          178 :         gate!(st, &tenant, &headers, "5.16.1").await?;
     545              :         // 6.3.5: the @context rules apply to the snapshot body like to any
     546              :         // other POST payload (media type, Link header, body @context)
     547          176 :         let v = parse_body(&st.loader, &headers, &body, BodyKind::Standard)
     548          176 :             .await?
     549              :             .value;
     550          164 :         let o = validate(&v, Mode::Create)?;
     551          132 :         let (id, meta) = new_meta(o, &tenant, &headers)?;
     552          132 :         snap_insert(&st, &tenant, &id, &meta).await?;
     553          128 :         evict_over_cap(&st, &tenant, &id).await;
     554          128 :         let (st2, t2, id2) = (st.clone(), tenant.clone(), id.clone());
     555          128 :         crate::spawn(async move {
     556          128 :             fill_snapshot(&st2, &t2, &id2).await;
     557          128 :         });
     558          128 :         Ok::<_, ApiError>(created(format!("/ngsi-ld/v1/snapshots/{id}"), &tenant))
     559          178 :     };
     560          178 :     go.await.unwrap_or_else(|e| e.into_response())
     561          178 : }
     562              : 
     563              : /// 5.16.1.4 background fill: execute every (temporal) query, store the
     564              : /// results under the synthetic tenant, derive the status, notify.
     565          128 : async fn fill_snapshot(st: &AppState, tenant: &TenantId, id: &str) {
     566          128 :     let Some(meta) = snap_get(st, tenant, id).await else {
     567            0 :         return;
     568              :     };
     569          128 :     let Some(synth) = synth_tenant(&meta) else {
     570            0 :         return;
     571              :     };
     572          128 :     let ctx = st.loader.core();
     573          128 :     let (mut n_fail, mut n_res, mut n_empty, mut copied) = (0usize, 0usize, 0usize, 0usize);
     574          132 :     let mut detail = |r: Result<usize, NgsiError>| -> Value {
     575          132 :         match r {
     576              :             Ok(0) => {
     577           46 :                 n_empty += 1;
     578           46 :                 json!({"resultStatus": "empty"})
     579              :             }
     580              :             Ok(_) => {
     581           80 :                 n_res += 1;
     582           80 :                 json!({"resultStatus": "success"})
     583              :             }
     584            6 :             Err(e) => {
     585            6 :                 n_fail += 1;
     586            6 :                 json!({"resultStatus": "failure",
     587            6 :                        "problemDetails": crate::negotiate::problem_value(&e)})
     588              :             }
     589              :         }
     590          132 :     };
     591          128 :     let cap = fill_cap(st);
     592          128 :     let mut q_details = Vec::new();
     593          128 :     for q in meta
     594          128 :         .get("snapshotQueries")
     595          128 :         .and_then(Value::as_array)
     596          128 :         .into_iter()
     597          128 :         .flatten()
     598              :     {
     599          122 :         if fill_cancelled(st, tenant, id, &synth).await {
     600            0 :             return;
     601          122 :         }
     602          122 :         let r = run_query(st, tenant, &synth, q, &ctx, cap - copied).await;
     603          122 :         if let Ok(n) = &r {
     604          116 :             copied += n;
     605          116 :         }
     606          122 :         q_details.push(detail(r));
     607              :     }
     608          128 :     let mut tq_details = Vec::new();
     609          128 :     for q in meta
     610          128 :         .get("snapshotTemporalQueries")
     611          128 :         .and_then(Value::as_array)
     612          128 :         .into_iter()
     613          128 :         .flatten()
     614              :     {
     615           10 :         if fill_cancelled(st, tenant, id, &synth).await {
     616            0 :             return;
     617           10 :         }
     618           10 :         let r = run_temporal_query(st, tenant, &synth, id, q, cap - copied).await;
     619           10 :         if let Ok(n) = &r {
     620           10 :             copied += n;
     621           10 :         }
     622           10 :         tq_details.push(detail(r));
     623              :     }
     624          128 :     if fill_cancelled(st, tenant, id, &synth).await {
     625            0 :         return;
     626          128 :     }
     627          128 :     let status = if n_fail == 0 && n_empty == 0 && n_res > 0 {
     628           76 :         "success"
     629           52 :     } else if n_res > 0 {
     630            0 :         "partial"
     631           52 :     } else if n_empty > 0 {
     632           46 :         "empty"
     633              :     } else {
     634            6 :         "failure"
     635              :     };
     636          128 :     finish(st, tenant, id, status, Some((q_details, tq_details))).await;
     637          128 : }
     638              : 
     639              : /// One 5.2.23 Query executed per the DISTRIBUTED query behaviour
     640              : /// (5.16.1.4 -> 5.7.2.4): local content plus every matching Context
     641              : /// Source; results are copied into the snapshot's synthetic tenant. Every
     642              : /// page is retrieved ("all pages are to be retrieved completely") within
     643              : /// `budget` documents — beyond it nothing is copied (5.5.6).
     644          122 : async fn run_query(
     645          122 :     st: &AppState,
     646          122 :     tenant: &TenantId,
     647          122 :     synth: &TenantId,
     648          122 :     q: &Value,
     649          122 :     ctx: &antares_jsonld::Context,
     650          122 :     budget: usize,
     651          122 : ) -> Result<usize, NgsiError> {
     652          122 :     let qo = q
     653          122 :         .as_object()
     654          122 :         .ok_or_else(|| bad("Query must be an object".into()))?;
     655          122 :     let mut vp: HashMap<String, String> = HashMap::new();
     656          122 :     crate::paging::query_doc_params(qo, false, &mut vp)?;
     657          366 :     for k in ["limit", "offset", "count"] {
     658          366 :         vp.remove(k);
     659          366 :     }
     660          122 :     let headers = HeaderMap::new();
     661          122 :     let mut warnings = Vec::new();
     662          122 :     let fed = if crate::federation::active(&vp) {
     663          122 :         crate::federation::fed_query(st, tenant, &headers, ctx, &vp, &mut warnings).await?
     664              :     } else {
     665            0 :         Vec::new()
     666              :     };
     667              :     // 5.16.1.4: "If the size of the respective results require pagination,
     668              :     // all pages are to be retrieved completely." They are retrieved one at a
     669              :     // time. A query wider than the budget is refused, and it has to be
     670              :     // refused without first holding its whole match set in memory to measure
     671              :     // it: the store's pre-LIMIT count is that measurement. The count is the
     672              :     // match set's only while every predicate reached the store — an
     673              :     // idPattern is applied after it (5.2.33), so with one present the
     674              :     // documents that survive are counted instead.
     675          122 :     let exact_total = !vp.contains_key("idPattern");
     676              :     // Federated candidates are merged after the store answers (5.7.2.4), so
     677              :     // that round takes no page and is the whole match set; only a local
     678              :     // query walks on.
     679          122 :     let mut fed = fed;
     680          122 :     let mut n = 0usize;
     681          122 :     let mut offset = 0usize;
     682              :     loop {
     683              :         // Only the first round asks the store to count: the pre-LIMIT total
     684              :         // makes it visit the whole match set, and the budget needs that once.
     685              :         // Later rounds carry the next-page hint in the same member, which is
     686              :         // not a count and is not read as one.
     687          128 :         let counted = offset == 0;
     688          128 :         if counted {
     689          122 :             vp.insert("count".into(), "true".into());
     690          122 :         } else {
     691            6 :             vp.remove("count");
     692            6 :         }
     693          128 :         let batch = crate::entities::filter_entities_paged(
     694          128 :             st,
     695          128 :             tenant,
     696          128 :             &vp,
     697          128 :             ctx,
     698          128 :             std::mem::take(&mut fed),
     699          128 :             Some((offset, st.max_limit)),
     700          128 :             None,
     701          128 :         )
     702          128 :         .await
     703          128 :         .map_err(|e| match e {
     704            0 :             ApiError::Ngsi(n) => n,
     705            0 :             other => opaque("query execution", &other),
     706            0 :         })?;
     707          128 :         if exact_total && counted {
     708          122 :             if let Some(total) = batch.total.filter(|_| batch.paged) {
     709            8 :                 if total > budget {
     710            0 :                     return Err(too_many(total, budget));
     711            8 :                 }
     712          114 :             }
     713            6 :         }
     714          128 :         let rows = batch.rows;
     715          128 :         let got = batch.docs.len();
     716          128 :         if n + got > budget {
     717            4 :             return Err(too_many(n + got, budget));
     718          124 :         }
     719              :         // A write the store refuses fails the query. What this returns is
     720              :         // what the caller adds to `copied`, and `copied` decides both the
     721              :         // budget left for the next query and whether the synthetic tenant is
     722              :         // materialized — an empty snapshot has to read as empty rather than
     723              :         // as a tenant that never existed. Counting a copy that never happened
     724              :         // costs the snapshot both, and 5.2.41 would publish the query as a
     725              :         // success besides.
     726          124 :         for doc in batch.docs {
     727          112 :             if let Some(id) = doc.get("id").and_then(Value::as_str) {
     728          112 :                 st.store
     729          112 :                     .create(synth, Kind::Entity, id, doc.clone())
     730          112 :                     .await?;
     731            0 :             }
     732              :         }
     733          122 :         n += got;
     734          122 :         match crate::entities::next_scan_offset(offset, rows, 0, batch.paged, st.max_limit) {
     735            6 :             Some(next) => offset = next,
     736          116 :             None => break,
     737              :         }
     738              :     }
     739          116 :     Ok(n)
     740          122 : }
     741              : 
     742              : /// One temporal Query (temporalQ mandatory) — ids via the 5.7.4.4 path,
     743              : /// full evolutions copied via the store, every page retrieved but never
     744              : /// more than `budget` evolutions (5.5.6).
     745           10 : async fn run_temporal_query(
     746           10 :     st: &AppState,
     747           10 :     tenant: &TenantId,
     748           10 :     synth: &TenantId,
     749           10 :     id: &str,
     750           10 :     q: &Value,
     751           10 :     budget: usize,
     752           10 : ) -> Result<usize, NgsiError> {
     753           10 :     let qo = q
     754           10 :         .as_object()
     755           10 :         .ok_or_else(|| bad("Query must be an object".into()))?;
     756           10 :     let mut vp: HashMap<String, String> = HashMap::new();
     757           10 :     crate::paging::query_doc_params(qo, true, &mut vp)?;
     758           30 :     for k in ["limit", "offset", "count"] {
     759           30 :         vp.remove(k);
     760           30 :     }
     761           10 :     let mut headers = HeaderMap::new();
     762           10 :     if let Ok(v) = tenant.as_str().parse() {
     763           10 :         headers.insert("NGSILD-Tenant", v);
     764           10 :     }
     765              :     // 5.16.1.4: "If the size of the respective results require pagination,
     766              :     // all pages are to be retrieved completely."
     767           10 :     let mut n = 0usize;
     768           10 :     let mut offset = 0usize;
     769              :     loop {
     770              :         // a snapshot deleted (or evicted) mid-fill stops the paging; what is
     771              :         // already copied is reaped by the caller
     772           12 :         if snap_get(st, tenant, id).await.is_none() {
     773            0 :             break;
     774           12 :         }
     775           12 :         vp.insert("limit".into(), st.max_limit.to_string());
     776           12 :         vp.insert("offset".into(), offset.to_string());
     777              :         // The fill runs after the request that created the snapshot has
     778              :         // been answered, under a header map that is not the client's, so
     779              :         // the narrowing the creating subject was given is not in scope here
     780              :         // — a snapshot under a policy is P5's box (ADR-0020: a `Filter` on
     781              :         // a fill is a `Deny`, and the fill records whose it is).
     782              :         // The merged documents come back beside the response: an Evolution
     783              :         // this broker holds is copied from the store below, whole, and one a
     784              :         // registered Context Source holds exists only here.
     785           12 :         let mut merged: Vec<Value> = Vec::new();
     786           12 :         let resp = crate::temporal::query_temporal_collected(
     787           12 :             st,
     788           12 :             &vp,
     789           12 :             &headers,
     790           12 :             &crate::policy::Filter::default(),
     791           12 :             Some(&mut merged),
     792           12 :         )
     793           12 :         .await
     794           12 :         .map_err(|e| match e {
     795            0 :             ApiError::Ngsi(n) => n,
     796            0 :             other => opaque("temporal query execution", &other),
     797            0 :         })?;
     798           12 :         let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
     799           12 :             .await
     800           12 :             .map_err(|e| opaque("temporal result read", &e))?;
     801           12 :         let arr: Vec<Value> = serde_json::from_slice::<Value>(&bytes)
     802           12 :             .ok()
     803           12 :             .and_then(|v| v.as_array().cloned())
     804           12 :             .unwrap_or_default();
     805           12 :         let got = arr.len();
     806           12 :         if n + got > budget {
     807            0 :             return Err(too_many(n + got, budget));
     808           12 :         }
     809           16 :         for d in arr {
     810           16 :             let Some(eid) = d.get("id").and_then(Value::as_str) else {
     811            0 :                 continue;
     812              :             };
     813              :             // `None` is an Entity with no Temporal Evolution to copy; an Err
     814              :             // is the store refusing the read, which is not the same thing and
     815              :             // is not this snapshot's to swallow.
     816              :             //
     817              :             // The copy REPLACES: a snapshot carrying both kinds of query runs
     818              :             // its Entity queries first, and copying an Entity into the
     819              :             // synthetic tenant fires the 5.6.11 auto-record hook, which leaves
     820              :             // a Temporal Evolution holding only the instant of the copy. An
     821              :             // insert-if-absent would find that stub and keep it, so the
     822              :             // snapshot would answer temporal reads with one instance of a
     823              :             // history the source has many of — and report the query a success.
     824           16 :             let held = st
     825           16 :                 .temporal
     826           16 :                 .get_temporal(
     827           16 :                     tenant,
     828           16 :                     eid,
     829           16 :                     &antares_store::filter::TemporalFilter::default(),
     830           16 :                 )
     831           16 :                 .await?;
     832              :             // Nothing local means the Evolution came from a registered
     833              :             // Context Source (5.7.4.4). The query's merged answer is every
     834              :             // instance of it this broker was given, so it is what the
     835              :             // snapshot keeps — a local copy is the source's whole history,
     836              :             // a remote one is as much of it as the fan-out returned.
     837           16 :             let doc = match held {
     838           14 :                 Some(doc) => Some(doc),
     839            2 :                 None => merged
     840            2 :                     .iter()
     841            4 :                     .find(|m| m.get("id").and_then(Value::as_str) == Some(eid))
     842            2 :                     .cloned(),
     843              :             };
     844           16 :             if let Some(doc) = doc {
     845           16 :                 st.temporal.upsert(synth, eid, doc).await?;
     846           16 :                 n += 1;
     847            0 :             }
     848              :         }
     849           12 :         if got < st.max_limit {
     850           10 :             break;
     851            2 :         }
     852            2 :         offset += st.max_limit;
     853              :     }
     854           10 :     Ok(n)
     855           10 : }
     856              : 
     857          136 : async fn finish(
     858          136 :     st: &AppState,
     859          136 :     tenant: &TenantId,
     860          136 :     id: &str,
     861          136 :     status: &str,
     862          136 :     details: Option<(Vec<Value>, Vec<Value>)>,
     863          136 : ) {
     864          136 :     let Some(mut meta) = snap_get(st, tenant, id).await else {
     865            0 :         return;
     866              :     };
     867          136 :     if let Some(o) = meta.as_object_mut() {
     868          136 :         o.insert("snapshotStatus".into(), Value::String(status.into()));
     869          136 :         o.insert("modifiedAt".into(), Value::String(now_iso()));
     870          136 :         if let Some((q, tq)) = details {
     871          128 :             if !q.is_empty() {
     872          122 :                 o.insert("snapshotQueriesDetails".into(), Value::Array(q));
     873          122 :             }
     874          128 :             if !tq.is_empty() {
     875           10 :                 o.insert("snapshotTemporalQueriesDetails".into(), Value::Array(tq));
     876          118 :             }
     877            8 :         }
     878            0 :     }
     879          136 :     snap_put(st, tenant, meta.clone()).await;
     880          136 :     send_notification(st, &meta).await;
     881          136 : }
     882              : 
     883              : /// 5.16.6 / 5.3.4 SnapshotNotification (sent only when endpoint is set).
     884          146 : async fn send_notification(st: &AppState, meta: &Value) {
     885          146 :     let Some(uri) = meta.get("endpoint").and_then(Value::as_str) else {
     886          144 :         return;
     887              :     };
     888            2 :     if st.egress.check_url(uri).await.is_err() {
     889            0 :         return;
     890            2 :     }
     891            2 :     let mut body = json!({
     892            2 :         "id": format!("urn:ngsi-ld:SnapshotNotification:{}", uuid::Uuid::new_v4()),
     893            2 :         "type": "SnapshotNotification",
     894            2 :         "notifiedAt": now_iso(),
     895            2 :         "snapshotId": meta.get("id").cloned().unwrap_or_default(),
     896            2 :         "snapshotStatus": meta.get("snapshotStatus").cloned().unwrap_or_default(),
     897            2 :         "snapshotPriority": meta.get("snapshotPriority").cloned().unwrap_or_default(),
     898            2 :         "expiresAt": meta.get("expiresAt").cloned().unwrap_or_default(),
     899              :     });
     900              :     // 5.3.4 Table 5.3.4-1 names the temporal list
     901              :     // "temporalSnapshotQueriesDetails" (unlike 5.2.41's
     902              :     // "snapshotTemporalQueriesDetails") — the notification datatype's own
     903              :     // table governs the notification payload.
     904            4 :     for (from, to) in [
     905            2 :         ("snapshotQueriesDetails", "snapshotQueriesDetails"),
     906            2 :         (
     907            2 :             "snapshotTemporalQueriesDetails",
     908            2 :             "temporalSnapshotQueriesDetails",
     909            2 :         ),
     910            2 :     ] {
     911            4 :         if let Some(v) = meta.get(from) {
     912            4 :             body[to] = v.clone();
     913            4 :         }
     914              :     }
     915            2 :     let mut req = st.http.post(uri).header("Content-Type", "application/json");
     916            2 :     if let Some(ri) = meta.get("receiverInfo").and_then(Value::as_array) {
     917            0 :         for kv in ri {
     918            0 :             if let (Some(k), Some(v)) = (
     919            0 :                 kv.get("key").and_then(Value::as_str),
     920            0 :                 kv.get("value").and_then(Value::as_str),
     921            0 :             ) {
     922            0 :                 req = req.header(k, v);
     923            0 :             }
     924              :         }
     925            2 :     }
     926            2 :     let req = req.body(serde_json::to_vec(&body).unwrap_or_default());
     927            2 :     let _ = antares_jsonld::io_deadline(req.send(), 8_000).await;
     928          146 : }
     929              : 
     930              : // ---------- 5.16.7 Purge Snapshots (DELETE /snapshots, 6.36.3.2) ----------
     931              : 
     932           46 : pub async fn purge_snapshots(
     933           46 :     State(st): State<AppState>,
     934           46 :     CleanParams(params): CleanParams,
     935           46 :     headers: HeaderMap,
     936           46 : ) -> Response {
     937           46 :     let go = async {
     938           46 :         let tenant = tenant_from(&headers)?;
     939           46 :         check_params(&params, &["q", "local"])?;
     940           46 :         gate!(st, &tenant, &headers, "5.16.7").await?;
     941              :         // 5.16.7.4: the query is mandatory and restricted to Snapshot members
     942           44 :         let q = params
     943           44 :             .get("q")
     944           44 :             .ok_or_else(|| bad("purge requires a q over Snapshot members (5.16.7.4)".into()))?;
     945           42 :         let ast = antares_ql::parse_q(q)?;
     946              :         // 5.16.7.4: the query is "restricted to members of the Snapshot
     947              :         // data type" (Tables 5.2.41-1/-2)
     948              :         const MEMBERS: [&str; 15] = [
     949              :             "id",
     950              :             "type",
     951              :             "snapshotQueries",
     952              :             "snapshotTemporalQueries",
     953              :             "snapshotLifetime",
     954              :             "snapshotPriority",
     955              :             "endpoint",
     956              :             "receiverInfo",
     957              :             "snapshotStatus",
     958              :             "snapshotQueriesDetails",
     959              :             "snapshotTemporalQueriesDetails",
     960              :             "createdAt",
     961              :             "modifiedAt",
     962              :             "expiresAt",
     963              :             "lastUsedAt",
     964              :         ];
     965           42 :         if let Some(alien) = ast
     966           42 :             .attribute_paths()
     967           42 :             .into_iter()
     968           42 :             .find(|a| !MEMBERS.contains(a))
     969              :         {
     970           12 :             return Err(bad(format!(
     971           12 :                 "purge q is restricted to Snapshot members (5.16.7.4): {alien:?}"
     972           12 :             ))
     973           12 :             .into());
     974           30 :         }
     975           30 :         let ctx = st.loader.core();
     976           30 :         let victims: Vec<Value> = st
     977           30 :             .store
     978           30 :             .list(&tenant, Kind::Snapshot)
     979           30 :             .await
     980           30 :             .unwrap_or_default()
     981           30 :             .into_iter()
     982           30 :             .filter(|meta| is_snapshot(meta) && crate::registry::csf_matches(&ast, meta, &ctx))
     983           30 :             .collect();
     984           30 :         for meta in victims {
     985            8 :             if let Some(id) = meta.get("id").and_then(Value::as_str) {
     986            8 :                 snap_remove(&st, &tenant, id, &meta).await;
     987            0 :             }
     988              :         }
     989           30 :         Ok::<_, ApiError>(no_content(&tenant))
     990           46 :     };
     991           46 :     go.await.unwrap_or_else(|e| e.into_response())
     992           46 : }
     993              : 
     994              : /// 5.16.2.4 / 5.16.3.4 / 5.16.4.4 / 5.16.5.4: every /snapshots/{id} method
     995              : /// opens the same way — the tenant it runs in, `local` (6.3.18) as the only
     996              : /// parameter this resource takes, and an id that must be a valid URI before
     997              : /// the store is touched at all.
     998          306 : fn open_snapshot(
     999          306 :     params: &HashMap<String, String>,
    1000          306 :     headers: &HeaderMap,
    1001          306 :     id: &str,
    1002          306 : ) -> ApiResult<TenantId> {
    1003          306 :     let tenant = tenant_from(headers)?;
    1004          306 :     check_params(params, &["local"])?;
    1005          306 :     antares_model::EntityId::new(id)
    1006          306 :         .map_err(|_| bad(format!("snapshot id is not a valid URI: {id:?}")))?;
    1007          266 :     Ok(tenant)
    1008          306 : }
    1009              : 
    1010              : // ---------- 5.16.3/5.16.4/5.16.5: /snapshots/{id} (6.37) ----------
    1011              : 
    1012          200 : pub async fn retrieve_snapshot(
    1013          200 :     State(st): State<AppState>,
    1014          200 :     Path(id): Path<String>,
    1015          200 :     CleanParams(params): CleanParams,
    1016          200 :     headers: HeaderMap,
    1017          200 : ) -> Response {
    1018          200 :     let go = async {
    1019          200 :         let tenant = open_snapshot(&params, &headers, &id)?;
    1020          190 :         gate!(st, &tenant, &headers, "5.16.3", ids: &[&id]).await?;
    1021          190 :         let accept = parse_accept(&headers)?;
    1022          190 :         let meta = snap_get(&st, &tenant, &id)
    1023          190 :             .await
    1024          190 :             .ok_or_else(|| NgsiError::ResourceNotFound(format!("snapshot {id} not found")))?;
    1025          164 :         let ctx = st.loader.core();
    1026          164 :         Ok::<_, ApiError>(respond(
    1027          164 :             StatusCode::OK,
    1028          164 :             present(&meta),
    1029          164 :             &ctx,
    1030          164 :             accept,
    1031          164 :             &tenant,
    1032          164 :         ))
    1033          200 :     };
    1034          200 :     go.await.unwrap_or_else(|e| e.into_response())
    1035          200 : }
    1036              : 
    1037           36 : pub async fn update_snapshot(
    1038           36 :     State(st): State<AppState>,
    1039           36 :     Path(id): Path<String>,
    1040           36 :     CleanParams(params): CleanParams,
    1041           36 :     headers: HeaderMap,
    1042           36 :     body: Bytes,
    1043           36 : ) -> Response {
    1044           36 :     let go = async {
    1045           36 :         let tenant = open_snapshot(&params, &headers, &id)?;
    1046           26 :         gate!(st, &tenant, &headers, "5.16.4", ids: &[&id]).await?;
    1047           26 :         let accept = parse_accept(&headers)?;
    1048              :         // 5.16.4.4 binds this operation to 5.5.4, hence to the 6.3.5 rules
    1049           26 :         let v = parse_body(&st.loader, &headers, &body, BodyKind::Standard)
    1050           26 :             .await?
    1051              :             .value;
    1052           22 :         let frag = validate(&v, Mode::Update)?;
    1053           16 :         let mut meta = snap_get(&st, &tenant, &id)
    1054           16 :             .await
    1055           16 :             .ok_or_else(|| NgsiError::ResourceNotFound(format!("snapshot {id} not found")))?;
    1056            6 :         if let Some(o) = meta.as_object_mut() {
    1057              :             // 5.16.4.4 / 5.5.8 merge of the updatable members
    1058           24 :             for k in [
    1059            6 :                 "snapshotLifetime",
    1060            6 :                 "snapshotPriority",
    1061            6 :                 "endpoint",
    1062            6 :                 "receiverInfo",
    1063            6 :             ] {
    1064           24 :                 match frag.get(k) {
    1065           18 :                     None => {}
    1066            0 :                     Some(Value::Null) => {
    1067            0 :                         o.remove(k);
    1068            0 :                     }
    1069            6 :                     Some(v) => {
    1070            6 :                         o.insert(k.into(), v.clone());
    1071            6 :                     }
    1072              :                 }
    1073              :             }
    1074            6 :             if frag.contains_key("snapshotLifetime") {
    1075              :                 // "it is possible to indirectly update expiresAt" (5.2.41)
    1076            0 :                 o.insert("expiresAt".into(), Value::String(expires_at(o)?));
    1077            6 :             }
    1078            6 :             o.insert("modifiedAt".into(), Value::String(now_iso()));
    1079            0 :         }
    1080            6 :         snap_put(&st, &tenant, meta.clone()).await;
    1081              :         // 5.16.6: notifications are also sent after any status update
    1082            6 :         let (st2, meta2) = (st.clone(), meta.clone());
    1083            6 :         crate::spawn(async move {
    1084            6 :             send_notification(&st2, &meta2).await;
    1085            6 :         });
    1086            6 :         let ctx = st.loader.core();
    1087            6 :         Ok::<_, ApiError>(respond(
    1088            6 :             StatusCode::OK,
    1089            6 :             present(&meta),
    1090            6 :             &ctx,
    1091            6 :             accept,
    1092            6 :             &tenant,
    1093            6 :         ))
    1094           36 :     };
    1095           36 :     go.await.unwrap_or_else(|e| e.into_response())
    1096           36 : }
    1097              : 
    1098           40 : pub async fn delete_snapshot(
    1099           40 :     State(st): State<AppState>,
    1100           40 :     Path(id): Path<String>,
    1101           40 :     CleanParams(params): CleanParams,
    1102           40 :     headers: HeaderMap,
    1103           40 : ) -> Response {
    1104           40 :     let go = async {
    1105           40 :         let tenant = open_snapshot(&params, &headers, &id)?;
    1106           30 :         gate!(st, &tenant, &headers, "5.16.5", ids: &[&id]).await?;
    1107           30 :         let meta = snap_get(&st, &tenant, &id)
    1108           30 :             .await
    1109           30 :             .ok_or_else(|| NgsiError::ResourceNotFound(format!("snapshot {id} not found")))?;
    1110           22 :         snap_remove(&st, &tenant, &id, &meta).await;
    1111           22 :         Ok::<_, ApiError>(no_content(&tenant))
    1112           40 :     };
    1113           40 :     go.await.unwrap_or_else(|e| e.into_response())
    1114           40 : }
    1115              : 
    1116              : // ---------- 5.16.2 Clone Snapshot (POST /snapshots/{id}/clone, 6.38) ----------
    1117              : 
    1118           30 : pub async fn clone_snapshot(
    1119           30 :     State(st): State<AppState>,
    1120           30 :     Path(id): Path<String>,
    1121           30 :     CleanParams(params): CleanParams,
    1122           30 :     headers: HeaderMap,
    1123           30 :     body: Bytes,
    1124           30 : ) -> Response {
    1125           30 :     let go = async {
    1126           30 :         let tenant = open_snapshot(&params, &headers, &id)?;
    1127           20 :         gate!(st, &tenant, &headers, "5.16.2", ids: &[&id]).await?;
    1128           18 :         let src = snap_get(&st, &tenant, &id)
    1129           18 :             .await
    1130           18 :             .ok_or_else(|| NgsiError::ResourceNotFound(format!("snapshot {id} not found")))?;
    1131              :         // 5.16.2.3 makes the clone body optional; a body that IS sent obeys
    1132              :         // the 6.3.5 @context rules
    1133           10 :         let v: Value = if body.is_empty() {
    1134            0 :             json!({})
    1135              :         } else {
    1136           10 :             parse_body(&st.loader, &headers, &body, BodyKind::Standard)
    1137           10 :                 .await?
    1138              :                 .value
    1139              :         };
    1140           10 :         let mut o = validate(&v, Mode::Clone)?;
    1141              :         // the clone carries the source's (read-only) query lineage
    1142           16 :         for k in ["snapshotQueries", "snapshotTemporalQueries"] {
    1143           16 :             if let Some(qv) = src.get(k) {
    1144            8 :                 o.insert(k.into(), qv.clone());
    1145            8 :             }
    1146              :         }
    1147            8 :         let (new_id, meta) = new_meta(o, &tenant, &headers)?;
    1148            8 :         snap_insert(&st, &tenant, &new_id, &meta).await?;
    1149              :         // a clone is a new snapshot: 5.5.15 resource pressure applies to it
    1150              :         // exactly as it does to a create, so cloning cannot grow the
    1151              :         // registry past the cap
    1152            8 :         evict_over_cap(&st, &tenant, &new_id).await;
    1153            8 :         let (st2, t2, sid, nid) = (st.clone(), tenant.clone(), id.clone(), new_id.clone());
    1154            8 :         crate::spawn(async move {
    1155            8 :             clone_fill(&st2, &t2, &sid, &nid).await;
    1156            8 :         });
    1157            8 :         Ok::<_, ApiError>(created(format!("/ngsi-ld/v1/snapshots/{new_id}"), &tenant))
    1158           30 :     };
    1159           30 :     go.await.unwrap_or_else(|e| e.into_response())
    1160           30 : }
    1161              : 
    1162              : /// 5.16.2.4 background copy: all Entity and Temporal data of the source.
    1163            8 : async fn clone_fill(st: &AppState, tenant: &TenantId, src_id: &str, new_id: &str) {
    1164            8 :     let (Some(src), Some(new)) = (
    1165            8 :         snap_get(st, tenant, src_id).await,
    1166            8 :         snap_get(st, tenant, new_id).await,
    1167              :     ) else {
    1168            0 :         finish(st, tenant, new_id, "failure", None).await;
    1169            0 :         return;
    1170              :     };
    1171            8 :     let (Some(from), Some(to)) = (synth_tenant(&src), synth_tenant(&new)) else {
    1172            0 :         finish(st, tenant, new_id, "failure", None).await;
    1173            0 :         return;
    1174              :     };
    1175            8 :     let mut failed = false;
    1176            8 :     match st.store.list(&from, Kind::Entity).await {
    1177            8 :         Ok(docs) => {
    1178           10 :             for doc in docs {
    1179           10 :                 if let Some(id) = doc.get("id").and_then(Value::as_str) {
    1180           10 :                     if st
    1181           10 :                         .store
    1182           10 :                         .create(&to, Kind::Entity, id, doc.clone())
    1183           10 :                         .await
    1184           10 :                         .is_err()
    1185            0 :                     {
    1186            0 :                         failed = true;
    1187           10 :                     }
    1188            0 :                 }
    1189              :             }
    1190              :         }
    1191            0 :         Err(_) => failed = true,
    1192              :     }
    1193            8 :     match st.temporal.list(&from).await {
    1194            8 :         Ok(docs) => {
    1195           10 :             for doc in docs {
    1196           10 :                 if let Some(id) = doc.get("id").and_then(Value::as_str) {
    1197           10 :                     if st.temporal.create(&to, id, doc.clone()).await.is_err() {
    1198            0 :                         failed = true;
    1199           10 :                     }
    1200            0 :                 }
    1201              :             }
    1202              :         }
    1203            0 :         Err(_) => failed = true,
    1204              :     }
    1205            8 :     if fill_cancelled(st, tenant, new_id, &to).await {
    1206            0 :         return;
    1207            8 :     }
    1208            8 :     finish(
    1209            8 :         st,
    1210            8 :         tenant,
    1211            8 :         new_id,
    1212            8 :         if failed { "failure" } else { "success" },
    1213            8 :         None,
    1214              :     )
    1215            8 :     .await;
    1216            8 : }
    1217              : 
    1218              : // ---------- 6.3.22: NGSILD-Snapshot scoping middleware ----------
    1219              : 
    1220              : /// 6.3.22 / 5.5.15: resolve the NGSILD-Snapshot header to the snapshot's
    1221              : /// synthetic tenant, so every Core/Temporal handler serves the frozen copy;
    1222              : /// the header is echoed on the response (and the synthetic tenant never
    1223              : /// leaks into NGSILD-Tenant).
    1224        26078 : pub async fn snapshot_layer(
    1225        26078 :     State(st): State<AppState>,
    1226        26078 :     req: axum::extract::Request,
    1227        26078 :     next: axum::middleware::Next,
    1228        26078 : ) -> Response {
    1229              :     // 6.3.22 gives the header one value, and it decides whether the request
    1230              :     // is answered from a frozen copy or from live data: an ambiguous one
    1231              :     // would serve the request against a dataset nobody named.
    1232        26078 :     let sid = match single_header(req.headers(), "NGSILD-Snapshot") {
    1233        25978 :         Ok(None) => return next.run(req).await,
    1234           96 :         Ok(Some(sid)) => sid,
    1235            4 :         Err(e) => return e.into_response(),
    1236              :     };
    1237              :     // 6.3.22: "If the HTTP header NGSILD-Snapshot is present in the HTTP
    1238              :     // request, it shall also be present in HTTP response" — every exit,
    1239              :     // the unscoped and the error ones included, leaves through here
    1240           96 :     let mut resp = scoped(&st, &sid, req, next).await;
    1241           96 :     if let Ok(v) = sid.parse() {
    1242           96 :         resp.headers_mut().insert("NGSILD-Snapshot", v);
    1243           96 :     }
    1244           96 :     resp
    1245        26078 : }
    1246              : 
    1247           96 : async fn scoped(
    1248           96 :     st: &AppState,
    1249           96 :     sid: &str,
    1250           96 :     mut req: axum::extract::Request,
    1251           96 :     next: axum::middleware::Next,
    1252           96 : ) -> Response {
    1253              :     // the Snapshot API's own resources (6.36-6.38) are never
    1254              :     // snapshot-scoped — that is the /snapshots RESOURCE, not any path
    1255              :     // merely containing the word (an Attribute may be named "snapshots")
    1256           96 :     if req
    1257           96 :         .uri()
    1258           96 :         .path()
    1259           96 :         .trim_start_matches(API_ROOT)
    1260           96 :         .starts_with("/snapshots")
    1261              :     {
    1262            4 :         return next.run(req).await;
    1263           92 :     }
    1264           92 :     let tenant = match tenant_from(req.headers()) {
    1265           92 :         Ok(t) => t,
    1266            0 :         Err(e) => return e.into_response(),
    1267              :     };
    1268           92 :     let Some(meta) = snap_get(st, &tenant, sid).await else {
    1269           16 :         return ApiError::from(NgsiError::ResourceNotFound(format!(
    1270           16 :             "snapshot {sid} not found"
    1271           16 :         )))
    1272           16 :         .into_response();
    1273              :     };
    1274           76 :     let Some(synth) = synth_tenant(&meta) else {
    1275            0 :         return ApiError::from(NgsiError::InternalError("snapshot without tenant".into()))
    1276            0 :             .into_response();
    1277              :     };
    1278           76 :     snap_touch(st, &tenant, sid).await;
    1279           76 :     if let Ok(v) = synth.as_str().parse() {
    1280           76 :         req.headers_mut().insert("NGSILD-Tenant", v);
    1281           76 :     }
    1282           76 :     let mut resp = next.run(req).await;
    1283              :     // restore the caller's tenant view (the synthetic one is internal)
    1284           76 :     match tenant.as_str() {
    1285           76 :         "default" => {
    1286           66 :             resp.headers_mut().remove("NGSILD-Tenant");
    1287           66 :         }
    1288           10 :         t => {
    1289           10 :             if let Ok(v) = t.parse() {
    1290           10 :                 resp.headers_mut().insert("NGSILD-Tenant", v);
    1291           10 :             }
    1292              :         }
    1293              :     }
    1294           76 :     resp
    1295           96 : }
    1296              : 
    1297              : /// 5.5.6: the fill copies its results into a second (isolated) tenant, so an
    1298              : /// unbounded result set doubles the stored data set in one request. The
    1299              : /// implementation threshold — "up to each implementation" — is a hundred
    1300              : /// full pages per snapshot; beyond it the query reports TooManyResults.
    1301          132 : fn fill_cap(st: &AppState) -> usize {
    1302          132 :     st.max_limit.saturating_mul(100)
    1303          132 : }
    1304              : 
    1305              : /// 5.16.1.4 + 5.5.15: the copied data is reachable only through its snapshot
    1306              : /// document, so a fill whose snapshot was deleted (or evicted under resource
    1307              : /// pressure) must stop and free what it already copied.
    1308          276 : async fn fill_cancelled(st: &AppState, tenant: &TenantId, id: &str, synth: &TenantId) -> bool {
    1309          276 :     if snap_get(st, tenant, id).await.is_some() {
    1310          272 :         return false;
    1311            4 :     }
    1312            4 :     purge_synth(st, synth).await;
    1313            4 :     true
    1314          276 : }
    1315              : 
    1316              : /// 5.5.6: "When a query operation is producing so many results that can
    1317              : /// potentially exhaust client or server resources … implementations shall
    1318              : /// raise an error of type TooManyResults" — reported per query in the
    1319              : /// 5.2.42 ExecutionResultDetails of the snapshot.
    1320            4 : fn too_many(got: usize, budget: usize) -> NgsiError {
    1321            4 :     NgsiError::TooManyResults(format!(
    1322            4 :         "snapshot query yields {got} results, {budget} left of the snapshot ceiling"
    1323            4 :     ))
    1324            4 : }
    1325              : 
    1326              : #[cfg(test)]
    1327              : mod clause_5_5_6 {
    1328              :     use super::*;
    1329              : 
    1330              :     /// 5.5.6 InternalError: the client-visible `detail` — served in
    1331              :     /// snapshotQueriesDetails.problemDetails and copied into the 5.3.4
    1332              :     /// SnapshotNotification body — must be generic. The Debug/IO text of
    1333              :     /// the underlying failure belongs in the server log only.
    1334              :     #[test]
    1335            4 :     fn snapshot_fill_error_detail_is_generic() {
    1336            4 :         let inner = ApiError::Bare(StatusCode::UNSUPPORTED_MEDIA_TYPE);
    1337            4 :         let pd = crate::negotiate::problem_value(&opaque("query execution", &inner));
    1338            4 :         let detail = pd["detail"].as_str().unwrap_or_default();
    1339            4 :         assert_eq!(detail, "query execution failed");
    1340            4 :         assert!(
    1341            4 :             !detail.contains("Bare") && !detail.contains("Unsupported"),
    1342              :             "internal error text leaked into the client-visible detail: {detail}"
    1343              :         );
    1344            4 :     }
    1345              : }
    1346              : 
    1347              : #[cfg(test)]
    1348              : mod clause_5_16 {
    1349              :     use super::*;
    1350              :     use axum::body::Body;
    1351              :     use axum::http::Request;
    1352              :     use tower::ServiceExt;
    1353              : 
    1354           40 :     async fn state() -> AppState {
    1355           40 :         crate::wired_state("antares-snapshots").await
    1356           40 :     }
    1357              : 
    1358              :     /// 5.16 with RFC 9110 §9.2.1: reading an expired Snapshot must not
    1359              :     /// write. The meta row survives the read; the sweep is what frees it,
    1360              :     /// its reverse-index entry and the synthetic tenant's data.
    1361              :     #[tokio::test(flavor = "multi_thread")]
    1362            4 :     async fn an_expired_snapshot_is_refused_by_a_read_that_writes_nothing() {
    1363            4 :         let st = state().await;
    1364            4 :         let t = TenantId::default();
    1365            4 :         let id = "urn:ngsi-ld:Snapshot:stale";
    1366            4 :         let synth = "snap-default-stale";
    1367            4 :         st.store
    1368            4 :             .create(
    1369            4 :                 &t,
    1370            4 :                 Kind::Snapshot,
    1371            4 :                 id,
    1372            4 :                 json!({"id": id, "type": "Snapshot", "__tenant": synth,
    1373            4 :                        "expiresAt": "2000-01-01T00:00:00Z"}),
    1374            4 :             )
    1375            4 :             .await
    1376            4 :             .expect("seed");
    1377            4 :         let idx = TenantId::new_internal("snap-index").expect("index tenant");
    1378            4 :         st.store
    1379            4 :             .create(&idx, Kind::Snapshot, synth, json!({"id": synth}))
    1380            4 :             .await
    1381            4 :             .expect("seed index");
    1382              : 
    1383            4 :         assert!(snap_get(&st, &t, id).await.is_none(), "5.16: not served");
    1384            4 :         assert!(
    1385            4 :             st.store
    1386            4 :                 .get(&t, Kind::Snapshot, id)
    1387            4 :                 .await
    1388            4 :                 .expect("store")
    1389            4 :                 .is_some(),
    1390              :             "the read deleted the meta: a GET must be safe (RFC 9110 9.2.1)"
    1391              :         );
    1392              : 
    1393            4 :         assert_eq!(sweep_expired_snapshots(&st, &t).await, 1, "the sweep reaps");
    1394            4 :         assert!(
    1395            4 :             st.store
    1396            4 :                 .get(&t, Kind::Snapshot, id)
    1397            4 :                 .await
    1398            4 :                 .expect("store")
    1399            4 :                 .is_none(),
    1400              :             "the sweep left the meta behind"
    1401              :         );
    1402              :         // The teardown runs behind the sweep: the copy is dropped first and
    1403              :         // the entry that names it last, so a broker that stops in the middle
    1404              :         // leaves a pointer to data that is still there.
    1405            4 :         for _ in 0..100 {
    1406            6 :             if st
    1407            6 :                 .store
    1408            6 :                 .get(&idx, Kind::Snapshot, synth)
    1409            6 :                 .await
    1410            6 :                 .expect("store")
    1411            6 :                 .is_none()
    1412            4 :             {
    1413            4 :                 return;
    1414            4 :             }
    1415            4 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    1416            4 :         }
    1417            4 :         panic!("the sweep left the reverse-index entry behind");
    1418            4 :     }
    1419              : 
    1420              :     /// The sweep takes the expiry and nothing else: a live Snapshot and the
    1421              :     /// reverse-index markers that share its storage both survive.
    1422              :     #[tokio::test]
    1423            4 :     async fn the_sweep_keeps_a_live_snapshot_and_the_index_markers() {
    1424            4 :         let st = state().await;
    1425            4 :         let t = TenantId::default();
    1426            4 :         let live = "urn:ngsi-ld:Snapshot:live";
    1427            4 :         st.store
    1428            4 :             .create(
    1429            4 :                 &t,
    1430            4 :                 Kind::Snapshot,
    1431            4 :                 live,
    1432            4 :                 json!({"id": live, "type": "Snapshot",
    1433            4 :                        "expiresAt": "2999-01-01T00:00:00Z"}),
    1434            4 :             )
    1435            4 :             .await
    1436            4 :             .expect("seed");
    1437              :         // a marker doc: same Kind, not a Snapshot, and long past any expiry
    1438            4 :         st.store
    1439            4 :             .create(
    1440            4 :                 &t,
    1441            4 :                 Kind::Snapshot,
    1442            4 :                 "snap-default-marker",
    1443            4 :                 json!({"id": "snap-default-marker", "expiresAt": "2000-01-01T00:00:00Z"}),
    1444            4 :             )
    1445            4 :             .await
    1446            4 :             .expect("seed marker");
    1447              : 
    1448            4 :         assert_eq!(sweep_expired_snapshots(&st, &t).await, 0);
    1449            4 :         assert!(snap_get(&st, &t, live).await.is_some(), "live one survives");
    1450            4 :         assert!(
    1451            4 :             st.store
    1452            4 :                 .get(&t, Kind::Snapshot, "snap-default-marker")
    1453            4 :                 .await
    1454            4 :                 .expect("store")
    1455            4 :                 .is_some(),
    1456            4 :             "the sweep took a reverse-index marker for a Snapshot"
    1457            4 :         );
    1458            4 :     }
    1459              : 
    1460              :     /// One request through the full router — the 6.3.22 middleware included.
    1461          528 :     async fn send(
    1462          528 :         st: &AppState,
    1463          528 :         method: &str,
    1464          528 :         path: &str,
    1465          528 :         body: Option<(&str, String)>,
    1466          528 :         extra: &[(&str, &str)],
    1467          528 :     ) -> (StatusCode, HeaderMap, Value) {
    1468          528 :         let mut b = Request::builder().method(method).uri(path);
    1469          528 :         for (k, v) in extra {
    1470           24 :             b = b.header(*k, *v);
    1471           24 :         }
    1472          528 :         let req = match body {
    1473          464 :             Some((ct, payload)) => b
    1474          464 :                 .header("Content-Type", ct)
    1475          464 :                 .header("Content-Length", payload.len())
    1476          464 :                 .body(Body::from(payload)),
    1477           64 :             None => b.body(Body::empty()),
    1478              :         }
    1479          528 :         .expect("request");
    1480          528 :         let res = crate::router(st.clone())
    1481          528 :             .oneshot(req)
    1482          528 :             .await
    1483          528 :             .expect("response");
    1484          528 :         let status = res.status();
    1485          528 :         let headers = res.headers().clone();
    1486          528 :         let bytes = axum::body::to_bytes(res.into_body(), usize::MAX)
    1487          528 :             .await
    1488          528 :             .expect("body");
    1489          528 :         let body = if bytes.is_empty() {
    1490          456 :             Value::Null
    1491              :         } else {
    1492           72 :             serde_json::from_slice(&bytes).unwrap_or(Value::Null)
    1493              :         };
    1494          528 :         (status, headers, body)
    1495          528 :     }
    1496              : 
    1497          440 :     async fn post_json(st: &AppState, path: &str, doc: &Value) -> (StatusCode, HeaderMap, Value) {
    1498          440 :         send(
    1499          440 :             st,
    1500          440 :             "POST",
    1501          440 :             path,
    1502          440 :             Some(("application/json", doc.to_string())),
    1503          440 :             &[],
    1504          440 :         )
    1505          440 :         .await
    1506          440 :     }
    1507              : 
    1508           40 :     fn header<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> {
    1509           40 :         h.get(name).and_then(|v| v.to_str().ok())
    1510           40 :     }
    1511              : 
    1512              :     /// Create a snapshot over every Vehicle and wait for the background fill.
    1513           24 :     async fn snapshot_over_vehicles(st: &AppState) -> String {
    1514           24 :         let doc = json!({"type": "Snapshot",
    1515           24 :             "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]});
    1516           24 :         let (status, headers, body) = post_json(st, "/ngsi-ld/v1/snapshots", &doc).await;
    1517           24 :         assert_eq!(status, StatusCode::CREATED, "{body}");
    1518           24 :         let loc = header(&headers, "Location").expect("Location").to_owned();
    1519           24 :         for _ in 0..200 {
    1520           24 :             let (_, _, b) = send(st, "GET", &loc, None, &[]).await;
    1521           24 :             if b["snapshotStatus"] != "preparing" {
    1522           24 :                 return b["id"].as_str().expect("snapshot id").to_owned();
    1523            0 :             }
    1524            0 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    1525              :         }
    1526            0 :         panic!("snapshot never left preparing");
    1527           24 :     }
    1528              : 
    1529           16 :     async fn stored(st: &AppState, id: &str) -> Value {
    1530           16 :         st.store
    1531           16 :             .get(&TenantId::default(), Kind::Snapshot, id)
    1532           16 :             .await
    1533           16 :             .ok()
    1534           16 :             .flatten()
    1535           16 :             .expect("snapshot document")
    1536           16 :     }
    1537              : 
    1538              :     /// 6.3.22: the Snapshot API's own resources are never snapshot-scoped —
    1539              :     /// but that exemption is the /snapshots RESOURCE, not any path in which
    1540              :     /// the word occurs. An Entity attribute named "snapshots" stays scoped
    1541              :     /// to the frozen copy, so the live entity is untouched.
    1542              :     #[tokio::test(flavor = "multi_thread")]
    1543            4 :     async fn clause_6_3_22_scope_guard_matches_only_the_snapshot_resource() {
    1544            4 :         let st = state().await;
    1545            4 :         let ent = json!({"id": "urn:ngsi-ld:Vehicle:g1", "type": "Vehicle",
    1546            4 :             "snapshots": {"type": "Property", "value": 3}});
    1547            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/entities", &ent).await;
    1548            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    1549            4 :         let sid = snapshot_over_vehicles(&st).await;
    1550              : 
    1551            4 :         let (status, headers, b) = send(
    1552            4 :             &st,
    1553            4 :             "DELETE",
    1554            4 :             "/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:g1/attrs/snapshots",
    1555            4 :             None,
    1556            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    1557              :         )
    1558            4 :         .await;
    1559            4 :         assert_eq!(status, StatusCode::NO_CONTENT, "{b}");
    1560            4 :         assert_eq!(header(&headers, "NGSILD-Snapshot"), Some(sid.as_str()));
    1561              : 
    1562            4 :         let (status, _, live) = send(
    1563            4 :             &st,
    1564            4 :             "GET",
    1565            4 :             "/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:g1",
    1566            4 :             None,
    1567            4 :             &[],
    1568              :         )
    1569            4 :         .await;
    1570            4 :         assert_eq!(status, StatusCode::OK, "{live}");
    1571            4 :         assert!(
    1572            4 :             live.get("snapshots").is_some(),
    1573              :             "a snapshot-scoped request deleted the LIVE attribute: {live}"
    1574              :         );
    1575            4 :         let (_, _, frozen) = send(
    1576            4 :             &st,
    1577            4 :             "GET",
    1578            4 :             "/ngsi-ld/v1/entities/urn:ngsi-ld:Vehicle:g1",
    1579            4 :             None,
    1580            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    1581              :         )
    1582            4 :         .await;
    1583            4 :         assert!(
    1584            4 :             frozen.get("snapshots").is_none(),
    1585            4 :             "the frozen copy still carries the deleted attribute: {frozen}"
    1586            4 :         );
    1587            4 :     }
    1588              : 
    1589              :     /// 6.3.22: "If the HTTP header NGSILD-Snapshot is present in the HTTP
    1590              :     /// request, it shall also be present in HTTP response" — on the
    1591              :     /// unscoped Snapshot resources and on the error exits as well.
    1592              :     #[tokio::test(flavor = "multi_thread")]
    1593            4 :     async fn clause_6_3_22_header_is_echoed_on_every_exit() {
    1594            4 :         let st = state().await;
    1595            4 :         let sid = snapshot_over_vehicles(&st).await;
    1596            4 :         let (status, headers, b) = send(
    1597            4 :             &st,
    1598            4 :             "GET",
    1599            4 :             &format!("/ngsi-ld/v1/snapshots/{sid}"),
    1600            4 :             None,
    1601            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    1602              :         )
    1603            4 :         .await;
    1604            4 :         assert_eq!(status, StatusCode::OK, "{b}");
    1605            4 :         assert_eq!(header(&headers, "NGSILD-Snapshot"), Some(sid.as_str()));
    1606              : 
    1607            4 :         let unknown = "urn:ngsi-ld:Snapshot:nope";
    1608            4 :         let (status, headers, b) = send(
    1609            4 :             &st,
    1610            4 :             "GET",
    1611            4 :             "/ngsi-ld/v1/entities?type=Vehicle",
    1612            4 :             None,
    1613            4 :             &[("NGSILD-Snapshot", unknown)],
    1614              :         )
    1615            4 :         .await;
    1616            4 :         assert_eq!(status, StatusCode::NOT_FOUND, "{b}");
    1617            4 :         assert_eq!(header(&headers, "NGSILD-Snapshot"), Some(unknown));
    1618            4 :         assert!(
    1619            4 :             !b.to_string().contains("snap-"),
    1620            4 :             "the synthetic tenant leaked to the client: {b}"
    1621            4 :         );
    1622            4 :     }
    1623              : 
    1624              :     /// 5.16.1.4: "If the NGSI-LD endpoint already knows about this Snapshot,
    1625              :     /// as there is an existing Snapshot whose id (URI) is equivalent, an
    1626              :     /// error of type AlreadyExists shall be raised" — and the rejected
    1627              :     /// create must not have replaced the existing snapshot.
    1628              :     #[tokio::test(flavor = "multi_thread")]
    1629            4 :     async fn clause_5_16_1_4_duplicate_id_is_already_exists() {
    1630            4 :         let st = state().await;
    1631            4 :         let mut doc = json!({"id": "urn:ngsi-ld:Snapshot:dup", "type": "Snapshot",
    1632            4 :             "snapshotPriority": 3,
    1633            4 :             "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]});
    1634            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/snapshots", &doc).await;
    1635            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    1636            4 :         doc["snapshotPriority"] = json!(9);
    1637            4 :         let (status, _, b) = post_json(&st, "/ngsi-ld/v1/snapshots", &doc).await;
    1638            4 :         assert_eq!(status, StatusCode::CONFLICT, "{b}");
    1639            4 :         assert!(
    1640            4 :             b["type"]
    1641            4 :                 .as_str()
    1642            4 :                 .unwrap_or_default()
    1643            4 :                 .ends_with("AlreadyExists"),
    1644              :             "{b}"
    1645              :         );
    1646            4 :         let (_, _, got) = send(
    1647            4 :             &st,
    1648            4 :             "GET",
    1649            4 :             "/ngsi-ld/v1/snapshots/urn:ngsi-ld:Snapshot:dup",
    1650            4 :             None,
    1651            4 :             &[],
    1652              :         )
    1653            4 :         .await;
    1654            4 :         assert_eq!(
    1655            4 :             got["snapshotPriority"], 3,
    1656              :             "the rejected create overwrote the existing snapshot: {got}"
    1657              :         );
    1658            4 :         assert!(
    1659            4 :             got.get("__tenant").is_none(),
    1660            4 :             "internal member served to the client: {got}"
    1661            4 :         );
    1662            4 :     }
    1663              : 
    1664              :     /// 6.3.5 (5.16.4.4 → 5.5.4): snapshot bodies obey the @context rules —
    1665              :     /// application/json with a body @context is BadRequestData,
    1666              :     /// application/ld+json without one is BadRequestData, any other media
    1667              :     /// type is a bare 415, and @context is never stored in the snapshot.
    1668              :     #[tokio::test(flavor = "multi_thread")]
    1669            4 :     async fn clause_6_3_5_snapshot_bodies_follow_the_context_rules() {
    1670            4 :         let st = state().await;
    1671            4 :         let core = "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld";
    1672            4 :         let plain = json!({"type": "Snapshot",
    1673            4 :             "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]});
    1674            4 :         let mut with_ctx = plain.clone();
    1675            4 :         with_ctx["@context"] = json!(core);
    1676              : 
    1677            8 :         for (ct, doc) in [
    1678            4 :             ("application/json", &with_ctx),
    1679            4 :             ("application/ld+json", &plain),
    1680            4 :         ] {
    1681            8 :             let (status, _, b) = send(
    1682            8 :                 &st,
    1683            8 :                 "POST",
    1684            8 :                 "/ngsi-ld/v1/snapshots",
    1685            8 :                 Some((ct, doc.to_string())),
    1686            8 :                 &[],
    1687              :             )
    1688            8 :             .await;
    1689            8 :             assert_eq!(status, StatusCode::BAD_REQUEST, "{ct}: {b}");
    1690              :         }
    1691            4 :         let (status, _, _) = send(
    1692            4 :             &st,
    1693            4 :             "POST",
    1694            4 :             "/ngsi-ld/v1/snapshots",
    1695            4 :             Some(("text/plain", plain.to_string())),
    1696            4 :             &[],
    1697              :         )
    1698            4 :         .await;
    1699            4 :         assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
    1700              : 
    1701            4 :         let (status, headers, b) = send(
    1702            4 :             &st,
    1703            4 :             "POST",
    1704            4 :             "/ngsi-ld/v1/snapshots",
    1705            4 :             Some(("application/ld+json", with_ctx.to_string())),
    1706            4 :             &[],
    1707              :         )
    1708            4 :         .await;
    1709            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    1710            4 :         let loc = header(&headers, "Location").expect("Location").to_owned();
    1711            4 :         let (_, _, got) = send(&st, "GET", &loc, None, &[]).await;
    1712            4 :         assert!(
    1713            4 :             got.get("@context").is_none(),
    1714              :             "@context stored as a snapshot member: {got}"
    1715              :         );
    1716            4 :         let patch = json!({"snapshotPriority": 7, "@context": core});
    1717            4 :         let (status, _, b) = send(
    1718            4 :             &st,
    1719            4 :             "PATCH",
    1720            4 :             &loc,
    1721            4 :             Some(("application/json", patch.to_string())),
    1722            4 :             &[],
    1723              :         )
    1724            4 :         .await;
    1725            4 :         assert_eq!(status, StatusCode::BAD_REQUEST, "{b}");
    1726            4 :     }
    1727              : 
    1728              :     /// 5.5.6: "When a query operation is producing so many results that can
    1729              :     /// potentially exhaust client or server resources … implementations
    1730              :     /// shall raise an error of type TooManyResults." A fill copies its
    1731              :     /// results, so the ceiling is enforced before anything is copied.
    1732              :     #[tokio::test(flavor = "multi_thread")]
    1733            4 :     async fn clause_5_5_6_fill_stops_at_the_result_ceiling() {
    1734            4 :         let mut st = state().await;
    1735            4 :         st.max_limit = 1;
    1736            4 :         st.default_limit = 1;
    1737          404 :         for i in 0..=fill_cap(&st) {
    1738          404 :             let ent = json!({"id": format!("urn:ngsi-ld:Vehicle:c{i}"), "type": "Vehicle"});
    1739          404 :             let (status, _, b) = post_json(&st, "/ngsi-ld/v1/entities", &ent).await;
    1740          404 :             assert_eq!(status, StatusCode::CREATED, "{b}");
    1741              :         }
    1742            4 :         let sid = snapshot_over_vehicles(&st).await;
    1743            4 :         let ready = stored(&st, &sid).await;
    1744            4 :         assert_eq!(ready["snapshotStatus"], "failure", "{ready}");
    1745            4 :         let detail = &ready["snapshotQueriesDetails"][0];
    1746            4 :         assert_eq!(detail["resultStatus"], "failure", "{ready}");
    1747            4 :         assert!(
    1748            4 :             detail["problemDetails"]["type"]
    1749            4 :                 .as_str()
    1750            4 :                 .unwrap_or_default()
    1751            4 :                 .ends_with("TooManyResults"),
    1752              :             "{detail}"
    1753              :         );
    1754            4 :         let (status, _, list) = send(
    1755            4 :             &st,
    1756            4 :             "GET",
    1757            4 :             "/ngsi-ld/v1/entities?type=Vehicle&limit=1",
    1758            4 :             None,
    1759            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    1760              :         )
    1761            4 :         .await;
    1762            4 :         assert_eq!(status, StatusCode::OK, "{list}");
    1763            4 :         assert_eq!(
    1764            4 :             list,
    1765            4 :             json!([]),
    1766            4 :             "the oversized result set was copied into the snapshot anyway"
    1767            4 :         );
    1768            4 :     }
    1769              : 
    1770              :     /// 5.16.1.4 + 5.5.15: the filled copy is reachable only through its
    1771              :     /// snapshot document, so a fill whose snapshot has been deleted (or
    1772              :     /// evicted) stops and frees what it already copied.
    1773              :     #[tokio::test(flavor = "multi_thread")]
    1774            4 :     async fn clause_5_16_1_4_fill_stops_and_reaps_when_its_snapshot_is_gone() {
    1775            4 :         let st = state().await;
    1776            4 :         let tenant = TenantId::default();
    1777            4 :         let orphan = TenantId::new_internal("snap-reap-test").expect("tenant");
    1778            4 :         let _ = st
    1779            4 :             .store
    1780            4 :             .create(
    1781            4 :                 &orphan,
    1782            4 :                 Kind::Entity,
    1783            4 :                 "urn:ngsi-ld:Vehicle:r1",
    1784            4 :                 json!({"id": "urn:ngsi-ld:Vehicle:r1"}),
    1785            4 :             )
    1786            4 :             .await;
    1787            4 :         assert!(
    1788            4 :             fill_cancelled(&st, &tenant, "urn:ngsi-ld:Snapshot:gone", &orphan).await,
    1789              :             "a fill kept running after its snapshot was deleted"
    1790              :         );
    1791            4 :         assert!(
    1792            4 :             st.store
    1793            4 :                 .list(&orphan, Kind::Entity)
    1794            4 :                 .await
    1795            4 :                 .unwrap_or_default()
    1796            4 :                 .is_empty(),
    1797              :             "the orphaned copy outlived its snapshot"
    1798              :         );
    1799              : 
    1800            4 :         let sid = snapshot_over_vehicles(&st).await;
    1801            4 :         let synth = synth_tenant(&stored(&st, &sid).await).expect("__tenant");
    1802            4 :         let _ = st
    1803            4 :             .store
    1804            4 :             .create(
    1805            4 :                 &synth,
    1806            4 :                 Kind::Entity,
    1807            4 :                 "urn:ngsi-ld:Vehicle:r2",
    1808            4 :                 json!({"id": "urn:ngsi-ld:Vehicle:r2"}),
    1809            4 :             )
    1810            4 :             .await;
    1811            4 :         assert!(
    1812            4 :             !fill_cancelled(&st, &tenant, &sid, &synth).await,
    1813              :             "a live snapshot must not cancel its own fill"
    1814              :         );
    1815            4 :         assert_eq!(
    1816            4 :             st.store
    1817            4 :                 .list(&synth, Kind::Entity)
    1818            4 :                 .await
    1819            4 :                 .unwrap_or_default()
    1820            4 :                 .len(),
    1821            4 :             1,
    1822            4 :             "a live snapshot's copy was reaped"
    1823            4 :         );
    1824            4 :     }
    1825              : 
    1826              :     /// 5.16.5.4 + 5.5.15: deleting a snapshot frees the whole isolated copy,
    1827              :     /// including the subscriptions created through the 6.3.22 header — no
    1828              :     /// client can reach them afterwards, so nothing may keep firing.
    1829              :     #[tokio::test(flavor = "multi_thread")]
    1830            4 :     async fn clause_5_16_5_delete_frees_the_whole_snapshot_copy() {
    1831            4 :         let st = state().await;
    1832            4 :         let sid = snapshot_over_vehicles(&st).await;
    1833            4 :         let synth = synth_tenant(&stored(&st, &sid).await).expect("__tenant");
    1834            4 :         let sub = json!({"type": "Subscription", "entities": [{"type": "Vehicle"}],
    1835            4 :             "notification": {"endpoint": {"uri": "http://127.0.0.1:9/none"}}});
    1836            4 :         let (status, _, b) = send(
    1837            4 :             &st,
    1838            4 :             "POST",
    1839            4 :             "/ngsi-ld/v1/subscriptions",
    1840            4 :             Some(("application/json", sub.to_string())),
    1841            4 :             &[("NGSILD-Snapshot", sid.as_str())],
    1842              :         )
    1843            4 :         .await;
    1844            4 :         assert_eq!(status, StatusCode::CREATED, "{b}");
    1845            4 :         assert!(
    1846            4 :             !st.store
    1847            4 :                 .list(&synth, Kind::Subscription)
    1848            4 :                 .await
    1849            4 :                 .unwrap_or_default()
    1850            4 :                 .is_empty(),
    1851              :             "the scoped subscription did not land in the snapshot copy"
    1852              :         );
    1853              : 
    1854            4 :         let (status, _, _) = send(
    1855            4 :             &st,
    1856            4 :             "DELETE",
    1857            4 :             &format!("/ngsi-ld/v1/snapshots/{sid}"),
    1858            4 :             None,
    1859            4 :             &[],
    1860              :         )
    1861            4 :         .await;
    1862            4 :         assert_eq!(status, StatusCode::NO_CONTENT);
    1863            4 :         for _ in 0..100 {
    1864            5 :             let left = st
    1865            5 :                 .store
    1866            5 :                 .list(&synth, Kind::Subscription)
    1867            5 :                 .await
    1868            5 :                 .unwrap_or_default()
    1869            5 :                 .len();
    1870            5 :             if left == 0 {
    1871            4 :                 return;
    1872            4 :             }
    1873            4 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    1874            4 :         }
    1875            4 :         panic!("a snapshot-scoped subscription outlived its snapshot");
    1876            4 :     }
    1877              : 
    1878              :     /// 5.2.41 Table 5.2.41-1: the snapshot id "cannot be later modified in
    1879              :     /// update operations" — whatever JSON type the client sends it as.
    1880              :     #[test]
    1881            4 :     fn clause_5_16_4_4_id_cannot_be_modified() {
    1882            4 :         assert!(validate(&json!({"id": "urn:ngsi-ld:Snapshot:1"}), Mode::Update).is_err());
    1883            4 :         assert!(
    1884            4 :             validate(&json!({"id": 5}), Mode::Update).is_err(),
    1885              :             "a non-string id escaped the 5.2.41 restriction"
    1886              :         );
    1887            4 :         assert!(validate(&json!({"snapshotPriority": 7}), Mode::Update).is_ok());
    1888            4 :     }
    1889              : 
    1890              :     /// 5.16.1.4: expiresAt is set "taking into account the snapshotLifetime
    1891              :     /// requested, but applying the configured limit of the NGSI-LD system";
    1892              :     /// a malformed duration is BadRequestData, and a snapshot past its
    1893              :     /// expiresAt is gone together with its copy.
    1894              :     #[tokio::test(flavor = "multi_thread")]
    1895            4 :     async fn clause_5_16_1_4_lifetime_is_clamped_and_expiry_removes_the_snapshot() {
    1896            4 :         let mut o = Map::new();
    1897            4 :         o.insert("snapshotLifetime".into(), json!("P10Y"));
    1898            4 :         let exp = chrono::DateTime::parse_from_rfc3339(&expires_at(&o).expect("expiresAt"))
    1899            4 :             .expect("rfc3339")
    1900            4 :             .with_timezone(&chrono::Utc);
    1901            4 :         let limit = chrono::Utc::now() + chrono::Duration::seconds(MAX_LIFETIME_SECS);
    1902            4 :         assert!(exp <= limit, "the configured limit was not applied: {exp}");
    1903            4 :         assert!(
    1904            4 :             exp > chrono::Utc::now() + chrono::Duration::days(6),
    1905              :             "the suggested lifetime was ignored: {exp}"
    1906              :         );
    1907            4 :         assert!(validate(
    1908            4 :             &json!({"type": "Snapshot", "snapshotLifetime": "tomorrow",
    1909            4 :                 "snapshotQueries": [{"type": "Query", "entities": [{"type": "Vehicle"}]}]}),
    1910            4 :             Mode::Create
    1911            4 :         )
    1912            4 :         .is_err());
    1913              : 
    1914            4 :         let st = state().await;
    1915            4 :         let sid = snapshot_over_vehicles(&st).await;
    1916            4 :         let synth = synth_tenant(&stored(&st, &sid).await).expect("__tenant");
    1917            4 :         let _ = st
    1918            4 :             .store
    1919            4 :             .create(
    1920            4 :                 &synth,
    1921            4 :                 Kind::Entity,
    1922            4 :                 "urn:ngsi-ld:Vehicle:e1",
    1923            4 :                 json!({"id": "urn:ngsi-ld:Vehicle:e1"}),
    1924            4 :             )
    1925            4 :             .await;
    1926            4 :         let _ = st
    1927            4 :             .store
    1928            4 :             .mutate(&TenantId::default(), Kind::Snapshot, &sid, |d| {
    1929            4 :                 if let Some(o) = d.as_object_mut() {
    1930            4 :                     o.insert("expiresAt".into(), json!("2000-01-01T00:00:00.000Z"));
    1931            4 :                 }
    1932            4 :                 Ok::<_, std::convert::Infallible>(())
    1933            4 :             })
    1934            4 :             .await;
    1935            4 :         let (status, _, b) = send(
    1936            4 :             &st,
    1937            4 :             "GET",
    1938            4 :             &format!("/ngsi-ld/v1/snapshots/{sid}"),
    1939            4 :             None,
    1940            4 :             &[],
    1941              :         )
    1942            4 :         .await;
    1943            4 :         assert_eq!(status, StatusCode::NOT_FOUND, "an expired snapshot: {b}");
    1944            4 :         assert!(
    1945            4 :             !st.store
    1946            4 :                 .list(&synth, Kind::Entity)
    1947            4 :                 .await
    1948            4 :                 .unwrap_or_default()
    1949            4 :                 .is_empty(),
    1950              :             "the GET freed the copy: reading must not write"
    1951              :         );
    1952              :         // The sweep is what frees it, and the purge it starts is background.
    1953            4 :         assert_eq!(
    1954            4 :             sweep_expired_snapshots(&st, &TenantId::default()).await,
    1955              :             1,
    1956              :             "the sweep did not reap the expired snapshot"
    1957              :         );
    1958            4 :         for _ in 0..100 {
    1959            6 :             if st
    1960            6 :                 .store
    1961            6 :                 .list(&synth, Kind::Entity)
    1962            6 :                 .await
    1963            6 :                 .unwrap_or_default()
    1964            6 :                 .is_empty()
    1965            4 :             {
    1966            4 :                 return;
    1967            4 :             }
    1968            4 :             tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    1969            4 :         }
    1970            4 :         panic!("the copy of an expired snapshot was not freed");
    1971            4 :     }
    1972              : }
        

Generated by: LCOV version 2.0-1