LCOV - code coverage report
Current view: top level - antares-jsonld/src - expand.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.8 % 3110 3073
Test Date: 2026-11-02 13:21:27 Functions: 93.1 % 423 394

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! NGSI-LD expansion + structural validation (the NGSIObject-equivalent
       3              : //! pass): compacted/concise input → internal expanded form.
       4              : //!
       5              : //! Internal (expanded) form:
       6              : //! - `id` — string URI
       7              : //! - `type` — ALWAYS an array of absolute IRIs
       8              : //! - `scope` — array of scope strings (when present)
       9              : //! - every attribute key is an absolute IRI, its value ALWAYS an array of
      10              : //!   normalized instance objects; instance members keep their short NGSI-LD
      11              : //!   names (`type`, `value`, `object`, `datasetId`, `observedAt`, …) and
      12              : //!   sub-attributes are IRI-keyed arrays recursively.
      13              : 
      14              : use crate::context::Context;
      15              : use antares_model::NgsiError;
      16              : use serde_json::{json, Map, Value};
      17              : 
      18              : /// The NGSI-LD Attribute type names (Table 5.2.4-1 and 4.5.x).
      19              : pub const ATTR_TYPES: &[&str] = &[
      20              :     "Property",
      21              :     "Relationship",
      22              :     "GeoProperty",
      23              :     "LanguageProperty",
      24              :     "JsonProperty",
      25              :     "VocabProperty",
      26              :     "ListProperty",
      27              :     "ListRelationship",
      28              : ];
      29              : 
      30              : /// 4.5.1: "Terms defined in the Core Context as non-reified Properties (such
      31              : /// as datasetId, instanceId, etc.) shall not be used as Attribute names."
      32              : /// These are the core terms whose IRI local name equals the term (the reified
      33              : /// value containers like value→hasValue alias away and cannot collide).
      34              : const NON_REIFIED_TERMS: &[&str] = &[
      35              :     "datasetId",
      36              :     "instanceId",
      37              :     "observedAt",
      38              :     "unitCode",
      39              :     "lang",
      40              :     "objectType",
      41              :     "previousValue",
      42              :     "previousObject",
      43              :     "previousLanguageMap",
      44              :     // 4.5.1/4.5.2.2 System Generated + 4.22: the core context maps these 1:1
      45              :     // onto their own IRI, so an attribute carrying the fully-qualified
      46              :     // spelling compacts back onto the Entity's system member.
      47              :     "createdAt",
      48              :     "modifiedAt",
      49              :     "deletedAt",
      50              :     "expiresAt",
      51              :     "scope",
      52              : ];
      53              : 
      54              : /// 5.2.5 Table 5.2.5-2 output-only members plus the 4.5.2.2 Prohibited ones:
      55              : /// "shall never include" `entity`/`entityList` (inline Linked Entity
      56              : /// retrieval) and the `previous*` family (showChanges notifications).
      57              : const OUTPUT_ONLY: &[&str] = &[
      58              :     "entity",
      59              :     "entityList",
      60              :     "previousValue",
      61              :     "previousObject",
      62              :     "previousLanguageMap",
      63              :     "previousJson",
      64              :     "previousVocab",
      65              :     "previousValueList",
      66              :     "previousObjectList",
      67              : ];
      68              : 
      69              : /// Instance members that are NOT sub-attributes: everything 4.5 gives an
      70              : /// Attribute instance beside its sub-Attributes. The one list — a walk over
      71              : /// an instance decides what is a sub-Attribute by asking it, so a second
      72              : /// copy is a copy free to drift as the clause grows.
      73              : pub const RESERVED_MEMBERS: &[&str] = &[
      74              :     "type",
      75              :     "value",
      76              :     "object",
      77              :     "objectType",
      78              :     "datasetId",
      79              :     "observedAt",
      80              :     "unitCode",
      81              :     "lang",
      82              :     "languageMap",
      83              :     "vocab",
      84              :     "json",
      85              :     "valueList",
      86              :     "objectList",
      87              :     "entity",
      88              :     "entityList",
      89              :     "entityIdSealed",
      90              :     "entityTypeSealed",
      91              :     "valueType",
      92              :     "createdAt",
      93              :     "modifiedAt",
      94              :     "deletedAt",
      95              :     "expiresAt",
      96              :     "instanceId",
      97              :     "previousValue",
      98              :     "previousObject",
      99              :     "previousLanguageMap",
     100              :     "previousJson",
     101              :     "previousVocab",
     102              :     "previousValueList",
     103              :     "previousObjectList",
     104              : ];
     105              : 
     106              : const GEO_TYPES: &[&str] = &[
     107              :     "Point",
     108              :     "MultiPoint",
     109              :     "LineString",
     110              :     "MultiLineString",
     111              :     "Polygon",
     112              :     "MultiPolygon",
     113              : ];
     114              : 
     115              : /// Entity members that must be GeoProperties (core IRIs).
     116              : const GEO_ENTITY_MEMBERS: &[&str] = &[
     117              :     "https://uri.etsi.org/ngsi-ld/location",
     118              :     "https://uri.etsi.org/ngsi-ld/observationSpace",
     119              :     "https://uri.etsi.org/ngsi-ld/operationSpace",
     120              : ];
     121              : 
     122              : /// Switches for [`expand_entity`] that depend on which operation the input
     123              : /// payload belongs to.
     124              : #[derive(Debug, Clone, Copy, Default)]
     125              : pub struct ExpandOpts {
     126              :     /// Fragment mode: id/type not required (append/update/partial inputs).
     127              :     pub fragment: bool,
     128              :     /// Allow NGSI-LD null (`"urn:ngsi-ld:null"`) as a deletion marker
     129              :     /// (merge-patch inputs, 5.5.12).
     130              :     pub allow_null: bool,
     131              :     /// Merge fragment (5.5.12): the one input where 5.5.4 permits
     132              :     /// "urn:ngsi-ld:null" as the value of a key inside a JSON object that is
     133              :     /// a Property's value. Implies `allow_null`.
     134              :     pub merge: bool,
     135              :     /// Temporal representation: repeated instances of the same datasetId are
     136              :     /// legal (4.5.6), so the multi-instance uniqueness check is skipped.
     137              :     pub temporal: bool,
     138              :     /// Keep instance-level createdAt/modifiedAt: federation import needs them
     139              :     /// for 4.5.5.3 recency resolution. Provisioning paths re-stamp, so the
     140              :     /// flag stays off everywhere else.
     141              :     pub sys: bool,
     142              : }
     143              : 
     144              : /// 5.5.4 General NGSI-LD validation: "urn:ngsi-ld:null" as a first-level
     145              : /// member value is BadRequestData — legal only in NGSI-LD Fragments used in
     146              : /// partial update and merge operations (5.5.8, 5.5.12).
     147        36310 : pub fn reject_first_level_nulls(doc: &Map<String, Value>) -> Result<(), NgsiError> {
     148       150950 :     for (k, v) in doc {
     149       150950 :         if v.as_str() == Some("urn:ngsi-ld:null") {
     150           32 :             return Err(NgsiError::BadRequestData(format!(
     151           32 :                 "member {k}: \"urn:ngsi-ld:null\" is only allowed in partial \
     152           32 :                  update or merge fragments (5.5.4)"
     153           32 :             )));
     154       150918 :         }
     155              :     }
     156        36278 :     Ok(())
     157        36310 : }
     158              : 
     159              : /// Does any key-value pair anywhere inside `v` carry the NGSI-LD Null as its
     160              : /// value? (5.5.4: banned inside a JSON object that is a Property's value.)
     161       203778 : fn has_object_member_null(v: &Value) -> bool {
     162       203778 :     match v {
     163         6460 :         Value::Object(m) => m
     164         6460 :             .values()
     165        12864 :             .any(|x| x.as_str() == Some("urn:ngsi-ld:null") || has_object_member_null(x)),
     166        12359 :         Value::Array(a) => a.iter().any(has_object_member_null),
     167       184959 :         _ => false,
     168              :     }
     169       203778 : }
     170              : 
     171              : /// Expand an Entity (or fragment) against `ctx` and validate its structure
     172              : /// per 4.5.x/5.2.4; violations are `BadRequestData`.
     173        44624 : pub fn expand_entity(
     174        44624 :     doc: &Map<String, Value>,
     175        44624 :     ctx: &Context,
     176        44624 :     opts: ExpandOpts,
     177        44624 : ) -> Result<Value, NgsiError> {
     178        44624 :     let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
     179        44624 :     let mut out = Map::new();
     180              : 
     181              :     // 5.5.4: first-level member nulls are only legal in null-allowing
     182              :     // (partial update / merge) fragments.
     183        44624 :     if !opts.allow_null {
     184        29392 :         reject_first_level_nulls(doc)?;
     185        15232 :     }
     186              : 
     187              :     // id
     188        44600 :     match doc.get("id").or_else(|| doc.get("@id")) {
     189        30545 :         Some(Value::String(id)) => {
     190        30545 :             antares_model::EntityId::new(id)?;
     191        30469 :             out.insert("id".into(), Value::String(id.clone()));
     192              :         }
     193            2 :         Some(_) => return Err(bad("entity id must be a string URI")),
     194        14043 :         None if opts.fragment => {}
     195           10 :         None => return Err(bad("entity id is required")),
     196              :     }
     197              : 
     198              :     // type
     199        44512 :     match doc.get("type").or_else(|| doc.get("@type")) {
     200        30491 :         Some(v) => {
     201        30491 :             let types = expand_types(v, ctx)?;
     202        30464 :             out.insert("type".into(), Value::Array(types));
     203              :         }
     204        14015 :         None if opts.fragment => {}
     205            6 :         None => return Err(bad("entity type is required")),
     206              :     }
     207              :     // TS 104 175 Annex B: the type-scoped core contexts of the Entity's
     208              :     // types name its own members. A fragment without a type has none in
     209              :     // force, as a JSON-LD processor sees the same body.
     210        44479 :     let types: Vec<&str> = out
     211        44479 :         .get("type")
     212        44479 :         .and_then(Value::as_array)
     213        44479 :         .map(|ts| ts.iter().filter_map(Value::as_str).collect())
     214        44479 :         .unwrap_or_default();
     215        44479 :     let members = ctx.within_types(&types);
     216              : 
     217              :     // scope
     218        44479 :     if let Some(v) = doc.get("scope") {
     219              :         // 4.18: "urn:ngsi-ld:null" shall ONLY appear for deleted scopes —
     220              :         // creatable solely on null-allowing (merge/patch) inputs.
     221         7929 :         let valid_scope = |s: &str| -> bool {
     222         7929 :             if s == "urn:ngsi-ld:null" {
     223           20 :                 return opts.allow_null;
     224         7909 :             }
     225         7909 :             valid_scope_value(s)
     226         7929 :         };
     227         6055 :         let scopes: Vec<Value> = match v {
     228         1153 :             Value::String(s) => vec![Value::String(s.clone())],
     229         4900 :             Value::Array(a) => {
     230         4900 :                 let mut items = Vec::new();
     231         6778 :                 for s in a {
     232           80 :                     match s {
     233         6696 :                         Value::String(s) => items.push(Value::String(s.clone())),
     234              :                         // 4.5.6: on temporal input the scope is the temporal
     235              :                         // representation of a Property — instance objects
     236              :                         // whose value is a scope string or array thereof.
     237           80 :                         Value::Object(o) if opts.temporal => {
     238           78 :                             let vals: Vec<&str> = match o.get("value") {
     239           68 :                                 Some(Value::String(s)) => vec![s.as_str()],
     240            8 :                                 Some(Value::Array(vs)) if vs.iter().all(Value::is_string) => {
     241            6 :                                     vs.iter().filter_map(Value::as_str).collect()
     242              :                                 }
     243            4 :                                 _ => return Err(bad("scope instance needs a string value")),
     244              :                             };
     245           80 :                             for sv in vals {
     246           80 :                                 if !valid_scope(sv) {
     247            2 :                                     return Err(bad(&format!(
     248            2 :                                         "invalid scope {sv:?} (4.18 grammar)"
     249            2 :                                     )));
     250           78 :                                 }
     251              :                             }
     252           72 :                             items.push(s.clone());
     253              :                         }
     254            4 :                         _ => return Err(bad("scope entries must be strings")),
     255              :                     }
     256              :                 }
     257         4890 :                 items
     258              :             }
     259            2 :             _ => return Err(bad("scope must be a string or array of strings")),
     260              :         };
     261         7857 :         for s in scopes.iter().filter_map(Value::as_str) {
     262         7849 :             if !valid_scope(s) {
     263           32 :                 return Err(bad(&format!("invalid scope {s:?} (4.18 grammar)")));
     264         7817 :             }
     265              :         }
     266              :         // The sentinel deletes the whole scope member (5.5.12), so it is the
     267              :         // whole value or it is not there. Mixed with real scopes it would be
     268              :         // stored as one of them, and 4.18 has no scope that spells it.
     269         6011 :         if scopes.len() > 1 && scopes.iter().any(is_ngsi_null) {
     270            6 :             return Err(bad(
     271            6 :                 "\"urn:ngsi-ld:null\" is the whole scope or none of it (4.18, 5.5.12)",
     272            6 :             ));
     273         6005 :         }
     274         6005 :         out.insert("scope".into(), Value::Array(scopes));
     275        38424 :     }
     276              : 
     277              :     // expiresAt (4.22 transient storage): the one client-settable temporal
     278              :     // meta member. Keep it as a bare top-level DateTime string — the shape the
     279              :     // read-boundary filter (filter::expired_at), the GC sweep, the postgres
     280              :     // `expires_at` column extraction and temporal `meta_of` all expect. Missing
     281              :     // this made 4.22 dead code on every backend.
     282        44429 :     if let Some(v) = doc.get("expiresAt") {
     283              :         // In a merge/partial FRAGMENT an NGSI-LD Null asks for the expiry's
     284              :         // removal (5.5.12) — pass it through for merge_into to act on. 5.5.4
     285              :         // limits the marker to those fragments, so on a whole-Entity input it
     286              :         // stays BadRequestData: a temporal import allows nulls for its 4.5.7
     287              :         // tombstones, and storing the marker as a lifetime there poisons every
     288              :         // later read of the tenant.
     289          106 :         let is_null_removal = opts.allow_null
     290           36 :             && (opts.fragment || opts.merge)
     291           28 :             && v.as_str() == Some("urn:ngsi-ld:null");
     292          106 :         let s = v
     293          106 :             .as_str()
     294          106 :             .filter(|s| is_null_removal || parse_datetime(s))
     295          106 :             .ok_or_else(|| bad("expiresAt must be an ISO 8601 DateTime"))?;
     296           88 :         out.insert("expiresAt".into(), Value::String(s.to_owned()));
     297        44323 :     }
     298              : 
     299              :     // 4.8: with sys expansion the ENTITY-level system timestamps survive —
     300              :     // a federated import (5.7.2.4 forwards request options=sysAttrs) must
     301              :     // keep the remote system's createdAt/modifiedAt/deletedAt rather than
     302              :     // dropping them (they are re-stamped only on local writes).
     303        44411 :     if opts.sys {
     304         2172 :         for k in ["createdAt", "modifiedAt", "deletedAt"] {
     305         2172 :             if let Some(Value::String(ts)) = doc.get(k) {
     306          836 :                 if parse_datetime(ts) {
     307          834 :                     out.insert(k.to_owned(), Value::String(ts.clone()));
     308          834 :                 }
     309         1336 :             }
     310              :         }
     311        43687 :     }
     312       174052 :     for (key, v) in doc {
     313       174052 :         match key.as_str() {
     314       174052 :             "id" | "@id" | "type" | "@type" | "@context" | "scope" | "expiresAt" | "createdAt"
     315       106643 :             | "modifiedAt" | "deletedAt" => continue,
     316       102427 :             _ => {}
     317              :         }
     318       102427 :         if key.is_empty() {
     319            2 :             return Err(bad("empty attribute name"));
     320       102425 :         }
     321       102425 :         let iri = expand_attr_name(key, &members)?;
     322              :         // 4.5.1: core non-reified terms shall not be used as Attribute names.
     323       102423 :         if iri
     324       102423 :             .strip_prefix(crate::context::NGSI_LD_BASE)
     325       102423 :             .is_some_and(|t| NON_REIFIED_TERMS.contains(&t))
     326              :         {
     327           30 :             return Err(bad(&format!(
     328           30 :                 "{key} is a core non-reified term and cannot be used as an Attribute name (4.5.1)"
     329           30 :             )));
     330       102393 :         }
     331       102393 :         let instances = expand_attribute(key, v, &ctx.within(&iri), opts, 0)?;
     332       101702 :         if GEO_ENTITY_MEMBERS.contains(&iri.as_str()) {
     333         5069 :             for inst in &instances {
     334         5069 :                 let t = inst.get("type").and_then(Value::as_str);
     335         5069 :                 let is_deletion = opts.allow_null && inst.get("value").is_some_and(is_ngsi_null);
     336         5069 :                 if t != Some("GeoProperty") && !is_deletion {
     337           14 :                     return Err(bad(&format!("{key} must be a GeoProperty")));
     338         5055 :                 }
     339              :             }
     340        97015 :         }
     341       101688 :         if !opts.temporal {
     342        99376 :             validate_dataset_ids(key, &instances)?;
     343         2312 :         }
     344              :         // 4.5.5.1: "There can only be one default Attribute instance for an
     345              :         // Attribute with a given Attribute name in any request or response" —
     346              :         // a term and its own expanded IRI are ONE Attribute name, so keeping
     347              :         // the last writer would silently discard the other member's data.
     348       101670 :         if out
     349       101670 :             .insert(iri.clone(), Value::Array(instances.into_iter().collect()))
     350       101670 :             .is_some()
     351              :         {
     352            2 :             return Err(bad(&format!(
     353            2 :                 "attribute {key} expands to {iri}, which another member of \
     354            2 :                  this Entity already defines (4.5.5.1)"
     355            2 :             )));
     356       101668 :         }
     357              :     }
     358        43652 :     Ok(Value::Object(out))
     359        44624 : }
     360              : 
     361              : /// Expand a `type` member (string or array) to absolute IRIs; a name that
     362              : /// does not expand to one is `BadRequestData` (5.5.4, 4.6.2).
     363        30497 : pub fn expand_types(v: &Value, ctx: &Context) -> Result<Vec<Value>, NgsiError> {
     364        30497 :     let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
     365              :     // 5.5.4/4.6.2: an Entity Type must expand to an absolute IRI; a name that
     366              :     // is a JSON-LD-keyword alias in the @context (e.g. "type" → "@type") is
     367              :     // invalid (001_02_04).
     368        31983 :     let one = |t: &str| -> Result<Value, NgsiError> {
     369              :         // 4.5.0: the NGSI-LD Null asks for a deletion, and an Entity's type
     370              :         // is mandatory (4.5.1): it is never a type, not even on a merge.
     371        31983 :         if is_ngsi_null(&Value::String(t.to_owned())) {
     372            8 :             return Err(bad("the NGSI-LD Null is no Entity Type (4.5.0, 4.5.1)"));
     373        31975 :         }
     374              :         // 4.6.2: Entity Type names obey the name grammar (BadRequestData).
     375        31975 :         if !valid_name(t) {
     376            8 :             return Err(bad(&format!(
     377            8 :                 "entity type {t:?} violates the 4.6.2 name grammar"
     378            8 :             )));
     379        31967 :         }
     380        31967 :         let iri = ctx.expand_key(t);
     381        31967 :         if crate::context::is_absolute_iri(&iri) {
     382        31962 :             Ok(Value::String(iri))
     383              :         } else {
     384            5 :             Err(bad(&format!("entity type {t:?} does not expand to an IRI")))
     385              :         }
     386        31983 :     };
     387        28503 :     match v {
     388        28503 :         Value::String(t) if !t.is_empty() => Ok(vec![one(t)?]),
     389         1992 :         Value::Array(a) if !a.is_empty() => {
     390         1990 :             let mut out = Vec::new();
     391         3486 :             for t in a {
     392         3484 :                 match t {
     393         3484 :                     Value::String(t) if !t.is_empty() => out.push(one(t)?),
     394            4 :                     _ => return Err(bad("entity type entries must be non-empty strings")),
     395              :                 }
     396              :             }
     397         1982 :             Ok(out)
     398              :         }
     399            6 :         _ => Err(bad("entity type must be a non-empty string or array")),
     400              :     }
     401        30497 : }
     402              : 
     403              : /// An expanded document as the object it is. `expand_entity` and
     404              : /// `expand_attr_fragment` both return a JSON object, so a value that is not
     405              : /// one did not come from them: the caller wired the wrong value in, and the
     406              : /// mistake stays inside the one request instead of taking the process down.
     407        13732 : pub fn expanded_object(v: &Value) -> Result<&serde_json::Map<String, Value>, NgsiError> {
     408        13732 :     v.as_object()
     409        13732 :         .ok_or_else(|| NgsiError::InternalError("expanded document is not a JSON object".into()))
     410        13732 : }
     411              : 
     412              : /// The `id` of an expanded Entity. `expand_entity` validates it as a URI
     413              : /// before it returns, so the same rule as `expanded_object` applies.
     414        19270 : pub fn expanded_id(v: &Value) -> Result<&str, NgsiError> {
     415        19270 :     v.get("id")
     416        19270 :         .and_then(Value::as_str)
     417        19270 :         .ok_or_else(|| NgsiError::InternalError("expanded entity carries no id".into()))
     418        19270 : }
     419              : 
     420              : /// The `id` of an expanded Entity about to be created. 5.7.2 names Entities
     421              : /// by a comma-separated `id` list, so an id holding a comma could never be
     422              : /// queried by id and splits in two on every page rebuilt from such a list
     423              : /// (EntityMap, temporal pages, federation forwards): refused at create.
     424        17504 : pub fn new_entity_id(v: &Value) -> Result<&str, NgsiError> {
     425        17504 :     let id = expanded_id(v)?;
     426        17502 :     if id.contains(',') {
     427           22 :         return Err(NgsiError::BadRequestData(format!(
     428           22 :             "entity id {id:?} holds a comma: an id query (5.7.2) names Entities as a comma-separated list, so such an id cannot be created; use another character, e.g. {:?}",
     429           22 :             id.replace(',', "-")
     430           22 :         )));
     431        17480 :     }
     432        17480 :     Ok(id)
     433        17504 : }
     434              : 
     435              : /// 4.5.1/5.5.4: an Attribute or sub-Attribute name shall expand to an
     436              : /// absolute IRI. A user @context is merged before the Core one (4.4), so a
     437              : /// term defined as `{"@id": "id"}` stays RELATIVE and would otherwise land
     438              : /// on a reserved member (`id`, `value`, `datasetId`, `observedAt`, …) and
     439              : /// overwrite it — skipping that member's own validation. Same rule
     440              : /// expand_types applies to Entity Type names.
     441              : ///
     442              : /// Public because the Attribute name also arrives in a URL path (5.6.4,
     443              : /// 5.6.5, 5.6.19, 5.6.13, 5.6.14), where the clauses require the same
     444              : /// "fully qualified name (URI)" from the same 5.5.7 expansion. A path name
     445              : /// that skips this check lands on a member of the stored document that is
     446              : /// not an Attribute.
     447       105500 : pub fn expand_attr_name(name: &str, ctx: &Context) -> Result<String, NgsiError> {
     448       105500 :     let iri = ctx.expand_key(name);
     449       105500 :     if crate::context::is_absolute_iri(&iri) {
     450       105478 :         Ok(iri)
     451              :     } else {
     452           22 :         Err(NgsiError::BadRequestData(format!(
     453           22 :             "attribute name {name:?} does not expand to an absolute IRI"
     454           22 :         )))
     455              :     }
     456       105500 : }
     457              : 
     458              : /// Table 5.2.6-1 `objectType` / Table 5.2.35-1 `vocab`: "String or String[]",
     459              : /// "Both short hand string(s) (type name) or URI(s) are allowed" — every entry
     460              : /// is @vocab-coerced against the request @context, so the short and the
     461              : /// expanded spelling of one target type cannot be stored differently.
     462          146 : fn expand_terms(name: &str, member: &str, v: &Value, ctx: &Context) -> Result<Value, NgsiError> {
     463          146 :     let bad = || NgsiError::BadRequestData(format!("attribute {name}: invalid {member}"));
     464          146 :     match v {
     465          115 :         Value::String(s) => Ok(Value::String(ctx.expand_key(s))),
     466           21 :         Value::Array(a) => Ok(Value::Array(
     467           21 :             a.iter()
     468           38 :                 .map(|s| {
     469           38 :                     s.as_str()
     470           38 :                         .map(|s| Value::String(ctx.expand_key(s)))
     471           38 :                         .ok_or_else(bad)
     472           38 :                 })
     473           21 :                 .collect::<Result<_, _>>()?,
     474              :         )),
     475           10 :         _ => Err(bad()),
     476              :     }
     477          146 : }
     478              : 
     479              : /// 4.5.5.1: a datasetId is a URI string; "datasetId": "@none" designates the
     480              : /// default Attribute instance, which never carries one — normalized to
     481              : /// absent (`Ok(None)`) so storage, matching and responses treat it as such.
     482              : /// 5.5.8/5.5.12: "A datasetId cannot be deleted by setting it to the value
     483              : /// urn:ngsi-ld:null" — rejected on every input.
     484        36465 : fn dataset_id_member(d: &Value) -> Result<Option<Value>, NgsiError> {
     485        36465 :     let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
     486        36465 :     let s = d.as_str().ok_or_else(|| bad("datasetId must be a URI"))?;
     487        36457 :     if s == "urn:ngsi-ld:null" {
     488           12 :         return Err(bad(
     489           12 :             "a datasetId cannot be set or deleted via \"urn:ngsi-ld:null\" (5.5.8)",
     490           12 :         ));
     491        36445 :     }
     492        36445 :     if s == "@none" {
     493           14 :         return Ok(None);
     494        36431 :     }
     495        36431 :     antares_model::EntityId::new(s).map_err(|_| bad("datasetId must be a URI"))?;
     496        36427 :     Ok(Some(d.clone()))
     497        36465 : }
     498              : 
     499              : /// 5.2.1: "In all other cases, implementations shall raise an error of type
     500              : /// BadRequestData if an NGSI-LD Null value is encountered"; 5.5.4 bans it as
     501              : /// the value of a key-value pair inside a Property's compound value except
     502              : /// in merge fragments. The concise forms hand the client's JSON back as the
     503              : /// Property value unchanged, so they carry the same two checks as the
     504              : /// normalized path.
     505          234 : fn check_value_nulls(name: &str, val: &Value, opts: ExpandOpts) -> Result<(), NgsiError> {
     506          234 :     let bad = NgsiError::BadRequestData;
     507          234 :     let nullish = match val {
     508          102 :         Value::String(s) => s == "urn:ngsi-ld:null",
     509           16 :         Value::Array(a) => a.iter().any(is_ngsi_null),
     510          116 :         _ => false,
     511              :     };
     512          234 :     if !opts.allow_null && nullish {
     513            6 :         return Err(bad(format!(
     514            6 :             "attribute {name}: the NGSI-LD Null is only allowed in \
     515            6 :              partial update or merge inputs (5.2.1)"
     516            6 :         )));
     517          228 :     }
     518          228 :     if !opts.merge && has_object_member_null(val) {
     519            2 :         return Err(bad(format!(
     520            2 :             "attribute {name}: \"urn:ngsi-ld:null\" inside a compound value \
     521            2 :              is only allowed in merge fragments (5.5.4)"
     522            2 :         )));
     523          226 :     }
     524          226 :     Ok(())
     525          234 : }
     526              : 
     527              : /// The NGSI-LD null sentinel — ONLY the string form (a plain JSON null is
     528              : /// invalid data, 057_03_02).
     529       413562 : pub fn is_ngsi_null(v: &Value) -> bool {
     530       173057 :     matches!(v, Value::String(s) if s == "urn:ngsi-ld:null")
     531       413562 : }
     532              : 
     533              : /// A LanguageProperty deletion carries `{"@none": "urn:ngsi-ld:null"}`.
     534         1898 : pub fn is_ngsi_null_langmap(v: &Value) -> bool {
     535         1898 :     is_ngsi_null(v)
     536         1896 :         || v.as_object()
     537         1896 :             .is_some_and(|m| m.len() == 1 && m.get("@none").is_some_and(is_ngsi_null))
     538         1898 : }
     539              : 
     540              : /// 4.5.21.2/4.5.22.2: a List deletion is "an array consisting of a single
     541              : /// NGSI-LD Null" as the valueList/objectList (bare null tolerated too).
     542           86 : pub fn is_ngsi_null_list(v: &Value) -> bool {
     543           86 :     is_ngsi_null(v) || matches!(v.as_array().map(Vec::as_slice), Some([only]) if is_ngsi_null(only))
     544           86 : }
     545              : 
     546              : /// Whole-instance deletion marker (merge patch, 5.5.12).
     547        52274 : pub fn is_deletion_instance(inst: &Value) -> bool {
     548        52274 :     is_ngsi_null(inst)
     549        52270 :         || inst.as_object().is_some_and(|o| {
     550        52260 :             o.get("value").is_some_and(is_ngsi_null)
     551        52154 :                 || o.get("object").is_some_and(is_ngsi_null)
     552        52098 :                 || o.get("languageMap").is_some_and(is_ngsi_null_langmap)
     553        52046 :                 || o.get("json").is_some_and(is_ngsi_null)
     554        52044 :                 || o.get("vocab").is_some_and(is_ngsi_null)
     555        52042 :                 || o.get("valueList").is_some_and(is_ngsi_null_list)
     556        52040 :                 || o.get("objectList").is_some_and(is_ngsi_null_list)
     557        52260 :         })
     558        52274 : }
     559              : 
     560              : /// Whether a fragment member removes its target member (5.5.12, 10.2.5.4): JSON null, the
     561              : /// NGSI-LD Null, or a sub-Attribute every instance of which is a deletion instance.
     562          784 : pub fn removes_member(v: &Value) -> bool {
     563          784 :     v.is_null()
     564          782 :         || is_ngsi_null(v)
     565          776 :         || v.as_array().is_some_and(|instances| {
     566           74 :             !instances.is_empty() && instances.iter().all(is_deletion_instance)
     567           74 :         })
     568          784 : }
     569              : 
     570              : /// 4.18 Scope grammar: [/] ScopeLevel *(/ScopeLevel), ScopeLevel =
     571              : /// unicodeLetter *(letter/digit/_) — shared by entity scopes and the 5.2.9
     572              : /// registration scope member.
     573         7945 : pub fn valid_scope_value(s: &str) -> bool {
     574         7945 :     let body = s.strip_prefix('/').unwrap_or(s);
     575         7945 :     !body.is_empty()
     576        14196 :         && body.split('/').all(|level| {
     577        14196 :             let mut ch = level.chars();
     578        14196 :             ch.next().is_some_and(char::is_alphabetic)
     579        18494 :                 && ch.all(|c| c.is_alphabetic() || c.is_numeric() || c == '_')
     580        14196 :         })
     581         7945 : }
     582              : 
     583              : /// 4.6.2 Supported names: `name = unicodeLetter *(unicodeLetter /
     584              : /// unicodeNumber / "_")`. A key containing ':' is a compact or absolute IRI
     585              : /// (the spec's prefix:name production) and is outside the term grammar.
     586              : // Known ceiling: colon-keys are exempt wholesale — a malformed "pre fix:x" slips
     587              : // through as an IRI; tighten to per-part validation if it ever matters.
     588       844185 : pub fn valid_name(s: &str) -> bool {
     589       844185 :     if s.contains(':') {
     590           26 :         return true;
     591       844159 :     }
     592       844159 :     let mut ch = s.chars();
     593       844159 :     ch.next().is_some_and(char::is_alphabetic)
     594      1167558 :         && ch.all(|c| c.is_alphabetic() || c.is_numeric() || c == '_')
     595       844185 : }
     596              : 
     597              : /// Expand one attribute's value into a normalized instance list.
     598       104352 : fn expand_attribute(
     599       104352 :     name: &str,
     600       104352 :     v: &Value,
     601       104352 :     ctx: &Context,
     602       104352 :     opts: ExpandOpts,
     603       104352 :     depth: usize,
     604       104352 : ) -> Result<Vec<Value>, NgsiError> {
     605       104352 :     let bad = NgsiError::BadRequestData;
     606       104352 :     if depth > 8 {
     607            2 :         return Err(bad(format!("attribute {name}: nesting too deep")));
     608       104350 :     }
     609              :     // 4.6.2: Property/Relationship names with characters outside the name
     610              :     // grammar raise BadRequestData.
     611       104350 :     if !valid_name(name) {
     612           16 :         return Err(bad(format!(
     613           16 :             "attribute name {name:?} violates the 4.6.2 name grammar"
     614           16 :         )));
     615       104334 :     }
     616         4369 :     match v {
     617         4369 :         Value::Array(items) if items.iter().all(looks_like_instance) && !items.is_empty() => {
     618         4355 :             let mut out = Vec::new();
     619        46665 :             for item in items {
     620        46665 :                 out.push(expand_instance(name, item, ctx, opts, depth)?);
     621              :             }
     622         4351 :             Ok(out)
     623              :         }
     624        99979 :         _ => Ok(vec![expand_instance(name, v, ctx, opts, depth)?]),
     625              :     }
     626       104352 : }
     627              : 
     628        46677 : fn looks_like_instance(v: &Value) -> bool {
     629        46677 :     v.as_object().is_some_and(|o| {
     630        46667 :         o.get("type")
     631        46667 :             .and_then(Value::as_str)
     632        46667 :             .is_some_and(|t| ATTR_TYPES.contains(&t))
     633            6 :             || [
     634            6 :                 "value",
     635            6 :                 "object",
     636            6 :                 "languageMap",
     637            6 :                 "vocab",
     638            6 :                 "json",
     639            6 :                 "valueList",
     640            6 :                 "objectList",
     641            6 :             ]
     642            6 :             .iter()
     643           18 :             .any(|k| o.contains_key(*k))
     644        46667 :     })
     645        46677 : }
     646              : 
     647              : /// 4.5.2.2 Prohibited (mirrored by 4.5.3.2 and the 4.5.18-4.5.24
     648              : /// subclasses): an instance "shall never include" the value-defining
     649              : /// member of a DIFFERENT attribute type, nor the output-only members
     650              : /// inline Linked Entity retrieval and showChanges notifications produce.
     651              : /// `entityIdSealed`/`entityTypeSealed` are the one exception the clause
     652              : /// grants, on the `ngsildproof` Property alone, and because they are
     653              : /// reserved members this is also where they are copied out.
     654       146368 : fn check_prohibited_members(
     655       146368 :     name: &str,
     656       146368 :     obj: &Map<String, Value>,
     657       146368 :     attr_type: &str,
     658       146368 :     ctx: &Context,
     659       146368 :     opts: ExpandOpts,
     660       146368 :     out: &mut Map<String, Value>,
     661       146368 : ) -> Result<(), NgsiError> {
     662       146368 :     let bad = NgsiError::BadRequestData;
     663              :     const VALUE_OWNERS: &[(&str, &[&str])] = &[
     664              :         ("value", &["Property", "GeoProperty"]),
     665              :         ("object", &["Relationship"]),
     666              :         ("languageMap", &["LanguageProperty"]),
     667              :         ("json", &["JsonProperty"]),
     668              :         ("vocab", &["VocabProperty"]),
     669              :         ("valueList", &["ListProperty"]),
     670              :         ("objectList", &["ListRelationship"]),
     671              :     ];
     672      1024242 :     for (m, owners) in VALUE_OWNERS {
     673      1024242 :         if obj.contains_key(*m) && !owners.contains(&attr_type) {
     674           74 :             return Err(bad(format!(
     675           74 :                 "attribute {name}: {m} is not allowed on a {attr_type} (4.5.2.2)"
     676           74 :             )));
     677      1024168 :         }
     678              :     }
     679              :     // 5.2.6/5.2.37: a Relationship's members name an object, never a
     680              :     // datatype; valueType belongs to a Property value (4.5.2.2).
     681       146294 :     if obj.contains_key("valueType") && matches!(attr_type, "Relationship" | "ListRelationship") {
     682            4 :         return Err(bad(format!(
     683            4 :             "attribute {name}: valueType is not allowed on a {attr_type} (5.2.6)"
     684            4 :         )));
     685       146290 :     }
     686      1316504 :     if let Some(m) = OUTPUT_ONLY.iter().find(|m| obj.contains_key(**m)) {
     687           18 :         return Err(bad(format!(
     688           18 :             "attribute {name}: {m} is output-only and not allowed in input (4.5.2.2)"
     689           18 :         )));
     690       146272 :     }
     691              :     // 4.5.2.2/4.5.2.3 grant the only exception there is: "unless the
     692              :     // PROPERTY name is ngsildproof", the member being defined as "a
     693              :     // Property ... with the non-reified subproperties". 4.5.3.2 and
     694              :     // 4.5.3.3 repeat the ban for a Relationship with no exception at
     695              :     // all, so the attribute name alone is not the test.
     696       146272 :     let sealed_ok = attr_type == "Property" && name == "ngsildproof";
     697       146272 :     if !sealed_ok && (obj.contains_key("entityIdSealed") || obj.contains_key("entityTypeSealed")) {
     698           38 :         return Err(bad(format!(
     699           38 :             "attribute {name}: entityIdSealed/entityTypeSealed are only allowed \
     700           38 :              on the ngsildproof Property (4.5.2.2, 4.5.3.2)"
     701           38 :         )));
     702       146234 :     }
     703              :     // 4.5.2.2 / C.11 / annex B: ngsildproof's NON-REIFIED sealed
     704              :     // subproperties — entityIdSealed is a plain string term,
     705              :     // entityTypeSealed is "@type": "@vocab" (it seals the entity type,
     706              :     // so its value expands like a type name). They are reserved
     707              :     // members, so without this explicit copy they silently vanish.
     708       146234 :     if sealed_ok {
     709           38 :         if let Some(v) = obj.get("entityIdSealed") {
     710           22 :             let s = v.as_str().ok_or_else(|| {
     711            4 :                 bad(format!(
     712            4 :                     "attribute {name}: entityIdSealed must be a string (4.5.2.2)"
     713            4 :                 ))
     714            4 :             })?;
     715           18 :             out.insert("entityIdSealed".into(), Value::String(s.to_owned()));
     716           16 :         }
     717           34 :         if let Some(v) = obj.get("entityTypeSealed") {
     718           22 :             let s = v.as_str().ok_or_else(|| {
     719            6 :                 bad(format!(
     720            6 :                     "attribute {name}: entityTypeSealed must be a string (4.5.2.2)"
     721            6 :                 ))
     722            6 :             })?;
     723           16 :             out.insert("entityTypeSealed".into(), Value::String(ctx.expand_key(s)));
     724           12 :         }
     725              :         // "The value of its \"value\" element shall be an object
     726              :         // containing the W3C Data integrity \"proof\" structure"
     727           28 :         if let Some(v) = obj.get("value") {
     728           26 :             if !v.is_object() && !(opts.allow_null && is_ngsi_null(v)) {
     729            8 :                 return Err(bad(format!(
     730            8 :                     "attribute {name}: ngsildproof value shall be an object \
     731            8 :                      containing the W3C proof structure (4.5.2.2)"
     732            8 :                 )));
     733           18 :             }
     734            2 :         }
     735       146196 :     }
     736              :     // 4.5.3.2: "unitCode shall never be present, as Relationships are
     737              :     // unitless." 4.5.18.2/3 and 4.5.20.2/3 extend the prohibition to
     738              :     // LanguageProperty and VocabProperty ("always strings and hence
     739              :     // unitless").
     740              :     // (4.5.24.2/3 add JsonProperty — "raw JSON objects are unitless".)
     741       146216 :     if obj.contains_key("unitCode")
     742           24 :         && matches!(
     743         1017 :             attr_type,
     744         1017 :             "Relationship"
     745         1009 :                 | "ListRelationship"
     746         1007 :                 | "LanguageProperty"
     747         1005 :                 | "VocabProperty"
     748         1001 :                 | "JsonProperty"
     749              :         )
     750              :     {
     751           24 :         return Err(bad(format!(
     752           24 :             "attribute {name}: unitCode is not allowed on a {attr_type}"
     753           24 :         )));
     754       146192 :     }
     755       146192 :     Ok(())
     756       146368 : }
     757              : 
     758              : /// The members Table 5.2.5-1 allows on any Attribute instance beside its
     759              : /// value: the 4.5.5 `datasetId`, the 4.8 temporal members, the system
     760              : /// attributes, and `unitCode`, `valueType`, `lang` and `objectType`,
     761              : /// each expanded the way its own subclause defines.
     762       145916 : fn expand_common_members(
     763       145916 :     name: &str,
     764       145916 :     obj: &Map<String, Value>,
     765       145916 :     attr_type: &str,
     766       145916 :     ctx: &Context,
     767       145916 :     opts: ExpandOpts,
     768       145916 :     out: &mut Map<String, Value>,
     769       145916 : ) -> Result<(), NgsiError> {
     770       145916 :     let bad = NgsiError::BadRequestData;
     771       145916 :     if let Some(d) = obj.get("datasetId") {
     772        36421 :         if let Some(d) = dataset_id_member(d).map_err(|e| bad(format!("attribute {name}: {e}")))? {
     773        36401 :             out.insert("datasetId".into(), d);
     774        36401 :         }
     775       109495 :     }
     776              :     // 5.5.12: in a merge (or partial update) input the NGSI-LD Null removes the member
     777       145908 :     let nulls = opts.allow_null || opts.merge;
     778       291816 :     for k in ["observedAt", "expiresAt"] {
     779       291816 :         if let Some(v) = obj.get(k).filter(|v| nulls && is_ngsi_null(v)) {
     780           10 :             out.insert(k.into(), v.clone());
     781       291806 :         }
     782              :     }
     783       145908 :     if let Some(o) = obj
     784       145908 :         .get("observedAt")
     785       145908 :         .filter(|v| !(nulls && is_ngsi_null(v)))
     786              :     {
     787        63823 :         let s = o
     788        63823 :             .as_str()
     789        63823 :             .filter(|s| parse_datetime(s))
     790        63823 :             .ok_or_else(|| bad(format!("attribute {name}: invalid observedAt")))?;
     791        63786 :         out.insert("observedAt".into(), Value::String(point_fraction(s)));
     792        82085 :     }
     793       145871 :     if let Some(e) = obj.get("expiresAt").filter(|v| !(nulls && is_ngsi_null(v))) {
     794              :         // 4.22 transient attribute instances carry their own expiresAt.
     795          102 :         let s = e
     796          102 :             .as_str()
     797          102 :             .filter(|s| parse_datetime(s))
     798          102 :             .ok_or_else(|| bad(format!("attribute {name}: invalid expiresAt")))?;
     799           90 :         out.insert("expiresAt".into(), Value::String(point_fraction(s)));
     800       145769 :     }
     801       145859 :     if opts.sys {
     802              :         // 4.8/4.5.7: deletedAt marks a deletion instance in a Temporal
     803              :         // Evolution — dropping it here would strip remote tombstones of the
     804              :         // timestamp their deletedAt-window matching needs (5.7.3.4 merge).
     805         2886 :         for k in ["createdAt", "modifiedAt", "deletedAt"] {
     806         2886 :             if let Some(Value::String(s)) = obj.get(k) {
     807          836 :                 if parse_datetime(s) {
     808          834 :                     out.insert(k.into(), Value::String(s.clone()));
     809          834 :                 }
     810         2050 :             }
     811              :         }
     812       144897 :     }
     813       145859 :     if let Some(u) = obj.get("unitCode") {
     814         1001 :         if !u.is_string() {
     815            4 :             return Err(bad(format!("attribute {name}: unitCode must be a string")));
     816          997 :         }
     817          997 :         out.insert("unitCode".into(), u.clone());
     818       144858 :     }
     819       145855 :     if let Some(vt) = obj.get("valueType") {
     820              :         // 4.5.2.2: "valueType": a string value which shall be type coerced
     821              :         // into a datatype URI — the non-reified alternative to a native
     822              :         // JSON-LD @type on the Property value.
     823           24 :         let s = vt
     824           24 :             .as_str()
     825           24 :             .ok_or_else(|| bad(format!("attribute {name}: valueType must be a string")))?;
     826              :         // Table 5.2.32-1: on a LanguageProperty valueType "shall be equal
     827              :         // to langString" (the rdf:langString datatype) — kept literal.
     828           22 :         if attr_type == "LanguageProperty" {
     829           12 :             if s != "langString" {
     830            6 :                 return Err(bad(format!(
     831            6 :                     "attribute {name}: valueType shall be \"langString\" on a LanguageProperty"
     832            6 :                 )));
     833            6 :             }
     834            6 :             out.insert("valueType".into(), vt.clone());
     835           10 :         } else {
     836           10 :             out.insert("valueType".into(), Value::String(ctx.expand_key(s)));
     837           10 :         }
     838       145831 :     }
     839       145847 :     if let Some(l) = obj.get("lang") {
     840              :         // 4.15: the language filter augments the converted Property with "an
     841              :         // additional non-reified subproperty lang indicating the actual
     842              :         // language returned" — a langtag. The member is broker-produced and
     843              :         // the clause says nothing about a client supplying one, so it is
     844              :         // kept; a non-string would leave the instance in a shape no reader
     845              :         // of 4.15 can interpret.
     846           12 :         let s = l
     847           12 :             .as_str()
     848           12 :             .ok_or_else(|| bad(format!("attribute {name}: lang must be a language tag")))?;
     849            2 :         out.insert("lang".into(), Value::String(s.to_owned()));
     850       145835 :     }
     851       145837 :     if let Some(ot) = obj.get("objectType") {
     852           24 :         out.insert(
     853           24 :             "objectType".into(),
     854           24 :             expand_terms(name, "objectType", ot, ctx)?,
     855              :         );
     856       145813 :     }
     857       145831 :     Ok(())
     858       145916 : }
     859              : 
     860              : /// 4.5.3: a Relationship's `object` is a URI, or a non-empty array of
     861              : /// URIs; the NGSI-LD Null passes under `allow_null`. The one check the
     862              : /// full-instance and the partial-update fragment paths share.
     863        11140 : fn check_object(name: &str, objv: &Value, opts: ExpandOpts) -> Result<(), NgsiError> {
     864        11140 :     let bad = NgsiError::BadRequestData;
     865            8 :     match objv {
     866        11124 :         Value::String(s) => {
     867        11124 :             if !(opts.allow_null && s == "urn:ngsi-ld:null") {
     868        11078 :                 antares_model::EntityId::new(s)
     869        11078 :                     .map_err(|_| bad(format!("attribute {name}: object must be a URI")))?;
     870           46 :             }
     871              :         }
     872            8 :         Value::Array(items) if !items.is_empty() => {
     873           10 :             for s in items {
     874           10 :                 let s = s
     875           10 :                     .as_str()
     876           10 :                     .ok_or_else(|| bad(format!("attribute {name}: object entries must be URIs")))?;
     877            8 :                 antares_model::EntityId::new(s)
     878            8 :                     .map_err(|_| bad(format!("attribute {name}: object must be a URI")))?;
     879              :             }
     880              :         }
     881           10 :         _ => return Err(bad(format!("attribute {name}: invalid object"))),
     882              :     }
     883        11118 :     Ok(())
     884        11140 : }
     885              : 
     886              : /// 4.5.22.2/4.5.22.3: objectList is an ordered array of Relationship
     887              : /// objects — `{"object": <URI>}` objects (normalized) or bare URI strings
     888              : /// (concise). Internal form is bare URIs; the normalized output shape is
     889              : /// restored at compaction. The `[null]` deletion form (single NGSI-LD Null)
     890              : /// passes through under allow_null.
     891           66 : fn object_list(name: &str, l: &Value, opts: ExpandOpts) -> Result<Value, NgsiError> {
     892           66 :     let bad = NgsiError::BadRequestData;
     893           62 :     match l {
     894           66 :         _ if opts.allow_null && is_ngsi_null_list(l) => Ok(l.clone()),
     895           58 :         Value::Array(items) => {
     896           58 :             let mut uris = Vec::with_capacity(items.len());
     897           88 :             for it in items {
     898           80 :                 let uri = match it {
     899           64 :                     Value::String(s) => s.as_str(),
     900           20 :                     Value::Object(o)
     901           20 :                         if o.len() == 1 && o.get("object").is_some_and(Value::is_string) =>
     902              :                     {
     903           16 :                         o["object"].as_str().unwrap_or_default()
     904              :                     }
     905              :                     _ => {
     906            8 :                         return Err(bad(format!(
     907            8 :                             "attribute {name}: objectList entries must be URIs \
     908            8 :                              or {{\"object\": <URI>}} objects"
     909            8 :                         )))
     910              :                     }
     911              :                 };
     912           80 :                 antares_model::EntityId::new(uri)
     913           80 :                     .map_err(|_| bad(format!("attribute {name}: objectList entry is not a URI")))?;
     914           72 :                 uris.push(Value::String(uri.to_owned()));
     915              :             }
     916           42 :             Ok(Value::Array(uris))
     917              :         }
     918            4 :         _ => Err(bad(format!(
     919            4 :             "attribute {name}: objectList must be an array"
     920            4 :         ))),
     921              :     }
     922           66 : }
     923              : 
     924              : /// Expand a single instance (normalized or concise) to normalized form.
     925              : ///
     926              : /// This is where the 4.2.2 Meta Model's own SHALLs are enforced: "An NGSI-LD
     927              : /// Property shall have a value, stated through hasValue" and "An NGSI-LD
     928              : /// Relationship shall have an object stated through hasObject" — a Property
     929              : /// without `value` (and each specialized property type without its own
     930              : /// value member, 5.2.5/5.2.32/5.2.35–5.2.38) or a Relationship without a
     931              : /// URI `object` is rejected as BadRequestData. "An NGSI-LD Value shall be
     932              : /// either a rdfs:Literal or a node object" — any JSON literal, array or
     933              : /// object is accepted as `value`, a bare JSON `null` is not (4.5.2, the
     934              : /// null sentinel is the string form only).
     935       146644 : fn expand_instance(
     936       146644 :     name: &str,
     937       146644 :     v: &Value,
     938       146644 :     ctx: &Context,
     939       146644 :     opts: ExpandOpts,
     940       146644 :     depth: usize,
     941       146644 : ) -> Result<Value, NgsiError> {
     942       146644 :     let bad = NgsiError::BadRequestData;
     943              : 
     944              :     // NGSI-LD null: attribute deletion marker (merge-patch only).
     945       146644 :     if is_ngsi_null(v) {
     946           26 :         if opts.allow_null {
     947           24 :             return Ok(json!({"type": "Property", "value": "urn:ngsi-ld:null"}));
     948            2 :         }
     949            2 :         return Err(bad(format!("attribute {name}: null is not allowed here")));
     950       146618 :     }
     951              : 
     952       146618 :     let obj = match v {
     953       146490 :         Value::Object(o) => o,
     954              :         // concise: primitive / array value ⇒ Property
     955          128 :         prim => {
     956          128 :             check_value_nulls(name, prim, opts)?;
     957          126 :             return Ok(json!({"type": "Property", "value": prim.clone()}));
     958              :         }
     959              :     };
     960              :     // The core @context aliases type to @type: an Attribute written with the
     961              :     // keyword is the same document, one with both names collides (JSON-LD
     962              :     // 1.1 API expansion "colliding keywords").
     963              :     let aliased;
     964       146490 :     let obj = match obj.get("@type") {
     965            6 :         Some(_) if obj.contains_key("type") => {
     966            4 :             return Err(bad(format!("attribute {name}: both type and @type given")))
     967              :         }
     968            2 :         Some(t) => {
     969            2 :             let mut m = obj.clone();
     970            2 :             m.remove("@type");
     971            2 :             m.insert("type".into(), t.clone());
     972            2 :             aliased = m;
     973            2 :             &aliased
     974              :         }
     975       146484 :         None => obj,
     976              :     };
     977              :     let core;
     978       146486 :     let obj = match core_members(name, obj, ctx)? {
     979           18 :         Some(m) => {
     980           18 :             core = m;
     981           18 :             &core
     982              :         }
     983       146464 :         None => obj,
     984              :     };
     985              : 
     986       146482 :     let declared = match obj.get("type") {
     987          124 :         None => None,
     988       146352 :         Some(Value::String(t)) => Some(t.as_str()),
     989            6 :         Some(t) => return Err(bad(format!("attribute {name}: type {t} is not a string"))),
     990              :     };
     991       146368 :     let attr_type: &str = match declared {
     992       146352 :         Some(t) if ATTR_TYPES.contains(&t) => t,
     993           78 :         Some(t) if GEO_TYPES.contains(&t) && obj.contains_key("coordinates") => {
     994              :             // concise GeoProperty: bare GeoJSON object as the value. 4.7.3
     995              :             // mandates `coordinates` "as defined by the relevant GeoJSON
     996              :             // Geometry", so the concise form is held to the same RFC 7946
     997              :             // restrictions as the verbose one.
     998           70 :             check_value_nulls(name, v, opts)?;
     999           70 :             validate_geojson(name, v)?;
    1000           32 :             return Ok(json!({"type": "GeoProperty", "value": v.clone()}));
    1001              :         }
    1002            8 :         Some(t) => {
    1003            8 :             return Err(bad(format!(
    1004            8 :                 "attribute {name}: invalid attribute type {t:?}"
    1005            8 :             )))
    1006              :         }
    1007              :         None => {
    1008              :             // concise object form — infer from members
    1009          124 :             if obj.contains_key("object") {
    1010           20 :                 "Relationship"
    1011          104 :             } else if obj.contains_key("languageMap") {
    1012           12 :                 "LanguageProperty"
    1013           92 :             } else if obj.contains_key("vocab") {
    1014            4 :                 "VocabProperty"
    1015           88 :             } else if obj.contains_key("json") {
    1016           12 :                 "JsonProperty"
    1017           76 :             } else if obj.contains_key("valueList") {
    1018            4 :                 "ListProperty"
    1019           72 :             } else if obj.contains_key("objectList") {
    1020            2 :                 "ListRelationship"
    1021           70 :             } else if obj.contains_key("value") {
    1022              :                 // 4.5.2.3: type may be omitted — "Property can be inferred by
    1023              :                 // the presence of the value attribute. An exception to this
    1024              :                 // inference rule occurs for geospatial Property Values, where
    1025              :                 // the GeoProperty sub-type shall be inferred instead, if the
    1026              :                 // Property Value resolves to a supported GeoJSON geometry."
    1027           40 :                 let v = &obj["value"];
    1028           40 :                 if v.get("type")
    1029           40 :                     .and_then(Value::as_str)
    1030           40 :                     .is_some_and(|t| GEO_TYPES.contains(&t))
    1031           16 :                     && v.get("coordinates").is_some()
    1032              :                 {
    1033           12 :                     "GeoProperty"
    1034              :                 } else {
    1035           28 :                     "Property"
    1036              :                 }
    1037              :             } else {
    1038              :                 // whole object is a Property value (4.5.2.3)
    1039           30 :                 check_value_nulls(name, v, opts)?;
    1040           28 :                 if embeds_context(v) {
    1041            2 :                     return Err(bad(format!(
    1042            2 :                         "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
    1043            2 :                     )));
    1044           26 :                 }
    1045           26 :                 return Ok(json!({"type": "Property", "value": v.clone()}));
    1046              :             }
    1047              :         }
    1048              :     };
    1049              : 
    1050       146368 :     let mut out = Map::new();
    1051       146368 :     out.insert("type".into(), Value::String(attr_type.to_owned()));
    1052              : 
    1053       146368 :     check_prohibited_members(name, obj, attr_type, ctx, opts, &mut out)?;
    1054              : 
    1055              :     // required member per type
    1056       146192 :     match attr_type {
    1057       146192 :         "Property" => {
    1058       127693 :             let val = obj
    1059       127693 :                 .get("value")
    1060       127693 :                 .ok_or_else(|| bad(format!("attribute {name}: Property needs value")))?;
    1061       127682 :             if val.is_null() {
    1062           21 :                 return Err(bad(format!(
    1063           21 :                     "attribute {name}: JSON null is not a valid value (use \"urn:ngsi-ld:null\")"
    1064           21 :                 )));
    1065       127661 :             }
    1066       127661 :             if let Some(vo) = val.as_object().filter(|o| o.contains_key("@value")) {
    1067           20 :                 check_value_object(name, vo, ctx)?;
    1068       127641 :             }
    1069              :             // 5.5.7: no part of a user @context below the top level, a
    1070              :             // structured Property value included
    1071       127653 :             if embeds_context(val) {
    1072            2 :                 return Err(bad(format!(
    1073            2 :                     "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
    1074            2 :                 )));
    1075       127651 :             }
    1076       127651 :             out.insert("value".into(), val.clone());
    1077              :         }
    1078        18499 :         "GeoProperty" => {
    1079         5178 :             let val = obj
    1080         5178 :                 .get("value")
    1081         5178 :                 .ok_or_else(|| bad(format!("attribute {name}: GeoProperty needs value")))?;
    1082              :             // 4.7.2: a whole geometry may arrive as an encoded JSON string,
    1083              :             // accepted "if and only if" it parses into a valid geometry —
    1084              :             // normalized here to the object form so storage, geo-queries and
    1085              :             // responses all see one representation.
    1086         5168 :             let val = match val {
    1087           82 :                 Value::String(s) if !(opts.allow_null && is_ngsi_null(val)) => {
    1088           56 :                     serde_json::from_str::<Value>(s).map_err(|_| {
    1089            8 :                         bad(format!(
    1090            8 :                             "attribute {name}: string-encoded geometry is not valid JSON"
    1091            8 :                         ))
    1092            8 :                     })?
    1093              :                 }
    1094         5120 :                 _ => val.clone(),
    1095              :             };
    1096         5168 :             if !(opts.allow_null && is_ngsi_null(&val)) {
    1097         5142 :                 validate_geojson(name, &val)?;
    1098           26 :             }
    1099         5066 :             out.insert("value".into(), val);
    1100              :         }
    1101        13321 :         "Relationship" => {
    1102        11139 :             let objv = obj
    1103        11139 :                 .get("object")
    1104        11139 :                 .ok_or_else(|| bad(format!("attribute {name}: Relationship needs object")))?;
    1105        11130 :             check_object(name, objv, opts)?;
    1106        11112 :             out.insert("object".into(), objv.clone());
    1107              :         }
    1108         2182 :         "LanguageProperty" => {
    1109              :             // 4.5.18.2: "a JSON object consisting of a set of non-empty
    1110              :             // language tags (RFC 5646) or the language tag "@none"", each
    1111              :             // mapping to a single string or array of strings.
    1112         1803 :             let lm = obj
    1113         1803 :                 .get("languageMap")
    1114         1803 :                 .ok_or_else(|| bad(format!("attribute {name}: needs languageMap")))?;
    1115              :             // 4.6.5: {"@none": "urn:ngsi-ld:null"} is exclusively the
    1116              :             // partial/merge-patch deletion encoding — outside allow_null it
    1117              :             // is an NGSI-LD Null in a create and thus BadRequestData.
    1118         1790 :             let ok = if is_ngsi_null_langmap(lm) {
    1119           50 :                 opts.allow_null
    1120              :             } else {
    1121         1740 :                 valid_language_map(lm)
    1122              :             };
    1123         1790 :             if !ok {
    1124           38 :                 return Err(bad(format!("attribute {name}: invalid languageMap")));
    1125         1752 :             }
    1126         1752 :             out.insert("languageMap".into(), lm.clone());
    1127              :         }
    1128          379 :         "JsonProperty" => {
    1129              :             // TS 104 175 5.2.6.4.10 types json by the NGSI-LD Value of 3.1:
    1130              :             // "JSON value (i.e. a string, a number, true or false, an object,
    1131              :             // an array)" — CIM 009 4.5.24.2's object-only rule is lifted.
    1132              :             // Never expanded or compacted; kept verbatim. JSON null
    1133              :             // is no NGSI-LD Value; the bare NGSI-LD Null is the deletion form
    1134              :             // and passes only under allow_null.
    1135          145 :             let j = obj
    1136          145 :                 .get("json")
    1137          145 :                 .ok_or_else(|| bad(format!("attribute {name}: needs json")))?;
    1138          141 :             let ok = if is_ngsi_null(j) {
    1139            6 :                 opts.allow_null
    1140              :             } else {
    1141          135 :                 !j.is_null()
    1142              :             };
    1143          141 :             if !ok {
    1144           10 :                 return Err(bad(format!(
    1145           10 :                     "attribute {name}: json must be a JSON value (string, number, boolean, object or array), not null"
    1146           10 :                 )));
    1147          131 :             }
    1148          131 :             out.insert("json".into(), j.clone());
    1149              :         }
    1150          234 :         "VocabProperty" => {
    1151          118 :             let vv = obj
    1152          118 :                 .get("vocab")
    1153          118 :                 .ok_or_else(|| bad(format!("attribute {name}: needs vocab")))?;
    1154          114 :             out.insert("vocab".into(), expand_terms(name, "vocab", vv, ctx)?);
    1155              :         }
    1156          116 :         "ListProperty" => {
    1157           54 :             let l = obj
    1158           54 :                 .get("valueList")
    1159           54 :                 .ok_or_else(|| bad(format!("attribute {name}: needs valueList")))?;
    1160           50 :             if !l.is_array() && !(opts.allow_null && is_ngsi_null(l)) {
    1161            6 :                 return Err(bad(format!("attribute {name}: valueList must be an array")));
    1162           44 :             }
    1163              :             // 5.5.7: no user @context below the top level, inside a list's
    1164              :             // values as inside a Property value
    1165           44 :             if embeds_context(l) {
    1166            2 :                 return Err(bad(format!(
    1167            2 :                     "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
    1168            2 :                 )));
    1169           42 :             }
    1170           42 :             out.insert("valueList".into(), l.clone());
    1171              :         }
    1172           62 :         "ListRelationship" => {
    1173           62 :             let l = obj
    1174           62 :                 .get("objectList")
    1175           62 :                 .ok_or_else(|| bad(format!("attribute {name}: needs objectList")))?;
    1176           58 :             out.insert("objectList".into(), object_list(name, l, opts)?);
    1177              :         }
    1178              :         // 4.5.2 closes the set of Attribute types, and `attr_type` is
    1179              :         // either a member of ATTR_TYPES or one of the inferred literals
    1180              :         // above — so every reachable value has an arm. A member added to
    1181              :         // that list without an arm here would arrive as a client-supplied
    1182              :         // `"type"`, which is a request to answer, not a reason to panic:
    1183              :         // pinned by
    1184              :         // `every_declarable_attribute_type_is_dispatched_not_unreachable`.
    1185              :         _ => {
    1186            0 :             return Err(NgsiError::InternalError(format!(
    1187            0 :                 "attribute type {attr_type} has no expansion arm"
    1188            0 :             )))
    1189              :         }
    1190              :     }
    1191              : 
    1192              :     // optional standard members
    1193       145904 :     expand_common_members(name, obj, attr_type, ctx, opts, &mut out)?;
    1194              : 
    1195              :     // sub-attributes
    1196       395625 :     for (k, sub) in obj {
    1197       395625 :         if k == "@context" {
    1198              :             // 4.5.1/5.5.7: "Attributes shall not contain any embedded
    1199              :             // @context" — a nested user context could override core terms,
    1200              :             // so it "should result in an error of type BadRequestData".
    1201            8 :             return Err(bad(format!(
    1202            8 :                 "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
    1203            8 :             )));
    1204       395617 :         }
    1205       395617 :         if RESERVED_MEMBERS.contains(&k.as_str()) {
    1206       393734 :             continue;
    1207         1883 :         }
    1208         1883 :         if k.is_empty() {
    1209            2 :             return Err(bad(format!("attribute {name}: empty sub-attribute name")));
    1210         1881 :         }
    1211         1881 :         let iri = expand_attr_name(k, ctx)?;
    1212         1879 :         let instances = expand_attribute(k, sub, &ctx.within(&iri), opts, depth.saturating_add(1))?;
    1213              :         // 4.5.5.1 again, one level down: two sub-attribute names expanding to
    1214              :         // one IRI would drop whichever the map orders first.
    1215         1847 :         if out.insert(iri.clone(), Value::Array(instances)).is_some() {
    1216            2 :             return Err(bad(format!(
    1217            2 :                 "attribute {name}: sub-attribute {k} expands to {iri}, which \
    1218            2 :                  another member already defines (4.5.5.1)"
    1219            2 :             )));
    1220         1845 :         }
    1221              :     }
    1222              : 
    1223              :     // 5.2.1: "In all other cases, implementations shall raise an error of
    1224              :     // type BadRequestData if an NGSI-LD Null value is encountered" — the
    1225              :     // deletion marker is only meaningful on partial-update/merge inputs
    1226              :     // (allow_null). `json` is exempt: raw JSON is never interpreted.
    1227       145777 :     if !opts.allow_null {
    1228        84831 :         let nullish = |v: &Value| match v {
    1229        22369 :             Value::String(s) => s == "urn:ngsi-ld:null",
    1230        43997 :             Value::Array(a) => a.iter().any(|x| x.as_str() == Some("urn:ngsi-ld:null")),
    1231        57802 :             _ => false,
    1232        84831 :         };
    1233       416629 :         for k in ["value", "object", "vocab", "valueList", "objectList"] {
    1234       416629 :             if out.get(k).is_some_and(&nullish) {
    1235           26 :                 return Err(bad(format!(
    1236           26 :                     "attribute {name}: the NGSI-LD Null is only allowed in \
    1237           26 :                      partial update or merge inputs (5.2.1)"
    1238           26 :                 )));
    1239       416603 :             }
    1240              :         }
    1241        83319 :         if out
    1242        83319 :             .get("languageMap")
    1243        83319 :             .and_then(Value::as_object)
    1244        83319 :             .is_some_and(|m| m.values().any(nullish))
    1245              :         {
    1246            2 :             return Err(bad(format!(
    1247            2 :                 "attribute {name}: the NGSI-LD Null is only allowed in \
    1248            2 :                  partial update or merge inputs (5.2.1)"
    1249            2 :             )));
    1250        83317 :         }
    1251        62432 :     }
    1252              : 
    1253              :     // 5.5.4: "urn:ngsi-ld:null" as the value of a key-value pair within a
    1254              :     // JSON object that is the Property's value is BadRequestData — excepted
    1255              :     // solely for merge fragments (5.5.12). `json` stays exempt (raw JSON).
    1256       145749 :     if !opts.merge && out.get("value").is_some_and(has_object_member_null) {
    1257           18 :         return Err(bad(format!(
    1258           18 :             "attribute {name}: \"urn:ngsi-ld:null\" inside a compound value \
    1259           18 :              is only allowed in merge fragments (5.5.4)"
    1260           18 :         )));
    1261       145731 :     }
    1262              : 
    1263       145731 :     Ok(Value::Object(out))
    1264       146644 : }
    1265              : 
    1266              : /// 5.5.7: "The term to URI expansion or compaction shall be performed using a
    1267              : /// @context as described by the JSON-LD specification", so a core member
    1268              : /// written as its full IRI (`https://uri.etsi.org/ngsi-ld/observedAt`,
    1269              : /// `.../hasValue`) or an Attribute type written as one
    1270              : /// (`https://uri.etsi.org/ngsi-ld/Property`) is the same document as the one
    1271              : /// using the term. Matched by the short key alone, the IRI spelling became a
    1272              : /// sub-Property. `None` when nothing is spelled out.
    1273       146486 : fn core_members(
    1274       146486 :     name: &str,
    1275       146486 :     obj: &Map<String, Value>,
    1276       146486 :     ctx: &Context,
    1277       146486 : ) -> Result<Option<Map<String, Value>>, NgsiError> {
    1278       397219 :     let core = |k: &str| -> Option<&'static str> {
    1279       397219 :         if !k.starts_with(crate::context::NGSI_LD_BASE) {
    1280       397179 :             return None;
    1281           40 :         }
    1282           40 :         RESERVED_MEMBERS
    1283           40 :             .iter()
    1284           40 :             .copied()
    1285          222 :             .find(|r| *r != "type" && ctx.expand_key(r) == k)
    1286       397219 :     };
    1287       146486 :     let type_iri = obj
    1288       146486 :         .get("type")
    1289       146486 :         .and_then(Value::as_str)
    1290       146486 :         .and_then(|t| t.strip_prefix(crate::context::NGSI_LD_BASE))
    1291       146486 :         .filter(|t| ATTR_TYPES.contains(t));
    1292       397161 :     if type_iri.is_none() && !obj.keys().any(|k| core(k).is_some()) {
    1293       146464 :         return Ok(None);
    1294           22 :     }
    1295           22 :     let mut out = Map::new();
    1296           58 :     for (k, v) in obj {
    1297           58 :         let (key, v) = match (k.as_str(), core(k)) {
    1298           58 :             ("type", _) => (
    1299              :                 "type",
    1300           22 :                 type_iri.map_or_else(|| v.clone(), |t| Value::String(t.into())),
    1301              :             ),
    1302           20 :             (_, Some(term)) => (term, v.clone()),
    1303           16 :             (k, None) => (k, v.clone()),
    1304              :         };
    1305           58 :         if out.insert(key.to_owned(), v).is_some() {
    1306            4 :             return Err(NgsiError::BadRequestData(format!(
    1307            4 :                 "attribute {name}: {key} given both as a term and as its IRI (5.5.7)"
    1308            4 :             )));
    1309           54 :         }
    1310              :     }
    1311           18 :     Ok(Some(out))
    1312       146486 : }
    1313              : 
    1314              : /// 4.5.18.2: "a JSON object consisting of a set of non-empty language tags
    1315              : /// ... or the language tag "@none"", each a string or an array of strings.
    1316         1782 : fn valid_language_map(lm: &Value) -> bool {
    1317         1782 :     lm.as_object().is_some_and(|m| {
    1318         3493 :         m.keys().all(|k| k == "@none" || is_language_tag(k))
    1319         3465 :             && m.values().all(|v| {
    1320         3465 :                 v.is_string() || v.as_array().is_some_and(|a| a.iter().all(Value::is_string))
    1321         3465 :             })
    1322         1780 :     })
    1323         1782 : }
    1324              : 
    1325              : /// Whether a JSON value holds an `@context` member at any depth.
    1326       178059 : fn embeds_context(v: &Value) -> bool {
    1327       178059 :     match v {
    1328         1372 :         Value::Object(m) => m.contains_key("@context") || m.values().any(embeds_context),
    1329         6971 :         Value::Array(a) => a.iter().any(embeds_context),
    1330       169716 :         _ => false,
    1331              :     }
    1332       178059 : }
    1333              : 
    1334              : /// A Property value carrying `@value` is a JSON-LD value object (JSON-LD
    1335              : /// 1.1 section 9.5): "MUST NOT contain any other keys" than `@type`,
    1336              : /// `@language`, `@direction`, `@index` and `@context`, and a typed one's
    1337              : /// `@type` is an IRI string. Typed as `DateTime`, its `@value` is a 4.6.3
    1338              : /// DateTime.
    1339           22 : fn check_value_object(name: &str, vo: &Map<String, Value>, ctx: &Context) -> Result<(), NgsiError> {
    1340           22 :     let bad = |m: &str| Err(NgsiError::BadRequestData(format!("attribute {name}: {m}")));
    1341           46 :     if let Some(k) = vo.keys().find(|k| {
    1342           42 :         !matches!(
    1343           46 :             k.as_str(),
    1344           46 :             "@value" | "@type" | "@language" | "@direction" | "@index" | "@context"
    1345              :         )
    1346           46 :     }) {
    1347            4 :         return bad(&format!(
    1348            4 :             "value object member {k:?} is not allowed (JSON-LD 1.1 9.5)"
    1349            4 :         ));
    1350           18 :     }
    1351           18 :     match vo.get("@type") {
    1352            2 :         None => Ok(()),
    1353           14 :         Some(Value::String(t)) => {
    1354           14 :             let datetime = format!("{}DateTime", crate::context::NGSI_LD_BASE);
    1355           14 :             let is_datetime = ctx.expand_key(t) == datetime || *t == datetime;
    1356           14 :             if is_datetime
    1357            8 :                 && !vo
    1358            8 :                     .get("@value")
    1359            8 :                     .and_then(Value::as_str)
    1360            8 :                     .is_some_and(parse_datetime)
    1361              :             {
    1362            4 :                 return bad("a DateTime value object needs an ISO 8601 DateTime @value (4.6.3)");
    1363           10 :             }
    1364           10 :             Ok(())
    1365              :         }
    1366            2 :         Some(_) => bad("value object @type must be a string (JSON-LD 1.1 9.5)"),
    1367              :     }
    1368           22 : }
    1369              : 
    1370              : /// RFC 5646 section 2.1 well-formedness, as far as the shape goes: hyphen
    1371              : /// separated subtags of 1-8 letters or digits, led by a 2-8 letter language
    1372              : /// or the `x`/`i` singleton of a private-use or grandfathered tag.
    1373              : // No registry lookup, so a well-formed but unregistered subtag passes
    1374         3483 : fn is_language_tag(tag: &str) -> bool {
    1375         3483 :     let mut subtags = tag.split('-');
    1376         3483 :     let first = subtags.next().unwrap_or_default();
    1377         6920 :     let lead = (2..=8).contains(&first.len()) && first.bytes().all(|b| b.is_ascii_alphabetic());
    1378         3483 :     let singleton = matches!(first, "x" | "X" | "i" | "I");
    1379         3483 :     let rest: Vec<&str> = subtags.collect();
    1380         3483 :     (lead || (singleton && !rest.is_empty()))
    1381         3459 :         && rest
    1382         3459 :             .iter()
    1383         3459 :             .all(|t| (1..=8).contains(&t.len()) && t.bytes().all(|b| b.is_ascii_alphanumeric()))
    1384         3483 : }
    1385              : 
    1386              : /// Expand a PARTIAL-UPDATE attribute fragment (5.6.4): reserved members are
    1387              : /// kept, others become sub-attributes — and crucially NO attribute-type
    1388              : /// inference happens (a fragment `{providedBy: …}` patches the sub-attribute,
    1389              : /// it is not a concise Property value).
    1390          376 : pub fn expand_attr_fragment(obj: &Map<String, Value>, ctx: &Context) -> Result<Value, NgsiError> {
    1391              :     /// the attribute name the member checks report: a fragment has none
    1392              :     const FRAGMENT: &str = "fragment";
    1393          376 :     let bad = NgsiError::BadRequestData;
    1394          376 :     let opts = ExpandOpts {
    1395          376 :         fragment: true,
    1396          376 :         allow_null: true,
    1397          376 :         ..Default::default()
    1398          376 :     };
    1399          376 :     let mut out = Map::new();
    1400          852 :     for (k, v) in obj {
    1401          852 :         match k.as_str() {
    1402              :             // 5.2.5 Table 5.2.5-2 / 4.5.2.2 System Generated: output-only
    1403              :             // members "shall not be provided by Context Producers. In the
    1404              :             // event that they are provided (in update or create operations)
    1405              :             // NGSI-LD implementations shall ignore them." The sealed
    1406              :             // subproperties are Prohibited outside a full ngsildproof
    1407              :             // instance, which this path cannot identify.
    1408          852 :             "@context" | "createdAt" | "modifiedAt" | "deletedAt" | "instanceId"
    1409          690 :             | "entityIdSealed" | "entityTypeSealed" => continue,
    1410          686 :             _ if OUTPUT_ONLY.contains(&k.as_str()) => continue,
    1411          668 :             "type" => {
    1412          190 :                 let t = v
    1413          190 :                     .as_str()
    1414          190 :                     .filter(|t| ATTR_TYPES.contains(t))
    1415          190 :                     .ok_or_else(|| bad("invalid attribute type in fragment".into()))?;
    1416          188 :                 out.insert("type".into(), Value::String(t.to_owned()));
    1417              :             }
    1418              :             // 4.6.3: both members are ISO 8601 DateTimes (4.8 observedAt,
    1419              :             // 4.22 expiresAt) — the same check the full-instance path runs.
    1420              :             // 10.2.5.4: "NGSI-LD Nulls should be supported by this operation": the Null
    1421              :             // removes the member from the patched instance (5.5.12).
    1422          478 :             "observedAt" | "expiresAt" if is_ngsi_null(v) => {
    1423            6 :                 out.insert(k.clone(), v.clone());
    1424            6 :             }
    1425              :             "observedAt" | "expiresAt" => {
    1426           28 :                 let sdt = v
    1427           28 :                     .as_str()
    1428           28 :                     .filter(|s| parse_datetime(s))
    1429           28 :                     .ok_or_else(|| bad(format!("invalid {k} in fragment")))?;
    1430           18 :                 out.insert(k.clone(), Value::String(sdt.to_owned()));
    1431              :             }
    1432          444 :             "value" => {
    1433          202 :                 if v.is_null() {
    1434            2 :                     return Err(bad("JSON null is not a valid value".into()));
    1435          200 :                 }
    1436              :                 // 5.5.4: a null inside a compound value is legal in merge
    1437              :                 // fragments only — a partial update (5.5.8) is not one.
    1438          200 :                 if has_object_member_null(v) {
    1439            6 :                     return Err(bad("\"urn:ngsi-ld:null\" inside a compound value is only \
    1440            6 :                          allowed in merge fragments (5.5.4)"
    1441            6 :                         .into()));
    1442          194 :                 }
    1443              :                 // the value rules a full Property instance obeys
    1444          194 :                 if let Some(vo) = v.as_object().filter(|o| o.contains_key("@value")) {
    1445            2 :                     check_value_object(FRAGMENT, vo, ctx)?;
    1446          192 :                 }
    1447          192 :                 if embeds_context(v) {
    1448            2 :                     return Err(bad(
    1449            2 :                         "embedded @context is not allowed in a fragment (4.5.1/5.5.7)".into(),
    1450            2 :                     ));
    1451          190 :                 }
    1452          190 :                 out.insert("value".into(), v.clone());
    1453              :             }
    1454              :             // 4.5.5.1/5.5.8: the fragment's datasetId selects the instance to
    1455              :             // patch and is copied onto it — it obeys the same URI-string rule
    1456              :             // as a full instance, or the patched instance stops answering to
    1457              :             // the datasetId lookups that keep one default instance per name.
    1458          242 :             "datasetId" => {
    1459           44 :                 if let Some(d) = dataset_id_member(v)? {
    1460           26 :                     out.insert("datasetId".into(), d);
    1461           26 :                 }
    1462              :             }
    1463          198 :             "languageMap"
    1464           32 :                 if !(is_ngsi_null(v) || is_ngsi_null_langmap(v) || valid_language_map(v)) =>
    1465              :             {
    1466            6 :                 return Err(bad("invalid languageMap in fragment (4.5.18.2)".into()));
    1467              :             }
    1468              :             // 10.2.5.4/5.5.12: a null (either form) removes the member from
    1469              :             // the patched instance (`removes_member`), whatever the member
    1470          192 :             _ if RESERVED_MEMBERS.contains(&k.as_str()) && (v.is_null() || is_ngsi_null(v)) => {
    1471           42 :                 out.insert(k.clone(), v.clone());
    1472           42 :             }
    1473              :             // 5.5.7: the rest of the reserved members are held to the rules,
    1474              :             // and expanded the way, a full instance's are — or one PATCH
    1475              :             // stores a Relationship target that is no URI, an objectList
    1476              :             // outside the bare-URI internal form, or a short-term vocab that
    1477              :             // a create would have stored as an IRI
    1478          150 :             "object" => {
    1479           10 :                 check_object(FRAGMENT, v, opts)?;
    1480            6 :                 out.insert(k.clone(), v.clone());
    1481              :             }
    1482          140 :             "objectList" => {
    1483            8 :                 out.insert(k.clone(), object_list(FRAGMENT, v, opts)?);
    1484              :             }
    1485          132 :             "vocab" => {
    1486            8 :                 out.insert(k.clone(), expand_terms(FRAGMENT, k, v, ctx)?);
    1487              :             }
    1488          124 :             "valueList" if !(v.is_array() || is_ngsi_null_list(v)) => {
    1489            2 :                 return Err(bad("valueList in fragment must be an array".into()));
    1490              :             }
    1491              :             // 5.5.7, as for a fragment's value
    1492            2 :             "valueList" if embeds_context(v) => {
    1493            2 :                 return Err(bad(
    1494            2 :                     "embedded @context is not allowed in a fragment (4.5.1/5.5.7)".into(),
    1495            2 :                 ));
    1496              :             }
    1497          120 :             "unitCode" | "lang" | "valueType" | "objectType" => {
    1498              :                 // the fragment may carry no type; Table 5.2.32-1's literal
    1499              :                 // langString then still reads as the LanguageProperty's
    1500           12 :                 let attr_type = match obj.get("type").and_then(Value::as_str) {
    1501            4 :                     Some(t) => t,
    1502            8 :                     None if v.as_str() == Some("langString") => "LanguageProperty",
    1503            8 :                     None => "",
    1504              :                 };
    1505           12 :                 let one = Map::from_iter([(k.clone(), v.clone())]);
    1506           12 :                 expand_common_members(FRAGMENT, &one, attr_type, ctx, opts, &mut out)?;
    1507              :             }
    1508          108 :             _ if RESERVED_MEMBERS.contains(&k.as_str()) => {
    1509           28 :                 out.insert(k.clone(), v.clone());
    1510           28 :             }
    1511              :             _ => {
    1512           80 :                 let iri = expand_attr_name(k, ctx)?;
    1513           80 :                 let instances = expand_attribute(k, v, &ctx.within(&iri), opts, 1)?;
    1514              :                 // 4.5.5.1: one Attribute name = one member of the fragment.
    1515           80 :                 if out.insert(iri.clone(), Value::Array(instances)).is_some() {
    1516            2 :                     return Err(bad(format!(
    1517            2 :                         "sub-attribute {k} expands to {iri}, which another \
    1518            2 :                          member of this fragment already defines (4.5.5.1)"
    1519            2 :                     )));
    1520           78 :                 }
    1521              :             }
    1522              :         }
    1523              :     }
    1524          310 :     Ok(Value::Object(out))
    1525          376 : }
    1526              : 
    1527              : /// 4.5.5.1: "There can only be one default Attribute instance for an
    1528              : /// Attribute with a given Attribute name in any request or response";
    1529              : /// datasetIds must be distinct per attribute (explicit "@none" is normalized
    1530              : /// to absent before this check, so absent + "@none" counts as two defaults).
    1531        99376 : fn validate_dataset_ids(name: &str, instances: &[Value]) -> Result<(), NgsiError> {
    1532              :     // A set, not a scanned list: the instance count is bounded only by the
    1533              :     // request body, so a linear scan per instance makes the check quadratic
    1534              :     // in what a client sends.
    1535        99376 :     let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
    1536        99376 :     let mut default_count = 0usize;
    1537       121576 :     for inst in instances {
    1538       121576 :         match inst.get("datasetId").and_then(Value::as_str) {
    1539        24209 :             Some(d) => {
    1540        24209 :                 if !seen.insert(d) {
    1541           12 :                     return Err(NgsiError::BadRequestData(format!(
    1542           12 :                         "attribute {name}: duplicate datasetId {d}"
    1543           12 :                     )));
    1544        24197 :                 }
    1545              :             }
    1546        97367 :             None => default_count = default_count.saturating_add(1),
    1547              :         }
    1548              :     }
    1549        99364 :     if default_count > 1 {
    1550            6 :         return Err(NgsiError::BadRequestData(format!(
    1551            6 :             "attribute {name}: more than one instance without datasetId"
    1552            6 :         )));
    1553        99358 :     }
    1554        99358 :     Ok(())
    1555        99376 : }
    1556              : 
    1557              : /// RFC 7946 3.1.1: "A position is an array of numbers. There MUST be two or
    1558              : /// more elements. The first two elements are longitude and latitude \[…\]
    1559              : /// using decimal numbers", read in the coordinate reference system the
    1560              : /// format fixes: "a geographic coordinate reference system, using the World
    1561              : /// Geodetic System 1984 \[…\] datum, with longitude and latitude units of
    1562              : /// decimal degrees" (RFC 7946 4). 4.7.2 adds that the coordinates are "values
    1563              : /// of a JSON-LD floating point number data type".
    1564              : ///
    1565              : /// The range is not decoration: a latitude of 999 reaches PostGIS as a
    1566              : /// `::geography` cast that errors, so a single accepted write would break
    1567              : /// every later `near` query in that tenant.
    1568        22787 : fn check_position(p: &Value) -> Result<(), String> {
    1569        22787 :     let a = p.as_array().ok_or("position is not an array")?;
    1570        22761 :     if a.len() < 2 {
    1571           32 :         return Err(format!("position has {} elements (minimum 2)", a.len()));
    1572        22729 :     }
    1573        45466 :     let mut n = a.iter().map(|c| c.as_f64().filter(|f| f.is_finite()));
    1574        22729 :     let (Some(Some(lon)), Some(Some(lat))) = (n.next(), n.next()) else {
    1575           14 :         return Err("position holds a value that is not a number".into());
    1576              :     };
    1577        22715 :     if n.any(|c| c.is_none()) {
    1578            2 :         return Err("position holds a value that is not a number".into());
    1579        22713 :     }
    1580        22713 :     if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) {
    1581           36 :         return Err(format!(
    1582           36 :             "position [{lon}, {lat}] is outside the WGS84 range [-180 -90, 180 90]"
    1583           36 :         ));
    1584        22677 :     }
    1585        22677 :     Ok(())
    1586        22787 : }
    1587              : 
    1588              : /// RFC 7946 3.1.4: a LineString is "two or more positions".
    1589           99 : fn check_line(v: &Value) -> Result<(), String> {
    1590           99 :     let a = v.as_array().ok_or("LineString is not an array")?;
    1591           99 :     if a.len() < 2 {
    1592           12 :         return Err(format!("LineString has {} positions (minimum 2)", a.len()));
    1593           87 :     }
    1594           87 :     a.iter().try_for_each(check_position)
    1595           99 : }
    1596              : 
    1597              : /// RFC 7946 3.1.6: a linear ring is "closed \[…\] with four or more positions",
    1598              : /// "the first and last positions \[…\] equivalent".
    1599         1402 : fn check_ring(v: &Value) -> Result<(), String> {
    1600         1402 :     let a = v.as_array().ok_or("linear ring is not an array")?;
    1601         1402 :     if a.len() < 4 {
    1602           42 :         return Err(format!("linear ring has {} positions (minimum 4)", a.len()));
    1603         1360 :     }
    1604         1360 :     if a.first() != a.last() {
    1605            4 :         return Err("linear ring is not closed (first != last position)".into());
    1606         1356 :     }
    1607         1356 :     a.iter().try_for_each(check_position)
    1608         1402 : }
    1609              : 
    1610         1514 : fn each(v: &Value, what: &str, f: impl FnMut(&Value) -> Result<(), String>) -> Result<(), String> {
    1611         1514 :     v.as_array()
    1612         1514 :         .ok_or_else(|| format!("{what} is not an array"))?
    1613         1504 :         .iter()
    1614         1504 :         .try_for_each(f)
    1615         1514 : }
    1616              : 
    1617              : /// The nesting RFC 7946 3.1 gives each geometry type its `coordinates`.
    1618              : /// Empty multi-geometries are geometries: only the shapes the RFC names a
    1619              : /// minimum for carry one.
    1620        10200 : pub fn check_geometry(gtype: &str, coords: &Value) -> Result<(), String> {
    1621        10200 :     match gtype {
    1622        10200 :         "Point" => check_position(coords),
    1623         1185 :         "MultiPoint" => each(coords, "MultiPoint coordinates", check_position),
    1624         1145 :         "LineString" => check_line(coords),
    1625         1050 :         "MultiLineString" => each(coords, "MultiLineString coordinates", check_line),
    1626         1030 :         "Polygon" => each(coords, "Polygon coordinates", check_ring),
    1627          428 :         "MultiPolygon" => each(coords, "MultiPolygon coordinates", |p| {
    1628          428 :             each(p, "MultiPolygon polygon", check_ring)
    1629          428 :         }),
    1630            4 :         _ => Err(format!("{gtype} is not a supported GeoJSON geometry type")),
    1631              :     }
    1632        10200 : }
    1633              : 
    1634              : /// 4.6.3: supported Value geometries are "All the GeoJSON Geometries \[8\]
    1635              : /// with the exception of GeometryCollection" — GEO_TYPES holds exactly that
    1636              : /// set. 4.7.2 accepts a geometry "if and only if \[…\] meeting the syntax and
    1637              : /// restrictions mandated by IETF RFC 7946 \[8\] when representing a valid
    1638              : /// Geometry of the type specified", so the shape of `coordinates` is checked
    1639              : /// against the declared type, not merely for being an array: a geometry that
    1640              : /// is not one must not reach storage, the 4.5.16 GeoJSON rendering path or a
    1641              : /// PostGIS cast.
    1642         5294 : pub fn validate_geojson(name: &str, v: &Value) -> Result<(), NgsiError> {
    1643         5294 :     let bad = |m: &str| NgsiError::BadRequestData(format!("attribute {name}: {m}"));
    1644         5294 :     let shape = v.as_object().and_then(|o| {
    1645         5284 :         let t = o.get("type").and_then(Value::as_str)?;
    1646         5282 :         GEO_TYPES.contains(&t).then_some((t, o.get("coordinates")?))
    1647         5284 :     });
    1648         5294 :     let Some((gtype, coords)) = shape else {
    1649           30 :         return Err(bad("value is not a valid GeoJSON geometry"));
    1650              :     };
    1651         5264 :     check_geometry(gtype, coords).map_err(|e| bad(&e))
    1652         5294 : }
    1653              : 
    1654              : /// ISO 8601 DateTime check — 4.6.3, `YYYY-MM-DDThh:mm:ss[.ffffff]Z`.
    1655              : ///
    1656              : /// The clause is strict in three ways this used to get wrong:
    1657              : /// - "The trailing timestamp component … shall always be equal to the
    1658              : ///   character `Z`. Therefore, all timestamps shall be expressed in UTC" —
    1659              : ///   so `+HH:MM`/`-HH:MM` offsets are INVALID, not an alternative form.
    1660              : /// - "All the referred components shall appear in the string; reduced
    1661              : ///   representations are not permitted" — a bare 19-char form has no zone.
    1662              : /// - "The Seconds component may optionally contain a decimal fraction …
    1663              : ///   up to a maximum of six \[digits\]. … In requests, also a comma instead of a
    1664              : ///   decimal point may be used as separator for compatibility reasons."
    1665              : ///
    1666              : /// Digit-shape alone is not enough: `2026-13-45T00:00:00Z` is all digits in
    1667              : /// the right places, and letting it through let one write make every later
    1668              : /// temporal query in that tenant fail on the `::timestamptz` cast.
    1669        79840 : pub fn parse_datetime(s: &str) -> bool {
    1670              :     // the mandatory Z, then at least YYYY-MM-DDThh:mm:ss
    1671        79840 :     let Some(body) = s.strip_suffix('Z') else {
    1672         9830 :         return false;
    1673              :     };
    1674        69984 :     let [y0, y1, y2, y3, b'-', mo0, mo1, b'-', d0, d1, b'T', h0, h1, b':', mi0, mi1, b':', s0, s1, ..] =
    1675        70010 :         body.as_bytes()
    1676              :     else {
    1677           26 :         return false;
    1678              :     };
    1679        69984 :     if ![y0, y1, y2, y3, mo0, mo1, d0, d1, h0, h1, mi0, mi1, s0, s1]
    1680        69984 :         .iter()
    1681       979764 :         .all(|c| c.is_ascii_digit())
    1682              :     {
    1683            2 :         return false;
    1684        69982 :     }
    1685              :     // the 19 bytes matched above are ASCII, so both cuts are char boundaries
    1686        69982 :     let (Some(seconds), Some(frac)) = (body.get(..19), body.get(19..)) else {
    1687            0 :         return false;
    1688              :     };
    1689              :     // between second 19 and the trailing Z: nothing, or a fraction of 1..=6
    1690        69982 :     if !frac.is_empty() {
    1691         2867 :         let Some(rest) = frac.strip_prefix('.').or_else(|| frac.strip_prefix(',')) else {
    1692            2 :             return false;
    1693              :         };
    1694         8567 :         if rest.is_empty() || rest.len() > 6 || !rest.bytes().all(|c| c.is_ascii_digit()) {
    1695            6 :             return false;
    1696         2859 :         }
    1697        67115 :     }
    1698              :     // real calendar date/time, not just digits in the right slots
    1699        69974 :     let normalized = format!("{seconds}Z");
    1700        69974 :     chrono::DateTime::parse_from_rfc3339(&normalized).is_ok()
    1701        79840 : }
    1702              : 
    1703              : /// A DateTime `parse_datetime` accepted, with a comma fraction separator
    1704              : /// turned into the decimal point (4.6.3: the comma only "In requests").
    1705        63876 : fn point_fraction(s: &str) -> String {
    1706        63876 :     match s.get(19..20) {
    1707        63876 :         Some(",") => format!("{}.{}", &s[..19], &s[20..]),
    1708        63872 :         _ => s.to_owned(),
    1709              :     }
    1710        63876 : }
    1711              : 
    1712              : #[cfg(test)]
    1713              : mod tests {
    1714              :     use super::*;
    1715              :     use serde_json::json;
    1716              : 
    1717              :     /// 4.5.5.1: explicit "datasetId": "@none" designates the default
    1718              :     /// instance — normalized to absent, so it never appears in responses and
    1719              :     /// absent + "@none" in one request is two default instances (rejected).
    1720              :     #[test]
    1721            2 :     fn dataset_id_none_is_the_default_instance() {
    1722            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1723            2 :             "speed": {"type": "Property", "value": 1, "datasetId": "@none"}});
    1724            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1725            2 :             .expect("@none accepted");
    1726            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
    1727            2 :         assert!(inst.get("datasetId").is_none(), "@none must be dropped");
    1728              :         // absent + "@none" = two defaults → BadRequestData
    1729            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1730            2 :             "speed": [{"type": "Property", "value": 1},
    1731            2 :                       {"type": "Property", "value": 2, "datasetId": "@none"}]});
    1732            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1733            2 :     }
    1734              : 
    1735              :     /// 4.5.5.1: "there cannot be several Attribute instances with the same
    1736              :     /// datasetId" — two instances naming one datasetId are BadRequestData
    1737              :     /// wherever the repeat sits, and the instance count of one Attribute is
    1738              :     /// bounded by the request body alone, so the check may not scan the
    1739              :     /// instances it has already seen for each new one.
    1740              :     #[test]
    1741            2 :     fn clause_4_5_5_1_a_repeated_dataset_id_is_refused_at_any_position() {
    1742         8006 :         let inst = |i: usize| {
    1743         8006 :             json!({"type": "Property", "value": i,
    1744         8006 :                    "datasetId": format!("urn:ngsi-ld:Dataset:{i:05}")})
    1745         8006 :         };
    1746              :         const N: usize = 4000;
    1747            2 :         let distinct: Vec<Value> = (0..N).map(inst).collect();
    1748            2 :         let doc = json!({"id": "urn:ngsi-ld:X:1", "type": "T", "speed": distinct.clone()});
    1749            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1750            2 :             .expect("distinct datasetIds are legal however many there are");
    1751            2 :         assert_eq!(
    1752            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/speed"]
    1753            2 :                 .as_array()
    1754            2 :                 .expect("array")
    1755            2 :                 .len(),
    1756              :             N
    1757              :         );
    1758              :         // the repeat at the front, in the middle and at the end: a check that
    1759              :         // stops early, or one that only compares neighbours, misses two of them
    1760            6 :         for at in [1usize, N / 2, N - 1] {
    1761            6 :             let mut insts = distinct.clone();
    1762            6 :             insts[at] = inst(0);
    1763            6 :             let doc = json!({"id": "urn:ngsi-ld:X:1", "type": "T", "speed": insts});
    1764            6 :             let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
    1765            6 :             let Err(NgsiError::BadRequestData(msg)) = out else {
    1766            0 :                 panic!("a repeat at {at} must be BadRequestData: {out:?}");
    1767              :             };
    1768            6 :             assert!(msg.contains("duplicate datasetId"), "{msg}");
    1769              :         }
    1770            2 :     }
    1771              : 
    1772              :     /// 4.5.2.2 / C.11: "ngsildproof": a Property with the non-reified
    1773              :     /// subproperties "entityIdSealed" and "entityTypeSealed" as specified
    1774              :     /// in [35]; annex B maps entityIdSealed as a plain term and
    1775              :     /// entityTypeSealed with "@type": "@vocab" (the value expands like a
    1776              :     /// type name). Both must survive the expand→compact round trip — they
    1777              :     /// were RESERVED_MEMBERS with no explicit copy and silently vanished.
    1778              :     #[test]
    1779            2 :     fn ngsildproof_sealed_members_round_trip() {
    1780            2 :         let doc = json!({"id": "urn:ngsi-ld:Store:002", "type": "Store",
    1781            2 :             "ngsildproof": {"type": "Property",
    1782            2 :                 "entityIdSealed": "urn:ngsi-ld:Store:002",
    1783            2 :                 "entityTypeSealed": "Store",
    1784            2 :                 "value": {"type": "DataIntegrityProof",
    1785            2 :                           "cryptosuite": "eddsa-rdfc-2022",
    1786            2 :                           "created": "2025-01-27T21:02:24Z",
    1787            2 :                           "proofPurpose": "assertionMethod",
    1788            2 :                           "proofValue": "zQeVbY4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY"}}});
    1789            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1790            2 :             .expect("C.11-shaped ngsildproof is valid");
    1791            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/ngsildproof"][0];
    1792            2 :         assert_eq!(inst["entityIdSealed"], "urn:ngsi-ld:Store:002");
    1793            2 :         assert_eq!(
    1794            2 :             inst["entityTypeSealed"], "https://uri.etsi.org/ngsi-ld/default-context/Store",
    1795              :             "entityTypeSealed is @vocab-coerced (annex B)"
    1796              :         );
    1797              : 
    1798            2 :         let back = crate::compact::compact_entity(&out, &core());
    1799            2 :         let np = &back["ngsildproof"];
    1800            2 :         assert_eq!(np["entityIdSealed"], "urn:ngsi-ld:Store:002");
    1801            2 :         assert_eq!(np["entityTypeSealed"], "Store", "compacts back to the term");
    1802            2 :         assert_eq!(
    1803            2 :             np["value"]["proofValue"], "zQeVbY4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY",
    1804              :             "the W3C proof structure is untouched"
    1805              :         );
    1806              :         // negative: non-reified means BARE strings — never Property objects,
    1807              :         // and never dropped
    1808            2 :         assert!(np["entityIdSealed"].is_string());
    1809            2 :         assert!(np["entityTypeSealed"].is_string());
    1810            2 :     }
    1811              : 
    1812              :     /// 4.5.2.2: the sealed members are strings ([35] seals the entity id and
    1813              :     /// type); "The value of its \"value\" element shall be an object
    1814              :     /// containing the W3C Data integrity \"proof\" structure" — a
    1815              :     /// non-object proof value is BadRequestData.
    1816              :     #[test]
    1817            2 :     fn ngsildproof_shapes_are_validated() {
    1818              :         // non-string sealed members
    1819            4 :         for bad_seal in [json!(42), json!({"type": "Property", "value": true})] {
    1820            4 :             let doc = json!({"id": "urn:x", "type": "Store",
    1821            4 :                 "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
    1822            4 :                     "entityIdSealed": bad_seal}});
    1823            4 :             assert!(
    1824            4 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1825              :                 "entityIdSealed must be a string"
    1826              :             );
    1827              :         }
    1828            2 :         let doc = json!({"id": "urn:x", "type": "Store",
    1829            2 :             "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
    1830            2 :                 "entityTypeSealed": ["Store"]}});
    1831            2 :         assert!(
    1832            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1833              :             "entityTypeSealed must be a string"
    1834              :         );
    1835              :         // the proof value shall be an object
    1836            2 :         let doc = json!({"id": "urn:x", "type": "Store",
    1837            2 :             "ngsildproof": {"type": "Property", "value": "not-a-proof"}});
    1838            2 :         assert!(
    1839            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1840              :             "ngsildproof value shall be an object (4.5.2.2)"
    1841              :         );
    1842              :         // sealed members on an ordinary attribute stay rejected (the
    1843              :         // existing 4.5.2.2 guard — pinned here as the negative pair)
    1844            2 :         let doc = json!({"id": "urn:x", "type": "Store",
    1845            2 :             "speed": {"type": "Property", "value": 1, "entityIdSealed": "urn:x"}});
    1846            2 :         assert!(
    1847            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1848              :             "sealed members only under ngsildproof"
    1849              :         );
    1850            2 :     }
    1851              : 
    1852              :     /// 4.15: the language filter augments the converted Property with "a
    1853              :     /// non-reified subproperty lang indicating the actual language
    1854              :     /// returned" — a langtag string (RFC 5646). The member is broker-
    1855              :     /// produced, and the clause is silent on a client supplying one, so it
    1856              :     /// is stored; it is not stored in a shape no consumer can read. Every
    1857              :     /// other non-reified member of an instance (unitCode, valueType,
    1858              :     /// datasetId, observedAt) is checked here, and lang was copied through
    1859              :     /// whatever its JSON type.
    1860              :     #[test]
    1861            2 :     fn clause_4_15_a_supplied_lang_member_is_a_string() {
    1862            8 :         for bad_lang in [json!({"en": "x"}), json!(["fr"]), json!(7), json!(true)] {
    1863            8 :             let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
    1864            8 :                 "street": {"type": "Property", "value": "Grand Place", "lang": bad_lang}});
    1865            8 :             let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
    1866            8 :             assert!(out.is_err(), "lang must be a langtag string: {out:?}");
    1867              :         }
    1868            2 :         let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
    1869            2 :             "street": {"type": "Property", "value": "Grand Place", "lang": "fr"}});
    1870            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1871            2 :             .expect("a langtag string is kept");
    1872            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/street"][0];
    1873            2 :         assert_eq!(inst["lang"], "fr");
    1874              :         // and it stays a member of the instance, never a reified
    1875              :         // sub-attribute of its own
    1876            2 :         assert!(
    1877            2 :             out.get("https://uri.etsi.org/ngsi-ld/default-context/lang")
    1878            2 :                 .is_none(),
    1879              :             "lang is non-reified: {out}"
    1880              :         );
    1881            2 :     }
    1882              : 
    1883              :     /// 4.5.3.2 Prohibited: on a Relationship "entityIdSealed" and
    1884              :     /// "entityTypeSealed" shall never be present — flat, with no exception.
    1885              :     /// 4.5.2.2 and 4.5.2.3 write the only exception there is as "unless the
    1886              :     /// PROPERTY name is ngsildproof", and the member itself is defined as
    1887              :     /// "a Property ... with the non-reified subproperties". The attribute
    1888              :     /// name alone is therefore not the test: an attribute called
    1889              :     /// ngsildproof that is not a Property seals nothing.
    1890              :     #[test]
    1891            2 :     fn clause_4_5_3_2_sealed_members_are_carried_by_a_property_only() {
    1892            8 :         for not_a_property in [
    1893            2 :             json!({"type": "Relationship", "object": "urn:ngsi-ld:Store:1",
    1894            2 :                    "entityIdSealed": "urn:ngsi-ld:Store:1"}),
    1895            2 :             json!({"type": "Relationship", "object": "urn:ngsi-ld:Store:1",
    1896            2 :                    "entityTypeSealed": "Store"}),
    1897            2 :             json!({"type": "LanguageProperty", "languageMap": {"en": "x"},
    1898            2 :                    "entityIdSealed": "urn:ngsi-ld:Store:1"}),
    1899            2 :             json!({"type": "ListRelationship", "objectList": ["urn:ngsi-ld:Store:1"],
    1900            2 :                    "entityTypeSealed": "Store"}),
    1901            2 :         ] {
    1902            8 :             let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
    1903            8 :                              "ngsildproof": not_a_property});
    1904            8 :             let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
    1905            8 :             assert!(
    1906            8 :                 out.is_err(),
    1907              :                 "a non-Property ngsildproof carries no sealed member: {out:?}"
    1908              :             );
    1909              :         }
    1910              :         // the concise form infers the attribute type, and the inference
    1911              :         // decides the same way: `object` makes this a Relationship
    1912            2 :         let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
    1913            2 :             "ngsildproof": {"object": "urn:ngsi-ld:Store:1",
    1914            2 :                             "entityIdSealed": "urn:ngsi-ld:Store:1"}});
    1915            2 :         assert!(
    1916            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1917              :             "the concise Relationship form is a Relationship (4.5.3.3)"
    1918              :         );
    1919              :         // and the Property form the clause does allow still round-trips
    1920            2 :         let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
    1921            2 :             "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
    1922            2 :                             "entityIdSealed": "urn:ngsi-ld:Store:1",
    1923            2 :                             "entityTypeSealed": "Store"}});
    1924            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1925            2 :             .expect("the ngsildproof Property is the one carrier");
    1926            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/ngsildproof"][0];
    1927            2 :         assert_eq!(inst["entityIdSealed"], "urn:ngsi-ld:Store:1");
    1928            2 :     }
    1929              : 
    1930              :     /// 4.5.3.3: "type: If missing, Relationship can be inferred by the
    1931              :     /// presence of the object attribute" — and the shared prohibitions apply
    1932              :     /// to the inferred instance too.
    1933              :     #[test]
    1934            2 :     fn concise_relationship_inference() {
    1935            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1936            2 :             "isParked": {"object": "urn:ngsi-ld:P:1",
    1937            2 :                          "observedAt": "2026-01-01T00:00:00Z"}});
    1938            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1939            2 :             .expect("concise relationship");
    1940            2 :         let rel = &out["https://uri.etsi.org/ngsi-ld/default-context/isParked"][0];
    1941            2 :         assert_eq!(rel["type"], "Relationship");
    1942            2 :         assert_eq!(rel["object"], "urn:ngsi-ld:P:1");
    1943              :         // inferred Relationship still rejects a Property value member
    1944            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1945            2 :             "isParked": {"object": "urn:ngsi-ld:P:1", "value": 1}});
    1946            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1947            2 :     }
    1948              : 
    1949              :     /// 4.5.3.2: a normalized Relationship "shall never include" unitCode
    1950              :     /// ("Relationships are unitless") or the value-defining members of the
    1951              :     /// Property family — while objectType stays a legal optional member.
    1952              :     #[test]
    1953            2 :     fn relationship_prohibited_members_rejected() {
    1954           12 :         let mk = |extra: (&str, Value)| {
    1955           12 :             json!({
    1956           12 :                 "id": "urn:x", "type": "T",
    1957           12 :                 "isParked": {"type": "Relationship", "object": "urn:ngsi-ld:P:1",
    1958           12 :                              extra.0: extra.1}
    1959              :             })
    1960           12 :         };
    1961           10 :         for (m, v) in [
    1962            2 :             ("unitCode", json!("MTR")),
    1963            2 :             ("value", json!(1)),
    1964            2 :             ("languageMap", json!({"en": "x"})),
    1965            2 :             ("valueList", json!([1])),
    1966            2 :             ("previousObject", json!("urn:a")),
    1967            2 :         ] {
    1968           10 :             let doc = mk((m, v));
    1969           10 :             assert!(
    1970           10 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1971              :                 "{m} must be prohibited on a Relationship"
    1972              :             );
    1973              :         }
    1974            2 :         let ok = mk(("objectType", json!("Parking")));
    1975            2 :         assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
    1976            2 :     }
    1977              : 
    1978              :     /// 4.5.2.3: concise Property forms — a geometry-shaped value infers
    1979              :     /// GeoProperty (both as the whole object and as the value member); an
    1980              :     /// object carrying a "type" member is treated as normalized; a concise
    1981              :     /// object mixing value with another type's defining member rejects.
    1982              :     #[test]
    1983            2 :     fn concise_property_inference_rules() {
    1984            2 :         let geo = json!({"type": "Point", "coordinates": [1.0, 2.0]});
    1985              :         // whole object IS the geometry
    1986            2 :         let doc = json!({"id": "urn:x", "type": "T", "area": geo});
    1987            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1988            2 :             .expect("bare geometry");
    1989            2 :         assert_eq!(
    1990            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/area"][0]["type"],
    1991              :             "GeoProperty"
    1992              :         );
    1993              :         // geometry as the value member of a type-less object
    1994            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1995            2 :             "area": {"value": {"type": "Point", "coordinates": [1.0, 2.0]},
    1996            2 :                      "observedAt": "2026-01-01T00:00:00Z"}});
    1997            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1998            2 :             .expect("geometry value");
    1999            2 :         assert_eq!(
    2000            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/area"][0]["type"],
    2001              :             "GeoProperty"
    2002              :         );
    2003              :         // an object with a "type" member is normalized — unknown type rejects
    2004            2 :         let doc = json!({"id": "urn:x", "type": "T", "a": {"type": "Custom", "x": 1}});
    2005            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2006              :         // concise mix of value and a foreign defining member rejects
    2007            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2008            2 :             "a": {"value": 1, "languageMap": {"en": "x"}}});
    2009            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2010            2 :     }
    2011              : 
    2012              :     /// 4.5.2.2: a normalized Property "shall never include" the value-defining
    2013              :     /// members of other attribute types, output-only members, or the sealed
    2014              :     /// members outside ngsildproof — and valueType coerces to a datatype URI.
    2015              :     #[test]
    2016            2 :     fn property_prohibited_members_rejected() {
    2017           22 :         let mk = |extra: (&str, Value)| {
    2018           22 :             json!({
    2019           22 :                 "id": "urn:x", "type": "T",
    2020           22 :                 "speed": {"type": "Property", "value": 1, extra.0: extra.1}
    2021              :             })
    2022           22 :         };
    2023           22 :         for (m, v) in [
    2024            2 :             ("object", json!("urn:ngsi-ld:other:1")),
    2025            2 :             ("languageMap", json!({"en": "hi"})),
    2026            2 :             ("json", json!({"k": 1})),
    2027            2 :             ("vocab", json!("term")),
    2028            2 :             ("valueList", json!([1, 2])),
    2029            2 :             ("objectList", json!(["urn:a"])),
    2030            2 :             ("entity", json!({"id": "urn:a", "type": "T"})),
    2031            2 :             ("entityList", json!([])),
    2032            2 :             ("previousValue", json!(0)),
    2033            2 :             ("previousObject", json!("urn:a")),
    2034            2 :             ("entityIdSealed", json!(true)),
    2035            2 :         ] {
    2036           22 :             let doc = mk((m, v));
    2037           22 :             assert!(
    2038           22 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2039              :                 "{m} must be prohibited on a Property"
    2040              :             );
    2041              :         }
    2042              :         // valueType is a legal optional member and coerces to a datatype URI
    2043            2 :         let doc = json!({
    2044            2 :             "id": "urn:x", "type": "T",
    2045            2 :             "speed": {"type": "Property", "value": 1.5, "valueType": "xsd:double"}
    2046              :         });
    2047            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2048            2 :             .expect("valueType is optional");
    2049            2 :         let attr = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
    2050            2 :         assert!(attr["valueType"]
    2051            2 :             .as_str()
    2052            2 :             .is_some_and(|s| s.contains("double")));
    2053            2 :     }
    2054              : 
    2055              :     /// 4.5.1: "Terms defined in the Core Context as non-reified Properties
    2056              :     /// (such as datasetId, instanceId, etc.) shall not be used as Attribute
    2057              :     /// names."
    2058              :     #[test]
    2059            2 :     fn core_non_reified_terms_rejected_as_attribute_names() {
    2060            8 :         for name in ["datasetId", "instanceId", "observedAt", "unitCode"] {
    2061            8 :             let doc = json!({
    2062            8 :                 "id": "urn:x", "type": "T",
    2063            8 :                 name: {"type": "Property", "value": 1}
    2064              :             });
    2065            8 :             assert!(
    2066            8 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2067              :                 "{name} must be rejected as an Attribute name"
    2068              :             );
    2069              :         }
    2070            2 :         let ok = json!({
    2071            2 :             "id": "urn:x", "type": "T",
    2072            2 :             "speed": {"type": "Property", "value": 1}
    2073              :         });
    2074            2 :         assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
    2075            2 :     }
    2076              : 
    2077              :     /// 4.5.1: "Attributes shall not contain any embedded @context" — 5.5.7:
    2078              :     /// such content "should result in an error of type BadRequestData".
    2079              :     #[test]
    2080            2 :     fn embedded_context_in_attribute_rejected() {
    2081            2 :         let doc = json!({
    2082            2 :             "id": "urn:x", "type": "T",
    2083            2 :             "speed": {"type": "Property", "value": 1,
    2084            2 :                       "@context": {"speed": "https://evil.example/speed"}}
    2085              :         });
    2086            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2087              :         // nested inside a sub-attribute as well
    2088            2 :         let doc = json!({
    2089            2 :             "id": "urn:x", "type": "T",
    2090            2 :             "speed": {"type": "Property", "value": 1,
    2091            2 :                       "source": {"type": "Property", "value": "s",
    2092            2 :                                  "@context": {"x": "https://e/x"}}}
    2093              :         });
    2094            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2095              :         // inside a structured Property value, normalized and concise
    2096            4 :         for speed in [
    2097            2 :             json!({"type": "Property", "value": {"street": "Main", "@context": "https://e/c"}}),
    2098            2 :             json!({"street": "Main", "n": [{"@context": "https://e/c"}]}),
    2099            2 :         ] {
    2100            4 :             let doc = json!({"id": "urn:x", "type": "T", "speed": speed});
    2101            4 :             assert!(
    2102            4 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err()
    2103              :             );
    2104              :         }
    2105              :         // a JsonProperty's json is opaque (@json): an @context there is data
    2106            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2107            2 :                          "cfg": {"type": "JsonProperty", "json": {"@context": "https://e/c"}}});
    2108            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
    2109            2 :     }
    2110              : 
    2111              :     /// The core @context aliases type to @type (Coraine
    2112              :     /// duplicate_id_type_alias_every_level 01, 02, 04).
    2113              :     #[test]
    2114            2 :     fn attribute_type_keyword_alias() {
    2115            2 :         let doc = json!({"id": "urn:x", "type": "T", "p": {"@type": "Property", "value": 1}});
    2116            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).unwrap();
    2117            2 :         let p = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
    2118            2 :         assert_eq!(p["type"], json!("Property"));
    2119            2 :         assert_eq!(p.get("@type"), None);
    2120            4 :         for p in [
    2121            2 :             json!({"type": "Property", "@type": "Property", "value": 1}),
    2122            2 :             json!({"type": "Property", "value": 1, "s": {"type": "Property", "@type": "Property", "value": 2}}),
    2123            2 :         ] {
    2124            4 :             let doc = json!({"id": "urn:x", "type": "T", "p": p});
    2125            4 :             assert!(
    2126            4 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err()
    2127              :             );
    2128              :         }
    2129            2 :     }
    2130              : 
    2131              :     /// 5.2.6: a Relationship has no valueType member (Coraine value_type_subattr 03).
    2132              :     #[test]
    2133            2 :     fn value_type_on_a_relationship_rejected() {
    2134            4 :         for r in [
    2135            2 :             json!({"type": "Relationship", "object": "urn:o", "valueType": "Integer"}),
    2136            2 :             json!({"type": "ListRelationship", "objectList": [{"object": "urn:o"}], "valueType": "Integer"}),
    2137            2 :         ] {
    2138            4 :             let doc = json!({"id": "urn:x", "type": "T", "r": r});
    2139            4 :             assert!(
    2140            4 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err()
    2141              :             );
    2142              :         }
    2143            2 :         let ok = json!({"id": "urn:x", "type": "T", "p": {"type": "Property", "value": 1, "valueType": "Integer"}});
    2144            2 :         assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
    2145            2 :     }
    2146              : 
    2147              :     /// 5.5.7: a core member or Attribute type spelled as its full IRI
    2148              :     /// expands to the same document as the term.
    2149              :     #[test]
    2150            2 :     fn a_core_member_iri_is_the_member() {
    2151           12 :         let iri = |t: &str| format!("{}{t}", crate::context::NGSI_LD_BASE);
    2152            2 :         let pairs = [
    2153            2 :             (
    2154            2 :                 json!({"type": "Property", "value": 1, iri("observedAt"): "2026-01-01T00:00:00Z"}),
    2155            2 :                 json!({"type": "Property", "value": 1, "observedAt": "2026-01-01T00:00:00Z"}),
    2156            2 :             ),
    2157            2 :             (
    2158            2 :                 json!({"type": iri("Relationship"), iri("hasObject"): "urn:o"}),
    2159            2 :                 json!({"type": "Relationship", "object": "urn:o"}),
    2160            2 :             ),
    2161            2 :             (
    2162            2 :                 json!({"type": iri("Unknown"), "value": 1}),
    2163            2 :                 json!({"type": iri("Unknown"), "value": 1}),
    2164            2 :             ),
    2165            2 :         ];
    2166            6 :         for (sent, same) in pairs {
    2167            6 :             let a = json!({"id": "urn:x", "type": "T", "p": sent});
    2168            6 :             let b = json!({"id": "urn:x", "type": "T", "p": same});
    2169            6 :             let got = expand_entity(a.as_object().unwrap(), &core(), ExpandOpts::default())
    2170            6 :                 .map_err(|e| e.to_string());
    2171            6 :             let want = expand_entity(b.as_object().unwrap(), &core(), ExpandOpts::default())
    2172            6 :                 .map_err(|e| e.to_string());
    2173            6 :             assert_eq!(got, want);
    2174              :         }
    2175            2 :         let both = json!({"id": "urn:x", "type": "T", "p": {"type": "Property", "value": 1, iri("hasValue"): 2}});
    2176            2 :         let err = expand_entity(both.as_object().unwrap(), &core(), ExpandOpts::default())
    2177            2 :             .map_err(|e| e.to_string());
    2178            2 :         let refused = err
    2179            2 :             .err()
    2180            2 :             .map(|e| e.contains("given both as a term and as its IRI"));
    2181            2 :         assert_eq!(refused, Some(true));
    2182            2 :     }
    2183              : 
    2184          536 :     fn core() -> std::sync::Arc<Context> {
    2185          536 :         std::sync::Arc::new(crate::core_context())
    2186          536 :     }
    2187              : 
    2188              :     #[test]
    2189            2 :     fn expands_simple_entity() {
    2190            2 :         let doc = serde_json::json!({
    2191            2 :             "id": "urn:ngsi-ld:Building:1",
    2192            2 :             "type": "Building",
    2193            2 :             "name": {"type": "Property", "value": "Eiffel Tower"}
    2194              :         });
    2195            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2196            2 :             .expect("expand");
    2197            2 :         assert_eq!(out["id"], "urn:ngsi-ld:Building:1");
    2198            2 :         assert_eq!(
    2199            2 :             out["type"][0],
    2200              :             "https://uri.etsi.org/ngsi-ld/default-context/Building"
    2201              :         );
    2202            2 :         let name = &out["https://uri.etsi.org/ngsi-ld/default-context/name"];
    2203            2 :         assert_eq!(name[0]["value"], "Eiffel Tower");
    2204            2 :     }
    2205              : 
    2206              :     #[test]
    2207            2 :     fn expires_at_kept_as_meta_not_property() {
    2208              :         // 4.22: a top-level expiresAt must survive as a bare DateTime string
    2209              :         // (the shape the read-boundary filter / GC / expires_at column read),
    2210              :         // never as a Property under its IRI.
    2211            2 :         let doc = serde_json::json!({
    2212            2 :             "id": "urn:ngsi-ld:T:1",
    2213            2 :             "type": "T",
    2214            2 :             "expiresAt": "2020-01-01T00:00:00Z",
    2215            2 :             "foo": {"type": "Property", "value": 1}
    2216              :         });
    2217            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2218            2 :             .expect("expand");
    2219            2 :         assert_eq!(out["expiresAt"], "2020-01-01T00:00:00Z");
    2220            2 :         assert!(out.get("https://uri.etsi.org/ngsi-ld/expiresAt").is_none());
    2221              :         // a non-DateTime expiresAt is rejected
    2222            2 :         let bad = serde_json::json!({"id": "urn:ngsi-ld:T:2", "type": "T", "expiresAt": "soon"});
    2223            2 :         assert!(expand_entity(bad.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2224            2 :     }
    2225              : 
    2226              :     #[test]
    2227            2 :     fn concise_and_multi_instance() {
    2228            2 :         let doc = serde_json::json!({
    2229            2 :             "id": "urn:ngsi-ld:Vehicle:1",
    2230            2 :             "type": "Vehicle",
    2231            2 :             "speed": 55,
    2232            2 :             "brand": [
    2233            2 :                 {"type": "Property", "value": "Volvo", "datasetId": "urn:ngsi-ld:d:1"},
    2234            2 :                 {"type": "Property", "value": "Ford"}
    2235              :             ]
    2236              :         });
    2237            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2238            2 :             .expect("expand");
    2239            2 :         let speed = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"];
    2240            2 :         assert_eq!(speed[0]["type"], "Property");
    2241            2 :         assert_eq!(speed[0]["value"], 55);
    2242            2 :         let brand = &out["https://uri.etsi.org/ngsi-ld/default-context/brand"];
    2243            2 :         assert_eq!(brand.as_array().unwrap().len(), 2);
    2244            2 :     }
    2245              : 
    2246              :     /// 4.5.2.2: "type": "Equal to "Property""; a type that is no string
    2247              :     /// names no attribute type (Coraine attribute_type_invalid).
    2248              :     #[test]
    2249            2 :     fn rejects_an_attribute_type_that_is_no_string() {
    2250            6 :         for t in [
    2251            2 :             serde_json::json!(42),
    2252            2 :             serde_json::json!(["Property"]),
    2253            2 :             serde_json::json!({}),
    2254            2 :         ] {
    2255            6 :             let doc = serde_json::json!({"id": "urn:a", "type": "T", "p": {"type": t, "value": 1}});
    2256            6 :             let err = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
    2257            6 :             assert!(err.is_err(), "{t}");
    2258              :         }
    2259            2 :     }
    2260              : 
    2261              :     /// 4.6.3: the fraction separator is "a decimal point"; "In requests, also
    2262              :     /// a comma ... may be used", so what is stored, and later answered, uses
    2263              :     /// the point (Coraine datetime_comma_separator).
    2264              :     #[test]
    2265            2 :     fn a_comma_fraction_is_stored_with_a_decimal_point() {
    2266            2 :         let doc = serde_json::json!({"id": "urn:a", "type": "T", "p": {"type": "Property", "value": 1,
    2267            2 :             "observedAt": "2026-04-28T10:00:00,5Z", "expiresAt": "2099-01-01T00:00:00,25Z"}});
    2268            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2269            2 :             .expect("expand");
    2270            2 :         let p = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
    2271            2 :         assert_eq!(p["observedAt"], "2026-04-28T10:00:00.5Z");
    2272            2 :         assert_eq!(p["expiresAt"], "2099-01-01T00:00:00.25Z");
    2273            2 :     }
    2274              : 
    2275              :     /// 4.5.18.2: languageMap is "a JSON object"; a partial update fragment
    2276              :     /// is held to it as a create is (Coraine langprop_patch_attribute).
    2277              :     #[test]
    2278            2 :     fn a_fragment_language_map_is_an_object_of_strings() {
    2279            6 :         for bad in [
    2280            2 :             serde_json::json!(13),
    2281            2 :             serde_json::json!({"en": 1}),
    2282            2 :             serde_json::json!({"": "x"}),
    2283            2 :         ] {
    2284            6 :             let f = serde_json::json!({"languageMap": bad});
    2285            6 :             assert!(
    2286            6 :                 expand_attr_fragment(f.as_object().unwrap(), &core()).is_err(),
    2287              :                 "{bad}"
    2288              :             );
    2289              :         }
    2290            6 :         for ok in [
    2291            2 :             serde_json::json!({"en": "x", "es": ["y"]}),
    2292            2 :             serde_json::json!({"@none": "urn:ngsi-ld:null"}),
    2293            2 :             serde_json::json!("urn:ngsi-ld:null"),
    2294            2 :         ] {
    2295            6 :             let f = serde_json::json!({"languageMap": ok});
    2296            6 :             assert!(
    2297            6 :                 expand_attr_fragment(f.as_object().unwrap(), &core()).is_ok(),
    2298              :                 "{ok}"
    2299              :             );
    2300              :         }
    2301            2 :     }
    2302              : 
    2303              :     /// JSON-LD 1.1 9.5 value objects and 4.6.3 DateTime in a Property value
    2304              :     /// (Coraine value_type_atvalue_validation, entity_attr_typed_value_object).
    2305              :     #[test]
    2306            2 :     fn a_property_value_object_is_checked() {
    2307           16 :         let attempt = |value: serde_json::Value| {
    2308           16 :             let doc = serde_json::json!({"id": "urn:ngsi-ld:E:1", "type": "T",
    2309           16 :                 "p": {"type": "Property", "value": value}});
    2310           16 :             expand_entity(
    2311           16 :                 doc.as_object().expect("object"),
    2312           16 :                 &core(),
    2313           16 :                 ExpandOpts::default(),
    2314              :             )
    2315           16 :         };
    2316            8 :         for bad in [
    2317            2 :             serde_json::json!({"@type": 1, "@value": "2018-12-04T12:00:00Z"}),
    2318            2 :             serde_json::json!({"@type": "DateTime", "a": 19, "@value": "2018-12-04T12:00:00Z"}),
    2319            2 :             serde_json::json!({"@type": "DateTime", "@value": 12}),
    2320            2 :             serde_json::json!({"@type": "DateTime", "@value": "banana"}),
    2321            2 :         ] {
    2322            8 :             assert!(attempt(bad.clone()).is_err(), "{bad}");
    2323              :         }
    2324            8 :         for ok in [
    2325            2 :             serde_json::json!({"@type": "DateTime", "@value": "2018-12-04T12:00:00Z"}),
    2326            2 :             serde_json::json!({"@type": "xsd:integer", "@value": 42}),
    2327            2 :             serde_json::json!({"@value": "plain", "@language": "en"}),
    2328            2 :             serde_json::json!({"a": 1, "b": 2}),
    2329            2 :         ] {
    2330            8 :             assert!(attempt(ok.clone()).is_ok(), "{ok}");
    2331              :         }
    2332            2 :     }
    2333              : 
    2334              :     /// 4.5.18.2: languageMap keys are RFC 5646 language tags (Coraine
    2335              :     /// languagemap_key_rfc5646).
    2336              :     #[test]
    2337            2 :     fn language_map_keys_are_rfc_5646_tags() {
    2338           12 :         for ok in [
    2339            2 :             "en",
    2340            2 :             "en-US",
    2341            2 :             "zh-Hans-CN",
    2342            2 :             "x-private",
    2343            2 :             "i-klingon",
    2344            2 :             "@none",
    2345            2 :         ] {
    2346           12 :             assert!(valid_language_map(&serde_json::json!({ok: "t"})), "{ok}");
    2347              :         }
    2348           18 :         for bad in [
    2349            2 :             "",
    2350            2 :             "123",
    2351            2 :             "!!",
    2352            2 :             "en_US",
    2353            2 :             "en-",
    2354            2 :             "e",
    2355            2 :             "x",
    2356            2 :             "this is not a language",
    2357            2 :             "en-toolongsubtag",
    2358            2 :         ] {
    2359           18 :             assert!(!valid_language_map(&serde_json::json!({bad: "t"})), "{bad}");
    2360              :         }
    2361            2 :     }
    2362              : 
    2363              :     /// 4.5.0, 4.5.1: the NGSI-LD Null is never an Entity Type, alone or in
    2364              :     /// an array (Coraine entity_member_delete_and_null 12-13).
    2365              :     #[test]
    2366            2 :     fn the_ngsi_null_is_no_entity_type() {
    2367            4 :         for v in [
    2368            2 :             serde_json::json!("urn:ngsi-ld:null"),
    2369            2 :             serde_json::json!(["T", "urn:ngsi-ld:null"]),
    2370            2 :         ] {
    2371            4 :             assert!(expand_types(&v, &core()).is_err(), "{v}");
    2372              :         }
    2373            2 :         assert!(expand_types(&serde_json::json!("T"), &core()).is_ok());
    2374            2 :     }
    2375              : 
    2376              :     #[test]
    2377            2 :     fn rejects_missing_type() {
    2378            2 :         let doc = serde_json::json!({"id": "urn:ngsi-ld:Building:1"});
    2379            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2380            2 :     }
    2381              : 
    2382              :     /// 4.8: "Temporal Properties in NGSI-LD shall be represented based on
    2383              :     /// the DateTime data type as mandated by clause 4.6.3" and "a
    2384              :     /// TemporalProperty does not allow reification" — only a valid UTC
    2385              :     /// DateTime STRING is a legal observedAt.
    2386              :     #[test]
    2387            2 :     fn rejects_bad_observed_at() {
    2388            6 :         for bad in [
    2389            2 :             serde_json::json!("not-a-date"),
    2390            2 :             // 4.6.3: trailing component shall be Z — offsets are invalid
    2391            2 :             serde_json::json!("2026-08-10T12:00:00+02:00"),
    2392            2 :             // non-reified: a Property-shaped observedAt is not a DateTime
    2393            2 :             serde_json::json!({"type": "Property", "value": "2026-08-10T12:00:00Z"}),
    2394            2 :         ] {
    2395            6 :             let doc = serde_json::json!({
    2396            6 :                 "id": "urn:ngsi-ld:Building:1",
    2397            6 :                 "type": "Building",
    2398            6 :                 "a": {"type": "Property", "value": 1, "observedAt": bad}
    2399              :             });
    2400            6 :             assert!(
    2401            6 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2402              :                 "observedAt {bad} must be rejected (4.8/4.6.3)"
    2403              :             );
    2404              :         }
    2405            2 :     }
    2406              : 
    2407              :     #[test]
    2408            2 :     fn relationship_needs_uri_object() {
    2409            2 :         let doc = serde_json::json!({
    2410            2 :             "id": "urn:ngsi-ld:A:1",
    2411            2 :             "type": "T",
    2412            2 :             "rel": {"type": "Relationship", "object": "not a uri"}
    2413              :         });
    2414            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2415            2 :     }
    2416              : 
    2417              :     /// 4.2.2 Meta Model: "An NGSI-LD Property shall have a value, stated
    2418              :     /// through hasValue. An NGSI-LD Relationship shall have an object stated
    2419              :     /// through hasObject." The member is REQUIRED — a typed attribute
    2420              :     /// without it is rejected, per specialized type as well (5.2.32/5.2.38).
    2421              :     #[test]
    2422            2 :     fn meta_model_required_member_per_attribute_type() {
    2423           14 :         for (ty, wrong_member) in [
    2424            2 :             ("Property", "object"),
    2425            2 :             ("Relationship", "value"),
    2426            2 :             ("LanguageProperty", "value"),
    2427            2 :             ("JsonProperty", "value"),
    2428            2 :             ("VocabProperty", "value"),
    2429            2 :             ("ListProperty", "value"),
    2430            2 :             ("ListRelationship", "valueList"),
    2431            2 :         ] {
    2432           14 :             let doc = serde_json::json!({
    2433           14 :                 "id": "urn:ngsi-ld:A:1",
    2434           14 :                 "type": "T",
    2435           14 :                 "attr": {"type": ty, wrong_member: "x"}
    2436              :             });
    2437           14 :             assert!(
    2438           14 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2439              :                 "{ty} without its required member must be rejected (4.2.2)"
    2440              :             );
    2441              :         }
    2442            2 :     }
    2443              : 
    2444              :     /// 4.2.2: "An NGSI-LD Value shall be either a rdfs:Literal or a node
    2445              :     /// object" — every JSON literal, array and object is a legal value;
    2446              :     /// a bare JSON null is NOT (the null sentinel is the string form,
    2447              :     /// 4.5.2 / 057_03_02).
    2448              :     #[test]
    2449            2 :     fn meta_model_value_space() {
    2450           12 :         for v in [
    2451            2 :             serde_json::json!(17),
    2452            2 :             serde_json::json!(1.5),
    2453            2 :             serde_json::json!(true),
    2454            2 :             serde_json::json!("text"),
    2455            2 :             serde_json::json!([1, 2, 3]),
    2456            2 :             serde_json::json!({"nested": {"deep": [1]}}),
    2457            2 :         ] {
    2458           12 :             let doc = serde_json::json!({
    2459           12 :                 "id": "urn:ngsi-ld:A:1", "type": "T",
    2460           12 :                 "attr": {"type": "Property", "value": v}
    2461              :             });
    2462           12 :             assert!(
    2463           12 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok(),
    2464              :                 "literal/node-object value must be accepted (4.2.2): {v}"
    2465              :             );
    2466              :         }
    2467            2 :         let doc = serde_json::json!({
    2468            2 :             "id": "urn:ngsi-ld:A:1", "type": "T",
    2469            2 :             "attr": {"type": "Property", "value": null}
    2470              :         });
    2471            2 :         assert!(
    2472            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2473              :             "bare JSON null is not an NGSI-LD Value (4.2.2/4.5.2)"
    2474              :         );
    2475            2 :     }
    2476              : 
    2477              :     #[test]
    2478            2 :     fn location_must_be_geo() {
    2479            2 :         let doc = serde_json::json!({
    2480            2 :             "id": "urn:ngsi-ld:A:1",
    2481            2 :             "type": "T",
    2482            2 :             "location": {"type": "Property", "value": 3}
    2483              :         });
    2484            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2485            2 :         let ok = serde_json::json!({
    2486            2 :             "id": "urn:ngsi-ld:A:1",
    2487            2 :             "type": "T",
    2488            2 :             "location": {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1.0, 2.0]}}
    2489              :         });
    2490            2 :         assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
    2491            2 :     }
    2492              : 
    2493              :     #[test]
    2494            2 :     fn datetime_validation() {
    2495              :         // 4.6.3, p.80-81. Accepted forms:
    2496            2 :         assert!(parse_datetime("2020-09-09T16:40:00Z"));
    2497            2 :         assert!(parse_datetime("2020-09-09T16:40:00.000Z"));
    2498            2 :         assert!(
    2499            2 :             parse_datetime("2020-09-09T16:40:00.123456Z"),
    2500              :             "6 fraction digits"
    2501              :         );
    2502              :         // "In requests, also a comma instead of a decimal point may be used as
    2503              :         // separator for compatibility reasons."
    2504            2 :         assert!(
    2505            2 :             parse_datetime("2020-09-09T16:40:00,123Z"),
    2506              :             "comma separator"
    2507              :         );
    2508            2 :         assert!(
    2509            2 :             parse_datetime("2020-02-29T00:00:00Z"),
    2510              :             "2020 is a leap year"
    2511              :         );
    2512              : 
    2513              :         // Rejected. NOTE: the offset case previously asserted the OPPOSITE —
    2514              :         // 4.6.3 is explicit that "the trailing timestamp component … shall
    2515              :         // always be equal to the character Z. Therefore, all timestamps shall
    2516              :         // be expressed in UTC", so an offset is invalid, not an alternative.
    2517            2 :         assert!(
    2518            2 :             !parse_datetime("2020-09-09T16:40:00+02:00"),
    2519              :             "offset forbidden"
    2520              :         );
    2521            2 :         assert!(
    2522            2 :             !parse_datetime("2020-09-09T16:40:00-05:00"),
    2523              :             "offset forbidden"
    2524              :         );
    2525              :         // "All the referred components shall appear in the string; reduced
    2526              :         // representations are not permitted."
    2527            2 :         assert!(!parse_datetime("2020-09-09T16:40:00"), "no zone");
    2528            2 :         assert!(!parse_datetime("2020-09-09"));
    2529            2 :         assert!(!parse_datetime("nope"));
    2530              :         // fraction bounds: 1..=6 digits, and a separator is required
    2531            2 :         assert!(!parse_datetime("2020-09-09T16:40:00.Z"), "empty fraction");
    2532            2 :         assert!(!parse_datetime("2020-09-09T16:40:00.1234567Z"), "7 digits");
    2533            2 :         assert!(!parse_datetime("2020-09-09T16:40:00123Z"), "no separator");
    2534              :         // calendar reality — digit-shape alone let this through, and one such
    2535              :         // write made every later temporal query in the tenant 500 on the:
    2536              :         // :timestamptz cast
    2537            2 :         assert!(!parse_datetime("2026-13-45T00:00:00Z"), "month 13, day 45");
    2538            2 :         assert!(
    2539            2 :             !parse_datetime("2021-02-29T00:00:00Z"),
    2540              :             "2021 is not a leap year"
    2541              :         );
    2542            2 :         assert!(!parse_datetime("2020-09-09T25:00:00Z"), "hour 25");
    2543            2 :     }
    2544              : 
    2545              :     /// 4.7.1/4.7.2/4.7.3 Geospatial Properties: location & co. must be
    2546              :     /// GeoProperties (4.7.1); a whole geometry MAY arrive as an encoded JSON
    2547              :     /// string, accepted "if and only if" it parses into a valid geometry of
    2548              :     /// the stated type (4.7.2, normalized to the object form); the concise
    2549              :     /// forms infer GeoProperty from a resolving geometry value (4.7.3).
    2550              :     #[test]
    2551            2 :     fn geo_property_rules() {
    2552           14 :         let ent = |attr: Value| {
    2553           14 :             let doc = json!({"id": "urn:x", "type": "T", "g": attr});
    2554           14 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2555           14 :         };
    2556              :         // 4.7.2: string-encoded geometry accepted and normalized to the object
    2557            2 :         let out = ent(json!({"type": "GeoProperty",
    2558            2 :             "value": "{\"type\": \"Point\", \"coordinates\": [17.1, 48.7]}"}))
    2559            2 :         .expect("string-encoded geometry");
    2560            2 :         let val = &out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["value"];
    2561            2 :         assert!(
    2562            2 :             !val.is_string(),
    2563              :             "value must be normalized, not stay a string"
    2564              :         );
    2565            2 :         assert_eq!(val["type"], "Point");
    2566            2 :         assert_eq!(val["coordinates"][0], 17.1);
    2567              :         // iff: unparseable, non-geometry and GeometryCollection strings → 400
    2568            6 :         for bad_s in [
    2569            2 :             "not json",
    2570            2 :             "{\"a\": 1}",
    2571            2 :             "{\"type\": \"GeometryCollection\", \"geometries\": []}",
    2572            2 :         ] {
    2573            6 :             let err = ent(json!({"type": "GeoProperty", "value": bad_s})).expect_err(bad_s);
    2574            6 :             assert!(matches!(err, NgsiError::BadRequestData(_)), "{bad_s}");
    2575              :         }
    2576              :         // 4.7.1: location shall be a GeoProperty
    2577            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2578            2 :             "location": {"type": "Property", "value": 1}});
    2579            2 :         assert!(
    2580            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2581              :             "location as plain Property"
    2582              :         );
    2583              :         // 4.7.3: concise inference — bare geometry and resolving value
    2584            2 :         let out = ent(json!({"type": "Point", "coordinates": [1.0, 2.0]}))
    2585            2 :             .expect("bare geometry concise form");
    2586            2 :         assert_eq!(
    2587            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
    2588              :             "GeoProperty"
    2589              :         );
    2590            2 :         let out = ent(json!({"value": {"type": "Point", "coordinates": [1.0, 2.0]}}))
    2591            2 :             .expect("resolving value");
    2592            2 :         assert_eq!(
    2593            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
    2594              :             "GeoProperty"
    2595              :         );
    2596              :         // an ordinary string value must NOT be inferred as GeoProperty
    2597            2 :         let out = ent(json!({"value": "Point"})).expect("plain string value");
    2598            2 :         assert_eq!(
    2599            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
    2600              :             "Property"
    2601              :         );
    2602            2 :     }
    2603              : 
    2604              :     /// 4.7.2: a geometry is accepted "if and only if" it meets "the syntax
    2605              :     /// and restrictions mandated by IETF RFC 7946 \[8\] when representing a
    2606              :     /// valid Geometry of the type specified", and its coordinates are "values
    2607              :     /// of a JSON-LD floating point number data type". The verbose, concise
    2608              :     /// and string-encoded forms are one Value and all three are held to it.
    2609              :     #[test]
    2610            2 :     fn geojson_geometries_meet_the_rfc_7946_restrictions() {
    2611          150 :         let ent = |attr: Value| {
    2612          150 :             let doc = json!({"id": "urn:x", "type": "T", "location": attr});
    2613          150 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2614          150 :         };
    2615            2 :         let bad_geoms = vec![
    2616              :             // RFC 7946 3.1.1: "A position is an array of numbers. There MUST
    2617              :             // be two or more elements."
    2618            2 :             (
    2619            2 :                 "point is not an array",
    2620            2 :                 json!({"type": "Point", "coordinates": 1}),
    2621            2 :             ),
    2622            2 :             (
    2623            2 :                 "point is an object",
    2624            2 :                 json!({"type": "Point", "coordinates": {"lon": 1}}),
    2625            2 :             ),
    2626            2 :             (
    2627            2 :                 "one element",
    2628            2 :                 json!({"type": "Point", "coordinates": [1.0]}),
    2629            2 :             ),
    2630            2 :             (
    2631            2 :                 "empty position",
    2632            2 :                 json!({"type": "Point", "coordinates": []}),
    2633            2 :             ),
    2634              :             // 4.7.2: coordinates are floating point numbers.
    2635            2 :             (
    2636            2 :                 "strings, not numbers",
    2637            2 :                 json!({"type": "Point", "coordinates": ["1", "2"]}),
    2638            2 :             ),
    2639            2 :             (
    2640            2 :                 "null coordinate",
    2641            2 :                 json!({"type": "Point", "coordinates": [1.0, null]}),
    2642            2 :             ),
    2643            2 :             (
    2644            2 :                 "nested where a position belongs",
    2645            2 :                 json!({"type": "Point", "coordinates": [[1.0, 2.0]]}),
    2646            2 :             ),
    2647              :             // RFC 7946 4: the CRS is WGS84 "with longitude and latitude units
    2648              :             // of decimal degrees" — 999 is not a latitude. Left through, it
    2649              :             // reaches PostGIS as a `::geography` cast that errors, so one
    2650              :             // write breaks every later `near` query in the tenant.
    2651            2 :             (
    2652            2 :                 "latitude past the pole",
    2653            2 :                 json!({"type": "Point", "coordinates": [0.0, 999.0]}),
    2654            2 :             ),
    2655            2 :             (
    2656            2 :                 "longitude past the antimeridian",
    2657            2 :                 json!({"type": "Point", "coordinates": [181.0, 0.0]}),
    2658            2 :             ),
    2659            2 :             (
    2660            2 :                 "latitude below the pole",
    2661            2 :                 json!({"type": "Point", "coordinates": [0.0, -90.5]}),
    2662            2 :             ),
    2663              :             // RFC 7946 3.1.4: a LineString needs "two or more positions".
    2664            2 :             (
    2665            2 :                 "one-position LineString",
    2666            2 :                 json!({"type": "LineString", "coordinates": [[1.0, 2.0]]}),
    2667            2 :             ),
    2668            2 :             (
    2669            2 :                 "LineString of numbers",
    2670            2 :                 json!({"type": "LineString", "coordinates": [1.0, 2.0]}),
    2671            2 :             ),
    2672              :             // RFC 7946 3.1.6: rings are closed and have four or more positions.
    2673            2 :             (
    2674            2 :                 "open ring",
    2675            2 :                 json!({"type": "Polygon", "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]]}),
    2676            2 :             ),
    2677            2 :             (
    2678            2 :                 "short ring",
    2679            2 :                 json!({"type": "Polygon", "coordinates": [[[0.0, 0.0], [1.0, 0.0], [0.0, 0.0]]]}),
    2680            2 :             ),
    2681            2 :             (
    2682            2 :                 "ring out of range",
    2683            2 :                 json!({"type": "Polygon",
    2684            2 :                 "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 91.0], [0.0, 0.0]]]}),
    2685            2 :             ),
    2686            2 :             (
    2687            2 :                 "polygon of positions",
    2688            2 :                 json!({"type": "Polygon", "coordinates": [[0.0, 0.0]]}),
    2689            2 :             ),
    2690            2 :             (
    2691            2 :                 "MultiPolygon nested one level short",
    2692            2 :                 json!({"type": "MultiPolygon",
    2693            2 :                 "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]}),
    2694            2 :             ),
    2695              :         ];
    2696           34 :         for (why, geom) in &bad_geoms {
    2697              :             // verbose form
    2698           34 :             let err = ent(json!({"type": "GeoProperty", "value": geom})).expect_err(why);
    2699           34 :             assert!(
    2700           34 :                 matches!(err, NgsiError::BadRequestData(_)),
    2701              :                 "{why}: {err:?}"
    2702              :             );
    2703              :             // 4.7.3 concise form — the same Value, the same restrictions
    2704           34 :             let err = ent(geom.clone()).expect_err(&format!("{why} (concise)"));
    2705           34 :             assert!(
    2706           34 :                 matches!(err, NgsiError::BadRequestData(_)),
    2707              :                 "{why} (concise)"
    2708              :             );
    2709              :             // 4.7.2 string-encoded form
    2710           34 :             let encoded = serde_json::to_string(geom).expect("encode");
    2711           34 :             let err = ent(json!({"type": "GeoProperty", "value": encoded}))
    2712           34 :                 .expect_err(&format!("{why} (encoded)"));
    2713           34 :             assert!(
    2714           34 :                 matches!(err, NgsiError::BadRequestData(_)),
    2715              :                 "{why} (encoded)"
    2716              :             );
    2717              :         }
    2718              :         // the shapes RFC 7946 does allow stay accepted, in all three forms
    2719            2 :         let good = vec![
    2720            2 :             json!({"type": "Point", "coordinates": [17.1, 48.7]}),
    2721            2 :             json!({"type": "Point", "coordinates": [-180.0, -90.0]}),
    2722            2 :             json!({"type": "Point", "coordinates": [180.0, 90.0]}),
    2723              :             // "Altitude or elevation MAY be included as an optional third element"
    2724            2 :             json!({"type": "Point", "coordinates": [1.0, 2.0, 300.0]}),
    2725            2 :             json!({"type": "MultiPoint", "coordinates": [[1.0, 2.0], [3.0, 4.0]]}),
    2726            2 :             json!({"type": "LineString", "coordinates": [[1.0, 2.0], [3.0, 4.0]]}),
    2727            2 :             json!({"type": "MultiLineString", "coordinates": [[[1.0, 2.0], [3.0, 4.0]]]}),
    2728            2 :             json!({"type": "Polygon",
    2729            2 :                 "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]}),
    2730            2 :             json!({"type": "Polygon", "coordinates": [
    2731              :                 [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 0.0]],
    2732              :                 [[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 1.0]]]}),
    2733            2 :             json!({"type": "MultiPolygon",
    2734            2 :                 "coordinates": [[[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]]}),
    2735              :             // RFC 7946 3.1: an empty multi-geometry is still a geometry
    2736            2 :             json!({"type": "MultiPoint", "coordinates": []}),
    2737            2 :             json!({"type": "Polygon", "coordinates": []}),
    2738              :         ];
    2739           24 :         for geom in &good {
    2740           24 :             ent(json!({"type": "GeoProperty", "value": geom}))
    2741           24 :                 .unwrap_or_else(|e| panic!("{geom} rejected: {e:?}"));
    2742           24 :             ent(geom.clone()).unwrap_or_else(|e| panic!("{geom} concise rejected: {e:?}"));
    2743              :         }
    2744            2 :     }
    2745              : 
    2746              :     /// 5.5.4 General NGSI-LD validation: "urn:ngsi-ld:null" as a first-level
    2747              :     /// member value is BadRequestData outside partial-update/merge fragments;
    2748              :     /// as the value of a key inside a JSON object that is a Property's value
    2749              :     /// it is BadRequestData everywhere EXCEPT merge fragments (5.5.12).
    2750              :     #[test]
    2751            2 :     fn clause_5_5_4_null_placement() {
    2752            2 :         let e =
    2753           20 :             |doc: Value, opts: ExpandOpts| expand_entity(doc.as_object().unwrap(), &core(), opts);
    2754              :         // first-level member value — id and type are first-level members too
    2755            2 :         assert!(
    2756            2 :             e(
    2757            2 :                 json!({"id": "urn:ngsi-ld:null", "type": "T"}),
    2758            2 :                 ExpandOpts::default()
    2759            2 :             )
    2760            2 :             .is_err(),
    2761              :             "null URN as id must 400 on create"
    2762              :         );
    2763            2 :         assert!(
    2764            2 :             e(
    2765            2 :                 json!({"id": "urn:x", "type": "urn:ngsi-ld:null"}),
    2766            2 :                 ExpandOpts::default()
    2767            2 :             )
    2768            2 :             .is_err(),
    2769              :             "null URN as type must 400 on create"
    2770              :         );
    2771              :         // null inside a JSON object that is a Property value
    2772            2 :         let nested = json!({"id": "urn:x", "type": "T",
    2773            2 :             "p": {"type": "Property", "value": {"a": "urn:ngsi-ld:null"}}});
    2774            2 :         assert!(
    2775            2 :             e(nested.clone(), ExpandOpts::default()).is_err(),
    2776              :             "nested null in value object must 400 on create"
    2777              :         );
    2778              :         // partial update allows top-level nulls (allow_null) but the
    2779              :         // object-nested form is excepted for merge ONLY
    2780            2 :         assert!(
    2781            2 :             e(
    2782            2 :                 nested.clone(),
    2783            2 :                 ExpandOpts {
    2784            2 :                     fragment: true,
    2785            2 :                     allow_null: true,
    2786            2 :                     ..Default::default()
    2787            2 :                 }
    2788            2 :             )
    2789            2 :             .is_err(),
    2790              :             "nested null in value object must 400 on partial update"
    2791              :         );
    2792              :         // merge fragment: accepted and preserved for merge_into
    2793            2 :         let ok = e(
    2794            2 :             nested,
    2795            2 :             ExpandOpts {
    2796            2 :                 fragment: true,
    2797            2 :                 allow_null: true,
    2798            2 :                 merge: true,
    2799            2 :                 ..Default::default()
    2800            2 :             },
    2801            2 :         )
    2802            2 :         .expect("merge fragment keeps the nested null");
    2803            2 :         assert_eq!(
    2804            2 :             ok["https://uri.etsi.org/ngsi-ld/default-context/p"][0]["value"]["a"],
    2805              :             "urn:ngsi-ld:null"
    2806              :         );
    2807              :         // deep nesting (object in object, object in array) is caught too
    2808            2 :         let deep = json!({"id": "urn:x", "type": "T",
    2809            2 :             "p": {"type": "Property", "value": {"a": {"b": "urn:ngsi-ld:null"}}}});
    2810            2 :         assert!(
    2811            2 :             e(deep, ExpandOpts::default()).is_err(),
    2812              :             "deep nested null must 400"
    2813              :         );
    2814            2 :         let in_array = json!({"id": "urn:x", "type": "T",
    2815            2 :             "p": {"type": "Property", "value": [{"a": "urn:ngsi-ld:null"}]}});
    2816            2 :         assert!(
    2817            2 :             e(in_array, ExpandOpts::default()).is_err(),
    2818              :             "null in object in array must 400"
    2819              :         );
    2820              :         // negative: a benign object value passes and carries no null
    2821            2 :         let fine = e(
    2822            2 :             json!({"id": "urn:x", "type": "T",
    2823            2 :                 "p": {"type": "Property", "value": {"a": 1}}}),
    2824            2 :             ExpandOpts::default(),
    2825            2 :         )
    2826            2 :         .expect("plain object value stays legal");
    2827            2 :         assert!(
    2828            2 :             !fine.to_string().contains("urn:ngsi-ld:null"),
    2829              :             "no null leakage"
    2830              :         );
    2831              :         // top-level attribute null stays the fragment deletion form:
    2832              :         // rejected on create, accepted under allow_null (5.5.8/5.5.12)
    2833            2 :         let top = json!({"id": "urn:x", "type": "T", "p": "urn:ngsi-ld:null"});
    2834            2 :         assert!(e(top.clone(), ExpandOpts::default()).is_err());
    2835            2 :         e(
    2836            2 :             top,
    2837            2 :             ExpandOpts {
    2838            2 :                 fragment: true,
    2839            2 :                 allow_null: true,
    2840            2 :                 ..Default::default()
    2841            2 :             },
    2842            2 :         )
    2843            2 :         .expect("first-level null is the deletion form in fragments");
    2844            2 :     }
    2845              : 
    2846              :     /// 5.5.8: "A datasetId cannot be deleted by setting it to the value
    2847              :     /// urn:ngsi-ld:null" — such a fragment is rejected on every input,
    2848              :     /// including null-allowing (update/merge) ones.
    2849              :     #[test]
    2850            2 :     fn clause_5_5_8_dataset_id_null_rejected() {
    2851            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2852            2 :             "speed": {"type": "Property", "value": 1,
    2853            2 :                       "datasetId": "urn:ngsi-ld:null"}});
    2854            6 :         for opts in [
    2855            2 :             ExpandOpts::default(),
    2856            2 :             ExpandOpts {
    2857            2 :                 fragment: true,
    2858            2 :                 allow_null: true,
    2859            2 :                 ..Default::default()
    2860            2 :             },
    2861            2 :             ExpandOpts {
    2862            2 :                 fragment: true,
    2863            2 :                 allow_null: true,
    2864            2 :                 merge: true,
    2865            2 :                 ..Default::default()
    2866            2 :             },
    2867            2 :         ] {
    2868            6 :             assert!(
    2869            6 :                 expand_entity(doc.as_object().unwrap(), &core(), opts).is_err(),
    2870              :                 "datasetId null must be rejected (opts {opts:?})"
    2871              :             );
    2872              :         }
    2873              :         // the attribute-level fragment path (5.6.4) rejects it too
    2874            2 :         let frag = json!({"type": "Property", "value": 1,
    2875            2 :                           "datasetId": "urn:ngsi-ld:null"});
    2876            2 :         assert!(expand_attr_fragment(frag.as_object().unwrap(), &core()).is_err());
    2877              :         // a REAL datasetId still passes and is preserved
    2878            2 :         let ok = expand_entity(
    2879            2 :             json!({"id": "urn:x", "type": "T",
    2880            2 :                 "speed": {"type": "Property", "value": 1,
    2881            2 :                           "datasetId": "urn:ngsi-ld:Dataset:a"}})
    2882            2 :             .as_object()
    2883            2 :             .unwrap(),
    2884            2 :             &core(),
    2885            2 :             ExpandOpts::default(),
    2886              :         )
    2887            2 :         .expect("real datasetId");
    2888            2 :         assert_eq!(
    2889            2 :             ok["https://uri.etsi.org/ngsi-ld/default-context/speed"][0]["datasetId"],
    2890              :             "urn:ngsi-ld:Dataset:a"
    2891              :         );
    2892            2 :     }
    2893              : 
    2894              :     /// 4.6.5 Supported data types for LanguageMaps: keys are RFC 5646 tags
    2895              :     /// or "@none", values are strings or arrays of strings; the
    2896              :     /// {"@none": "urn:ngsi-ld:null"} form is ONLY the partial/merge-patch
    2897              :     /// deletion encoding — invalid in a create/append (no allow_null).
    2898              :     #[test]
    2899            2 :     fn language_map_data_types() {
    2900           10 :         let lp = |lm: Value, opts: ExpandOpts| {
    2901           10 :             let doc = json!({"id": "urn:x", "type": "T",
    2902           10 :                 "brandName": {"type": "LanguageProperty", "languageMap": lm}});
    2903           10 :             expand_entity(doc.as_object().unwrap(), &core(), opts)
    2904           10 :         };
    2905            2 :         let out = lp(
    2906            2 :             json!({"sk": "škola", "en": ["school", "academy"], "@none": "default"}),
    2907            2 :             ExpandOpts::default(),
    2908            2 :         )
    2909            2 :         .expect("strings and arrays of strings");
    2910            2 :         let m = &out["https://uri.etsi.org/ngsi-ld/default-context/brandName"][0]["languageMap"];
    2911            2 :         assert_eq!(m["en"][1], "academy");
    2912            2 :         assert!(m.get("urn:ngsi-ld:null").is_none(), "no null leakage");
    2913              :         // non-string value rejected
    2914            2 :         assert!(lp(json!({"en": 5}), ExpandOpts::default()).is_err());
    2915            2 :         assert!(lp(json!({"en": [5]}), ExpandOpts::default()).is_err());
    2916              :         // the null encoding is a deletion marker: rejected on create,
    2917              :         // accepted under allow_null (patch/merge)
    2918            2 :         assert!(
    2919            2 :             lp(json!({"@none": "urn:ngsi-ld:null"}), ExpandOpts::default()).is_err(),
    2920              :             "langmap null form invalid outside patch/merge"
    2921              :         );
    2922            2 :         lp(
    2923            2 :             json!({"@none": "urn:ngsi-ld:null"}),
    2924            2 :             ExpandOpts {
    2925            2 :                 allow_null: true,
    2926            2 :                 ..ExpandOpts::default()
    2927            2 :             },
    2928            2 :         )
    2929            2 :         .expect("deletion form under allow_null");
    2930            2 :     }
    2931              : 
    2932              :     /// 4.6.3 Supported data types for Values: "All the GeoJSON Geometries
    2933              :     /// [8] with the exception of GeometryCollection" — a GeoProperty value
    2934              :     /// of type GeometryCollection is BadRequestData; plain JSON values and
    2935              :     /// a bare JSON null follow 4.5.2 (null rejected outside merge-patch).
    2936              :     #[test]
    2937            2 :     fn value_data_types_rules() {
    2938            6 :         let geo = |val: Value| {
    2939            6 :             let doc = json!({"id": "urn:x", "type": "T",
    2940            6 :                 "location": {"type": "GeoProperty", "value": val}});
    2941            6 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2942            6 :         };
    2943            2 :         let out = geo(json!({"type": "Point", "coordinates": [17.1, 48.7]}))
    2944            2 :             .expect("Point is a supported geometry");
    2945            2 :         let loc = &out["https://uri.etsi.org/ngsi-ld/location"][0];
    2946            2 :         assert_eq!(loc["value"]["type"], "Point");
    2947            2 :         let err = geo(json!({"type": "GeometryCollection", "geometries": [
    2948            2 :             {"type": "Point", "coordinates": [1.0, 2.0]}]}))
    2949            2 :         .expect_err("GeometryCollection is excluded by 4.6.3");
    2950            2 :         assert!(matches!(err, NgsiError::BadRequestData(_)), "{err:?}");
    2951              :         // a geometry type must still carry coordinates
    2952            2 :         assert!(geo(json!({"type": "Point"})).is_err(), "no coordinates");
    2953              :         // bare JSON null is not a legal Value outside merge-patch (4.5.2)
    2954            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2955            2 :             "speed": {"type": "Property", "value": null}});
    2956            2 :         assert!(
    2957            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2958              :             "bare null value"
    2959              :         );
    2960            2 :     }
    2961              : 
    2962              :     /// 4.6.2 Supported names: name = unicodeLetter *(letter|number|_) —
    2963              :     /// Entity Type / Property / Relationship names with other characters are
    2964              :     /// BadRequestData; keys containing ':' (compact or absolute IRIs) are
    2965              :     /// out of the term grammar's scope.
    2966              :     #[test]
    2967            2 :     fn name_grammar_rules() {
    2968           20 :         let attr = |name: &str| {
    2969           20 :             let doc = json!({"id": "urn:x", "type": "T",
    2970           20 :                 name: {"type": "Property", "value": 1}});
    2971           20 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2972           20 :         };
    2973           10 :         for bad_name in ["my attr", "1temp", "temp-erature", "temp!", "_hidden"] {
    2974           10 :             let err = attr(bad_name).expect_err(bad_name);
    2975              :             // 4.6.2 names the error type: BadRequestData, nothing else
    2976           10 :             assert!(
    2977           10 :                 matches!(err, NgsiError::BadRequestData(_)),
    2978              :                 "{bad_name}: {err:?}"
    2979              :             );
    2980              :         }
    2981           10 :         for good in [
    2982            2 :             "teplota_1",
    2983            2 :             "Ωmega",
    2984            2 :             "výška",
    2985            2 :             "ns:temp",
    2986            2 :             "https://example.com/a b",
    2987           10 :         ] {
    2988           10 :             attr(good).expect(good);
    2989           10 :         }
    2990              :         // sub-attribute names obey the same grammar
    2991            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2992            2 :             "speed": {"type": "Property", "value": 1,
    2993            2 :                 "bad sub": {"type": "Property", "value": 2}}});
    2994            2 :         assert!(
    2995            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2996              :             "sub-attribute with space must be rejected"
    2997              :         );
    2998              :         // entity type names too; multi-type checks each entry
    2999            6 :         let ty = |t: Value| {
    3000            6 :             let doc = json!({"id": "urn:x", "type": t, "speed": {"type": "Property", "value": 1}});
    3001            6 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3002            6 :         };
    3003            2 :         assert!(ty(json!("My Type")).is_err(), "type with space");
    3004            2 :         assert!(
    3005            2 :             ty(json!(["T", "9T"])).is_err(),
    3006              :             "type starting with a digit"
    3007              :         );
    3008            2 :         ty(json!("Škola")).expect("unicode-letter type");
    3009            2 :     }
    3010              : 
    3011              :     /// 4.5.24.2/4.5.24.3, TS 104 175 5.2.6.4.10: JsonProperty — json is any
    3012              :     /// NGSI-LD Value (kept verbatim), unitCode and value prohibited, concise
    3013              :     /// inference from json.
    3014              :     #[test]
    3015            2 :     fn json_property_rules() {
    3016           24 :         let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "tickets": attr});
    3017              :         // valid: object and array-of-objects, kept verbatim (no expansion)
    3018            2 :         let doc = mk(json!({"type": "JsonProperty", "json": {"id": "x", "value": 1}}));
    3019            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3020            2 :             .expect("valid json object");
    3021            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0];
    3022            2 :         assert_eq!(
    3023            2 :             inst["json"],
    3024            2 :             json!({"id": "x", "value": 1}),
    3025              :             "raw JSON kept verbatim"
    3026              :         );
    3027            2 :         let doc = mk(json!({"type": "JsonProperty", "json": [{"a": 1}, {"b": 2}]}));
    3028            2 :         expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3029            2 :             .expect("array of objects");
    3030              :         // concise inference
    3031            2 :         let doc = mk(json!({"json": {"a": 1}}));
    3032            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3033            2 :             .expect("concise");
    3034            2 :         assert_eq!(
    3035            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0]["type"],
    3036              :             "JsonProperty"
    3037              :         );
    3038              :         // TS 104 175 5.2.6.4.10: every NGSI-LD Value kind, kept verbatim
    3039           10 :         for j in [
    3040            2 :             json!(5),
    3041            2 :             json!("x"),
    3042            2 :             json!(true),
    3043            2 :             json!(["a"]),
    3044            2 :             json!([1, {"a": 1}]),
    3045            2 :         ] {
    3046           10 :             let doc = mk(json!({"type": "JsonProperty", "json": j}));
    3047           10 :             let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3048           10 :                 .expect("any JSON value");
    3049           10 :             assert_eq!(
    3050           10 :                 out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0]["json"],
    3051              :                 j
    3052              :             );
    3053              :         }
    3054              :         // JSON null is no NGSI-LD Value; the NGSI-LD Null only deletes
    3055            2 :         let doc = mk(json!({"type": "JsonProperty", "json": null}));
    3056            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3057            2 :         let doc = mk(json!({"type": "JsonProperty", "json": "urn:ngsi-ld:null"}));
    3058            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3059              :         // unitCode prohibited
    3060            2 :         let doc = mk(json!({"type": "JsonProperty", "json": {"a": 1}, "unitCode": "MTR"}));
    3061            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3062              :         // value prohibited
    3063            2 :         let doc = mk(json!({"type": "JsonProperty", "json": {"a": 1}, "value": 1}));
    3064            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3065            2 :     }
    3066              : 
    3067              :     /// 4.5.21/4.5.22: ListProperty and ListRelationship — objectList accepts
    3068              :     /// bare URIs or {"object": URI} objects (normalized to bare URIs
    3069              :     /// internally), non-URIs rejected, [null] deletion form, value/object
    3070              :     /// prohibited, concise inference.
    3071              :     #[test]
    3072            2 :     fn list_property_and_relationship_rules() {
    3073           14 :         let mk = |name: &str, attr: Value| json!({"id": "urn:x", "type": "T", name: attr});
    3074              :         // ListProperty: ordered array of Property Values, value prohibited
    3075            2 :         let doc = mk(
    3076            2 :             "steps",
    3077            2 :             json!({"type": "ListProperty", "valueList": [1, "two", true]}),
    3078            2 :         );
    3079            2 :         expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).expect("valid");
    3080            2 :         let doc = mk(
    3081            2 :             "steps",
    3082            2 :             json!({"type": "ListProperty", "valueList": [1], "value": 2}),
    3083            2 :         );
    3084            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3085              :         // concise inference
    3086            2 :         let doc = mk("steps", json!({"valueList": [1]}));
    3087            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3088            2 :             .expect("concise");
    3089            2 :         assert_eq!(
    3090            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/steps"][0]["type"],
    3091              :             "ListProperty"
    3092              :         );
    3093              :         // ListRelationship: both entry forms normalize to bare URIs
    3094            2 :         let doc = mk(
    3095            2 :             "route",
    3096            2 :             json!({"type": "ListRelationship",
    3097            2 :             "objectList": ["urn:ngsi-ld:R:1", {"object": "urn:ngsi-ld:R:2"}]}),
    3098            2 :         );
    3099            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3100            2 :             .expect("both forms");
    3101            2 :         assert_eq!(
    3102            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/route"][0]["objectList"],
    3103            2 :             json!(["urn:ngsi-ld:R:1", "urn:ngsi-ld:R:2"])
    3104              :         );
    3105              :         // non-URI entry rejected
    3106            2 :         let doc = mk(
    3107            2 :             "route",
    3108            2 :             json!({"type": "ListRelationship", "objectList": ["not a uri"]}),
    3109            2 :         );
    3110            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3111              :         // object prohibited on ListRelationship
    3112            2 :         let doc = mk(
    3113            2 :             "route",
    3114            2 :             json!({"type": "ListRelationship",
    3115            2 :             "objectList": ["urn:ngsi-ld:R:1"], "object": "urn:ngsi-ld:R:2"}),
    3116            2 :         );
    3117            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3118              :         // [null] deletion form accepted under allow_null
    3119            2 :         let doc = mk(
    3120            2 :             "route",
    3121            2 :             json!({"type": "ListRelationship",
    3122            2 :             "objectList": ["urn:ngsi-ld:null"]}),
    3123            2 :         );
    3124            2 :         let opts = ExpandOpts {
    3125            2 :             allow_null: true,
    3126            2 :             fragment: true,
    3127            2 :             ..Default::default()
    3128            2 :         };
    3129            2 :         expand_entity(doc.as_object().unwrap(), &core(), opts).expect("deletion form");
    3130            2 :         assert!(is_ngsi_null_list(&json!(["urn:ngsi-ld:null"])));
    3131            2 :         assert!(!is_ngsi_null_list(&json!(["urn:ngsi-ld:null", "urn:x"])));
    3132            2 :     }
    3133              : 
    3134              :     /// 4.5.20.2/4.5.20.3: VocabProperty — vocab is a string or array of
    3135              :     /// strings coerced to IRIs; unitCode and value prohibited; concise
    3136              :     /// inference from vocab.
    3137              :     #[test]
    3138            2 :     fn vocab_property_rules() {
    3139           10 :         let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "category": attr});
    3140              :         // normalized: term expands to an IRI under the context
    3141            2 :         let doc = mk(json!({"type": "VocabProperty", "vocab": "non-commercial"}));
    3142            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3143            2 :             .expect("valid vocab");
    3144            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
    3145            2 :         assert_eq!(
    3146            2 :             inst["vocab"],
    3147              :             "https://uri.etsi.org/ngsi-ld/default-context/non-commercial"
    3148              :         );
    3149              :         // concise inference from vocab
    3150            2 :         let doc = mk(json!({"vocab": ["a", "b"]}));
    3151            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3152            2 :             .expect("concise");
    3153            2 :         assert_eq!(
    3154            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/category"][0]["type"],
    3155              :             "VocabProperty"
    3156              :         );
    3157              :         // non-string vocab rejected
    3158            2 :         let doc = mk(json!({"type": "VocabProperty", "vocab": 5}));
    3159            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3160              :         // unitCode prohibited
    3161            2 :         let doc = mk(json!({"type": "VocabProperty", "vocab": "x", "unitCode": "MTR"}));
    3162            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3163              :         // value prohibited
    3164            2 :         let doc = mk(json!({"type": "VocabProperty", "vocab": "x", "value": 1}));
    3165            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3166            2 :     }
    3167              : 
    3168              :     /// 4.5.18.2/4.5.18.3: LanguageProperty — non-empty language tags, unitCode
    3169              :     /// prohibited, value prohibited; concise inference from languageMap.
    3170              :     #[test]
    3171            2 :     fn language_property_rules() {
    3172           12 :         let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "says": attr});
    3173              :         // valid normalized + "@none" tag
    3174            2 :         let doc = mk(json!({"type": "LanguageProperty",
    3175            2 :                             "languageMap": {"en": "hi", "@none": "hey"}}));
    3176            2 :         expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).expect("valid");
    3177              :         // concise inference from languageMap
    3178            2 :         let doc = mk(json!({"languageMap": {"en": "hi"}}));
    3179            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    3180            2 :             .expect("concise");
    3181            2 :         assert_eq!(
    3182            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/says"][0]["type"],
    3183              :             "LanguageProperty"
    3184              :         );
    3185              :         // empty language tag rejected
    3186            2 :         let doc = mk(json!({"type": "LanguageProperty", "languageMap": {"": "hi"}}));
    3187            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3188              :         // unitCode prohibited
    3189            2 :         let doc = mk(json!({"type": "LanguageProperty",
    3190            2 :                             "languageMap": {"en": "hi"}, "unitCode": "MTR"}));
    3191            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3192              :         // value prohibited
    3193            2 :         let doc = mk(json!({"type": "LanguageProperty",
    3194            2 :                             "languageMap": {"en": "hi"}, "value": 1}));
    3195            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3196              :         // non-string languageMap values rejected
    3197            2 :         let doc = mk(json!({"type": "LanguageProperty", "languageMap": {"en": 5}}));
    3198            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    3199            2 :     }
    3200              : }
    3201              : 
    3202              : #[cfg(test)]
    3203              : mod bench {
    3204              :     use super::*;
    3205              : 
    3206              :     /// The go/no-go threshold for a hand-rolled JSON-LD processor was
    3207              :     /// ≥5k expansions/s/core. Antares hand-rolled its processor from day one
    3208              :     /// rather than forking a `json-ld` crate — this measures it. Run with
    3209              :     /// `cargo test -p ngsild-jsonld --release -- --ignored bench_expansion`.
    3210              :     #[test]
    3211              :     #[ignore = "benchmark — run explicitly in release"]
    3212            0 :     fn bench_expansion_rate() {
    3213            0 :         let ctx = crate::core_context();
    3214            0 :         let entity: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
    3215            0 :             r#"{
    3216            0 :             "id": "urn:ngsi-ld:Vehicle:bench-1", "type": "Vehicle",
    3217            0 :             "speed": {"type": "Property", "value": 55.1,
    3218            0 :                       "observedAt": "2026-08-04T12:00:00Z", "unitCode": "KMH",
    3219            0 :                       "source": {"type": "Property", "value": "GPS"}},
    3220            0 :             "heading": {"type": "Property", "value": 180},
    3221            0 :             "isParked": {"type": "Relationship",
    3222            0 :                           "object": "urn:ngsi-ld:OffStreetParking:p1",
    3223            0 :                           "providedBy": {"type": "Relationship",
    3224            0 :                                           "object": "urn:ngsi-ld:Person:bob"}},
    3225            0 :             "location": {"type": "GeoProperty",
    3226            0 :                           "value": {"type": "Point", "coordinates": [13.35, 52.51]}},
    3227            0 :             "name": {"type": "LanguageProperty",
    3228            0 :                       "languageMap": {"en": "car", "de": "Auto"}}
    3229            0 :         }"#,
    3230              :         )
    3231            0 :         .expect("entity");
    3232            0 :         let n = 20_000u32;
    3233            0 :         let start = std::time::Instant::now();
    3234            0 :         for _ in 0..n {
    3235            0 :             let out = expand_entity(&entity, &ctx, ExpandOpts::default()).expect("expand");
    3236            0 :             std::hint::black_box(out);
    3237            0 :         }
    3238            0 :         let secs = start.elapsed().as_secs_f64();
    3239            0 :         let rate = f64::from(n) / secs;
    3240            0 :         eprintln!("expansion rate: {rate:.0}/s/core ({n} iterations in {secs:.2}s)");
    3241            0 :         assert!(
    3242            0 :             rate >= 5_000.0,
    3243              :             "expansion rate {rate:.0}/s is below the 5k/s/core phase-0 gate"
    3244              :         );
    3245            0 :     }
    3246              : }
    3247              : 
    3248              : #[cfg(test)]
    3249              : mod clause_4_18 {
    3250              :     use super::*;
    3251              :     use serde_json::json;
    3252              : 
    3253           28 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3254           28 :         expand_entity(
    3255           28 :             doc.as_object().expect("obj"),
    3256           28 :             &std::sync::Arc::new(crate::core_context()),
    3257           28 :             ExpandOpts::default(),
    3258              :         )
    3259           28 :     }
    3260              : 
    3261           34 :     fn with_scope(s: serde_json::Value) -> serde_json::Value {
    3262           34 :         json!({"id": "urn:x", "type": "T", "scope": s})
    3263           34 :     }
    3264              : 
    3265              :     /// 4.18 Scope grammar: [/] ScopeLevel *(/ScopeLevel), ScopeLevel =
    3266              :     /// unicodeLetter *(letter/number/_). EXAMPLES 1-4 must pass.
    3267              :     #[test]
    3268            2 :     fn scope_grammar_accepts_the_examples() {
    3269            8 :         for s in [
    3270            2 :             "/Madrid",
    3271            2 :             "Madrid",
    3272            2 :             "/Madrid/Gardens/ParqueNorte",
    3273            2 :             "/CompanyA/OrganizationB/UnitC",
    3274            2 :         ] {
    3275            8 :             assert!(expand(with_scope(json!(s))).is_ok(), "{s} must be valid");
    3276              :         }
    3277            2 :         let out = expand(with_scope(json!(["/A", "B/C_2"]))).expect("multi scope");
    3278            2 :         assert_eq!(out["scope"], json!(["/A", "B/C_2"]));
    3279            2 :     }
    3280              : 
    3281              :     /// 4.18: levels start with a letter, carry only letters/digits/_, no
    3282              :     /// empty levels; "urn:ngsi-ld:null" "shall be only used and only appear
    3283              :     /// in case of deleted scopes" — never creatable.
    3284              :     #[test]
    3285            2 :     fn scope_grammar_rejects_malformed_values() {
    3286           14 :         for s in [
    3287            2 :             "9bad",  // level starts with a digit
    3288            2 :             "/a//b", // empty level
    3289            2 :             "a-b",   // '-' not a ScopeLevelChar
    3290            2 :             "/",     // no level at all
    3291            2 :             "",      // empty
    3292            2 :             "/a/b/", // trailing empty level
    3293            2 :             "a b",   // space
    3294            2 :         ] {
    3295           14 :             assert!(
    3296           14 :                 expand(with_scope(json!(s))).is_err(),
    3297              :                 "{s:?} must be rejected"
    3298              :             );
    3299              :         }
    3300            2 :         assert!(
    3301            2 :             expand(with_scope(json!("urn:ngsi-ld:null"))).is_err(),
    3302              :             "the NGSI-LD Null scope is only for deletions, not creation"
    3303              :         );
    3304            2 :         assert!(
    3305            2 :             expand(with_scope(json!(["/ok", "9bad"]))).is_err(),
    3306              :             "one bad entry poisons the array"
    3307              :         );
    3308            2 :     }
    3309              : 
    3310              :     /// 5.5.12 deletes a member whose value IS an NGSI-LD Null, so on the
    3311              :     /// inputs that admit one the sentinel is the whole scope. Beside a real
    3312              :     /// scope it would be stored as one, and 4.18 spells no such scope.
    3313              :     #[test]
    3314            2 :     fn a_null_scope_is_the_whole_value_or_none_of_it() {
    3315            6 :         let merge = |s: serde_json::Value| {
    3316            6 :             expand_entity(
    3317            6 :                 with_scope(s).as_object().expect("obj"),
    3318            6 :                 &std::sync::Arc::new(crate::core_context()),
    3319            6 :                 ExpandOpts {
    3320            6 :                     allow_null: true,
    3321            6 :                     ..ExpandOpts::default()
    3322            6 :                 },
    3323              :             )
    3324            6 :         };
    3325            2 :         let out = merge(json!("urn:ngsi-ld:null")).expect("the deletion form");
    3326            2 :         assert_eq!(out["scope"], json!(["urn:ngsi-ld:null"]));
    3327            2 :         assert_eq!(
    3328            2 :             merge(json!(["urn:ngsi-ld:null"])).expect("array form")["scope"],
    3329            2 :             json!(["urn:ngsi-ld:null"])
    3330              :         );
    3331            2 :         assert!(
    3332            2 :             merge(json!(["/Madrid", "urn:ngsi-ld:null"])).is_err(),
    3333              :             "a deletion mixed with a scope is neither"
    3334              :         );
    3335            2 :     }
    3336              : }
    3337              : 
    3338              : #[cfg(test)]
    3339              : mod clause_5_2_1 {
    3340              :     use super::*;
    3341              :     use serde_json::json;
    3342              : 
    3343            6 :     fn expand_create(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3344            6 :         expand_entity(
    3345            6 :             doc.as_object().expect("obj"),
    3346            6 :             &std::sync::Arc::new(crate::core_context()),
    3347            6 :             ExpandOpts::default(), // create: allow_null = false
    3348              :         )
    3349            6 :     }
    3350              : 
    3351              :     /// 5.2.1: outside partial-update/merge inputs, "implementations shall
    3352              :     /// raise an error of type BadRequestData if an NGSI-LD Null value is
    3353              :     /// encountered" — Property value, Relationship object, sub-attribute.
    3354              :     #[test]
    3355            2 :     fn ngsi_ld_null_is_rejected_outside_merge_inputs() {
    3356            6 :         for doc in [
    3357            2 :             json!({"id": "urn:x", "type": "T",
    3358            2 :                 "p": {"type": "Property", "value": "urn:ngsi-ld:null"}}),
    3359            2 :             json!({"id": "urn:x", "type": "T",
    3360            2 :                 "r": {"type": "Relationship", "object": "urn:ngsi-ld:null"}}),
    3361            2 :             json!({"id": "urn:x", "type": "T",
    3362            2 :                 "p": {"type": "Property", "value": 1,
    3363            2 :                     "sub": {"type": "Property", "value": "urn:ngsi-ld:null"}}}),
    3364            2 :         ] {
    3365            6 :             let e = expand_create(doc).expect_err("NGSI-LD Null on create must be rejected");
    3366            6 :             assert!(
    3367            6 :                 matches!(e, NgsiError::BadRequestData(_)),
    3368              :                 "must be BadRequestData, got {e:?}"
    3369              :             );
    3370              :         }
    3371            2 :     }
    3372              : 
    3373              :     /// 5.2.1: the deletion marker stays legal on null-allowing inputs
    3374              :     /// (merge/partial-update, 5.5.8/5.5.12).
    3375              :     #[test]
    3376            2 :     fn ngsi_ld_null_survives_on_merge_inputs() {
    3377            2 :         let doc = json!({"p": {"type": "Property", "value": "urn:ngsi-ld:null"}});
    3378            2 :         let out = expand_entity(
    3379            2 :             doc.as_object().expect("obj"),
    3380            2 :             &std::sync::Arc::new(crate::core_context()),
    3381            2 :             ExpandOpts {
    3382            2 :                 fragment: true,
    3383            2 :                 allow_null: true,
    3384            2 :                 ..ExpandOpts::default()
    3385            2 :             },
    3386              :         )
    3387            2 :         .expect("merge fragment expands");
    3388            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
    3389            2 :         assert!(
    3390            2 :             is_deletion_instance(inst),
    3391              :             "the marker must remain recognizable: {inst}"
    3392              :         );
    3393            2 :     }
    3394              : 
    3395              :     /// 5.5.4: the marker is legal only in a Fragment used in a partial update
    3396              :     /// or merge. A temporal import allows nulls for 4.5.7 tombstones, but its
    3397              :     /// document is a whole Entity — an entity-level expiresAt carrying the
    3398              :     /// marker there is BadRequestData, not a stored lifetime of
    3399              :     /// "urn:ngsi-ld:null".
    3400              :     #[test]
    3401            2 :     fn the_entity_expires_at_marker_is_only_a_fragment_form() {
    3402            2 :         let doc = json!({
    3403            2 :             "id": "urn:ngsi-ld:Vehicle:1",
    3404            2 :             "type": "Vehicle",
    3405            2 :             "expiresAt": "urn:ngsi-ld:null",
    3406            2 :             "speed": [{"type": "Property", "value": 1}]
    3407              :         });
    3408            2 :         let e = expand_entity(
    3409            2 :             doc.as_object().expect("obj"),
    3410            2 :             &std::sync::Arc::new(crate::core_context()),
    3411            2 :             ExpandOpts {
    3412            2 :                 allow_null: true,
    3413            2 :                 temporal: true,
    3414            2 :                 sys: true,
    3415            2 :                 ..ExpandOpts::default()
    3416            2 :             },
    3417              :         )
    3418            2 :         .expect_err("a whole entity cannot ask for the removal");
    3419            2 :         assert!(matches!(e, NgsiError::BadRequestData(_)), "{e:?}");
    3420              : 
    3421              :         // the fragment form still asks for the removal
    3422            2 :         let frag = json!({"expiresAt": "urn:ngsi-ld:null"});
    3423            2 :         let out = expand_entity(
    3424            2 :             frag.as_object().expect("obj"),
    3425            2 :             &std::sync::Arc::new(crate::core_context()),
    3426            2 :             ExpandOpts {
    3427            2 :                 fragment: true,
    3428            2 :                 allow_null: true,
    3429            2 :                 ..ExpandOpts::default()
    3430            2 :             },
    3431              :         )
    3432            2 :         .expect("merge fragment expands");
    3433            2 :         assert_eq!(out["expiresAt"], "urn:ngsi-ld:null");
    3434            2 :     }
    3435              : }
    3436              : 
    3437              : #[cfg(test)]
    3438              : mod clause_5_2_4 {
    3439              :     use super::*;
    3440              :     use serde_json::json;
    3441              : 
    3442           24 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3443           24 :         expand_entity(
    3444           24 :             doc.as_object().expect("obj"),
    3445           24 :             &std::sync::Arc::new(crate::core_context()),
    3446           24 :             ExpandOpts::default(),
    3447              :         )
    3448           24 :     }
    3449              : 
    3450              :     /// Table 5.2.4-1: id must be a valid URI; type accepts a short name, a
    3451              :     /// URI, or an array of either; expiresAt must be a 4.6.3 DateTime.
    3452              :     #[test]
    3453            2 :     fn entity_member_table_restrictions() {
    3454            2 :         assert!(expand(json!({"id": "not a uri", "type": "T"})).is_err());
    3455            2 :         assert!(expand(json!({"id": "urn:x", "type": "T"})).is_ok());
    3456            2 :         assert!(expand(json!({"id": "urn:x", "type": ["T", "https://ex.org/U"]})).is_ok());
    3457            2 :         assert!(expand(json!({"id": "urn:x", "type": 5})).is_err());
    3458            2 :         assert!(
    3459            2 :             expand(json!({"id": "urn:x", "type": "T", "expiresAt": "2020-01-01"})).is_err(),
    3460              :             "expiresAt must be a DateTime, not a Date"
    3461              :         );
    3462            2 :         assert!(
    3463            2 :             expand(json!({"id": "urn:x", "type": "T", "expiresAt": "2030-01-01T00:00:00Z"}))
    3464            2 :                 .is_ok()
    3465              :         );
    3466            2 :     }
    3467              : 
    3468              :     /// Table 5.2.4-1: location/observationSpace/operationSpace are
    3469              :     /// GeoProperties (5.2.7) — a plain Property under those names is a
    3470              :     /// violation (4.7.1).
    3471              :     #[test]
    3472            2 :     fn default_geo_names_must_be_geoproperties() {
    3473            6 :         for name in ["location", "observationSpace", "operationSpace"] {
    3474            6 :             let doc = json!({"id": "urn:x", "type": "T",
    3475            6 :                 name: {"type": "Property", "value": 3}});
    3476            6 :             assert!(expand(doc).is_err(), "{name} as a plain Property must 400");
    3477            6 :             let ok = json!({"id": "urn:x", "type": "T",
    3478            6 :                 name: {"type": "GeoProperty",
    3479            6 :                        "value": {"type": "Point", "coordinates": [8, 40]}}});
    3480            6 :             assert!(expand(ok).is_ok(), "{name} as a GeoProperty is fine");
    3481              :         }
    3482            2 :     }
    3483              : }
    3484              : 
    3485              : #[cfg(test)]
    3486              : mod clause_5_2_5 {
    3487              :     use super::*;
    3488              :     use serde_json::json;
    3489              : 
    3490           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3491           14 :         expand_entity(
    3492           14 :             doc.as_object().expect("obj"),
    3493           14 :             &std::sync::Arc::new(crate::core_context()),
    3494           14 :             ExpandOpts::default(),
    3495              :         )
    3496           14 :     }
    3497              : 
    3498           14 :     fn with_p(p: serde_json::Value) -> serde_json::Value {
    3499           14 :         json!({"id": "urn:x", "type": "T", "p": p})
    3500           14 :     }
    3501              : 
    3502              :     /// Table 5.2.5-1: value mandatory (any JSON value), datasetId a URI,
    3503              :     /// observedAt/expiresAt DateTimes, unitCode a string, sub-attributes
    3504              :     /// nest per their own tables.
    3505              :     #[test]
    3506            2 :     fn property_member_table_restrictions() {
    3507            2 :         assert!(
    3508            2 :             expand(with_p(json!({"type": "Property"}))).is_err(),
    3509              :             "value mandatory"
    3510              :         );
    3511            2 :         assert!(
    3512            2 :             expand(with_p(json!({"type": "Property", "value": {"k": [1, "x"]},
    3513            2 :             "datasetId": "urn:ds:1", "observedAt": "2020-09-09T16:40:00Z",
    3514            2 :             "unitCode": "CEL",
    3515            2 :             "sub": {"type": "Relationship", "object": "urn:o:1"}})))
    3516            2 :             .is_ok()
    3517              :         );
    3518            2 :         assert!(expand(with_p(json!({"type": "Property", "value": 1,
    3519            2 :             "datasetId": "not a uri"})))
    3520            2 :         .is_err());
    3521            2 :         assert!(expand(with_p(json!({"type": "Property", "value": 1,
    3522            2 :             "observedAt": "2020-09-09"})))
    3523            2 :         .is_err());
    3524            2 :         assert!(expand(with_p(json!({"type": "Property", "value": 1,
    3525            2 :             "unitCode": 7})))
    3526            2 :         .is_err());
    3527            2 :     }
    3528              : 
    3529              :     /// 5.2.5: in the concise representation type="Property" is inferred from
    3530              :     /// `value` — but a GeoJSON-object value "would be interpreted as a
    3531              :     /// GeoProperty" and so infers GeoProperty, not Property.
    3532              :     #[test]
    3533            2 :     fn concise_inference_and_the_geojson_value_carveout() {
    3534            2 :         let out = expand(with_p(json!({"value": 42}))).expect("concise Property");
    3535            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
    3536            2 :         assert_eq!(inst["type"], "Property");
    3537            2 :         let out = expand(with_p(json!({"value":
    3538            2 :             {"type": "Point", "coordinates": [8, 40]}})))
    3539            2 :         .expect("concise geo value");
    3540            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
    3541            2 :         assert_eq!(
    3542            2 :             inst["type"], "GeoProperty",
    3543              :             "a GeoJSON object value infers GeoProperty (5.2.5/5.2.7)"
    3544              :         );
    3545            2 :     }
    3546              : }
    3547              : 
    3548              : #[cfg(test)]
    3549              : mod clause_5_2_6 {
    3550              :     use super::*;
    3551              :     use serde_json::json;
    3552              : 
    3553           12 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3554           12 :         expand_entity(
    3555           12 :             doc.as_object().expect("obj"),
    3556           12 :             &std::sync::Arc::new(crate::core_context()),
    3557           12 :             ExpandOpts::default(),
    3558              :         )
    3559           12 :     }
    3560              : 
    3561           12 :     fn with_r(r: serde_json::Value) -> serde_json::Value {
    3562           12 :         json!({"id": "urn:x", "type": "T", "r": r})
    3563           12 :     }
    3564              : 
    3565              :     /// Table 5.2.6-1: object mandatory — a URI or an array of URIs; datasetId
    3566              :     /// a URI; objectType coerced; concise inference from `object`; unitCode
    3567              :     /// prohibited (4.5.3.2).
    3568              :     #[test]
    3569            2 :     fn relationship_member_table_restrictions() {
    3570            2 :         assert!(
    3571            2 :             expand(with_r(json!({"type": "Relationship"}))).is_err(),
    3572              :             "object mandatory"
    3573              :         );
    3574            2 :         assert!(expand(with_r(
    3575            2 :             json!({"type": "Relationship", "object": "not a uri"})
    3576            2 :         ))
    3577            2 :         .is_err());
    3578            2 :         assert!(
    3579            2 :             expand(with_r(json!({"type": "Relationship",
    3580            2 :             "object": ["urn:a", "urn:b"], "datasetId": "urn:ds:1",
    3581            2 :             "objectType": "Device"})))
    3582            2 :             .is_ok(),
    3583              :             "array of URIs is legal"
    3584              :         );
    3585            2 :         assert!(
    3586            2 :             expand(with_r(json!({"type": "Relationship",
    3587            2 :             "object": ["urn:a", "not a uri"]})))
    3588            2 :             .is_err(),
    3589              :             "every array entry must be a URI"
    3590              :         );
    3591            2 :         assert!(
    3592            2 :             expand(with_r(json!({"type": "Relationship", "object": "urn:a",
    3593            2 :             "unitCode": "C62"})))
    3594            2 :             .is_err(),
    3595              :             "Relationships are unitless"
    3596              :         );
    3597              :         // concise inference from the object member
    3598            2 :         let out = expand(with_r(json!({"object": "urn:o:1"}))).expect("concise");
    3599            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/r"][0];
    3600            2 :         assert_eq!(inst["type"], "Relationship");
    3601            2 :     }
    3602              : }
    3603              : 
    3604              : #[cfg(test)]
    3605              : mod clause_5_2_32 {
    3606              :     use super::*;
    3607              :     use serde_json::json;
    3608              : 
    3609           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3610           14 :         expand_entity(
    3611           14 :             doc.as_object().expect("obj"),
    3612           14 :             &std::sync::Arc::new(crate::core_context()),
    3613           14 :             ExpandOpts::default(),
    3614              :         )
    3615           14 :     }
    3616              : 
    3617           14 :     fn with_lp(lp: serde_json::Value) -> serde_json::Value {
    3618           14 :         json!({"id": "urn:x", "type": "T", "greeting": lp})
    3619           14 :     }
    3620              : 
    3621              :     /// Table 5.2.32-1: languageMap keys are non-empty language tags mapping
    3622              :     /// to strings or string arrays; valueType, when present, shall be equal
    3623              :     /// to "langString"; datasetId is a URI; observedAt a DateTime.
    3624              :     #[test]
    3625            2 :     fn language_property_member_table_restrictions() {
    3626            2 :         let ok = expand(with_lp(json!({"type": "LanguageProperty",
    3627            2 :             "languageMap": {"en": "hello", "sk": ["ahoj", "servus"]},
    3628            2 :             "valueType": "langString"})))
    3629            2 :         .expect("conformant LanguageProperty");
    3630            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/greeting"][0];
    3631            2 :         assert_eq!(attr["valueType"], "langString");
    3632            2 :         assert!(attr.get("value").is_none(), "languageMap, not value");
    3633            2 :         assert!(
    3634            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3635            2 :                 "languageMap": {"en": "x"}, "valueType": "xsd:string"})))
    3636            2 :             .is_err(),
    3637              :             "valueType shall be equal to langString"
    3638              :         );
    3639            2 :         assert!(
    3640            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3641            2 :                 "languageMap": {"en": 5}})))
    3642            2 :             .is_err(),
    3643              :             "languageMap values are strings or string arrays"
    3644              :         );
    3645            2 :         assert!(
    3646            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3647            2 :                 "languageMap": {"": "x"}})))
    3648            2 :             .is_err(),
    3649              :             "empty language tag"
    3650              :         );
    3651            2 :         assert!(
    3652            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3653            2 :                 "languageMap": {"en": ["a", 5]}})))
    3654            2 :             .is_err(),
    3655              :             "array entries must all be strings"
    3656              :         );
    3657            2 :         assert!(
    3658            2 :             expand(with_lp(json!({"type": "LanguageProperty"}))).is_err(),
    3659              :             "languageMap is mandatory"
    3660              :         );
    3661            2 :         assert!(
    3662            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3663            2 :                 "languageMap": {"en": "x"}, "observedAt": "not-a-date"})))
    3664            2 :             .is_err(),
    3665              :             "observedAt must be a DateTime"
    3666              :         );
    3667            2 :     }
    3668              : }
    3669              : 
    3670              : #[cfg(test)]
    3671              : mod clause_5_2_35 {
    3672              :     use super::*;
    3673              :     use serde_json::json;
    3674              : 
    3675           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3676           14 :         expand_entity(
    3677           14 :             doc.as_object().expect("obj"),
    3678           14 :             &std::sync::Arc::new(crate::core_context()),
    3679           14 :             ExpandOpts::default(),
    3680              :         )
    3681           14 :     }
    3682              : 
    3683           14 :     fn with_vp(vp: serde_json::Value) -> serde_json::Value {
    3684           14 :         json!({"id": "urn:x", "type": "T", "category": vp})
    3685           14 :     }
    3686              : 
    3687              :     /// Table 5.2.35-1: vocab is a String or String[] type-coerced to URIs
    3688              :     /// under the @context; unitCode is prohibited (4.5.20.2); concise form
    3689              :     /// infers VocabProperty from the vocab member.
    3690              :     #[test]
    3691            2 :     fn vocab_property_member_table_restrictions() {
    3692            2 :         let ok = expand(with_vp(json!({"type": "VocabProperty", "vocab": "term"})))
    3693            2 :             .expect("conformant VocabProperty");
    3694            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
    3695            2 :         assert_eq!(
    3696            2 :             attr["vocab"], "https://uri.etsi.org/ngsi-ld/default-context/term",
    3697              :             "vocab is term-expanded"
    3698              :         );
    3699            2 :         assert!(attr.get("value").is_none(), "vocab, not value");
    3700            2 :         let ok = expand(with_vp(
    3701            2 :             json!({"type": "VocabProperty", "vocab": ["a", "b"]}),
    3702              :         ))
    3703            2 :         .expect("string[] form");
    3704            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
    3705            2 :         assert_eq!(attr["vocab"].as_array().map(Vec::len), Some(2));
    3706            2 :         assert!(
    3707            2 :             expand(with_vp(json!({"type": "VocabProperty", "vocab": ["a", 5]}))).is_err(),
    3708              :             "vocab array entries must be strings"
    3709              :         );
    3710            2 :         assert!(
    3711            2 :             expand(with_vp(json!({"type": "VocabProperty", "vocab": 5}))).is_err(),
    3712              :             "vocab must be a string or string array"
    3713              :         );
    3714            2 :         assert!(
    3715            2 :             expand(with_vp(json!({"type": "VocabProperty"}))).is_err(),
    3716              :             "vocab is mandatory"
    3717              :         );
    3718            2 :         assert!(
    3719            2 :             expand(with_vp(
    3720            2 :                 json!({"type": "VocabProperty", "vocab": "t", "unitCode": "C"})
    3721            2 :             ))
    3722            2 :             .is_err(),
    3723              :             "unitCode prohibited (4.5.20.2)"
    3724              :         );
    3725              :         // concise: the vocab member alone infers VocabProperty
    3726            2 :         let ok = expand(with_vp(json!({"vocab": "term"}))).expect("concise inference");
    3727            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
    3728            2 :         assert_eq!(attr["type"], "VocabProperty");
    3729            2 :     }
    3730              : }
    3731              : 
    3732              : #[cfg(test)]
    3733              : mod clause_5_2_36 {
    3734              :     use super::*;
    3735              :     use serde_json::json;
    3736              : 
    3737           10 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3738           10 :         expand_entity(
    3739           10 :             doc.as_object().expect("obj"),
    3740           10 :             &std::sync::Arc::new(crate::core_context()),
    3741           10 :             ExpandOpts::default(),
    3742              :         )
    3743           10 :     }
    3744              : 
    3745           10 :     fn with_list(lp: serde_json::Value) -> serde_json::Value {
    3746           10 :         json!({"id": "urn:x", "type": "T", "readings": lp})
    3747           10 :     }
    3748              : 
    3749              :     /// Table 5.2.36-1: valueList is a mandatory ordered array of JSON
    3750              :     /// values; concise form infers ListProperty from the valueList member.
    3751              :     #[test]
    3752            2 :     fn list_property_member_table_restrictions() {
    3753            2 :         let ok = expand(with_list(json!({"type": "ListProperty",
    3754            2 :             "valueList": [1, "a", {"o": 2}]})))
    3755            2 :         .expect("conformant ListProperty");
    3756            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/readings"][0];
    3757            2 :         assert_eq!(attr["valueList"], json!([1, "a", {"o": 2}]), "order kept");
    3758            2 :         assert!(attr.get("value").is_none(), "valueList, not value");
    3759            2 :         assert!(
    3760            2 :             expand(with_list(json!({"type": "ListProperty", "valueList": 5}))).is_err(),
    3761              :             "valueList must be an array"
    3762              :         );
    3763            2 :         assert!(
    3764            2 :             expand(with_list(json!({"type": "ListProperty"}))).is_err(),
    3765              :             "valueList is mandatory"
    3766              :         );
    3767            2 :         let ok = expand(with_list(json!({"valueList": [1, 2]}))).expect("concise inference");
    3768            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/readings"][0];
    3769            2 :         assert_eq!(attr["type"], "ListProperty");
    3770            2 :     }
    3771              : 
    3772              :     /// 5.5.7: a user @context lives at the top level only; a ListProperty's
    3773              :     /// values may not carry one, in a full attribute or in a fragment, any
    3774              :     /// more than a Property value may.
    3775              :     #[test]
    3776            2 :     fn a_list_value_may_not_embed_a_context() {
    3777            2 :         let embedded = json!([{"@context": {"a": "http://evil/a"}, "a": 1}]);
    3778            2 :         assert!(
    3779            2 :             expand(with_list(
    3780            2 :                 json!({"type": "ListProperty", "valueList": embedded.clone()})
    3781            2 :             ))
    3782            2 :             .is_err(),
    3783              :             "a full ListProperty"
    3784              :         );
    3785            2 :         let fragment = json!({"type": "ListProperty", "valueList": embedded});
    3786            2 :         assert!(
    3787            2 :             expand_attr_fragment(
    3788            2 :                 fragment.as_object().expect("obj"),
    3789            2 :                 &std::sync::Arc::new(crate::core_context()),
    3790            2 :             )
    3791            2 :             .is_err(),
    3792              :             "a fragment"
    3793              :         );
    3794            2 :     }
    3795              : }
    3796              : 
    3797              : #[cfg(test)]
    3798              : mod clause_5_2_37 {
    3799              :     use super::*;
    3800              :     use serde_json::json;
    3801              : 
    3802           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3803           14 :         expand_entity(
    3804           14 :             doc.as_object().expect("obj"),
    3805           14 :             &std::sync::Arc::new(crate::core_context()),
    3806           14 :             ExpandOpts::default(),
    3807              :         )
    3808           14 :     }
    3809              : 
    3810           14 :     fn with_lr(lr: serde_json::Value) -> serde_json::Value {
    3811           14 :         json!({"id": "urn:x", "type": "T", "route" : lr})
    3812           14 :     }
    3813              : 
    3814              :     /// Table 5.2.37-1: objectList is a mandatory array of URIs — accepted
    3815              :     /// both as bare URI strings and as {"object": URI} entries (4.5.22.2);
    3816              :     /// invalid URIs rejected; unitCode prohibited; concise form infers
    3817              :     /// ListRelationship from the objectList member.
    3818              :     #[test]
    3819            2 :     fn list_relationship_member_table_restrictions() {
    3820            4 :         for form in [
    3821            2 :             json!(["urn:a", "urn:b"]),
    3822            2 :             json!([{"object": "urn:a"}, {"object": "urn:b"}]),
    3823            2 :         ] {
    3824            4 :             let ok = expand(with_lr(
    3825            4 :                 json!({"type": "ListRelationship", "objectList": form}),
    3826              :             ))
    3827            4 :             .expect("conformant ListRelationship");
    3828            4 :             let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
    3829            4 :             let list = attr["objectList"].as_array().expect("objectList");
    3830            4 :             assert_eq!(list.len(), 2);
    3831            4 :             assert!(attr.get("object").is_none(), "objectList, not object");
    3832              :         }
    3833            2 :         assert!(
    3834            2 :             expand(with_lr(
    3835            2 :                 json!({"type": "ListRelationship", "objectList": ["not a uri"]})
    3836            2 :             ))
    3837            2 :             .is_err(),
    3838              :             "objectList entries must be URIs"
    3839              :         );
    3840            2 :         assert!(
    3841            2 :             expand(with_lr(
    3842            2 :                 json!({"type": "ListRelationship", "objectList": "urn:a"})
    3843            2 :             ))
    3844            2 :             .is_err(),
    3845              :             "objectList must be an array"
    3846              :         );
    3847            2 :         assert!(
    3848            2 :             expand(with_lr(json!({"type": "ListRelationship"}))).is_err(),
    3849              :             "objectList is mandatory"
    3850              :         );
    3851            2 :         assert!(
    3852            2 :             expand(with_lr(
    3853            2 :                 json!({"type": "ListRelationship", "objectList": ["urn:a"],
    3854            2 :                 "unitCode": "C"})
    3855            2 :             ))
    3856            2 :             .is_err(),
    3857              :             "unitCode prohibited on a ListRelationship"
    3858              :         );
    3859            2 :         let ok = expand(with_lr(json!({"objectList": ["urn:a"]}))).expect("concise inference");
    3860            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
    3861            2 :         assert_eq!(attr["type"], "ListRelationship");
    3862            2 :     }
    3863              : }
    3864              : 
    3865              : #[cfg(test)]
    3866              : mod clause_5_2_38 {
    3867              :     use super::*;
    3868              :     use serde_json::json;
    3869              : 
    3870           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3871           14 :         expand_entity(
    3872           14 :             doc.as_object().expect("obj"),
    3873           14 :             &std::sync::Arc::new(crate::core_context()),
    3874           14 :             ExpandOpts::default(),
    3875              :         )
    3876           14 :     }
    3877              : 
    3878           14 :     fn with_jp(jp: serde_json::Value) -> serde_json::Value {
    3879           14 :         json!({"id": "urn:x", "type": "T", "payload": jp})
    3880           14 :     }
    3881              : 
    3882              :     /// Table 5.2.38-1 / TS 104 175 Table 5.2.6.4.10-1: json is a mandatory
    3883              :     /// NGSI-LD Value, never expanded; unitCode prohibited (4.5.24.2); concise form
    3884              :     /// infers JsonProperty from the json member.
    3885              :     #[test]
    3886            2 :     fn json_property_member_table_restrictions() {
    3887            2 :         let ok = expand(with_jp(json!({"type": "JsonProperty",
    3888            2 :             "json": {"type": "kept-verbatim", "en": 1}})))
    3889            2 :         .expect("conformant JsonProperty");
    3890            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
    3891            2 :         assert_eq!(
    3892            2 :             attr["json"],
    3893            2 :             json!({"type": "kept-verbatim", "en": 1}),
    3894              :             "raw JSON kept verbatim, no expansion"
    3895              :         );
    3896            2 :         assert!(attr.get("value").is_none(), "json, not value");
    3897            2 :         let ok = expand(with_jp(
    3898            2 :             json!({"type": "JsonProperty", "json": [{"a": 1}, {"b": 2}]}),
    3899              :         ))
    3900            2 :         .expect("array-of-objects form");
    3901            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
    3902            2 :         assert_eq!(attr["json"].as_array().map(Vec::len), Some(2));
    3903            2 :         let ok = expand(with_jp(json!({"type": "JsonProperty", "json": [1, 2]})))
    3904            2 :             .expect("TS 104 175 5.2.6.4.10: any JSON value");
    3905            2 :         assert_eq!(
    3906            2 :             ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0]["json"],
    3907            2 :             json!([1, 2])
    3908              :         );
    3909            2 :         assert!(
    3910            2 :             expand(with_jp(json!({"type": "JsonProperty", "json": null}))).is_err(),
    3911              :             "JSON null is no NGSI-LD Value"
    3912              :         );
    3913            2 :         assert!(
    3914            2 :             expand(with_jp(json!({"type": "JsonProperty"}))).is_err(),
    3915              :             "json is mandatory"
    3916              :         );
    3917            2 :         assert!(
    3918            2 :             expand(with_jp(
    3919            2 :                 json!({"type": "JsonProperty", "json": {"a": 1}, "unitCode": "C"})
    3920            2 :             ))
    3921            2 :             .is_err(),
    3922              :             "unitCode prohibited on a JsonProperty"
    3923              :         );
    3924            2 :         let ok = expand(with_jp(json!({"json": {"a": 1}}))).expect("concise inference");
    3925            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
    3926            2 :         assert_eq!(attr["type"], "JsonProperty");
    3927            2 :     }
    3928              : }
    3929              : 
    3930              : #[cfg(test)]
    3931              : mod clause_5_5_4 {
    3932              :     use super::*;
    3933              :     use serde_json::json;
    3934              : 
    3935           12 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3936           12 :         expand_entity(
    3937           12 :             doc.as_object().expect("obj"),
    3938           12 :             &std::sync::Arc::new(crate::core_context()),
    3939           12 :             ExpandOpts::default(),
    3940              :         )
    3941           12 :     }
    3942              : 
    3943              :     /// 5.5.4: outside fragments/notifications, "urn:ngsi-ld:null" is
    3944              :     /// BadRequestData as a first-level member value, as a Property value /
    3945              :     /// Relationship object, as the languageMap {"@none": null} form, AND as
    3946              :     /// a key value inside a JSON object that is a Property value.
    3947              :     #[test]
    3948            2 :     fn ngsi_null_rejected_everywhere_on_create() {
    3949           10 :         let with = |attr: serde_json::Value| json!({"id": "urn:x", "type": "T", "a": attr});
    3950            2 :         assert!(
    3951            2 :             expand(json!({"id": "urn:x", "type": "T", "scope": "urn:ngsi-ld:null"})).is_err(),
    3952              :             "first-level member value"
    3953              :         );
    3954            2 :         assert!(
    3955            2 :             expand(with(
    3956            2 :                 json!({"type": "Property", "value": "urn:ngsi-ld:null"})
    3957            2 :             ))
    3958            2 :             .is_err(),
    3959              :             "Property value"
    3960              :         );
    3961            2 :         assert!(
    3962            2 :             expand(with(
    3963            2 :                 json!({"type": "Relationship", "object": "urn:ngsi-ld:null"})
    3964            2 :             ))
    3965            2 :             .is_err(),
    3966              :             "Relationship object"
    3967              :         );
    3968            2 :         assert!(
    3969            2 :             expand(with(json!({"type": "LanguageProperty",
    3970            2 :                 "languageMap": {"@none": "urn:ngsi-ld:null"}})))
    3971            2 :             .is_err(),
    3972              :             "languageMap null form"
    3973              :         );
    3974            2 :         assert!(
    3975            2 :             expand(with(json!({"type": "Property",
    3976            2 :                 "value": {"nested": "urn:ngsi-ld:null"}})))
    3977            2 :             .is_err(),
    3978              :             "null inside a compound Property value"
    3979              :         );
    3980              :         // control: an ordinary compound value stays creatable
    3981            2 :         assert!(expand(with(json!({"type": "Property", "value": {"nested": 1}}))).is_ok());
    3982            2 :     }
    3983              : }
    3984              : 
    3985              : #[cfg(test)]
    3986              : mod clause_5_2_7 {
    3987              :     use super::*;
    3988              :     use serde_json::json;
    3989              : 
    3990           10 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3991           10 :         expand_entity(
    3992           10 :             doc.as_object().expect("obj"),
    3993           10 :             &std::sync::Arc::new(crate::core_context()),
    3994           10 :             ExpandOpts::default(),
    3995              :         )
    3996           10 :     }
    3997              : 
    3998           10 :     fn with_g(g: serde_json::Value) -> serde_json::Value {
    3999           10 :         json!({"id": "urn:x", "type": "T", "g": g})
    4000           10 :     }
    4001              : 
    4002              :     /// Table 5.2.7-1: value must be a 4.7 GeoJSON geometry object (a plain
    4003              :     /// number/string is a violation), GeometryCollection excluded (4.6.3);
    4004              :     /// unitCode prohibited — GeoProperties carry coordinates, not units.
    4005              :     #[test]
    4006            2 :     fn geoproperty_member_table_restrictions() {
    4007            2 :         assert!(expand(with_g(json!({"type": "GeoProperty", "value": 5}))).is_err());
    4008            2 :         assert!(expand(with_g(json!({"type": "GeoProperty",
    4009            2 :             "value": {"type": "Nonsense", "coordinates": [1, 2]}})))
    4010            2 :         .is_err());
    4011            2 :         assert!(expand(with_g(json!({"type": "GeoProperty",
    4012            2 :             "value": {"type": "GeometryCollection", "geometries": []}})))
    4013            2 :         .is_err());
    4014            2 :         assert!(expand(with_g(json!({"type": "GeoProperty",
    4015            2 :             "value": {"type": "Point", "coordinates": [8, 40]},
    4016            2 :             "datasetId": "urn:ds:1"})))
    4017            2 :         .is_ok());
    4018            2 :         assert!(expand(with_g(json!({"type": "GeoProperty",
    4019            2 :             "value": {"type": "LineString",
    4020            2 :                       "coordinates": [[8, 40], [9, 41]]}})))
    4021            2 :         .is_ok());
    4022            2 :     }
    4023              : }
    4024              : 
    4025              : #[cfg(test)]
    4026              : mod reserved_member_guards {
    4027              :     use super::*;
    4028              :     use serde_json::json;
    4029              : 
    4030              :     /// 4.5.1: an Attribute name is expanded against the @context, and the
    4031              :     /// user @context is merged BEFORE the core one — so a term whose "@id"
    4032              :     /// is a bare word stays a RELATIVE IRI. Expanding an attribute onto
    4033              :     /// "id" must not be allowed to replace the Entity id (5.5.4
    4034              :     /// BadRequestData), the same rule expand_types applies to type names.
    4035              :     #[test]
    4036            2 :     fn attribute_name_must_expand_to_an_absolute_iri() {
    4037            2 :         let ctx = crate::resolve_with(&crate::PinnedOnly, &json!({"hostile": {"@id": "id"}}))
    4038            2 :             .expect("inline @context");
    4039            2 :         assert_eq!(ctx.expand_key("hostile"), "id", "term maps to a bare word");
    4040              : 
    4041            2 :         let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "T",
    4042            2 :             "hostile": {"type": "Property", "value": 1}});
    4043            2 :         let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default());
    4044            2 :         assert!(
    4045            2 :             out.is_err(),
    4046              :             "an attribute name that does not expand to an absolute IRI is BadRequestData, got {out:?}"
    4047              :         );
    4048            2 :     }
    4049              : 
    4050              :     /// 4.5.1/5.5.4: the same at sub-attribute level — a term expanding onto
    4051              :     /// "observedAt" would replace the validated DateTime with an instance
    4052              :     /// array, bypassing the 4.6.3 DateTime check.
    4053              :     #[test]
    4054            2 :     fn sub_attribute_name_must_not_overwrite_observed_at() {
    4055            2 :         let ctx = crate::resolve_with(
    4056            2 :             &crate::PinnedOnly,
    4057            2 :             &json!({"hostile": {"@id": "observedAt"}}),
    4058              :         )
    4059            2 :         .expect("inline @context");
    4060            2 :         assert_eq!(ctx.expand_key("hostile"), "observedAt");
    4061              : 
    4062            2 :         let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "T",
    4063            2 :             "speed": {"type": "Property", "value": 1,
    4064            2 :                       "observedAt": "2026-01-01T00:00:00Z",
    4065            2 :                       "hostile": {"type": "Property", "value": "x"}}});
    4066            2 :         let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default());
    4067            2 :         assert!(out.is_err(), "expected BadRequestData, got {out:?}");
    4068              : 
    4069              :         // negative: with a well-behaved @context the sub-attribute lands on
    4070              :         // its own IRI and observedAt still holds the DateTime string.
    4071            2 :         let ctx = crate::resolve_with(
    4072            2 :             &crate::PinnedOnly,
    4073            2 :             &json!({"hostile": {"@id": "https://example.org/hostile"}}),
    4074              :         )
    4075            2 :         .expect("inline @context");
    4076            2 :         let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default())
    4077            2 :             .expect("absolute IRI is fine");
    4078            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
    4079            2 :         assert_eq!(inst["observedAt"], "2026-01-01T00:00:00Z");
    4080            2 :         assert!(inst["https://example.org/hostile"].is_array());
    4081            2 :     }
    4082              : 
    4083              :     /// 4.5.5.1/5.5.8: "datasetId" is a URI string in a partial-update
    4084              :     /// fragment exactly as in a full instance — a non-string one is copied
    4085              :     /// onto the target instance and hides its default slot from every
    4086              :     /// datasetId-absent lookup.
    4087              :     #[test]
    4088            2 :     fn fragment_dataset_id_must_be_a_uri_string() {
    4089           10 :         for bad in [
    4090            2 :             json!(42),
    4091            2 :             json!(["urn:ngsi-ld:Dataset:a"]),
    4092            2 :             json!({"object": "urn:ngsi-ld:Dataset:a"}),
    4093            2 :             json!(true),
    4094            2 :             json!("not a uri"),
    4095            2 :         ] {
    4096           10 :             let frag = json!({"type": "Property", "value": 1, "datasetId": bad});
    4097           10 :             let out = expand_attr_fragment(frag.as_object().expect("obj"), &core());
    4098           10 :             assert!(
    4099           10 :                 out.is_err(),
    4100              :                 "datasetId {bad} must be rejected, got {out:?}"
    4101              :             );
    4102              :         }
    4103            2 :         let frag = json!({"type": "Property", "value": 1, "datasetId": "urn:ngsi-ld:Dataset:a"});
    4104            2 :         let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
    4105            2 :             .expect("a URI datasetId is valid");
    4106            2 :         assert_eq!(out["datasetId"], "urn:ngsi-ld:Dataset:a");
    4107            2 :     }
    4108              : 
    4109              :     /// 5.2.1: "In all other cases, implementations shall raise an error of
    4110              :     /// type BadRequestData if an NGSI-LD Null value is encountered" — the
    4111              :     /// concise forms (a bare value, a bare object value) must not be a way
    4112              :     /// around it, or a plain append deletes the instance it targets.
    4113              :     #[test]
    4114            2 :     fn concise_values_do_not_smuggle_the_ngsi_null() {
    4115            8 :         let create = |attr: serde_json::Value| -> Result<Value, NgsiError> {
    4116            8 :             let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T", "a": attr});
    4117            8 :             expand_entity(
    4118            8 :                 doc.as_object().expect("obj"),
    4119            8 :                 &core(),
    4120            8 :                 ExpandOpts::default(),
    4121              :             )
    4122            8 :         };
    4123            8 :         for attr in [
    4124            2 :             json!(["urn:ngsi-ld:null"]),
    4125            2 :             json!({"foo": "urn:ngsi-ld:null"}),
    4126            2 :             json!({"type": "Property", "value": "urn:ngsi-ld:null"}),
    4127            2 :             json!({"value": ["urn:ngsi-ld:null"]}),
    4128            2 :         ] {
    4129            8 :             let out = create(attr.clone());
    4130            8 :             assert!(out.is_err(), "{attr} must be BadRequestData, got {out:?}");
    4131              :         }
    4132              :         // negative: the same documents stay legal on a merge fragment
    4133              :         // (5.5.12), and a create with no sentinel keeps its value intact.
    4134            2 :         let doc = json!({"a": {"foo": "urn:ngsi-ld:null"}});
    4135            2 :         assert!(expand_entity(
    4136            2 :             doc.as_object().expect("obj"),
    4137            2 :             &core(),
    4138            2 :             ExpandOpts {
    4139            2 :                 fragment: true,
    4140            2 :                 allow_null: true,
    4141            2 :                 merge: true,
    4142            2 :                 ..Default::default()
    4143            2 :             }
    4144            2 :         )
    4145            2 :         .is_ok());
    4146            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T", "a": {"foo": "bar"}});
    4147            2 :         let out = expand_entity(
    4148            2 :             doc.as_object().expect("obj"),
    4149            2 :             &core(),
    4150            2 :             ExpandOpts::default(),
    4151              :         )
    4152            2 :         .expect("plain compound value");
    4153            2 :         assert_eq!(
    4154            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/a"][0]["value"]["foo"],
    4155              :             "bar"
    4156              :         );
    4157            2 :     }
    4158              : 
    4159              :     /// 5.2.5 Table 5.2.5-2 preamble: the output-only members "shall not be
    4160              :     /// provided by Context Producers. In the event that they are provided (in
    4161              :     /// update or create operations) NGSI-LD implementations shall ignore
    4162              :     /// them." 4.5.2.2 Prohibited adds "shall never include" for entity,
    4163              :     /// entityList and the previous* family, and entityIdSealed/
    4164              :     /// entityTypeSealed "unless the Property name is ngsildproof".
    4165              :     #[test]
    4166            2 :     fn fragment_ignores_output_only_and_prohibited_members() {
    4167            2 :         let frag = json!({
    4168            2 :             "type": "Property",
    4169            2 :             "value": 5,
    4170            2 :             "previousValue": 999,
    4171            2 :             "previousObject": "urn:ngsi-ld:Other:1",
    4172            2 :             "previousLanguageMap": {"en": "x"},
    4173            2 :             "previousJson": {"a": 1},
    4174            2 :             "previousVocab": "x",
    4175            2 :             "previousValueList": [1],
    4176            2 :             "previousObjectList": ["urn:ngsi-ld:Other:1"],
    4177            2 :             "entity": {"id": "urn:evil", "type": "T"},
    4178            2 :             "entityList": [{"id": "urn:evil", "type": "T"}],
    4179            2 :             "entityIdSealed": "urn:evil",
    4180            2 :             "entityTypeSealed": "T",
    4181            2 :             "deletedAt": "2026-01-01T00:00:00Z",
    4182              :         });
    4183            2 :         let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
    4184            2 :             .expect("the ignored members must not make the fragment invalid");
    4185            2 :         let m = out.as_object().expect("object");
    4186           24 :         for k in [
    4187            2 :             "previousValue",
    4188            2 :             "previousObject",
    4189            2 :             "previousLanguageMap",
    4190            2 :             "previousJson",
    4191            2 :             "previousVocab",
    4192            2 :             "previousValueList",
    4193            2 :             "previousObjectList",
    4194            2 :             "entity",
    4195            2 :             "entityList",
    4196            2 :             "entityIdSealed",
    4197            2 :             "entityTypeSealed",
    4198            2 :             "deletedAt",
    4199            2 :         ] {
    4200           24 :             assert!(
    4201           24 :                 !m.contains_key(k),
    4202              :                 "{k} must be ignored on input, got {out:#}"
    4203              :             );
    4204              :         }
    4205              :         // negative: the members the fragment IS allowed to carry survive.
    4206            2 :         assert_eq!(m["value"], 5);
    4207            2 :         assert_eq!(m["type"], "Property");
    4208            2 :     }
    4209              : 
    4210              :     /// 4.6.3/4.22: expiresAt is an ISO 8601 DateTime in a partial-update
    4211              :     /// fragment exactly as in a full instance — an unparsable one must not
    4212              :     /// reach the transient-entity boundary check.
    4213              :     #[test]
    4214            2 :     fn fragment_expires_at_is_a_datetime() {
    4215            2 :         let frag = json!({"type": "Property", "value": 1, "expiresAt": "soon"});
    4216            2 :         let out = expand_attr_fragment(frag.as_object().expect("obj"), &core());
    4217            2 :         assert!(
    4218            2 :             matches!(out, Err(NgsiError::BadRequestData(_))),
    4219              :             "an invalid expiresAt is BadRequestData, got {out:?}"
    4220              :         );
    4221            2 :         let frag = json!({"type": "Property", "value": 1, "expiresAt": "2026-01-01T00:00:00Z"});
    4222            2 :         let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
    4223            2 :             .expect("a valid DateTime is kept");
    4224            2 :         assert_eq!(out["expiresAt"], "2026-01-01T00:00:00Z");
    4225            2 :     }
    4226              : 
    4227              :     /// 4.5.1: "Terms defined in the Core Context as non-reified Properties
    4228              :     /// (such as datasetId, instanceId, etc.) shall not be used as Attribute
    4229              :     /// names." createdAt/modifiedAt/deletedAt/expiresAt/scope map 1:1 onto
    4230              :     /// their core IRI, so the fully-qualified spelling would compact straight
    4231              :     /// back onto the Entity's own system member.
    4232              :     #[test]
    4233            2 :     fn core_system_member_iris_cannot_be_attribute_names() {
    4234           10 :         for term in ["createdAt", "modifiedAt", "deletedAt", "expiresAt", "scope"] {
    4235           10 :             let mut doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T"});
    4236           10 :             doc.as_object_mut().expect("obj").insert(
    4237           10 :                 format!("https://uri.etsi.org/ngsi-ld/{term}"),
    4238           10 :                 json!({"type": "Property", "value": "pwned"}),
    4239              :             );
    4240           10 :             let out = expand_entity(
    4241           10 :                 doc.as_object().expect("obj"),
    4242           10 :                 &core(),
    4243           10 :                 ExpandOpts::default(),
    4244              :             );
    4245           10 :             assert!(
    4246           10 :                 matches!(out, Err(NgsiError::BadRequestData(_))),
    4247              :                 "{term} as an Attribute name is BadRequestData, got {out:?}"
    4248              :             );
    4249              :         }
    4250              :         // negative: the system members themselves still expand normally.
    4251            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    4252            2 :             "expiresAt": "2026-01-01T00:00:00Z", "scope": "/a"});
    4253            2 :         let out = expand_entity(
    4254            2 :             doc.as_object().expect("obj"),
    4255            2 :             &core(),
    4256            2 :             ExpandOpts::default(),
    4257              :         )
    4258            2 :         .expect("plain system members");
    4259            2 :         assert_eq!(out["expiresAt"], "2026-01-01T00:00:00Z");
    4260            2 :     }
    4261              : 
    4262              :     /// Table 5.2.6-1: objectType is "String or String[]" and "Both short hand
    4263              :     /// string(s) (type name) or URI(s) are allowed" — both shapes are
    4264              :     /// @vocab-coerced, so the two spellings of one target type cannot be
    4265              :     /// stored differently.
    4266              :     #[test]
    4267            2 :     fn object_type_expands_in_both_string_and_array_form() {
    4268            2 :         let expanded = "https://uri.etsi.org/ngsi-ld/default-context/Device";
    4269           10 :         let rel = |ot: serde_json::Value| -> Result<Value, NgsiError> {
    4270           10 :             let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    4271           10 :                 "r": {"type": "Relationship", "object": "urn:ngsi-ld:D:1", "objectType": ot}});
    4272           10 :             expand_entity(
    4273           10 :                 doc.as_object().expect("obj"),
    4274           10 :                 &core(),
    4275           10 :                 ExpandOpts::default(),
    4276              :             )
    4277           10 :         };
    4278            2 :         let scalar = rel(json!("Device")).expect("scalar objectType");
    4279            2 :         let array = rel(json!(["Device"])).expect("array objectType");
    4280            6 :         let at = |v: &Value| {
    4281            6 :             v["https://uri.etsi.org/ngsi-ld/default-context/r"][0]["objectType"].clone()
    4282            6 :         };
    4283            2 :         assert_eq!(at(&scalar), json!(expanded));
    4284            2 :         assert_eq!(at(&array), json!([expanded]));
    4285              :         // negative: the bare term must never survive unexpanded.
    4286            2 :         assert_ne!(at(&array), json!(["Device"]));
    4287            6 :         for bad in [json!(42), json!({"a": 1}), json!([1])] {
    4288            6 :             let out = rel(bad.clone());
    4289            6 :             assert!(
    4290            6 :                 matches!(out, Err(NgsiError::BadRequestData(_))),
    4291              :                 "objectType {bad} must be rejected, got {out:?}"
    4292              :             );
    4293              :         }
    4294            2 :     }
    4295              : 
    4296              :     /// 4.6.3/Table 5.2.7-1: a GeoProperty value is a clause 4.7 GeoJSON
    4297              :     /// geometry, whose "coordinates" is an array — a scalar or object one is
    4298              :     /// not a geometry and must not reach the GeoJSON rendering path.
    4299              :     #[test]
    4300            2 :     fn geojson_coordinates_must_be_an_array() {
    4301            8 :         for bad in [json!("boom"), json!(42), json!({"lat": 1}), json!(null)] {
    4302            8 :             let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    4303            8 :                 "location": {"type": "GeoProperty",
    4304            8 :                              "value": {"type": "Point", "coordinates": bad}}});
    4305            8 :             let out = expand_entity(
    4306            8 :                 doc.as_object().expect("obj"),
    4307            8 :                 &core(),
    4308            8 :                 ExpandOpts::default(),
    4309              :             );
    4310            8 :             assert!(
    4311            8 :                 matches!(out, Err(NgsiError::BadRequestData(_))),
    4312              :                 "coordinates {bad} must be rejected, got {out:?}"
    4313              :             );
    4314              :         }
    4315              :         // negative: a real geometry still passes untouched.
    4316            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    4317            2 :             "location": {"type": "GeoProperty",
    4318            2 :                          "value": {"type": "Point", "coordinates": [1.0, 2.0]}}});
    4319            2 :         let out = expand_entity(
    4320            2 :             doc.as_object().expect("obj"),
    4321            2 :             &core(),
    4322            2 :             ExpandOpts::default(),
    4323              :         )
    4324            2 :         .expect("valid Point");
    4325            2 :         assert_eq!(
    4326            2 :             out["https://uri.etsi.org/ngsi-ld/location"][0]["value"]["coordinates"],
    4327            2 :             json!([1.0, 2.0])
    4328              :         );
    4329            2 :     }
    4330              : 
    4331              :     /// 4.5.5.1: "There can only be one default Attribute instance for an
    4332              :     /// Attribute with a given Attribute name in any request or response" — a
    4333              :     /// term and its own expanded IRI are one Attribute name, so accepting
    4334              :     /// both would silently discard one client's data.
    4335              :     #[test]
    4336            2 :     fn two_names_expanding_to_one_iri_are_rejected() {
    4337            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    4338            2 :             "temperature": {"type": "Property", "value": 1},
    4339            2 :             "https://uri.etsi.org/ngsi-ld/default-context/temperature":
    4340            2 :                 {"type": "Property", "value": 2}});
    4341            2 :         let out = expand_entity(
    4342            2 :             doc.as_object().expect("obj"),
    4343            2 :             &core(),
    4344            2 :             ExpandOpts::default(),
    4345              :         );
    4346            2 :         assert!(
    4347            2 :             matches!(out, Err(NgsiError::BadRequestData(_))),
    4348              :             "duplicate expanded Attribute name is BadRequestData, got {out:?}"
    4349              :         );
    4350              :         // the same collision one level down, on sub-attributes.
    4351            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    4352            2 :             "speed": {"type": "Property", "value": 1,
    4353            2 :                 "accuracy": {"type": "Property", "value": 1},
    4354            2 :                 "https://uri.etsi.org/ngsi-ld/default-context/accuracy":
    4355            2 :                     {"type": "Property", "value": 2}}});
    4356            2 :         let out = expand_entity(
    4357            2 :             doc.as_object().expect("obj"),
    4358            2 :             &core(),
    4359            2 :             ExpandOpts::default(),
    4360              :         );
    4361            2 :         assert!(
    4362            2 :             matches!(out, Err(NgsiError::BadRequestData(_))),
    4363              :             "duplicate expanded sub-Attribute name is BadRequestData, got {out:?}"
    4364              :         );
    4365            2 :     }
    4366              : 
    4367           82 :     fn core() -> std::sync::Arc<Context> {
    4368           82 :         std::sync::Arc::new(crate::core_context())
    4369           82 :     }
    4370              : 
    4371              :     /// `expand_instance` dispatches on `attr_type` and closes the match with
    4372              :     /// `unreachable!()`. That arm is reachable exactly when `ATTR_TYPES` — a
    4373              :     /// `pub` list, and the gate a CLIENT-supplied `"type"` passes through —
    4374              :     /// carries a member the match does not: 4.5.2 gives Attribute types a
    4375              :     /// closed set, but a later edition adding one to the list without an arm
    4376              :     /// turns `{"type": "<new>"}` into a panic on the request path instead of
    4377              :     /// a Table 6.3.2-1 error.
    4378              :     ///
    4379              :     /// So every member of the list is walked here. A type may legitimately
    4380              :     /// refuse an instance that carries the wrong members (BadRequestData is a
    4381              :     /// fine answer); it may not panic, and it may not answer with an error
    4382              :     /// that is not an NGSI-LD one.
    4383              :     #[test]
    4384            2 :     fn every_declarable_attribute_type_is_dispatched_not_unreachable() {
    4385           16 :         for t in ATTR_TYPES {
    4386           16 :             let doc = serde_json::json!({
    4387           16 :                 "id": "urn:ngsi-ld:Vehicle:dispatch",
    4388           16 :                 "type": "Vehicle",
    4389              :                 // Bare on purpose: 4.5.2.2 lets a value-defining member
    4390              :                 // appear only on its own type, so an instance carrying them
    4391              :                 // all is refused BEFORE the dispatch and would prove nothing.
    4392           16 :                 "attr": {"type": t},
    4393              :             });
    4394              :             // Whatever the answer is, reaching one is the assertion: an
    4395              :             // unhandled type would have panicked before returning.
    4396           16 :             let got = expand_entity(
    4397           16 :                 doc.as_object().expect("object"),
    4398           16 :                 &core(),
    4399           16 :                 ExpandOpts::default(),
    4400              :             );
    4401           16 :             if let Err(e) = got {
    4402           16 :                 assert_eq!(
    4403           16 :                     e.status(),
    4404              :                     400,
    4405              :                     "{t}: an instance carrying the wrong members is BadRequestData, not {e}"
    4406              :                 );
    4407            0 :             }
    4408              :         }
    4409            2 :     }
    4410              : }
    4411              : 
    4412              : /// The refusals and pass-throughs no clause test above reaches: each case is
    4413              : /// one malformed (or one borderline legal) input and the answer it gets.
    4414              : #[cfg(test)]
    4415              : mod edges {
    4416              :     use super::*;
    4417              :     use serde_json::json;
    4418              : 
    4419              :     const MERGE: ExpandOpts = ExpandOpts {
    4420              :         fragment: true,
    4421              :         allow_null: true,
    4422              :         merge: true,
    4423              :         temporal: false,
    4424              :         sys: false,
    4425              :     };
    4426              : 
    4427          102 :     fn run(doc: Value, opts: ExpandOpts) -> Result<Value, NgsiError> {
    4428          102 :         expand_entity(
    4429          102 :             doc.as_object().expect("obj"),
    4430          102 :             &std::sync::Arc::new(crate::core_context()),
    4431          102 :             opts,
    4432              :         )
    4433          102 :     }
    4434              : 
    4435           38 :     fn ok(doc: Value, opts: ExpandOpts) -> Value {
    4436           38 :         run(doc.clone(), opts).unwrap_or_else(|e| panic!("{doc} refused: {e:?}"))
    4437           38 :     }
    4438              : 
    4439           62 :     fn refused(doc: Value, opts: ExpandOpts) {
    4440           62 :         let out = run(doc.clone(), opts);
    4441           62 :         assert!(
    4442           62 :             matches!(out, Err(NgsiError::BadRequestData(_))),
    4443              :             "{doc} must be BadRequestData, got {out:?}"
    4444              :         );
    4445           62 :     }
    4446              : 
    4447           62 :     fn with(attr: Value) -> Value {
    4448           62 :         json!({"id": "urn:ngsi-ld:T:1", "type": "T", "a": attr})
    4449           62 :     }
    4450              : 
    4451              :     const A: &str = "https://uri.etsi.org/ngsi-ld/default-context/a";
    4452              : 
    4453              :     #[test]
    4454            2 :     fn entity_members() {
    4455            2 :         let d = ExpandOpts::default();
    4456            2 :         refused(json!({"id": 5, "type": "T"}), d);
    4457            2 :         refused(json!({"type": "T"}), d);
    4458            2 :         refused(json!({"id": "urn:x", "type": ["T", 5]}), d);
    4459            2 :         refused(json!({"id": "urn:x", "type": ["T", ""]}), d);
    4460            2 :         refused(
    4461            2 :             json!({"id": "urn:x", "type": "T", "": {"type": "Property", "value": 1}}),
    4462            2 :             d,
    4463              :         );
    4464            2 :         refused(
    4465            2 :             with(
    4466            2 :                 json!([{"type": "Property", "value": 1, "datasetId": "urn:d"},
    4467            2 :                         {"type": "Property", "value": 2, "datasetId": "urn:d"}]),
    4468              :             ),
    4469            2 :             d,
    4470              :         );
    4471            2 :         let sys = ExpandOpts { sys: true, ..d };
    4472            2 :         let out = ok(
    4473            2 :             json!({"id": "urn:x", "type": "T", "createdAt": "2020-01-01T00:00:00Z",
    4474            2 :                    "modifiedAt": "yesterday", "deletedAt": 5}),
    4475            2 :             sys,
    4476              :         );
    4477            2 :         assert_eq!(out["createdAt"], "2020-01-01T00:00:00Z");
    4478            2 :         assert!(out.get("modifiedAt").is_none());
    4479            2 :         assert!(out.get("deletedAt").is_none());
    4480            2 :         refused(json!({"id": "urn:x", "type": ""}), d);
    4481            2 :         refused(json!({"id": "urn:x", "type": []}), d);
    4482              :         // temporal input repeats datasetIds legally (4.5.6)
    4483            2 :         ok(
    4484            2 :             with(
    4485            2 :                 json!([{"type": "Property", "value": 1, "datasetId": "urn:d"},
    4486            2 :                         {"type": "Property", "value": 2, "datasetId": "urn:d"}]),
    4487              :             ),
    4488            2 :             ExpandOpts {
    4489            2 :                 temporal: true,
    4490            2 :                 ..d
    4491            2 :             },
    4492              :         );
    4493              :         // a merge may delete the location
    4494            2 :         ok(
    4495            2 :             json!({"id": "urn:x", "type": "T", "location": "urn:ngsi-ld:null"}),
    4496              :             MERGE,
    4497              :         );
    4498              :         // a merge of a whole Entity may remove its expiry too
    4499            2 :         let whole_merge = ExpandOpts {
    4500            2 :             allow_null: true,
    4501            2 :             merge: true,
    4502            2 :             ..d
    4503            2 :         };
    4504            2 :         ok(
    4505            2 :             json!({"id": "urn:x", "type": "T", "expiresAt": "urn:ngsi-ld:null"}),
    4506            2 :             whole_merge,
    4507              :         );
    4508            2 :     }
    4509              : 
    4510              :     /// 4.5.6: temporal input carries scope as Property instances.
    4511              :     #[test]
    4512            2 :     fn temporal_scope_instances() {
    4513            2 :         let t = ExpandOpts {
    4514            2 :             temporal: true,
    4515            2 :             ..Default::default()
    4516            2 :         };
    4517           14 :         let scope = |s: Value| json!({"id": "urn:x", "type": "T", "scope": s});
    4518            2 :         let out = ok(scope(json!([{"value": "/a"}, {"value": ["/b", "/c"]}])), t);
    4519            2 :         assert_eq!(out["scope"].as_array().map(Vec::len), Some(2));
    4520            2 :         refused(scope(json!([{"value": 5}])), t);
    4521            2 :         refused(scope(json!([{"value": ["/a", 5]}])), t);
    4522            2 :         refused(scope(json!([{"value": "/a b"}])), t);
    4523            2 :         refused(scope(json!([5])), t);
    4524            2 :         refused(scope(json!([{"value": "/a"}])), ExpandOpts::default());
    4525            2 :         refused(scope(json!(5)), ExpandOpts::default());
    4526            2 :     }
    4527              : 
    4528              :     #[test]
    4529            2 :     fn expanded_accessors_name_the_wiring_mistake() {
    4530            2 :         assert!(matches!(
    4531            2 :             expanded_object(&json!(1)),
    4532              :             Err(NgsiError::InternalError(_))
    4533              :         ));
    4534            2 :         assert!(expanded_object(&json!({})).is_ok());
    4535            2 :         assert!(matches!(
    4536            2 :             expanded_id(&json!({})),
    4537              :             Err(NgsiError::InternalError(_))
    4538              :         ));
    4539            2 :         assert_eq!(expanded_id(&json!({"id": "urn:x"})).ok(), Some("urn:x"));
    4540            2 :     }
    4541              : 
    4542              :     #[test]
    4543            2 :     fn a_new_entity_id_holding_a_comma_is_refused() {
    4544            2 :         assert_eq!(
    4545            2 :             new_entity_id(&json!({"id": "urn:x:a"})).ok(),
    4546              :             Some("urn:x:a")
    4547              :         );
    4548            2 :         let err = format!("{:?}", new_entity_id(&json!({"id": "urn:x:a,b"})));
    4549            2 :         assert!(err.starts_with("Err(BadRequestData("), "{err}");
    4550            2 :         assert!(err.contains("urn:x:a-b"), "{err}");
    4551            2 :         let none = format!("{:?}", new_entity_id(&json!({})));
    4552            2 :         assert!(none.starts_with("Err(InternalError("), "{none}");
    4553            2 :     }
    4554              : 
    4555              :     #[test]
    4556            2 :     fn deletion_markers() {
    4557            2 :         let n = "urn:ngsi-ld:null";
    4558           18 :         for inst in [
    4559            2 :             json!(n),
    4560            2 :             json!({"value": n}),
    4561            2 :             json!({"object": n}),
    4562            2 :             json!({"languageMap": {"@none": n}}),
    4563            2 :             json!({"languageMap": n}),
    4564            2 :             json!({"json": n}),
    4565            2 :             json!({"vocab": n}),
    4566            2 :             json!({"valueList": [n]}),
    4567            2 :             json!({"objectList": n}),
    4568            2 :         ] {
    4569           18 :             assert!(is_deletion_instance(&inst), "{inst}");
    4570              :         }
    4571            8 :         for inst in [
    4572            2 :             json!({"value": 1}),
    4573            2 :             json!(5),
    4574            2 :             json!({"languageMap": {"en": n, "@none": n}}),
    4575            2 :             json!({"valueList": ["x"]}),
    4576            2 :         ] {
    4577            8 :             assert!(!is_deletion_instance(&inst), "{inst}");
    4578              :         }
    4579            2 :         assert!(check_value_nulls("a", &json!(n), ExpandOpts::default()).is_err());
    4580            2 :         assert!(check_value_nulls("a", &json!([1, n]), ExpandOpts::default()).is_err());
    4581            2 :         assert!(check_value_nulls("a", &json!(n), MERGE).is_ok());
    4582            2 :     }
    4583              : 
    4584              :     #[test]
    4585            2 :     fn nesting_and_instance_arrays() {
    4586            2 :         let mut attr = json!({"type": "Property", "value": 1});
    4587           20 :         for _ in 0..10 {
    4588           20 :             attr = json!({"type": "Property", "value": 1, "s": attr});
    4589           20 :         }
    4590            2 :         refused(with(attr), ExpandOpts::default());
    4591              :         // members without "type" still mark an array element as an instance
    4592            2 :         let out = ok(
    4593            2 :             with(json!([{"value": 1, "datasetId": "urn:d1"}, {"value": 2, "datasetId": "urn:d2"}])),
    4594            2 :             ExpandOpts::default(),
    4595              :         );
    4596            2 :         assert_eq!(out[A].as_array().map(Vec::len), Some(2));
    4597              :         // an array of plain objects is one Property value
    4598            2 :         let out = ok(with(json!([{"x": 1}])), ExpandOpts::default());
    4599            2 :         assert_eq!(out[A].as_array().map(Vec::len), Some(1));
    4600            2 :         let out = ok(with(json!([])), ExpandOpts::default());
    4601            2 :         assert_eq!(out[A][0]["value"], json!([]));
    4602            2 :     }
    4603              : 
    4604              :     #[test]
    4605            2 :     fn instance_members() {
    4606            2 :         let d = ExpandOpts::default();
    4607           12 :         let p = |extra: Value| {
    4608           12 :             let mut o = json!({"type": "Property", "value": 1});
    4609           12 :             o.as_object_mut()
    4610           12 :                 .unwrap()
    4611           12 :                 .extend(extra.as_object().unwrap().clone());
    4612           12 :             with(o)
    4613           12 :         };
    4614            2 :         refused(
    4615            2 :             json!({"id": "urn:x", "type": "T",
    4616            2 :                        "ngsildproof": {"type": "Property", "entityIdSealed": "urn:x"}}),
    4617            2 :             d,
    4618              :         );
    4619            2 :         let out = ok(p(json!({"expiresAt": "2030-01-01T00:00:00Z"})), d);
    4620            2 :         assert_eq!(out[A][0]["expiresAt"], "2030-01-01T00:00:00Z");
    4621            2 :         refused(p(json!({"expiresAt": "soon"})), d);
    4622            2 :         refused(p(json!({"valueType": 5})), d);
    4623            2 :         let sys = ExpandOpts { sys: true, ..d };
    4624            2 :         let out = ok(
    4625            2 :             p(json!({"createdAt": "2020-01-01T00:00:00Z", "modifiedAt": "no", "deletedAt": 1})),
    4626            2 :             sys,
    4627              :         );
    4628            2 :         assert_eq!(out[A][0]["createdAt"], "2020-01-01T00:00:00Z");
    4629            2 :         assert!(out[A][0].get("modifiedAt").is_none());
    4630            2 :         assert!(out[A][0].get("deletedAt").is_none());
    4631              :         // a geometry type without coordinates is no geometry: a Property
    4632            2 :         let out = ok(with(json!({"value": {"type": "Point"}})), d);
    4633            2 :         assert_eq!(out[A][0]["type"], "Property");
    4634            2 :         refused(p(json!({"s": "urn:ngsi-ld:null"})), d);
    4635            2 :         refused(p(json!({"": {"type": "Property", "value": 2}})), d);
    4636            2 :         refused(
    4637            2 :             with(json!({"type": "LanguageProperty", "languageMap": {"en": "urn:ngsi-ld:null"}})),
    4638            2 :             d,
    4639              :         );
    4640            2 :     }
    4641              : 
    4642              :     #[test]
    4643            2 :     fn typed_values() {
    4644            2 :         let d = ExpandOpts::default();
    4645            2 :         let n = "urn:ngsi-ld:null";
    4646            2 :         refused(
    4647            2 :             with(json!({"type": "GeoProperty",
    4648            2 :                         "value": "{\"type\":\"Point\",\"coordinates\":[200,0]}"})),
    4649            2 :             d,
    4650              :         );
    4651            2 :         assert_eq!(
    4652            2 :             ok(with(json!({"type": "GeoProperty", "value": n})), MERGE)[A][0]["value"],
    4653              :             n
    4654              :         );
    4655            2 :         refused(
    4656            2 :             with(json!({"type": "Relationship", "object": "not a uri"})),
    4657            2 :             d,
    4658              :         );
    4659            2 :         refused(with(json!({"type": "Relationship", "object": [5]})), d);
    4660            2 :         refused(with(json!({"type": "Relationship", "object": 5})), d);
    4661            2 :         refused(with(json!({"type": "Relationship", "object": []})), d);
    4662            2 :         assert_eq!(
    4663            2 :             ok(with(json!({"type": "Relationship", "object": n})), MERGE)[A][0]["object"],
    4664              :             n
    4665              :         );
    4666            2 :         assert_eq!(
    4667            2 :             ok(with(json!({"type": "JsonProperty", "json": n})), MERGE)[A][0]["json"],
    4668              :             n
    4669              :         );
    4670              :         // merge inputs that carry no null take the ordinary checks
    4671            2 :         ok(
    4672            2 :             with(
    4673            2 :                 json!({"type": "GeoProperty", "value": "{\"type\":\"Point\",\"coordinates\":[1,2]}"}),
    4674              :             ),
    4675              :             MERGE,
    4676              :         );
    4677            2 :         ok(
    4678            2 :             with(json!({"type": "Relationship", "object": "urn:a"})),
    4679              :             MERGE,
    4680              :         );
    4681            2 :         ok(with(json!({"type": "ListProperty", "valueList": n})), MERGE);
    4682            2 :         refused(with(json!({"type": "ListProperty", "valueList": 5})), MERGE);
    4683            2 :         ok(
    4684            2 :             with(json!({"type": "ListRelationship", "objectList": ["urn:a"]})),
    4685              :             MERGE,
    4686              :         );
    4687            2 :         ok(
    4688            2 :             json!({"id": "urn:x", "type": "T", "ngsildproof": {"type": "Property", "value": n}}),
    4689              :             MERGE,
    4690              :         );
    4691            2 :         refused(
    4692            2 :             json!({"id": "urn:x", "type": "T", "ngsildproof": {"type": "Property", "value": 5}}),
    4693              :             MERGE,
    4694              :         );
    4695            2 :         let point = run(with(json!({"type": "Point"})), d);
    4696            2 :         assert!(point.is_err(), "{point:?}");
    4697            6 :         for list in [
    4698            2 :             json!([5]),
    4699            2 :             json!([{"object": "urn:a", "x": 1}]),
    4700            2 :             json!([{"object": 5}]),
    4701            6 :         ] {
    4702            6 :             refused(
    4703            6 :                 with(json!({"type": "ListRelationship", "objectList": list})),
    4704            6 :                 d,
    4705            6 :             );
    4706            6 :         }
    4707            2 :     }
    4708              : 
    4709              :     #[test]
    4710            2 :     fn attribute_fragments() {
    4711            2 :         let ctx = std::sync::Arc::new(crate::core_context());
    4712           10 :         let frag = |v: Value| expand_attr_fragment(v.as_object().expect("obj"), &ctx);
    4713            2 :         assert!(frag(json!({"type": "Foo"})).is_err());
    4714            2 :         assert!(frag(json!({"value": null})).is_err());
    4715            2 :         assert!(frag(json!({"value": {"x": "urn:ngsi-ld:null"}})).is_err());
    4716            2 :         let out = frag(json!({"datasetId": "@none", "unitCode": "C",
    4717            2 :                               "s": {"type": "Property", "value": 1}}))
    4718            2 :         .expect("fragment");
    4719            2 :         assert!(out.get("datasetId").is_none());
    4720            2 :         assert_eq!(out["unitCode"], "C");
    4721            2 :         assert_eq!(
    4722            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/s"][0]["value"],
    4723              :             1
    4724              :         );
    4725            2 :         assert!(frag(json!({"s": {"type": "Property", "value": 1},
    4726            2 :                             "https://uri.etsi.org/ngsi-ld/default-context/s":
    4727            2 :                                 {"type": "Property", "value": 2}}))
    4728            2 :         .is_err());
    4729            2 :     }
    4730              : 
    4731              :     /// 5.5.7 + 4.5.3/4.5.22: a partial-update fragment's reserved members
    4732              :     /// obey the rules a full instance's do — a Relationship target is a
    4733              :     /// URI, an objectList holds the bare-URI internal form, vocab and
    4734              :     /// objectType are expanded — or one PATCH stores what a create refuses.
    4735              :     #[test]
    4736            2 :     fn a_fragment_holds_reserved_members_to_the_instance_rules() {
    4737            2 :         let ctx = std::sync::Arc::new(crate::core_context());
    4738           52 :         let frag = |v: Value| expand_attr_fragment(v.as_object().expect("obj"), &ctx);
    4739           20 :         for bad in [
    4740            2 :             json!({"object": "not a uri"}),
    4741            2 :             json!({"object": 5}),
    4742            2 :             json!({"unitCode": 7}),
    4743            2 :             json!({"lang": ["fr"]}),
    4744            2 :             json!({"objectList": [5]}),
    4745            2 :             json!({"objectList": "urn:a"}),
    4746            2 :             json!({"valueList": 5}),
    4747            2 :             json!({"vocab": 5}),
    4748            2 :             json!({"value": {"@value": 1, "x": 2}}),
    4749            2 :             json!({"value": {"a": {"@context": "https://e/c"}}}),
    4750            2 :         ] {
    4751           20 :             assert!(frag(bad.clone()).is_err(), "{bad}");
    4752              :         }
    4753            2 :         let out = frag(json!({"vocab": "non-commercial"})).expect("vocab");
    4754            2 :         assert_eq!(
    4755            2 :             out["vocab"],
    4756              :             "https://uri.etsi.org/ngsi-ld/default-context/non-commercial"
    4757              :         );
    4758            2 :         let out = frag(json!({"objectList": [{"object": "urn:a"}, "urn:b"]})).expect("list");
    4759            2 :         assert_eq!(out["objectList"], json!(["urn:a", "urn:b"]));
    4760            2 :         let out = frag(json!({"objectType": "Parking", "object": "urn:a"})).expect("rel");
    4761            2 :         assert_eq!(
    4762            2 :             out["objectType"],
    4763              :             "https://uri.etsi.org/ngsi-ld/default-context/Parking"
    4764              :         );
    4765              :         // the deletion forms still remove the member (10.2.5.4)
    4766            4 :         for null in [json!("urn:ngsi-ld:null"), Value::Null] {
    4767           24 :             for k in ["object", "unitCode", "lang", "vocab", "objectType", "json"] {
    4768           24 :                 let one = Value::Object(Map::from_iter([(k.to_owned(), null.clone())]));
    4769           24 :                 let out = frag(one).unwrap_or_else(|e| panic!("{k}: {e:?}"));
    4770           24 :                 assert_eq!(out[k], null, "{k}");
    4771              :             }
    4772              :         }
    4773            2 :         let out = frag(json!({"objectList": ["urn:ngsi-ld:null"]})).expect("list deletion");
    4774            2 :         assert_eq!(out["objectList"], json!(["urn:ngsi-ld:null"]));
    4775            2 :     }
    4776              : 
    4777              :     #[test]
    4778            2 :     fn geometry_shapes() {
    4779            2 :         assert!(check_position(&json!([1, 2, "x"])).is_err());
    4780            2 :         assert!(check_ring(&json!([[0, 0], [1, 0], [0, 1], [1, 1]])).is_err());
    4781            8 :         for t in ["MultiPoint", "MultiLineString", "Polygon", "MultiPolygon"] {
    4782            8 :             assert!(check_geometry(t, &json!(5)).is_err(), "{t}");
    4783              :         }
    4784            2 :         assert!(check_geometry("MultiPolygon", &json!([5])).is_err());
    4785            2 :         assert!(check_geometry("Foo", &json!([0, 0])).is_err());
    4786            2 :     }
    4787              : 
    4788              :     #[test]
    4789            2 :     fn datetimes() {
    4790            2 :         assert!(!parse_datetime("2020Z"));
    4791            2 :         assert!(!parse_datetime("2020-01-0xT00:00:00Z"));
    4792            2 :         assert!(parse_datetime("2020-01-01T00:00:00Z"));
    4793            2 :     }
    4794              : }
    4795              : 
    4796              : #[cfg(test)]
    4797              : mod null_member_tests {
    4798              :     use super::*;
    4799              :     use serde_json::json;
    4800              : 
    4801              :     const SPEED: &str = "https://uri.etsi.org/ngsi-ld/default-context/speed";
    4802              : 
    4803              :     /// 5.5.12, 10.2.5.4: what removes a member from the patched instance.
    4804              :     #[test]
    4805            2 :     fn removes_member_is_null_the_ngsi_null_or_only_deletion_instances() {
    4806            2 :         assert!(removes_member(&Value::Null));
    4807            2 :         assert!(removes_member(&json!("urn:ngsi-ld:null")));
    4808            2 :         assert!(removes_member(&json!([
    4809            2 :             {"type": "Relationship", "object": "urn:ngsi-ld:null"},
    4810            2 :             "urn:ngsi-ld:null"
    4811            2 :         ])));
    4812              :         // an empty list deletes nothing, one live instance keeps the member
    4813            2 :         assert!(!removes_member(&json!([])));
    4814            2 :         assert!(!removes_member(&json!([
    4815            2 :             {"type": "Property", "value": "urn:ngsi-ld:null"},
    4816            2 :             {"type": "Property", "value": 1}
    4817            2 :         ])));
    4818            2 :         assert!(!removes_member(&json!({"type": "Property", "value": 1})));
    4819            2 :         assert!(!removes_member(&json!("urn:ngsi-ld:Thing:1")));
    4820            2 :     }
    4821              : 
    4822              :     /// 10.2.5.4 / 10.2.9.4: observedAt and expiresAt set to the Null are kept as the deletion
    4823              :     /// marker in a partial update or merge input, and stay invalid DateTimes on create.
    4824              :     #[test]
    4825            2 :     fn observed_at_and_expires_at_null_only_in_null_allowing_inputs() {
    4826            2 :         let ctx = crate::core_context();
    4827            2 :         let doc = json!({"id": "urn:x", "type": "T",
    4828            2 :             "speed": {"type": "Property", "value": 1,
    4829            2 :                       "observedAt": "urn:ngsi-ld:null", "expiresAt": "urn:ngsi-ld:null"}});
    4830            2 :         let doc = doc.as_object().unwrap();
    4831            2 :         assert!(
    4832            2 :             expand_entity(doc, &ctx, ExpandOpts::default()).is_err(),
    4833              :             "create refuses the Null"
    4834              :         );
    4835            4 :         for opts in [
    4836            2 :             ExpandOpts {
    4837            2 :                 fragment: true,
    4838            2 :                 allow_null: true,
    4839            2 :                 ..Default::default()
    4840            2 :             },
    4841            2 :             ExpandOpts {
    4842            2 :                 fragment: true,
    4843            2 :                 merge: true,
    4844            2 :                 ..Default::default()
    4845            2 :             },
    4846            2 :         ] {
    4847            4 :             let out = expand_entity(doc, &ctx, opts).expect("the Null is a deletion marker here");
    4848            4 :             assert_eq!(out[SPEED][0]["observedAt"], "urn:ngsi-ld:null", "{opts:?}");
    4849            4 :             assert_eq!(out[SPEED][0]["expiresAt"], "urn:ngsi-ld:null", "{opts:?}");
    4850              :         }
    4851              :         // a real DateTime passes through, anything else is still refused under allow_null
    4852            2 :         let opts = ExpandOpts {
    4853            2 :             fragment: true,
    4854            2 :             allow_null: true,
    4855            2 :             ..Default::default()
    4856            2 :         };
    4857            2 :         let ok = json!({"speed": {"type": "Property", "value": 1, "observedAt": "2026-10-15T00:00:00Z"}});
    4858            2 :         let out = expand_entity(ok.as_object().unwrap(), &ctx, opts).expect("DateTime accepted");
    4859            2 :         assert_eq!(out[SPEED][0]["observedAt"], "2026-10-15T00:00:00Z");
    4860            4 :         for k in ["observedAt", "expiresAt"] {
    4861            4 :             let bad = json!({"speed": {"type": "Property", "value": 1, k: "urn:ngsi-ld:Thing:1"}});
    4862            4 :             assert!(
    4863            4 :                 expand_entity(bad.as_object().unwrap(), &ctx, opts).is_err(),
    4864              :                 "{k}"
    4865              :             );
    4866              :         }
    4867            2 :     }
    4868              : 
    4869              :     /// 10.2.5.4 on the attribute path (5.6.4): the Null is kept for the patch to remove.
    4870              :     #[test]
    4871            2 :     fn attr_fragment_keeps_a_null_observed_at_and_expires_at() {
    4872            2 :         let ctx = crate::core_context();
    4873            2 :         let frag = json!({"type": "Property", "value": 1,
    4874            2 :             "observedAt": "urn:ngsi-ld:null", "expiresAt": "urn:ngsi-ld:null"});
    4875            2 :         let out = expand_attr_fragment(frag.as_object().unwrap(), &ctx).expect("Null accepted");
    4876            2 :         assert_eq!(out["observedAt"], "urn:ngsi-ld:null");
    4877            2 :         assert_eq!(out["expiresAt"], "urn:ngsi-ld:null");
    4878            2 :         let bad = json!({"type": "Property", "value": 1, "observedAt": "not a date"});
    4879            2 :         assert!(expand_attr_fragment(bad.as_object().unwrap(), &ctx).is_err());
    4880            2 :     }
    4881              : }
        

Generated by: LCOV version 2.0-1