LCOV - code coverage report
Current view: top level - antares-store/src - contract.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.8 % 598 597
Test Date: 2026-09-21 10:31:06 Functions: 38.9 % 54 21

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! The driver contract, as executable code.
       3              : //!
       4              : //! Every rule here is one a caller in `antares-api` relies on and no
       5              : //! backend may decide for itself. A driver that passes both functions can be
       6              : //! dropped into the broker; one that does not will break requests in ways
       7              : //! its own unit tests are free to miss, because a backend's tests assert
       8              : //! what that backend does, not what the seam promises.
       9              : //!
      10              : //! Both functions PANIC on the first violation, naming the rule. The caller
      11              : //! supplies two tenants that already exist (a backend may require a tenant
      12              : //! row before it accepts writes) and a prefix that makes the ids of this run
      13              : //! unique, so a shared database can host several runs at once.
      14              : //!
      15              : //! Both functions are `async`: a driver is awaited, never blocked on.
      16              : 
      17              : use crate::{CurrentStateDriver, CurrentStateDriverExt, Kind, TemporalDriver, TemporalDriverExt};
      18              : use antares_model::TenantId;
      19              : use serde_json::{json, Value};
      20              : 
      21          102 : fn doc(id: &str) -> Value {
      22          102 :     json!({"id": id, "type": "https://uri.etsi.org/ngsi-ld/default-context/T"})
      23          102 : }
      24              : 
      25              : /// Hold a current-state driver to the contract `antares-api` writes against.
      26              : ///
      27              : /// `a` and `b` are two existing tenants; `prefix` namespaces the ids this
      28              : /// run creates and deletes. Panics on the first rule broken.
      29            6 : pub async fn run_current_state_contract(
      30            6 :     d: &dyn CurrentStateDriver,
      31            6 :     a: &TenantId,
      32            6 :     b: &TenantId,
      33            6 :     prefix: &str,
      34            6 : ) {
      35            6 :     let e1 = format!("urn:ngsi-ld:{prefix}:1");
      36            6 :     let e2 = format!("urn:ngsi-ld:{prefix}:2");
      37            6 :     let gone = format!("urn:ngsi-ld:{prefix}:absent");
      38              : 
      39              :     // An absent row is absent, not an error and not an empty document.
      40            6 :     assert!(
      41            6 :         d.get(a, Kind::Entity, &gone).await.expect("get").is_none(),
      42              :         "get of an absent row must answer None"
      43              :     );
      44            6 :     assert!(
      45            6 :         !d.delete(a, Kind::Entity, &gone).await.expect("delete"),
      46              :         "delete of an absent row must answer false"
      47              :     );
      48              : 
      49              :     // ADR-0005 / ETSI 047_06: a mutate is a read-modify-write under the row
      50              :     // lock, and a missing row is None — NEVER an insert. A get+upsert
      51              :     // implementation lets a bookkeeping writeback racing a DELETE resurrect
      52              :     // the deleted row.
      53            6 :     let missed = d
      54            6 :         .mutate::<(), ()>(a, Kind::Entity, &gone, |_| Ok(()))
      55            6 :         .await
      56            6 :         .expect("mutate");
      57            6 :     assert!(missed.is_none(), "mutate of an absent row must answer None");
      58            6 :     assert!(
      59            6 :         d.get(a, Kind::Entity, &gone).await.expect("get").is_none(),
      60              :         "mutate of an absent row must not insert it (047_06)"
      61              :     );
      62              : 
      63              :     // create is create-if-absent: the second one is refused and changes
      64              :     // nothing.
      65            6 :     assert!(
      66            6 :         d.create(a, Kind::Entity, &e1, doc(&e1))
      67            6 :             .await
      68            6 :             .expect("create"),
      69              :         "the first create must report true"
      70              :     );
      71            6 :     assert!(
      72            6 :         !d.create(a, Kind::Entity, &e1, doc("urn:overwritten"))
      73            6 :             .await
      74            6 :             .expect("create"),
      75              :         "a create over an existing id must report false"
      76              :     );
      77            6 :     let row = d
      78            6 :         .get(a, Kind::Entity, &e1)
      79            6 :         .await
      80            6 :         .expect("get")
      81            6 :         .expect("row");
      82            6 :     assert_eq!(
      83            6 :         row["id"],
      84            6 :         e1.as_str(),
      85              :         "the refused create must not overwrite"
      86              :     );
      87              : 
      88              :     // A mutate that returns Ok commits; one that returns Err commits nothing.
      89            6 :     let applied = d
      90            6 :         .mutate::<(), ()>(a, Kind::Entity, &e1, |v| {
      91            6 :             v["marker"] = json!(1);
      92            6 :             Ok(())
      93            6 :         })
      94            6 :         .await
      95            6 :         .expect("mutate");
      96            6 :     assert_eq!(applied, Some(Ok(())), "a mutate over a present row applies");
      97            6 :     assert_eq!(
      98            6 :         d.get(a, Kind::Entity, &e1)
      99            6 :             .await
     100            6 :             .expect("get")
     101            6 :             .expect("row")["marker"],
     102            6 :         json!(1),
     103              :         "an accepted mutate must be visible to the next read"
     104              :     );
     105            6 :     let rejected = d
     106            6 :         .mutate::<(), &'static str>(a, Kind::Entity, &e1, |v| {
     107            6 :             v["marker"] = json!(2);
     108            6 :             Err("rejected")
     109            6 :         })
     110            6 :         .await
     111            6 :         .expect("mutate");
     112            6 :     assert_eq!(
     113              :         rejected,
     114              :         Some(Err("rejected")),
     115              :         "the closure's error crosses back"
     116              :     );
     117            6 :     assert_eq!(
     118            6 :         d.get(a, Kind::Entity, &e1)
     119            6 :             .await
     120            6 :             .expect("get")
     121            6 :             .expect("row")["marker"],
     122            6 :         json!(1),
     123              :         "a rejected mutate must commit nothing"
     124              :     );
     125              : 
     126              :     // Tenant isolation, on every read and every write path.
     127            6 :     assert!(
     128            6 :         d.get(b, Kind::Entity, &e1).await.expect("get").is_none(),
     129              :         "another tenant must not read the row"
     130              :     );
     131            6 :     assert!(
     132            6 :         !d.delete(b, Kind::Entity, &e1).await.expect("delete"),
     133              :         "another tenant must not delete the row"
     134              :     );
     135            6 :     assert!(
     136            6 :         d.mutate::<(), ()>(b, Kind::Entity, &e1, |_| Ok(()))
     137            6 :             .await
     138            6 :             .expect("mutate")
     139            6 :             .is_none(),
     140              :         "another tenant must not mutate the row"
     141              :     );
     142            6 :     assert!(
     143            6 :         d.list(b, Kind::Entity)
     144            6 :             .await
     145            6 :             .expect("list")
     146            6 :             .iter()
     147            6 :             .all(|r| r["id"] != e1.as_str()),
     148              :         "another tenant must not list the row"
     149              :     );
     150              : 
     151              :     // Kinds are separate namespaces: the same id under two kinds is two rows.
     152            6 :     assert!(
     153            6 :         d.create(a, Kind::Subscription, &e1, doc(&e1))
     154            6 :             .await
     155            6 :             .expect("create"),
     156              :         "the same id under another kind is a different row"
     157              :     );
     158            6 :     assert!(d.delete(a, Kind::Subscription, &e1).await.expect("delete"));
     159            6 :     assert!(
     160            6 :         d.get(a, Kind::Entity, &e1).await.expect("get").is_some(),
     161              :         "deleting one kind must not touch another"
     162              :     );
     163              : 
     164              :     // upsert answers whether a document was ALREADY there, and batch_upsert
     165              :     // answers the opposite polarity — created-flags, which the batch path
     166              :     // needs to split 201 from 204 (5.6.8). Getting one of the two backwards
     167              :     // is invisible until a batch reports every create as an update.
     168            6 :     assert!(
     169            6 :         !d.upsert(a, Kind::Entity, &e2, doc(&e2))
     170            6 :             .await
     171            6 :             .expect("upsert"),
     172              :         "upsert that created the row reports false"
     173              :     );
     174            6 :     assert!(
     175            6 :         d.upsert(a, Kind::Entity, &e2, doc(&e2))
     176            6 :             .await
     177            6 :             .expect("upsert"),
     178              :         "upsert over an existing row reports true"
     179              :     );
     180            6 :     let e3 = format!("urn:ngsi-ld:{prefix}:3");
     181            6 :     let flags = d
     182            6 :         .batch_upsert(a, vec![(e3.clone(), doc(&e3)), (e2.clone(), doc(&e2))])
     183            6 :         .await
     184            6 :         .expect("batch_upsert");
     185            6 :     assert_eq!(
     186              :         flags,
     187            6 :         vec![true, false],
     188              :         "batch_upsert answers created-flags in input order, the opposite \
     189              :          polarity of upsert"
     190              :     );
     191            6 :     assert!(d.delete(a, Kind::Entity, &e3).await.expect("delete"));
     192            6 :     let ids = vec![e2.clone(), gone.clone(), e1.clone()];
     193            6 :     let batch = d
     194           12 :         .batch_mutate::<()>(a, &ids, |_, v| {
     195           12 :             v["batched"] = json!(true);
     196           12 :             Ok(())
     197           12 :         })
     198            6 :         .await
     199            6 :         .expect("batch_mutate");
     200            6 :     assert_eq!(batch.len(), ids.len(), "one result per input id");
     201            6 :     assert!(batch[0].is_some(), "results align with ids: {ids:?}");
     202            6 :     assert!(batch[1].is_none(), "an absent id answers None, in place");
     203            6 :     assert!(batch[2].is_some(), "results align with ids: {ids:?}");
     204              : 
     205              :     // A query may over-return (the caller re-checks whatever the backend
     206              :     // could not decide) but must never drop a matching row, and must never
     207              :     // cross a tenant.
     208            6 :     let ids_ref: Vec<&str> = vec![&e1, &e2];
     209            6 :     let mine = d
     210            6 :         .query_entities(
     211            6 :             a,
     212            6 :             &crate::filter::EntityFilter {
     213            6 :                 ids: Some(&ids_ref),
     214            6 :                 ..Default::default()
     215            6 :             },
     216            6 :         )
     217            6 :         .await
     218            6 :         .expect("query_entities");
     219           12 :     for want in [&e1, &e2] {
     220           12 :         assert!(
     221           18 :             mine.rows.iter().any(|r| r["id"] == want.as_str()),
     222              :             "query must not drop a matching row: {want}"
     223              :         );
     224              :     }
     225            6 :     let theirs = d
     226            6 :         .query_entities(
     227            6 :             b,
     228            6 :             &crate::filter::EntityFilter {
     229            6 :                 ids: Some(&ids_ref),
     230            6 :                 ..Default::default()
     231            6 :             },
     232            6 :         )
     233            6 :         .await
     234            6 :         .expect("query_entities");
     235            6 :     assert!(
     236            6 :         theirs.rows.is_empty(),
     237              :         "a query must never cross a tenant: {:?}",
     238              :         theirs.rows
     239              :     );
     240              : 
     241              :     // Paging pushdown is optional; claiming it and not doing it is not.
     242            6 :     let paged = d
     243            6 :         .query_entities(
     244            6 :             a,
     245            6 :             &crate::filter::EntityFilter {
     246            6 :                 ids: Some(&ids_ref),
     247            6 :                 page: Some(crate::filter::Page {
     248            6 :                     offset: 0,
     249            6 :                     limit: 1,
     250            6 :                     count: true,
     251            6 :                 }),
     252            6 :                 ..Default::default()
     253            6 :             },
     254            6 :         )
     255            6 :         .await
     256            6 :         .expect("query_entities");
     257            6 :     if paged.paged {
     258            2 :         assert!(
     259            2 :             paged.rows.len() <= 1,
     260              :             "a driver reporting paged=true has applied the LIMIT"
     261              :         );
     262            2 :         assert!(
     263            2 :             paged.decided,
     264              :             "paged implies decided: a LIMIT over an undecided set pages the wrong rows"
     265              :         );
     266            4 :     }
     267              : 
     268              :     // `subscription_tenants` is the iteration domain the interval sweep and
     269              :     // both mirror hydrations walk. A backend may return MORE tenants than
     270              :     // hold a subscription — every caller lists per tenant afterwards and an
     271              :     // empty list costs nothing. It may never return FEWER: a tenant missing
     272              :     // here is a tenant whose periodic notifications never fire and whose
     273              :     // subscriptions never reach the mirror, with no error anywhere.
     274            6 :     let sub = format!("urn:ngsi-ld:Subscription:{prefix}:1");
     275            6 :     let sub_doc = json!({
     276            6 :         "id": &sub,
     277            6 :         "type": "Subscription",
     278            6 :         "status": "active",
     279            6 :         "notification": {"endpoint": {"uri": "http://sink.invalid/n"}},
     280              :     });
     281            6 :     d.upsert(a, Kind::Subscription, &sub, sub_doc)
     282            6 :         .await
     283            6 :         .expect("upsert subscription");
     284            6 :     let domain = d
     285            6 :         .subscription_tenants()
     286            6 :         .await
     287            6 :         .expect("subscription_tenants");
     288            6 :     assert!(
     289           22 :         domain.iter().any(|t| t == a.as_str()),
     290              :         "a tenant holding a subscription must appear in subscription_tenants: {domain:?}"
     291              :     );
     292              :     // A REGISTRATION puts a tenant in the domain too. One of the hydrations
     293              :     // that walks this domain fills the registration mirror, and the
     294              :     // federation path reads that mirror alone once it is installed — so a
     295              :     // tenant holding registrations and no subscription is a tenant that
     296              :     // silently forwards to no Context Source.
     297            6 :     let reg_only = TenantId::new(&format!("{prefix}regonly")).expect("tenant");
     298            6 :     let reg = format!("urn:ngsi-ld:ContextSourceRegistration:{prefix}:1");
     299            6 :     d.upsert(
     300            6 :         &reg_only,
     301            6 :         Kind::Registration,
     302            6 :         &reg,
     303            6 :         json!({
     304            6 :             "id": &reg,
     305            6 :             "type": "ContextSourceRegistration",
     306            6 :             "endpoint": "http://cs.invalid/ngsi-ld/v1",
     307            6 :             "information": [{"entities": [{"type": "Vehicle"}]}],
     308            6 :         }),
     309            6 :     )
     310            6 :     .await
     311            6 :     .expect("upsert registration");
     312            6 :     let domain = d
     313            6 :         .subscription_tenants()
     314            6 :         .await
     315            6 :         .expect("subscription_tenants");
     316            6 :     assert!(
     317           34 :         domain.iter().any(|t| t == reg_only.as_str()),
     318              :         "a tenant holding only a registration must appear in the mirror-hydration \
     319              :          domain, or its registration mirror hydrates empty: {domain:?}"
     320              :     );
     321              : 
     322              :     // Table 5.2.9-2 forward bookkeeping, pinned here for the same reason as
     323              :     // 5.2.14.2's: a backend may write it as one statement instead of a
     324              :     // read-modify-write, and the result must not depend on which it chose.
     325            6 :     let f1 = "2020-02-01T00:00:00.000Z";
     326            6 :     let f2 = "2020-02-02T00:00:00.000Z";
     327            6 :     let ok1 = d
     328            6 :         .record_forward(&reg_only, &reg, f1, true)
     329            6 :         .await
     330            6 :         .expect("record_forward")
     331            6 :         .expect("the registration is there");
     332            6 :     assert_eq!(
     333            6 :         ok1["timesSent"], 1,
     334              :         "the first forward moves timesSent to 1"
     335              :     );
     336            6 :     assert_eq!(ok1["lastSuccess"], f1, "lastSuccess takes the stamp");
     337            6 :     assert_eq!(ok1["status"], "ok", "a 2xx leaves the registration ok");
     338            6 :     assert!(
     339            6 :         ok1.get("timesFailed").is_none() && ok1.get("lastFailure").is_none(),
     340              :         "a registration that has only succeeded carries no failure members: {ok1}"
     341              :     );
     342              : 
     343            6 :     let bad = d
     344            6 :         .record_forward(&reg_only, &reg, f2, false)
     345            6 :         .await
     346            6 :         .expect("record_forward")
     347            6 :         .expect("the registration is still there");
     348            6 :     assert_eq!(
     349            6 :         bad["timesSent"], 2,
     350              :         "timesSent counts the failed attempt too (Table 5.2.9-2)"
     351              :     );
     352            6 :     assert_eq!(bad["timesFailed"], 1, "the failure moves timesFailed");
     353            6 :     assert_eq!(bad["lastFailure"], f2, "lastFailure takes the stamp");
     354            6 :     assert_eq!(bad["status"], "failed", "status names the LAST attempt");
     355            6 :     assert_eq!(
     356            6 :         bad["lastSuccess"], f1,
     357              :         "a failure never rewinds the last success"
     358              :     );
     359              : 
     360              :     // A registration deleted while its forward was in flight has no row to
     361              :     // book against, and the writeback must not resurrect it.
     362            6 :     let vanished = format!("urn:ngsi-ld:ContextSourceRegistration:{prefix}:gone");
     363            6 :     assert!(
     364            6 :         d.record_forward(&reg_only, &vanished, f1, true)
     365            6 :             .await
     366            6 :             .expect("record_forward")
     367            6 :             .is_none(),
     368              :         "an absent registration books nothing"
     369              :     );
     370            6 :     assert!(
     371            6 :         d.get(&reg_only, Kind::Registration, &vanished)
     372            6 :             .await
     373            6 :             .expect("get")
     374            6 :             .is_none(),
     375              :         "a bookkeeping writeback must never insert the row it missed"
     376              :     );
     377              : 
     378            6 :     d.delete(&reg_only, Kind::Registration, &reg)
     379            6 :         .await
     380            6 :         .expect("delete");
     381              :     // `list_page` is how the readers that must see EVERY document read —
     382              :     // the mirror seed above all — so a backend may not refuse it for volume
     383              :     // the way `list` may (5.5.6 licenses TooManyResults for a query
     384              :     // operation, which an internal bootstrap is not). Every arm must agree
     385              :     // on the walk, including the one that takes the trait default: ids
     386              :     // strictly greater than `after`, id-ordered, at most `limit`, a short
     387              :     // page means the end, and every stored document served exactly once.
     388            6 :     let mut ids: Vec<String> = (0..7)
     389           42 :         .map(|i| format!("urn:ngsi-ld:Subscription:{prefix}:page:{i}"))
     390            6 :         .collect();
     391           42 :     for id in &ids {
     392           42 :         d.upsert(
     393           42 :             a,
     394           42 :             Kind::Subscription,
     395           42 :             id,
     396           42 :             json!({"id": id, "type": "Subscription"}),
     397           42 :         )
     398           42 :         .await
     399           42 :         .expect("upsert page subscription");
     400              :     }
     401            6 :     ids.push(sub.clone());
     402            6 :     ids.sort();
     403              : 
     404            6 :     let mut walked: Vec<String> = Vec::new();
     405            6 :     let mut after: Option<String> = None;
     406              :     loop {
     407           18 :         let page = d
     408           18 :             .list_page(a, Kind::Subscription, after.as_deref(), 3)
     409           18 :             .await
     410           18 :             .expect("list_page");
     411           18 :         assert!(page.len() <= 3, "a page may not exceed its limit: {page:?}");
     412           18 :         let short = page.len() < 3;
     413           48 :         for doc in &page {
     414           48 :             let id = doc["id"].as_str().expect("a stored doc keeps its id");
     415           48 :             assert!(
     416           48 :                 after.as_deref().is_none_or(|prev| id > prev),
     417              :                 "`after` is exclusive and the walk ascends: {id} after {after:?}"
     418              :             );
     419           48 :             after = Some(id.to_owned());
     420           48 :             walked.push(id.to_owned());
     421              :         }
     422           18 :         if short {
     423            6 :             break;
     424           12 :         }
     425              :     }
     426            6 :     assert_eq!(
     427              :         walked, ids,
     428              :         "the walk must serve every subscription of the tenant exactly once, in id order"
     429              :     );
     430              :     // The other tenant's documents are not on this walk. 4.14: operations
     431              :     // "only apply to the information of the specified `Tenant` in isolation".
     432            6 :     assert!(
     433            6 :         d.list_page(b, Kind::Subscription, None, 100)
     434            6 :             .await
     435            6 :             .expect("list_page")
     436            6 :             .is_empty(),
     437              :         "a tenant with no subscriptions pages empty, whatever another tenant holds"
     438              :     );
     439              :     // Entities page like every other kind. 5.9.2.4's registration-vs-entity
     440              :     // conflict check reads them this way — it must see every Entity of the
     441              :     // tenant and has no TooManyResults to raise — so a backend may not refuse
     442              :     // this walk for volume either, and may not build the page by
     443              :     // materializing the tenant first.
     444              :     //
     445              :     // 4.22 applies INSIDE the page: an expired Entity is not there to be
     446              :     // served, and dropping one after the limit was applied would hand back a
     447              :     // short page, which every walker reads as the end of the tenant.
     448            6 :     let stem = format!("urn:ngsi-ld:{prefix}:page:");
     449           24 :     let live: Vec<String> = (0..4).map(|i| format!("{stem}live{i}")).collect();
     450              :     // sorts between live1 and live2, so it falls inside the first page below
     451            6 :     let expired = format!("{stem}live1x");
     452           24 :     for id in &live {
     453           24 :         d.upsert(a, Kind::Entity, id, doc(id))
     454           24 :             .await
     455           24 :             .expect("upsert page entity");
     456              :     }
     457            6 :     let mut gone_doc = doc(&expired);
     458            6 :     gone_doc["expiresAt"] = json!("2000-01-01T00:00:00.000Z");
     459            6 :     d.upsert(a, Kind::Entity, &expired, gone_doc)
     460            6 :         .await
     461            6 :         .expect("upsert expired entity");
     462              : 
     463            6 :     let page = d
     464            6 :         .list_page(a, Kind::Entity, Some(&stem), 3)
     465            6 :         .await
     466            6 :         .expect("list_page entities");
     467           18 :     let served: Vec<&str> = page.iter().filter_map(|d| d["id"].as_str()).collect();
     468            6 :     assert_eq!(
     469              :         served,
     470            6 :         [live[0].as_str(), live[1].as_str(), live[2].as_str()],
     471              :         "an expired entity may neither be served nor consume a slot of the page"
     472              :     );
     473              : 
     474            6 :     let mut walked: Vec<String> = Vec::new();
     475            6 :     let mut after = stem.clone();
     476              :     loop {
     477           18 :         let page = d
     478           18 :             .list_page(a, Kind::Entity, Some(&after), 2)
     479           18 :             .await
     480           18 :             .expect("list_page entities");
     481           18 :         let short = page.len() < 2;
     482           18 :         let mut moved = false;
     483           24 :         for doc in &page {
     484           24 :             let id = doc["id"].as_str().expect("a stored entity keeps its id");
     485           24 :             assert!(
     486           24 :                 id > after.as_str(),
     487              :                 "`after` is exclusive: {id} after {after}"
     488              :             );
     489           24 :             after = id.to_owned();
     490           24 :             moved = true;
     491           24 :             if id.starts_with(stem.as_str()) {
     492           24 :                 walked.push(id.to_owned());
     493           24 :             }
     494              :         }
     495           18 :         if short || !moved || !after.starts_with(stem.as_str()) {
     496            6 :             break;
     497           12 :         }
     498              :     }
     499            6 :     assert_eq!(
     500              :         walked, live,
     501              :         "the walk must serve every live entity of the tenant exactly once, in id \
     502              :          order, and never the expired one"
     503              :     );
     504           30 :     for id in live.iter().chain(std::iter::once(&expired)) {
     505           30 :         d.delete(a, Kind::Entity, id)
     506           30 :             .await
     507           30 :             .expect("delete page entity");
     508              :     }
     509              : 
     510              :     // 4.22 at the read boundary, the part a backend must not decide for
     511              :     // itself. The stamp marks "a certain Entity, Property or Relationship"
     512              :     // invalid; it is not a store-wide rule that hides any document carrying
     513              :     // one, and it reaches every Attribute of an Entity, sub-Attributes
     514              :     // included.
     515            6 :     let exp_e = format!("urn:ngsi-ld:{prefix}:expiry:entity");
     516            6 :     let mut expired_entity = doc(&exp_e);
     517            6 :     expired_entity["expiresAt"] = json!("2000-01-01T00:00:00.000Z");
     518            6 :     d.upsert(a, Kind::Entity, &exp_e, expired_entity)
     519            6 :         .await
     520            6 :         .expect("upsert expired entity");
     521            6 :     assert!(
     522            6 :         d.get(a, Kind::Entity, &exp_e).await.expect("get").is_none(),
     523              :         "an Entity past its expiresAt reads absent, not just out of a page"
     524              :     );
     525            6 :     d.delete(a, Kind::Entity, &exp_e).await.expect("delete");
     526              : 
     527              :     // An Attribute whose every instance expired leaves; the Entity carrying
     528              :     // it does not, and neither does a live sibling.
     529            6 :     let exp_a = format!("urn:ngsi-ld:{prefix}:expiry:attr");
     530           72 :     let iri = |n: &str| format!("https://uri.etsi.org/ngsi-ld/default-context/{n}");
     531            6 :     let mut with_attrs = doc(&exp_a);
     532            6 :     with_attrs[iri("gone")] = json!([{"value": 1, "expiresAt": "2000-01-01T00:00:00.000Z"}]);
     533            6 :     with_attrs[iri("kept")] = json!([{
     534            6 :         "value": 2,
     535            6 :         iri("subgone"): [{"value": 3, "expiresAt": "2000-01-01T00:00:00.000Z"}],
     536            6 :         iri("subkept"): [{"value": 4}],
     537              :     }]);
     538              :     // 4.6.3 leaves the seconds-fraction separator open, and ',' (0x2C) sorts
     539              :     // before both '.' and 'Z', so the two stamps have to be read as instants:
     540              :     // a backend that compares them as bytes serves this live Attribute as
     541              :     // expired. Every backend answers the same here or it is not the same
     542              :     // store.
     543            6 :     with_attrs[iri("commakept")] = json!([{"value": 5, "expiresAt": "2999-01-01T00:00:00,500Z"}]);
     544            6 :     d.upsert(a, Kind::Entity, &exp_a, with_attrs)
     545            6 :         .await
     546            6 :         .expect("upsert entity with an expired attribute");
     547            6 :     let served = d
     548            6 :         .get(a, Kind::Entity, &exp_a)
     549            6 :         .await
     550            6 :         .expect("get")
     551            6 :         .expect("an Entity outlives the expiry of one of its Attributes");
     552            6 :     assert!(
     553            6 :         served.get(iri("gone")).is_none(),
     554              :         "an Attribute whose only instance expired is not served"
     555              :     );
     556            6 :     assert_eq!(
     557            6 :         served[iri("kept")][0]["value"],
     558            6 :         json!(2),
     559              :         "a live Attribute survives its sibling's expiry"
     560              :     );
     561            6 :     assert!(
     562            6 :         served[iri("kept")][0].get(iri("subgone")).is_none(),
     563              :         "a sub-Attribute is a Property or Relationship too: past its stamp it \
     564              :          is not served"
     565              :     );
     566            6 :     assert_eq!(
     567            6 :         served[iri("kept")][0][iri("subkept")][0]["value"],
     568            6 :         json!(4),
     569              :         "a live sub-Attribute survives its sibling's expiry"
     570              :     );
     571            6 :     assert_eq!(
     572            6 :         served[iri("commakept")][0]["value"],
     573            6 :         json!(5),
     574              :         "a comma seconds-fraction is an instant, not a byte string"
     575              :     );
     576            6 :     d.delete(a, Kind::Entity, &exp_a).await.expect("delete");
     577              : 
     578              :     // 5.8.6: an expired SUBSCRIPTION is not deleted and stays retrievable —
     579              :     // the API turns the stamp into status "expired" and keeps it updatable.
     580              :     // A backend that hid every document with a past expiresAt would lose it.
     581            6 :     let exp_s = format!("urn:ngsi-ld:{prefix}:expiry:sub");
     582            6 :     d.upsert(
     583            6 :         a,
     584            6 :         Kind::Subscription,
     585            6 :         &exp_s,
     586            6 :         json!({
     587            6 :             "id": exp_s,
     588            6 :             "type": "Subscription",
     589            6 :             "expiresAt": "2000-01-01T00:00:00.000Z",
     590            6 :         }),
     591            6 :     )
     592            6 :     .await
     593            6 :     .expect("upsert expired subscription");
     594            6 :     assert!(
     595            6 :         d.get(a, Kind::Subscription, &exp_s)
     596            6 :             .await
     597            6 :             .expect("get")
     598            6 :             .is_some(),
     599              :         "an expired Subscription stays retrievable (5.8.6)"
     600              :     );
     601            6 :     d.delete(a, Kind::Subscription, &exp_s)
     602            6 :         .await
     603            6 :         .expect("delete");
     604              : 
     605              :     // `delete_entity_if` decides and deletes under one lock, and it decides
     606              :     // on the STORED document. 5.6.6.4: an Entity the caller's selector
     607              :     // excludes "is not known" for the operation, which is the same answer an
     608              :     // absent Entity gets — so a refusal and a miss are both `false`, and a
     609              :     // refusal must leave the document exactly where it was.
     610            6 :     let cond = format!("urn:ngsi-ld:{prefix}:cond");
     611            6 :     d.upsert(a, Kind::Entity, &cond, doc(&cond))
     612            6 :         .await
     613            6 :         .expect("upsert conditional-delete entity");
     614            6 :     assert!(
     615            6 :         !d.delete_entity_if(a, &cond, &|_| false)
     616            6 :             .await
     617            6 :             .expect("delete_entity_if"),
     618              :         "a refused predicate reports no deletion"
     619              :     );
     620            6 :     assert!(
     621            6 :         d.get(a, Kind::Entity, &cond).await.expect("get").is_some(),
     622              :         "a refused delete leaves the document in place"
     623              :     );
     624              :     // 4.14: the predicate never even runs for another tenant's document.
     625            6 :     assert!(
     626            6 :         !d.delete_entity_if(b, &cond, &|_| panic!(
     627              :             "another tenant's document reached the predicate"
     628              :         ))
     629            6 :         .await
     630            6 :         .expect("delete_entity_if"),
     631              :         "an Entity of another tenant is absent here"
     632              :     );
     633            6 :     assert!(
     634            6 :         d.get(a, Kind::Entity, &cond).await.expect("get").is_some(),
     635              :         "a delete addressed to another tenant may not touch this one"
     636              :     );
     637            6 :     assert!(
     638            6 :         d.delete_entity_if(a, &cond, &|v| v["id"] == cond.as_str())
     639            6 :             .await
     640            6 :             .expect("delete_entity_if"),
     641              :         "the predicate is handed the stored document, not the id"
     642              :     );
     643            6 :     assert!(
     644            6 :         d.get(a, Kind::Entity, &cond).await.expect("get").is_none(),
     645              :         "an accepted predicate deletes"
     646              :     );
     647            6 :     assert!(
     648            6 :         !d.delete_entity_if(a, &cond, &|_| true)
     649            6 :             .await
     650            6 :             .expect("delete_entity_if"),
     651              :         "an absent Entity is not deleted, whatever the predicate answers"
     652              :     );
     653              : 
     654              :     // `list_slice` is the window a 5.5.9.2 limit/offset listing serves from,
     655              :     // and it must agree with the walk above on both the order and the set:
     656              :     // the same ids, the same order, and a total that counts the whole match
     657              :     // set rather than the page. A backend may not refuse it for volume — the
     658              :     // window bounds the result by construction.
     659            6 :     let (page, total) = d
     660            6 :         .list_slice(a, Kind::Subscription, 0, 3)
     661            6 :         .await
     662            6 :         .expect("list_slice");
     663            6 :     assert_eq!(total, ids.len(), "the total counts the set, not the page");
     664            6 :     assert_eq!(page.len(), 3, "limit is a maximum, and 3 were available");
     665           18 :     for (i, doc) in page.iter().enumerate() {
     666           18 :         assert_eq!(
     667           18 :             doc["id"].as_str(),
     668           18 :             Some(ids[i].as_str()),
     669              :             "the window is the id-ordered prefix: {page:?}"
     670              :         );
     671              :     }
     672              :     // Every single-element window lands on the element the walk has there.
     673           48 :     for (i, want) in ids.iter().enumerate() {
     674           48 :         let (page, _) = d
     675           48 :             .list_slice(a, Kind::Subscription, i, 1)
     676           48 :             .await
     677           48 :             .expect("list_slice");
     678           48 :         assert_eq!(
     679           48 :             page.first().and_then(|d| d["id"].as_str()),
     680           48 :             Some(want.as_str()),
     681              :             "offset {i} served the wrong element: {page:?}"
     682              :         );
     683              :     }
     684              :     // limit 0 is legal (6.3.10, with count): the count without the page.
     685            6 :     let (page, total) = d
     686            6 :         .list_slice(a, Kind::Subscription, 0, 0)
     687            6 :         .await
     688            6 :         .expect("list_slice");
     689            6 :     assert!(page.is_empty(), "limit 0 returns no elements: {page:?}");
     690            6 :     assert_eq!(total, ids.len(), "limit 0 still counts the whole set");
     691              :     // An offset past the end is an empty page, not an error, and the total
     692              :     // is unchanged by where the client asked to start.
     693            6 :     let (page, total) = d
     694            6 :         .list_slice(a, Kind::Subscription, ids.len() + 10, 5)
     695            6 :         .await
     696            6 :         .expect("list_slice");
     697            6 :     assert!(page.is_empty(), "past the end is empty: {page:?}");
     698            6 :     assert_eq!(total, ids.len(), "an offset does not change the match set");
     699              :     // 4.14 again: the window is inside one tenant.
     700            6 :     let (page, total) = d
     701            6 :         .list_slice(b, Kind::Subscription, 0, 100)
     702            6 :         .await
     703            6 :         .expect("slice");
     704            6 :     assert!(
     705            6 :         page.is_empty() && total == 0,
     706              :         "another tenant's rows reached this window: {page:?} / {total}"
     707              :     );
     708              : 
     709           48 :     for id in ids.iter().filter(|i| *i != &sub) {
     710           42 :         d.delete(a, Kind::Subscription, id).await.expect("delete");
     711              :     }
     712              : 
     713              :     // 5.2.14.2 delivery bookkeeping. A backend may write this as one
     714              :     // statement instead of a read-modify-write; the result must not depend on
     715              :     // which it chose, so the rule is pinned here rather than in one backend's
     716              :     // own tests.
     717            6 :     let t1 = "2020-01-01T00:00:00.000Z";
     718            6 :     let t2 = "2020-01-02T00:00:00.000Z";
     719            6 :     let first = d
     720            6 :         .record_delivery(a, Kind::Subscription, &sub, t1)
     721            6 :         .await
     722            6 :         .expect("record_delivery")
     723            6 :         .expect("the subscription is there");
     724            6 :     let n1 = &first.doc["notification"];
     725            6 :     assert_eq!(n1["timesSent"], 1, "the first attempt moves timesSent to 1");
     726            6 :     assert_eq!(
     727            6 :         n1["lastNotification"], t1,
     728              :         "lastNotification takes the stamp"
     729              :     );
     730            6 :     assert_eq!(n1["lastSuccess"], t1, "lastSuccess takes the stamp");
     731            6 :     assert_eq!(n1["status"], "ok", "the attempt leaves the notification ok");
     732            6 :     assert!(
     733            6 :         first.doc.get("status").is_none(),
     734              :         "the top-level status is a rendered member, not a stored one: {}",
     735              :         first.doc
     736              :     );
     737            6 :     assert!(
     738            6 :         first.prev_success.is_none(),
     739              :         "a subscription that never succeeded has no previous lastSuccess"
     740              :     );
     741              : 
     742            6 :     let second = d
     743            6 :         .record_delivery(a, Kind::Subscription, &sub, t2)
     744            6 :         .await
     745            6 :         .expect("record_delivery")
     746            6 :         .expect("the subscription is still there");
     747            6 :     assert_eq!(
     748            6 :         second.doc["notification"]["timesSent"], 2,
     749              :         "timesSent counts attempts, it does not reset"
     750              :     );
     751            6 :     assert_eq!(
     752            6 :         second.prev_success.as_ref().and_then(Value::as_str),
     753            6 :         Some(t1),
     754              :         "the overwritten lastSuccess comes back, or a failed attempt cannot \
     755              :          roll it back"
     756              :     );
     757              : 
     758              :     // 5.8.6: a subscription deleted between matching and delivery has no row
     759              :     // to book against, and nothing may be sent.
     760            6 :     assert!(
     761            6 :         d.record_delivery(a, Kind::Subscription, &gone, t1)
     762            6 :             .await
     763            6 :             .expect("record_delivery")
     764            6 :             .is_none(),
     765              :         "an absent subscription books nothing"
     766              :     );
     767              : 
     768            6 :     assert!(
     769            6 :         d.delete(a, Kind::Subscription, &sub).await.expect("delete"),
     770              :         "the contract's own subscription must be removable"
     771              :     );
     772              : 
     773              :     // ADR-0021: a stored @context is a Tenant's document. "Hosted" and
     774              :     // "ImplicitlyCreated" rows hold term mappings authored through one
     775              :     // Tenant's requests, and 5.5.7 makes those mappings decide what that
     776              :     // Tenant's payloads mean, so a backend that answered every caller would
     777              :     // hand one Tenant's meaning of its own data to another. "Cached" is a
     778              :     // copy of a public document (5.13.1) and belongs to no Tenant.
     779            6 :     let hosted_id = format!("{prefix}-ctx-hosted");
     780            6 :     let cached_id = format!("{prefix}-ctx-cached");
     781            6 :     let hosted = json!({"localId": hosted_id, "kind": "Hosted", "owner": a.as_str(),
     782            6 :                         "body": {"@context": {"t": "https://example.org/t"}}});
     783            6 :     d.context_put(Some(a), &hosted_id, hosted.clone())
     784            6 :         .await
     785            6 :         .expect("the owning tenant stores its own @context");
     786            6 :     d.context_put(
     787            6 :         None,
     788            6 :         &cached_id,
     789            6 :         json!({"localId": cached_id, "kind": "Cached", "body": {"@context": {}}}),
     790            6 :     )
     791            6 :     .await
     792            6 :     .expect("a Cached copy belongs to no tenant");
     793              : 
     794            6 :     assert!(
     795            6 :         d.context_get(Some(a), &hosted_id)
     796            6 :             .await
     797            6 :             .expect("context_get")
     798            6 :             .is_some(),
     799              :         "the owning tenant must read its own @context"
     800              :     );
     801            6 :     assert!(
     802            6 :         d.context_get(Some(b), &hosted_id)
     803            6 :             .await
     804            6 :             .expect("context_get")
     805            6 :             .is_none(),
     806              :         "another tenant's Hosted @context must be as absent as one never stored"
     807              :     );
     808            6 :     assert!(
     809            6 :         d.context_get(None, &hosted_id)
     810            6 :             .await
     811            6 :             .expect("context_get")
     812            6 :             .is_none(),
     813              :         "no tenant in scope must reach a tenant's @context"
     814              :     );
     815            6 :     assert!(
     816            6 :         d.context_list_meta(Some(b))
     817            6 :             .await
     818            6 :             .expect("context_list_meta")
     819            6 :             .iter()
     820            6 :             .all(|r| r["localId"] != hosted_id.as_str()),
     821              :         "another tenant's @context must not be listed"
     822              :     );
     823            6 :     assert!(
     824            6 :         !d.context_delete(Some(b), &hosted_id)
     825            6 :             .await
     826            6 :             .expect("context_delete"),
     827              :         "another tenant must not delete it"
     828              :     );
     829            6 :     assert!(
     830            6 :         d.context_put(Some(b), &hosted_id, hosted).await.is_err(),
     831              :         "another tenant must not overwrite it either"
     832              :     );
     833            6 :     assert!(
     834            6 :         d.context_get(Some(a), &hosted_id)
     835            6 :             .await
     836            6 :             .expect("context_get")
     837            6 :             .is_some(),
     838              :         "the owner's @context must survive every foreign attempt"
     839              :     );
     840           18 :     for t in [Some(a), Some(b), None] {
     841           18 :         assert!(
     842           18 :             d.context_get(t, &cached_id)
     843           18 :                 .await
     844           18 :                 .expect("context_get")
     845           18 :                 .is_some(),
     846              :             "a Cached copy is a public document every tenant reaches"
     847              :         );
     848              :     }
     849            6 :     assert!(
     850            6 :         d.context_delete(Some(a), &hosted_id)
     851            6 :             .await
     852            6 :             .expect("context_delete"),
     853              :         "the contract's own @context must be removable by its owner"
     854              :     );
     855            6 :     assert!(
     856            6 :         d.context_delete(None, &cached_id)
     857            6 :             .await
     858            6 :             .expect("context_delete"),
     859              :         "and the Cached copy by anyone"
     860              :     );
     861              : 
     862           12 :     for id in [&e1, &e2] {
     863           12 :         assert!(
     864           12 :             d.delete(a, Kind::Entity, id).await.expect("delete"),
     865              :             "the contract's own rows must be removable"
     866              :         );
     867              :     }
     868            6 : }
     869              : 
     870              : /// Hold a temporal driver to the same shape. A driver that answers
     871              : /// `supported() == false` declares it keeps no history and is held to
     872              : /// nothing else.
     873            6 : pub async fn run_temporal_contract(
     874            6 :     d: &dyn TemporalDriver,
     875            6 :     a: &TenantId,
     876            6 :     b: &TenantId,
     877            6 :     prefix: &str,
     878            6 : ) {
     879            6 :     if !d.supported() {
     880            0 :         return;
     881            6 :     }
     882            6 :     let e1 = format!("urn:ngsi-ld:{prefix}:t1");
     883            6 :     let gone = format!("urn:ngsi-ld:{prefix}:tabsent");
     884              : 
     885            6 :     assert!(
     886            6 :         d.get(a, &gone).await.expect("get").is_none(),
     887              :         "get of an absent temporal document must answer None"
     888              :     );
     889            6 :     assert!(
     890            6 :         !d.delete(a, &gone).await.expect("delete"),
     891              :         "delete of an absent temporal document must answer false"
     892              :     );
     893            6 :     let missed = d
     894            6 :         .mutate::<(), ()>(a, &gone, |_| Ok(()))
     895            6 :         .await
     896            6 :         .expect("mutate");
     897            6 :     assert!(
     898            6 :         missed.is_none(),
     899              :         "mutate of an absent document must answer None"
     900              :     );
     901            6 :     assert!(
     902            6 :         d.get(a, &gone).await.expect("get").is_none(),
     903              :         "mutate of an absent document must not insert it (047_06)"
     904              :     );
     905              : 
     906            6 :     assert!(
     907            6 :         d.create(a, &e1, doc(&e1)).await.expect("create"),
     908              :         "the first create reports true"
     909              :     );
     910            6 :     assert!(
     911            6 :         !d.create(a, &e1, doc(&e1)).await.expect("create"),
     912              :         "a create over an existing id reports false"
     913              :     );
     914            6 :     let rejected = d
     915            6 :         .mutate::<(), &'static str>(a, &e1, |v| {
     916            6 :             v["marker"] = json!(2);
     917            6 :             Err("rejected")
     918            6 :         })
     919            6 :         .await
     920            6 :         .expect("mutate");
     921            6 :     assert_eq!(rejected, Some(Err("rejected")));
     922            6 :     assert!(
     923            6 :         d.get(a, &e1)
     924            6 :             .await
     925            6 :             .expect("get")
     926            6 :             .expect("row")
     927            6 :             .get("marker")
     928            6 :             .is_none(),
     929              :         "a rejected mutate must commit nothing"
     930              :     );
     931              : 
     932            6 :     assert!(
     933            6 :         d.get(b, &e1).await.expect("get").is_none(),
     934              :         "another tenant must not read the temporal document"
     935              :     );
     936            6 :     assert!(
     937            6 :         !d.delete(b, &e1).await.expect("delete"),
     938              :         "another tenant must not delete it"
     939              :     );
     940            6 :     assert!(
     941            6 :         d.list(b)
     942            6 :             .await
     943            6 :             .expect("list")
     944            6 :             .iter()
     945            6 :             .all(|r| r["id"] != e1.as_str()),
     946              :         "another tenant must not list it"
     947              :     );
     948              : 
     949            6 :     assert!(
     950            6 :         d.delete(a, &e1).await.expect("delete"),
     951              :         "the contract's own rows must be removable"
     952              :     );
     953            6 : }
        

Generated by: LCOV version 2.0-1