LCOV - code coverage report
Current view: top level - antares-jsonld/src - expand.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.2 % 2419 2326
Test Date: 2026-09-21 10:31:06 Functions: 84.9 % 331 281

            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        18922 : pub fn reject_first_level_nulls(doc: &Map<String, Value>) -> Result<(), NgsiError> {
     148        70946 :     for (k, v) in doc {
     149        70946 :         if v.as_str() == Some("urn:ngsi-ld:null") {
     150           22 :             return Err(NgsiError::BadRequestData(format!(
     151           22 :                 "member {k}: \"urn:ngsi-ld:null\" is only allowed in partial \
     152           22 :                  update or merge fragments (5.5.4)"
     153           22 :             )));
     154        70924 :         }
     155              :     }
     156        18900 :     Ok(())
     157        18922 : }
     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        45910 : fn has_object_member_null(v: &Value) -> bool {
     162        45910 :     match v {
     163          328 :         Value::Object(m) => m
     164          328 :             .values()
     165          612 :             .any(|x| x.as_str() == Some("urn:ngsi-ld:null") || has_object_member_null(x)),
     166          426 :         Value::Array(a) => a.iter().any(has_object_member_null),
     167        45156 :         _ => false,
     168              :     }
     169        45910 : }
     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        14804 : pub fn expand_entity(
     174        14804 :     doc: &Map<String, Value>,
     175        14804 :     ctx: &Context,
     176        14804 :     opts: ExpandOpts,
     177        14804 : ) -> Result<Value, NgsiError> {
     178        14804 :     let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
     179        14804 :     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        14804 :     if !opts.allow_null {
     184        14266 :         reject_first_level_nulls(doc)?;
     185          538 :     }
     186              : 
     187              :     // id
     188        14790 :     match doc.get("id").or_else(|| doc.get("@id")) {
     189        14450 :         Some(Value::String(id)) => {
     190        14450 :             antares_model::EntityId::new(id)?;
     191        14440 :             out.insert("id".into(), Value::String(id.clone()));
     192              :         }
     193            0 :         Some(_) => return Err(bad("entity id must be a string URI")),
     194          338 :         None if opts.fragment => {}
     195            2 :         None => return Err(bad("entity id is required")),
     196              :     }
     197              : 
     198              :     // type
     199        14778 :     match doc.get("type").or_else(|| doc.get("@type")) {
     200        14442 :         Some(v) => {
     201        14442 :             let types = expand_types(v, ctx)?;
     202        14432 :             out.insert("type".into(), Value::Array(types));
     203              :         }
     204          334 :         None if opts.fragment => {}
     205            2 :         None => return Err(bad("entity type is required")),
     206              :     }
     207              : 
     208              :     // scope
     209        14766 :     if let Some(v) = doc.get("scope") {
     210              :         // 4.18: "urn:ngsi-ld:null" shall ONLY appear for deleted scopes —
     211              :         // creatable solely on null-allowing (merge/patch) inputs.
     212         1044 :         let valid_scope = |s: &str| -> bool {
     213         1044 :             if s == "urn:ngsi-ld:null" {
     214           10 :                 return opts.allow_null;
     215         1034 :             }
     216         1034 :             valid_scope_value(s)
     217         1044 :         };
     218         1016 :         let scopes: Vec<Value> = match v {
     219          954 :             Value::String(s) => vec![Value::String(s.clone())],
     220           62 :             Value::Array(a) => {
     221           62 :                 let mut items = Vec::new();
     222           86 :                 for s in a {
     223           56 :                     match s {
     224           30 :                         Value::String(s) => items.push(Value::String(s.clone())),
     225              :                         // 4.5.6: on temporal input the scope is the temporal
     226              :                         // representation of a Property — instance objects
     227              :                         // whose value is a scope string or array thereof.
     228           56 :                         Value::Object(o) if opts.temporal => {
     229           56 :                             let vals: Vec<&str> = match o.get("value") {
     230           52 :                                 Some(Value::String(s)) => vec![s.as_str()],
     231            4 :                                 Some(Value::Array(vs)) if vs.iter().all(Value::is_string) => {
     232            4 :                                     vs.iter().filter_map(Value::as_str).collect()
     233              :                                 }
     234            0 :                                 _ => return Err(bad("scope instance needs a string value")),
     235              :                             };
     236           60 :                             for sv in vals {
     237           60 :                                 if !valid_scope(sv) {
     238            0 :                                     return Err(bad(&format!(
     239            0 :                                         "invalid scope {sv:?} (4.18 grammar)"
     240            0 :                                     )));
     241           60 :                                 }
     242              :                             }
     243           56 :                             items.push(s.clone());
     244              :                         }
     245            0 :                         _ => return Err(bad("scope entries must be strings")),
     246              :                     }
     247              :                 }
     248           62 :                 items
     249              :             }
     250            0 :             _ => return Err(bad("scope must be a string or array of strings")),
     251              :         };
     252         1016 :         for s in scopes.iter().filter_map(Value::as_str) {
     253          984 :             if !valid_scope(s) {
     254           32 :                 return Err(bad(&format!("invalid scope {s:?} (4.18 grammar)")));
     255          952 :             }
     256              :         }
     257              :         // The sentinel deletes the whole scope member (5.5.12), so it is the
     258              :         // whole value or it is not there. Mixed with real scopes it would be
     259              :         // stored as one of them, and 4.18 has no scope that spells it.
     260          984 :         if scopes.len() > 1 && scopes.iter().any(is_ngsi_null) {
     261            2 :             return Err(bad(
     262            2 :                 "\"urn:ngsi-ld:null\" is the whole scope or none of it (4.18, 5.5.12)",
     263            2 :             ));
     264          982 :         }
     265          982 :         out.insert("scope".into(), Value::Array(scopes));
     266        13750 :     }
     267              : 
     268              :     // expiresAt (4.22 transient storage): the one client-settable temporal
     269              :     // meta member. Keep it as a bare top-level DateTime string — the shape the
     270              :     // read-boundary filter (filter::expired_at), the GC sweep, the postgres
     271              :     // `expires_at` column extraction and temporal `meta_of` all expect. Missing
     272              :     // this made 4.22 dead code on every backend.
     273        14732 :     if let Some(v) = doc.get("expiresAt") {
     274              :         // In a merge/partial FRAGMENT an NGSI-LD Null asks for the expiry's
     275              :         // removal (5.5.12) — pass it through for merge_into to act on. 5.5.4
     276              :         // limits the marker to those fragments, so on a whole-Entity input it
     277              :         // stays BadRequestData: a temporal import allows nulls for its 4.5.7
     278              :         // tombstones, and storing the marker as a lifetime there poisons every
     279              :         // later read of the tenant.
     280           60 :         let is_null_removal = opts.allow_null
     281           14 :             && (opts.fragment || opts.merge)
     282            6 :             && v.as_str() == Some("urn:ngsi-ld:null");
     283           60 :         let s = v
     284           60 :             .as_str()
     285           60 :             .filter(|s| is_null_removal || parse_datetime(s))
     286           60 :             .ok_or_else(|| bad("expiresAt must be an ISO 8601 DateTime"))?;
     287           42 :         out.insert("expiresAt".into(), Value::String(s.to_owned()));
     288        14672 :     }
     289              : 
     290              :     // 4.8: with sys expansion the ENTITY-level system timestamps survive —
     291              :     // a federated import (5.7.2.4 forwards request options=sysAttrs) must
     292              :     // keep the remote system's createdAt/modifiedAt/deletedAt rather than
     293              :     // dropping them (they are re-stamped only on local writes).
     294        14714 :     if opts.sys {
     295         1512 :         for k in ["createdAt", "modifiedAt", "deletedAt"] {
     296         1512 :             if let Some(Value::String(ts)) = doc.get(k) {
     297          832 :                 if parse_datetime(ts) {
     298          832 :                     out.insert(k.to_owned(), Value::String(ts.clone()));
     299          832 :                 }
     300          680 :             }
     301              :         }
     302        14210 :     }
     303        50230 :     for (key, v) in doc {
     304        50230 :         match key.as_str() {
     305        50230 :             "id" | "@id" | "type" | "@type" | "@context" | "scope" | "expiresAt" | "createdAt"
     306        30508 :             | "modifiedAt" | "deletedAt" => continue,
     307        19722 :             _ => {}
     308              :         }
     309        19722 :         if key.is_empty() {
     310            0 :             return Err(bad("empty attribute name"));
     311        19722 :         }
     312        19722 :         let iri = expand_attr_name(key, ctx)?;
     313              :         // 4.5.1: core non-reified terms shall not be used as Attribute names.
     314        19720 :         if iri
     315        19720 :             .strip_prefix(crate::context::NGSI_LD_BASE)
     316        19720 :             .is_some_and(|t| NON_REIFIED_TERMS.contains(&t))
     317              :         {
     318           18 :             return Err(bad(&format!(
     319           18 :                 "{key} is a core non-reified term and cannot be used as an Attribute name (4.5.1)"
     320           18 :             )));
     321        19702 :         }
     322        19702 :         let instances = expand_attribute(key, v, ctx, opts, 0)?;
     323        19246 :         if GEO_ENTITY_MEMBERS.contains(&iri.as_str()) {
     324          202 :             for inst in &instances {
     325          202 :                 let t = inst.get("type").and_then(Value::as_str);
     326          202 :                 let is_deletion = opts.allow_null && inst.get("value").is_some_and(is_ngsi_null);
     327          202 :                 if t != Some("GeoProperty") && !is_deletion {
     328           10 :                     return Err(bad(&format!("{key} must be a GeoProperty")));
     329          192 :                 }
     330              :             }
     331        19048 :         }
     332        19236 :         if !opts.temporal {
     333        18784 :             validate_dataset_ids(key, &instances)?;
     334          452 :         }
     335              :         // 4.5.5.1: "There can only be one default Attribute instance for an
     336              :         // Attribute with a given Attribute name in any request or response" —
     337              :         // a term and its own expanded IRI are ONE Attribute name, so keeping
     338              :         // the last writer would silently discard the other member's data.
     339        19228 :         if out
     340        19228 :             .insert(iri.clone(), Value::Array(instances.into_iter().collect()))
     341        19228 :             .is_some()
     342              :         {
     343            2 :             return Err(bad(&format!(
     344            2 :                 "attribute {key} expands to {iri}, which another member of \
     345            2 :                  this Entity already defines (4.5.5.1)"
     346            2 :             )));
     347        19226 :         }
     348              :     }
     349        14218 :     Ok(Value::Object(out))
     350        14804 : }
     351              : 
     352              : /// Expand a `type` member (string or array) to absolute IRIs; a name that
     353              : /// does not expand to one is `BadRequestData` (5.5.4, 4.6.2).
     354        14442 : pub fn expand_types(v: &Value, ctx: &Context) -> Result<Vec<Value>, NgsiError> {
     355        14442 :     let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
     356              :     // 5.5.4/4.6.2: an Entity Type must expand to an absolute IRI; a name that
     357              :     // is a JSON-LD-keyword alias in the @context (e.g. "type" → "@type") is
     358              :     // invalid (001_02_04).
     359        14454 :     let one = |t: &str| -> Result<Value, NgsiError> {
     360              :         // 4.6.2: Entity Type names obey the name grammar (BadRequestData).
     361        14454 :         if !valid_name(t) {
     362            4 :             return Err(bad(&format!(
     363            4 :                 "entity type {t:?} violates the 4.6.2 name grammar"
     364            4 :             )));
     365        14450 :         }
     366        14450 :         let iri = ctx.expand_key(t);
     367        14450 :         if crate::context::is_absolute_iri(&iri) {
     368        14446 :             Ok(Value::String(iri))
     369              :         } else {
     370            4 :             Err(bad(&format!("entity type {t:?} does not expand to an IRI")))
     371              :         }
     372        14454 :     };
     373        14426 :     match v {
     374        14426 :         Value::String(t) if !t.is_empty() => Ok(vec![one(t)?]),
     375           14 :         Value::Array(a) if !a.is_empty() => {
     376           14 :             let mut out = Vec::new();
     377           28 :             for t in a {
     378           28 :                 match t {
     379           28 :                     Value::String(t) if !t.is_empty() => out.push(one(t)?),
     380            0 :                     _ => return Err(bad("entity type entries must be non-empty strings")),
     381              :                 }
     382              :             }
     383           12 :             Ok(out)
     384              :         }
     385            2 :         _ => Err(bad("entity type must be a non-empty string or array")),
     386              :     }
     387        14442 : }
     388              : 
     389              : /// An expanded document as the object it is. `expand_entity` and
     390              : /// `expand_attr_fragment` both return a JSON object, so a value that is not
     391              : /// one did not come from them: the caller wired the wrong value in, and the
     392              : /// mistake stays inside the one request instead of taking the process down.
     393          218 : pub fn expanded_object(v: &Value) -> Result<&serde_json::Map<String, Value>, NgsiError> {
     394          218 :     v.as_object()
     395          218 :         .ok_or_else(|| NgsiError::InternalError("expanded document is not a JSON object".into()))
     396          218 : }
     397              : 
     398              : /// The `id` of an expanded Entity. `expand_entity` validates it as a URI
     399              : /// before it returns, so the same rule as `expanded_object` applies.
     400        13008 : pub fn expanded_id(v: &Value) -> Result<&str, NgsiError> {
     401        13008 :     v.get("id")
     402        13008 :         .and_then(Value::as_str)
     403        13008 :         .ok_or_else(|| NgsiError::InternalError("expanded entity carries no id".into()))
     404        13008 : }
     405              : 
     406              : /// 4.5.1/5.5.4: an Attribute or sub-Attribute name shall expand to an
     407              : /// absolute IRI. A user @context is merged before the Core one (4.4), so a
     408              : /// term defined as `{"@id": "id"}` stays RELATIVE and would otherwise land
     409              : /// on a reserved member (`id`, `value`, `datasetId`, `observedAt`, …) and
     410              : /// overwrite it — skipping that member's own validation. Same rule
     411              : /// expand_types applies to Entity Type names.
     412              : ///
     413              : /// Public because the Attribute name also arrives in a URL path (5.6.4,
     414              : /// 5.6.5, 5.6.19, 5.6.13, 5.6.14), where the clauses require the same
     415              : /// "fully qualified name (URI)" from the same 5.5.7 expansion. A path name
     416              : /// that skips this check lands on a member of the stored document that is
     417              : /// not an Attribute.
     418        20178 : pub fn expand_attr_name(name: &str, ctx: &Context) -> Result<String, NgsiError> {
     419        20178 :     let iri = ctx.expand_key(name);
     420        20178 :     if crate::context::is_absolute_iri(&iri) {
     421        20138 :         Ok(iri)
     422              :     } else {
     423           40 :         Err(NgsiError::BadRequestData(format!(
     424           40 :             "attribute name {name:?} does not expand to an absolute IRI"
     425           40 :         )))
     426              :     }
     427        20178 : }
     428              : 
     429              : /// Table 5.2.6-1 `objectType` / Table 5.2.35-1 `vocab`: "String or String[]",
     430              : /// "Both short hand string(s) (type name) or URI(s) are allowed" — every entry
     431              : /// is @vocab-coerced against the request @context, so the short and the
     432              : /// expanded spelling of one target type cannot be stored differently.
     433           52 : fn expand_terms(name: &str, member: &str, v: &Value, ctx: &Context) -> Result<Value, NgsiError> {
     434           52 :     let bad = || NgsiError::BadRequestData(format!("attribute {name}: invalid {member}"));
     435           52 :     match v {
     436           34 :         Value::String(s) => Ok(Value::String(ctx.expand_key(s))),
     437           10 :         Value::Array(a) => Ok(Value::Array(
     438           10 :             a.iter()
     439           16 :                 .map(|s| {
     440           16 :                     s.as_str()
     441           16 :                         .map(|s| Value::String(ctx.expand_key(s)))
     442           16 :                         .ok_or_else(bad)
     443           16 :                 })
     444           10 :                 .collect::<Result<_, _>>()?,
     445              :         )),
     446            8 :         _ => Err(bad()),
     447              :     }
     448           52 : }
     449              : 
     450              : /// 4.5.5.1: a datasetId is a URI string; "datasetId": "@none" designates the
     451              : /// default Attribute instance, which never carries one — normalized to
     452              : /// absent (`Ok(None)`) so storage, matching and responses treat it as such.
     453              : /// 5.5.8/5.5.12: "A datasetId cannot be deleted by setting it to the value
     454              : /// urn:ngsi-ld:null" — rejected on every input.
     455        32100 : fn dataset_id_member(d: &Value) -> Result<Option<Value>, NgsiError> {
     456        32100 :     let bad = |m: &str| NgsiError::BadRequestData(m.to_owned());
     457        32100 :     let s = d.as_str().ok_or_else(|| bad("datasetId must be a URI"))?;
     458        32092 :     if s == "urn:ngsi-ld:null" {
     459           12 :         return Err(bad(
     460           12 :             "a datasetId cannot be set or deleted via \"urn:ngsi-ld:null\" (5.5.8)",
     461           12 :         ));
     462        32080 :     }
     463        32080 :     if s == "@none" {
     464            4 :         return Ok(None);
     465        32076 :     }
     466        32076 :     antares_model::EntityId::new(s).map_err(|_| bad("datasetId must be a URI"))?;
     467        32072 :     Ok(Some(d.clone()))
     468        32100 : }
     469              : 
     470              : /// 5.2.1: "In all other cases, implementations shall raise an error of type
     471              : /// BadRequestData if an NGSI-LD Null value is encountered"; 5.5.4 bans it as
     472              : /// the value of a key-value pair inside a Property's compound value except
     473              : /// in merge fragments. The concise forms hand the client's JSON back as the
     474              : /// Property value unchanged, so they carry the same two checks as the
     475              : /// normalized path.
     476          158 : fn check_value_nulls(name: &str, val: &Value, opts: ExpandOpts) -> Result<(), NgsiError> {
     477          158 :     let bad = NgsiError::BadRequestData;
     478          158 :     let nullish = match val {
     479           70 :         Value::String(s) => s == "urn:ngsi-ld:null",
     480            2 :         Value::Array(a) => a.iter().any(is_ngsi_null),
     481           86 :         _ => false,
     482              :     };
     483          158 :     if !opts.allow_null && nullish {
     484            2 :         return Err(bad(format!(
     485            2 :             "attribute {name}: the NGSI-LD Null is only allowed in \
     486            2 :              partial update or merge inputs (5.2.1)"
     487            2 :         )));
     488          156 :     }
     489          156 :     if !opts.merge && has_object_member_null(val) {
     490            2 :         return Err(bad(format!(
     491            2 :             "attribute {name}: \"urn:ngsi-ld:null\" inside a compound value \
     492            2 :              is only allowed in merge fragments (5.5.4)"
     493            2 :         )));
     494          154 :     }
     495          154 :     Ok(())
     496          158 : }
     497              : 
     498              : /// The NGSI-LD null sentinel — ONLY the string form (a plain JSON null is
     499              : /// invalid data, 057_03_02).
     500        52838 : pub fn is_ngsi_null(v: &Value) -> bool {
     501          292 :     matches!(v, Value::String(s) if s == "urn:ngsi-ld:null")
     502        52838 : }
     503              : 
     504              : /// A LanguageProperty deletion carries `{"@none": "urn:ngsi-ld:null"}`.
     505          116 : pub fn is_ngsi_null_langmap(v: &Value) -> bool {
     506          116 :     is_ngsi_null(v)
     507          116 :         || v.as_object()
     508          116 :             .is_some_and(|m| m.len() == 1 && m.get("@none").is_some_and(is_ngsi_null))
     509          116 : }
     510              : 
     511              : /// 4.5.21.2/4.5.22.2: a List deletion is "an array consisting of a single
     512              : /// NGSI-LD Null" as the valueList/objectList (bare null tolerated too).
     513           20 : pub fn is_ngsi_null_list(v: &Value) -> bool {
     514           20 :     is_ngsi_null(v)
     515           20 :         || v.as_array()
     516           20 :             .is_some_and(|a| a.len() == 1 && is_ngsi_null(&a[0]))
     517           20 : }
     518              : 
     519              : /// Whole-instance deletion marker (merge patch, 5.5.12).
     520          208 : pub fn is_deletion_instance(inst: &Value) -> bool {
     521          208 :     is_ngsi_null(inst)
     522          208 :         || inst.as_object().is_some_and(|o| {
     523          208 :             o.get("value").is_some_and(is_ngsi_null)
     524          196 :                 || o.get("object").is_some_and(is_ngsi_null)
     525          196 :                 || o.get("languageMap").is_some_and(is_ngsi_null_langmap)
     526          196 :                 || o.get("json").is_some_and(is_ngsi_null)
     527          196 :                 || o.get("vocab").is_some_and(is_ngsi_null)
     528          196 :                 || o.get("valueList").is_some_and(is_ngsi_null_list)
     529          196 :                 || o.get("objectList").is_some_and(is_ngsi_null_list)
     530          208 :         })
     531          208 : }
     532              : 
     533              : /// 4.18 Scope grammar: [/] ScopeLevel *(/ScopeLevel), ScopeLevel =
     534              : /// unicodeLetter *(letter/digit/_) — shared by entity scopes and the 5.2.9
     535              : /// registration scope member.
     536         1062 : pub fn valid_scope_value(s: &str) -> bool {
     537         1062 :     let body = s.strip_prefix('/').unwrap_or(s);
     538         1062 :     !body.is_empty()
     539         2004 :         && body.split('/').all(|level| {
     540         2004 :             let mut ch = level.chars();
     541         2004 :             ch.next().is_some_and(char::is_alphabetic)
     542         6700 :                 && ch.all(|c| c.is_alphabetic() || c.is_numeric() || c == '_')
     543         2004 :         })
     544         1062 : }
     545              : 
     546              : /// 4.6.2 Supported names: `name = unicodeLetter *(unicodeLetter /
     547              : /// unicodeNumber / "_")`. A key containing ':' is a compact or absolute IRI
     548              : /// (the spec's prefix:name production) and is outside the term grammar.
     549              : // Known ceiling: colon-keys are exempt wholesale — a malformed "pre fix:x" slips
     550              : // through as an IRI; tighten to per-part validation if it ever matters.
     551        34258 : pub(crate) fn valid_name(s: &str) -> bool {
     552        34258 :     if s.contains(':') {
     553           14 :         return true;
     554        34244 :     }
     555        34244 :     let mut ch = s.chars();
     556        34244 :     ch.next().is_some_and(char::is_alphabetic)
     557       134112 :         && ch.all(|c| c.is_alphabetic() || c.is_numeric() || c == '_')
     558        34258 : }
     559              : 
     560              : /// Expand one attribute's value into a normalized instance list.
     561        19804 : fn expand_attribute(
     562        19804 :     name: &str,
     563        19804 :     v: &Value,
     564        19804 :     ctx: &Context,
     565        19804 :     opts: ExpandOpts,
     566        19804 :     depth: usize,
     567        19804 : ) -> Result<Vec<Value>, NgsiError> {
     568        19804 :     let bad = NgsiError::BadRequestData;
     569        19804 :     if depth > 8 {
     570            0 :         return Err(bad(format!("attribute {name}: nesting too deep")));
     571        19804 :     }
     572              :     // 4.6.2: Property/Relationship names with characters outside the name
     573              :     // grammar raise BadRequestData.
     574        19804 :     if !valid_name(name) {
     575           12 :         return Err(bad(format!(
     576           12 :             "attribute name {name:?} violates the 4.6.2 name grammar"
     577           12 :         )));
     578        19792 :     }
     579          440 :     match v {
     580          440 :         Value::Array(items) if items.iter().all(looks_like_instance) && !items.is_empty() => {
     581          438 :             let mut out = Vec::new();
     582        32632 :             for item in items {
     583        32632 :                 out.push(expand_instance(name, item, ctx, opts, depth)?);
     584              :             }
     585          434 :             Ok(out)
     586              :         }
     587        19354 :         _ => Ok(vec![expand_instance(name, v, ctx, opts, depth)?]),
     588              :     }
     589        19804 : }
     590              : 
     591        32634 : fn looks_like_instance(v: &Value) -> bool {
     592        32634 :     v.as_object().is_some_and(|o| {
     593        32632 :         o.get("type")
     594        32632 :             .and_then(Value::as_str)
     595        32632 :             .is_some_and(|t| ATTR_TYPES.contains(&t))
     596            0 :             || [
     597            0 :                 "value",
     598            0 :                 "object",
     599            0 :                 "languageMap",
     600            0 :                 "vocab",
     601            0 :                 "json",
     602            0 :                 "valueList",
     603            0 :                 "objectList",
     604            0 :             ]
     605            0 :             .iter()
     606            0 :             .any(|k| o.contains_key(*k))
     607        32632 :     })
     608        32634 : }
     609              : 
     610              : /// 4.5.2.2 Prohibited (mirrored by 4.5.3.2 and the 4.5.18-4.5.24
     611              : /// subclasses): an instance "shall never include" the value-defining
     612              : /// member of a DIFFERENT attribute type, nor the output-only members
     613              : /// inline Linked Entity retrieval and showChanges notifications produce.
     614              : /// `entityIdSealed`/`entityTypeSealed` are the one exception the clause
     615              : /// grants, on the `ngsildproof` Property alone, and because they are
     616              : /// reserved members this is also where they are copied out.
     617        51812 : fn check_prohibited_members(
     618        51812 :     name: &str,
     619        51812 :     obj: &Map<String, Value>,
     620        51812 :     attr_type: &str,
     621        51812 :     ctx: &Context,
     622        51812 :     opts: ExpandOpts,
     623        51812 :     out: &mut Map<String, Value>,
     624        51812 : ) -> Result<(), NgsiError> {
     625        51812 :     let bad = NgsiError::BadRequestData;
     626              :     const VALUE_OWNERS: &[(&str, &[&str])] = &[
     627              :         ("value", &["Property", "GeoProperty"]),
     628              :         ("object", &["Relationship"]),
     629              :         ("languageMap", &["LanguageProperty"]),
     630              :         ("json", &["JsonProperty"]),
     631              :         ("vocab", &["VocabProperty"]),
     632              :         ("valueList", &["ListProperty"]),
     633              :         ("objectList", &["ListRelationship"]),
     634              :     ];
     635       362478 :     for (m, owners) in VALUE_OWNERS {
     636       362478 :         if obj.contains_key(*m) && !owners.contains(&attr_type) {
     637           46 :             return Err(bad(format!(
     638           46 :                 "attribute {name}: {m} is not allowed on a {attr_type} (4.5.2.2)"
     639           46 :             )));
     640       362432 :         }
     641              :     }
     642       465832 :     if let Some(m) = OUTPUT_ONLY.iter().find(|m| obj.contains_key(**m)) {
     643           10 :         return Err(bad(format!(
     644           10 :             "attribute {name}: {m} is output-only and not allowed in input (4.5.2.2)"
     645           10 :         )));
     646        51756 :     }
     647              :     // 4.5.2.2/4.5.2.3 grant the only exception there is: "unless the
     648              :     // PROPERTY name is ngsildproof", the member being defined as "a
     649              :     // Property ... with the non-reified subproperties". 4.5.3.2 and
     650              :     // 4.5.3.3 repeat the ban for a Relationship with no exception at
     651              :     // all, so the attribute name alone is not the test.
     652        51756 :     let sealed_ok = attr_type == "Property" && name == "ngsildproof";
     653        51756 :     if !sealed_ok && (obj.contains_key("entityIdSealed") || obj.contains_key("entityTypeSealed")) {
     654           14 :         return Err(bad(format!(
     655           14 :             "attribute {name}: entityIdSealed/entityTypeSealed are only allowed \
     656           14 :              on the ngsildproof Property (4.5.2.2, 4.5.3.2)"
     657           14 :         )));
     658        51742 :     }
     659              :     // 4.5.2.2 / C.11 / annex B: ngsildproof's NON-REIFIED sealed
     660              :     // subproperties — entityIdSealed is a plain string term,
     661              :     // entityTypeSealed is "@type": "@vocab" (it seals the entity type,
     662              :     // so its value expands like a type name). They are reserved
     663              :     // members, so without this explicit copy they silently vanish.
     664        51742 :     if sealed_ok {
     665           12 :         if let Some(v) = obj.get("entityIdSealed") {
     666            8 :             let s = v.as_str().ok_or_else(|| {
     667            4 :                 bad(format!(
     668            4 :                     "attribute {name}: entityIdSealed must be a string (4.5.2.2)"
     669            4 :                 ))
     670            4 :             })?;
     671            4 :             out.insert("entityIdSealed".into(), Value::String(s.to_owned()));
     672            4 :         }
     673            8 :         if let Some(v) = obj.get("entityTypeSealed") {
     674            6 :             let s = v.as_str().ok_or_else(|| {
     675            2 :                 bad(format!(
     676            2 :                     "attribute {name}: entityTypeSealed must be a string (4.5.2.2)"
     677            2 :                 ))
     678            2 :             })?;
     679            4 :             out.insert("entityTypeSealed".into(), Value::String(ctx.expand_key(s)));
     680            2 :         }
     681              :         // "The value of its \"value\" element shall be an object
     682              :         // containing the W3C Data integrity \"proof\" structure"
     683            6 :         if let Some(v) = obj.get("value") {
     684            6 :             if !v.is_object() && !(opts.allow_null && is_ngsi_null(v)) {
     685            2 :                 return Err(bad(format!(
     686            2 :                     "attribute {name}: ngsildproof value shall be an object \
     687            2 :                      containing the W3C proof structure (4.5.2.2)"
     688            2 :                 )));
     689            4 :             }
     690            0 :         }
     691        51730 :     }
     692              :     // 4.5.3.2: "unitCode shall never be present, as Relationships are
     693              :     // unitless." 4.5.18.2/3 and 4.5.20.2/3 extend the prohibition to
     694              :     // LanguageProperty and VocabProperty ("always strings and hence
     695              :     // unitless").
     696              :     // (4.5.24.2/3 add JsonProperty — "raw JSON objects are unitless".)
     697        51734 :     if obj.contains_key("unitCode")
     698           16 :         && matches!(
     699           54 :             attr_type,
     700           54 :             "Relationship"
     701           50 :                 | "ListRelationship"
     702           48 :                 | "LanguageProperty"
     703           46 :                 | "VocabProperty"
     704           42 :                 | "JsonProperty"
     705              :         )
     706              :     {
     707           16 :         return Err(bad(format!(
     708           16 :             "attribute {name}: unitCode is not allowed on a {attr_type}"
     709           16 :         )));
     710        51718 :     }
     711        51718 :     Ok(())
     712        51812 : }
     713              : 
     714              : /// The members Table 5.2.5-1 allows on any Attribute instance beside its
     715              : /// value: the 4.5.5 `datasetId`, the 4.8 temporal members, the system
     716              : /// attributes, and `unitCode`, `valueType`, `lang` and `objectType`,
     717              : /// each expanded the way its own subclause defines.
     718        51516 : fn expand_common_members(
     719        51516 :     name: &str,
     720        51516 :     obj: &Map<String, Value>,
     721        51516 :     attr_type: &str,
     722        51516 :     ctx: &Context,
     723        51516 :     opts: ExpandOpts,
     724        51516 :     out: &mut Map<String, Value>,
     725        51516 : ) -> Result<(), NgsiError> {
     726        51516 :     let bad = NgsiError::BadRequestData;
     727        51516 :     if let Some(d) = obj.get("datasetId") {
     728        32082 :         if let Some(d) = dataset_id_member(d).map_err(|e| bad(format!("attribute {name}: {e}")))? {
     729        32070 :             out.insert("datasetId".into(), d);
     730        32070 :         }
     731        19434 :     }
     732        51508 :     if let Some(o) = obj.get("observedAt") {
     733         1038 :         let s = o
     734         1038 :             .as_str()
     735         1038 :             .filter(|s| parse_datetime(s))
     736         1038 :             .ok_or_else(|| bad(format!("attribute {name}: invalid observedAt")))?;
     737         1012 :         out.insert("observedAt".into(), Value::String(s.to_owned()));
     738        50470 :     }
     739        51482 :     if let Some(e) = obj.get("expiresAt") {
     740              :         // 4.22 transient attribute instances carry their own expiresAt.
     741           62 :         let s = e
     742           62 :             .as_str()
     743           62 :             .filter(|s| parse_datetime(s))
     744           62 :             .ok_or_else(|| bad(format!("attribute {name}: invalid expiresAt")))?;
     745           54 :         out.insert("expiresAt".into(), Value::String(s.to_owned()));
     746        51420 :     }
     747        51474 :     if opts.sys {
     748              :         // 4.8/4.5.7: deletedAt marks a deletion instance in a Temporal
     749              :         // Evolution — dropping it here would strip remote tombstones of the
     750              :         // timestamp their deletedAt-window matching needs (5.7.3.4 merge).
     751         1578 :         for k in ["createdAt", "modifiedAt", "deletedAt"] {
     752         1578 :             if let Some(Value::String(s)) = obj.get(k) {
     753          832 :                 if parse_datetime(s) {
     754          832 :                     out.insert(k.into(), Value::String(s.clone()));
     755          832 :                 }
     756          746 :             }
     757              :         }
     758        50948 :     }
     759        51474 :     if let Some(u) = obj.get("unitCode") {
     760           38 :         if !u.is_string() {
     761            2 :             return Err(bad(format!("attribute {name}: unitCode must be a string")));
     762           36 :         }
     763           36 :         out.insert("unitCode".into(), u.clone());
     764        51436 :     }
     765        51472 :     if let Some(vt) = obj.get("valueType") {
     766              :         // 4.5.2.2: "valueType": a string value which shall be type coerced
     767              :         // into a datatype URI — the non-reified alternative to a native
     768              :         // JSON-LD @type on the Property value.
     769           14 :         let s = vt
     770           14 :             .as_str()
     771           14 :             .ok_or_else(|| bad(format!("attribute {name}: valueType must be a string")))?;
     772              :         // Table 5.2.32-1: on a LanguageProperty valueType "shall be equal
     773              :         // to langString" (the rdf:langString datatype) — kept literal.
     774           14 :         if attr_type == "LanguageProperty" {
     775           12 :             if s != "langString" {
     776            6 :                 return Err(bad(format!(
     777            6 :                     "attribute {name}: valueType shall be \"langString\" on a LanguageProperty"
     778            6 :                 )));
     779            6 :             }
     780            6 :             out.insert("valueType".into(), vt.clone());
     781            2 :         } else {
     782            2 :             out.insert("valueType".into(), Value::String(ctx.expand_key(s)));
     783            2 :         }
     784        51458 :     }
     785        51466 :     if let Some(l) = obj.get("lang") {
     786              :         // 4.15: the language filter augments the converted Property with "an
     787              :         // additional non-reified subproperty lang indicating the actual
     788              :         // language returned" — a langtag. The member is broker-produced and
     789              :         // the clause says nothing about a client supplying one, so it is
     790              :         // kept; a non-string would leave the instance in a shape no reader
     791              :         // of 4.15 can interpret.
     792           10 :         let s = l
     793           10 :             .as_str()
     794           10 :             .ok_or_else(|| bad(format!("attribute {name}: lang must be a language tag")))?;
     795            2 :         out.insert("lang".into(), Value::String(s.to_owned()));
     796        51456 :     }
     797        51458 :     if let Some(ot) = obj.get("objectType") {
     798           14 :         out.insert(
     799           14 :             "objectType".into(),
     800           14 :             expand_terms(name, "objectType", ot, ctx)?,
     801              :         );
     802        51444 :     }
     803        51452 :     Ok(())
     804        51516 : }
     805              : 
     806              : /// Expand a single instance (normalized or concise) to normalized form.
     807              : ///
     808              : /// This is where the 4.2.2 Meta Model's own SHALLs are enforced: "An NGSI-LD
     809              : /// Property shall have a value, stated through hasValue" and "An NGSI-LD
     810              : /// Relationship shall have an object stated through hasObject" — a Property
     811              : /// without `value` (and each specialized property type without its own
     812              : /// value member, 5.2.5/5.2.32/5.2.35–5.2.38) or a Relationship without a
     813              : /// URI `object` is rejected as BadRequestData. "An NGSI-LD Value shall be
     814              : /// either a rdfs:Literal or a node object" — any JSON literal, array or
     815              : /// object is accepted as `value`, a bare JSON `null` is not (4.5.2, the
     816              : /// null sentinel is the string form only).
     817        51986 : fn expand_instance(
     818        51986 :     name: &str,
     819        51986 :     v: &Value,
     820        51986 :     ctx: &Context,
     821        51986 :     opts: ExpandOpts,
     822        51986 :     depth: usize,
     823        51986 : ) -> Result<Value, NgsiError> {
     824        51986 :     let bad = NgsiError::BadRequestData;
     825              : 
     826              :     // NGSI-LD null: attribute deletion marker (merge-patch only).
     827        51986 :     if is_ngsi_null(v) {
     828           14 :         if opts.allow_null {
     829           14 :             return Ok(json!({"type": "Property", "value": "urn:ngsi-ld:null"}));
     830            0 :         }
     831            0 :         return Err(bad(format!("attribute {name}: null is not allowed here")));
     832        51972 :     }
     833              : 
     834        51972 :     let obj = match v {
     835        51898 :         Value::Object(o) => o,
     836              :         // concise: primitive / array value ⇒ Property
     837           74 :         prim => {
     838           74 :             check_value_nulls(name, prim, opts)?;
     839           72 :             return Ok(json!({"type": "Property", "value": prim.clone()}));
     840              :         }
     841              :     };
     842              : 
     843        51898 :     let declared = obj.get("type").and_then(Value::as_str);
     844        51812 :     let attr_type: &str = match declared {
     845        51816 :         Some(t) if ATTR_TYPES.contains(&t) => t,
     846           64 :         Some(t) if GEO_TYPES.contains(&t) && obj.contains_key("coordinates") => {
     847              :             // concise GeoProperty: bare GeoJSON object as the value. 4.7.3
     848              :             // mandates `coordinates` "as defined by the relevant GeoJSON
     849              :             // Geometry", so the concise form is held to the same RFC 7946
     850              :             // restrictions as the verbose one.
     851           62 :             check_value_nulls(name, v, opts)?;
     852           62 :             validate_geojson(name, v)?;
     853           28 :             return Ok(json!({"type": "GeoProperty", "value": v.clone()}));
     854              :         }
     855            2 :         Some(t) => {
     856            2 :             return Err(bad(format!(
     857            2 :                 "attribute {name}: invalid attribute type {t:?}"
     858            2 :             )))
     859              :         }
     860              :         None => {
     861              :             // concise object form — infer from members
     862           82 :             if obj.contains_key("object") {
     863           14 :                 "Relationship"
     864           68 :             } else if obj.contains_key("languageMap") {
     865           10 :                 "LanguageProperty"
     866           58 :             } else if obj.contains_key("vocab") {
     867            4 :                 "VocabProperty"
     868           54 :             } else if obj.contains_key("json") {
     869            4 :                 "JsonProperty"
     870           50 :             } else if obj.contains_key("valueList") {
     871            4 :                 "ListProperty"
     872           46 :             } else if obj.contains_key("objectList") {
     873            2 :                 "ListRelationship"
     874           44 :             } else if obj.contains_key("value") {
     875              :                 // 4.5.2.3: type may be omitted — "Property can be inferred by
     876              :                 // the presence of the value attribute. An exception to this
     877              :                 // inference rule occurs for geospatial Property Values, where
     878              :                 // the GeoProperty sub-type shall be inferred instead, if the
     879              :                 // Property Value resolves to a supported GeoJSON geometry."
     880           22 :                 let v = &obj["value"];
     881           22 :                 if v.get("type")
     882           22 :                     .and_then(Value::as_str)
     883           22 :                     .is_some_and(|t| GEO_TYPES.contains(&t))
     884            6 :                     && v.get("coordinates").is_some()
     885              :                 {
     886            6 :                     "GeoProperty"
     887              :                 } else {
     888           16 :                     "Property"
     889              :                 }
     890              :             } else {
     891              :                 // whole object is a Property value (4.5.2.3)
     892           22 :                 check_value_nulls(name, v, opts)?;
     893           20 :                 return Ok(json!({"type": "Property", "value": v.clone()}));
     894              :             }
     895              :         }
     896              :     };
     897              : 
     898        51812 :     let mut out = Map::new();
     899        51812 :     out.insert("type".into(), Value::String(attr_type.to_owned()));
     900              : 
     901        51812 :     check_prohibited_members(name, obj, attr_type, ctx, opts, &mut out)?;
     902              : 
     903              :     // required member per type
     904        51718 :     match attr_type {
     905        51718 :         "Property" => {
     906        44202 :             let val = obj
     907        44202 :                 .get("value")
     908        44202 :                 .ok_or_else(|| bad(format!("attribute {name}: Property needs value")))?;
     909        44194 :             if val.is_null() {
     910            8 :                 return Err(bad(format!(
     911            8 :                     "attribute {name}: JSON null is not a valid value (use \"urn:ngsi-ld:null\")"
     912            8 :                 )));
     913        44186 :             }
     914        44186 :             out.insert("value".into(), val.clone());
     915              :         }
     916         7516 :         "GeoProperty" => {
     917          282 :             let val = obj
     918          282 :                 .get("value")
     919          282 :                 .ok_or_else(|| bad(format!("attribute {name}: GeoProperty needs value")))?;
     920              :             // 4.7.2: a whole geometry may arrive as an encoded JSON string,
     921              :             // accepted "if and only if" it parses into a valid geometry —
     922              :             // normalized here to the object form so storage, geo-queries and
     923              :             // responses all see one representation.
     924          278 :             let val = match val {
     925           42 :                 Value::String(s) if !(opts.allow_null && is_ngsi_null(val)) => {
     926           42 :                     serde_json::from_str::<Value>(s).map_err(|_| {
     927            2 :                         bad(format!(
     928            2 :                             "attribute {name}: string-encoded geometry is not valid JSON"
     929            2 :                         ))
     930            2 :                     })?
     931              :                 }
     932          238 :                 _ => val.clone(),
     933              :             };
     934          278 :             if !(opts.allow_null && is_ngsi_null(&val)) {
     935          278 :                 validate_geojson(name, &val)?;
     936            0 :             }
     937          188 :             out.insert("value".into(), val);
     938              :         }
     939         7234 :         "Relationship" => {
     940         6978 :             let objv = obj
     941         6978 :                 .get("object")
     942         6978 :                 .ok_or_else(|| bad(format!("attribute {name}: Relationship needs object")))?;
     943            4 :             match objv {
     944         6966 :                 Value::String(s) => {
     945         6966 :                     if !(opts.allow_null && s == "urn:ngsi-ld:null") {
     946         6960 :                         antares_model::EntityId::new(s)
     947         6960 :                             .map_err(|_| bad(format!("attribute {name}: object must be a URI")))?;
     948            6 :                     }
     949              :                 }
     950            4 :                 Value::Array(items) if !items.is_empty() => {
     951            8 :                     for s in items {
     952            8 :                         let s = s.as_str().ok_or_else(|| {
     953            0 :                             bad(format!("attribute {name}: object entries must be URIs"))
     954            0 :                         })?;
     955            8 :                         antares_model::EntityId::new(s)
     956            8 :                             .map_err(|_| bad(format!("attribute {name}: object must be a URI")))?;
     957              :                     }
     958              :                 }
     959            0 :                 _ => return Err(bad(format!("attribute {name}: invalid object"))),
     960              :             }
     961         6964 :             out.insert("object".into(), objv.clone());
     962              :         }
     963          256 :         "LanguageProperty" => {
     964              :             // 4.5.18.2: "a JSON object consisting of a set of non-empty
     965              :             // language tags (RFC 5646) or the language tag "@none"", each
     966              :             // mapping to a single string or array of strings.
     967          124 :             let lm = obj
     968          124 :                 .get("languageMap")
     969          124 :                 .ok_or_else(|| bad(format!("attribute {name}: needs languageMap")))?;
     970              :             // 4.6.5: {"@none": "urn:ngsi-ld:null"} is exclusively the
     971              :             // partial/merge-patch deletion encoding — outside allow_null it
     972              :             // is an NGSI-LD Null in a create and thus BadRequestData.
     973          112 :             let ok = if is_ngsi_null_langmap(lm) {
     974           12 :                 opts.allow_null
     975              :             } else {
     976          100 :                 lm.as_object().is_some_and(|m| {
     977          162 :                     m.keys().all(|k| !k.is_empty())
     978          154 :                         && m.values().all(|v| {
     979          154 :                             v.is_string()
     980           30 :                                 || v.as_array().is_some_and(|a| a.iter().all(Value::is_string))
     981          154 :                         })
     982          100 :                 })
     983              :             };
     984          112 :             if !ok {
     985           30 :                 return Err(bad(format!("attribute {name}: invalid languageMap")));
     986           82 :             }
     987           82 :             out.insert("languageMap".into(), lm.clone());
     988              :         }
     989          132 :         "JsonProperty" => {
     990              :             // 4.5.24.2: json is "a raw JSON object (or array of objects)" —
     991              :             // never expanded or compacted; kept verbatim. The bare NGSI-LD
     992              :             // Null deletion form passes through under allow_null.
     993           34 :             let j = obj
     994           34 :                 .get("json")
     995           34 :                 .ok_or_else(|| bad(format!("attribute {name}: needs json")))?;
     996           30 :             let ok = j.is_object()
     997           10 :                 || j.as_array().is_some_and(|a| a.iter().all(Value::is_object))
     998            6 :                 || (opts.allow_null && is_ngsi_null(j));
     999           30 :             if !ok {
    1000            6 :                 return Err(bad(format!(
    1001            6 :                     "attribute {name}: json must be an object or array of objects"
    1002            6 :                 )));
    1003           24 :             }
    1004           24 :             out.insert("json".into(), j.clone());
    1005              :         }
    1006           98 :         "VocabProperty" => {
    1007           42 :             let vv = obj
    1008           42 :                 .get("vocab")
    1009           42 :                 .ok_or_else(|| bad(format!("attribute {name}: needs vocab")))?;
    1010           38 :             out.insert("vocab".into(), expand_terms(name, "vocab", vv, ctx)?);
    1011              :         }
    1012           56 :         "ListProperty" => {
    1013           26 :             let l = obj
    1014           26 :                 .get("valueList")
    1015           26 :                 .ok_or_else(|| bad(format!("attribute {name}: needs valueList")))?;
    1016           22 :             if !l.is_array() && !(opts.allow_null && is_ngsi_null(l)) {
    1017            2 :                 return Err(bad(format!("attribute {name}: valueList must be an array")));
    1018           20 :             }
    1019           20 :             out.insert("valueList".into(), l.clone());
    1020              :         }
    1021           30 :         "ListRelationship" => {
    1022              :             // 4.5.22.2/4.5.22.3: objectList is an ordered array of
    1023              :             // Relationship objects — {"object": <URI>} objects (normalized)
    1024              :             // or bare URI strings (concise). Internal form is bare URIs; the
    1025              :             // normalized output shape is restored at compaction. The [null]
    1026              :             // deletion form (single NGSI-LD Null) passes through under
    1027              :             // allow_null.
    1028           30 :             let l = obj
    1029           30 :                 .get("objectList")
    1030           30 :                 .ok_or_else(|| bad(format!("attribute {name}: needs objectList")))?;
    1031           24 :             let normalized = match l {
    1032           26 :                 _ if opts.allow_null && is_ngsi_null_list(l) => l.clone(),
    1033           22 :                 Value::Array(items) => {
    1034           22 :                     let mut uris = Vec::with_capacity(items.len());
    1035           38 :                     for it in items {
    1036           38 :                         let uri = match it {
    1037           30 :                             Value::String(s) => s.as_str(),
    1038            8 :                             Value::Object(o)
    1039            8 :                                 if o.len() == 1
    1040            8 :                                     && o.get("object").is_some_and(Value::is_string) =>
    1041              :                             {
    1042            8 :                                 o["object"].as_str().unwrap_or_default()
    1043              :                             }
    1044              :                             _ => {
    1045            0 :                                 return Err(bad(format!(
    1046            0 :                                     "attribute {name}: objectList entries must be URIs \
    1047            0 :                                      or {{\"object\": <URI>}} objects"
    1048            0 :                                 )))
    1049              :                             }
    1050              :                         };
    1051           38 :                         antares_model::EntityId::new(uri).map_err(|_| {
    1052            4 :                             bad(format!("attribute {name}: objectList entry is not a URI"))
    1053            4 :                         })?;
    1054           34 :                         uris.push(Value::String(uri.to_owned()));
    1055              :                     }
    1056           18 :                     Value::Array(uris)
    1057              :                 }
    1058              :                 _ => {
    1059            2 :                     return Err(bad(format!(
    1060            2 :                         "attribute {name}: objectList must be an array"
    1061            2 :                     )))
    1062              :                 }
    1063              :             };
    1064           20 :             out.insert("objectList".into(), normalized);
    1065              :         }
    1066              :         // 4.5.2 closes the set of Attribute types, and `attr_type` is
    1067              :         // either a member of ATTR_TYPES or one of the inferred literals
    1068              :         // above — so every reachable value has an arm. A member added to
    1069              :         // that list without an arm here would arrive as a client-supplied
    1070              :         // `"type"`, which is a request to answer, not a reason to panic:
    1071              :         // pinned by
    1072              :         // `every_declarable_attribute_type_is_dispatched_not_unreachable`.
    1073              :         _ => {
    1074            0 :             return Err(NgsiError::InternalError(format!(
    1075            0 :                 "attribute type {attr_type} has no expansion arm"
    1076            0 :             )))
    1077              :         }
    1078              :     }
    1079              : 
    1080              :     // optional standard members
    1081        51516 :     expand_common_members(name, obj, attr_type, ctx, opts, &mut out)?;
    1082              : 
    1083              :     // sub-attributes
    1084       137010 :     for (k, sub) in obj {
    1085       137010 :         if k == "@context" {
    1086              :             // 4.5.1/5.5.7: "Attributes shall not contain any embedded
    1087              :             // @context" — a nested user context could override core terms,
    1088              :             // so it "should result in an error of type BadRequestData".
    1089            4 :             return Err(bad(format!(
    1090            4 :                 "attribute {name}: embedded @context is not allowed (4.5.1/5.5.7)"
    1091            4 :             )));
    1092       137006 :         }
    1093       137006 :         if RESERVED_MEMBERS.contains(&k.as_str()) {
    1094       136922 :             continue;
    1095           84 :         }
    1096           84 :         if k.is_empty() {
    1097            0 :             return Err(bad(format!("attribute {name}: empty sub-attribute name")));
    1098           84 :         }
    1099           84 :         let iri = expand_attr_name(k, ctx)?;
    1100           82 :         let instances = expand_attribute(k, sub, ctx, opts, depth + 1)?;
    1101              :         // 4.5.5.1 again, one level down: two sub-attribute names expanding to
    1102              :         // one IRI would drop whichever the map orders first.
    1103           72 :         if out.insert(iri.clone(), Value::Array(instances)).is_some() {
    1104            2 :             return Err(bad(format!(
    1105            2 :                 "attribute {name}: sub-attribute {k} expands to {iri}, which \
    1106            2 :                  another member already defines (4.5.5.1)"
    1107            2 :             )));
    1108           70 :         }
    1109              :     }
    1110              : 
    1111              :     // 5.2.1: "In all other cases, implementations shall raise an error of
    1112              :     // type BadRequestData if an NGSI-LD Null value is encountered" — the
    1113              :     // deletion marker is only meaningful on partial-update/merge inputs
    1114              :     // (allow_null). `json` is exempt: raw JSON is never interpreted.
    1115        51434 :     if !opts.allow_null {
    1116        50636 :         let nullish = |v: &Value| match v {
    1117         9490 :             Value::String(s) => s == "urn:ngsi-ld:null",
    1118           92 :             Value::Array(a) => a.iter().any(|x| x.as_str() == Some("urn:ngsi-ld:null")),
    1119        41096 :             _ => false,
    1120        50634 :         };
    1121       253084 :         for k in ["value", "object", "vocab", "valueList", "objectList"] {
    1122       253084 :             if out.get(k).is_some_and(&nullish) {
    1123           26 :                 return Err(bad(format!(
    1124           26 :                     "attribute {name}: the NGSI-LD Null is only allowed in \
    1125           26 :                      partial update or merge inputs (5.2.1)"
    1126           26 :                 )));
    1127       253058 :             }
    1128              :         }
    1129        50610 :         if out
    1130        50610 :             .get("languageMap")
    1131        50610 :             .and_then(Value::as_object)
    1132        50610 :             .is_some_and(|m| m.values().any(nullish))
    1133              :         {
    1134            0 :             return Err(bad(format!(
    1135            0 :                 "attribute {name}: the NGSI-LD Null is only allowed in \
    1136            0 :                  partial update or merge inputs (5.2.1)"
    1137            0 :             )));
    1138        50610 :         }
    1139          798 :     }
    1140              : 
    1141              :     // 5.5.4: "urn:ngsi-ld:null" as the value of a key-value pair within a
    1142              :     // JSON object that is the Property's value is BadRequestData — excepted
    1143              :     // solely for merge fragments (5.5.12). `json` stays exempt (raw JSON).
    1144        51408 :     if !opts.merge && out.get("value").is_some_and(has_object_member_null) {
    1145           10 :         return Err(bad(format!(
    1146           10 :             "attribute {name}: \"urn:ngsi-ld:null\" inside a compound value \
    1147           10 :              is only allowed in merge fragments (5.5.4)"
    1148           10 :         )));
    1149        51398 :     }
    1150              : 
    1151        51398 :     Ok(Value::Object(out))
    1152        51986 : }
    1153              : 
    1154              : /// Expand a PARTIAL-UPDATE attribute fragment (5.6.4): reserved members are
    1155              : /// kept, others become sub-attributes — and crucially NO attribute-type
    1156              : /// inference happens (a fragment `{providedBy: …}` patches the sub-attribute,
    1157              : /// it is not a concise Property value).
    1158          108 : pub fn expand_attr_fragment(obj: &Map<String, Value>, ctx: &Context) -> Result<Value, NgsiError> {
    1159          108 :     let bad = NgsiError::BadRequestData;
    1160          108 :     let mut out = Map::new();
    1161          236 :     for (k, v) in obj {
    1162          236 :         match k.as_str() {
    1163              :             // 5.2.5 Table 5.2.5-2 / 4.5.2.2 System Generated: output-only
    1164              :             // members "shall not be provided by Context Producers. In the
    1165              :             // event that they are provided (in update or create operations)
    1166              :             // NGSI-LD implementations shall ignore them." The sealed
    1167              :             // subproperties are Prohibited outside a full ngsildproof
    1168              :             // instance, which this path cannot identify.
    1169          236 :             "@context" | "createdAt" | "modifiedAt" | "deletedAt" | "instanceId"
    1170          218 :             | "entityIdSealed" | "entityTypeSealed" => continue,
    1171          214 :             _ if OUTPUT_ONLY.contains(&k.as_str()) => continue,
    1172          196 :             "type" => {
    1173           58 :                 let t = v
    1174           58 :                     .as_str()
    1175           58 :                     .filter(|t| ATTR_TYPES.contains(t))
    1176           58 :                     .ok_or_else(|| bad("invalid attribute type in fragment".into()))?;
    1177           58 :                 out.insert("type".into(), Value::String(t.to_owned()));
    1178              :             }
    1179              :             // 4.6.3: both members are ISO 8601 DateTimes (4.8 observedAt,
    1180              :             // 4.22 expiresAt) — the same check the full-instance path runs.
    1181          138 :             "observedAt" | "expiresAt" => {
    1182           14 :                 let sdt = v
    1183           14 :                     .as_str()
    1184           14 :                     .filter(|s| parse_datetime(s))
    1185           14 :                     .ok_or_else(|| bad(format!("invalid {k} in fragment")))?;
    1186           12 :                 out.insert(k.clone(), Value::String(sdt.to_owned()));
    1187              :             }
    1188          124 :             "value" => {
    1189           82 :                 if v.is_null() {
    1190            0 :                     return Err(bad("JSON null is not a valid value".into()));
    1191           82 :                 }
    1192              :                 // 5.5.4: a null inside a compound value is legal in merge
    1193              :                 // fragments only — a partial update (5.5.8) is not one.
    1194           82 :                 if has_object_member_null(v) {
    1195            0 :                     return Err(bad("\"urn:ngsi-ld:null\" inside a compound value is only \
    1196            0 :                          allowed in merge fragments (5.5.4)"
    1197            0 :                         .into()));
    1198           82 :                 }
    1199           82 :                 out.insert("value".into(), v.clone());
    1200              :             }
    1201              :             // 4.5.5.1/5.5.8: the fragment's datasetId selects the instance to
    1202              :             // patch and is copied onto it — it obeys the same URI-string rule
    1203              :             // as a full instance, or the patched instance stops answering to
    1204              :             // the datasetId lookups that keep one default instance per name.
    1205           42 :             "datasetId" => {
    1206           18 :                 if let Some(d) = dataset_id_member(v)? {
    1207            2 :                     out.insert("datasetId".into(), d);
    1208            2 :                 }
    1209              :             }
    1210           24 :             _ if RESERVED_MEMBERS.contains(&k.as_str()) => {
    1211            4 :                 out.insert(k.clone(), v.clone());
    1212            4 :             }
    1213              :             _ => {
    1214           20 :                 let iri = expand_attr_name(k, ctx)?;
    1215           20 :                 let instances = expand_attribute(
    1216           20 :                     k,
    1217           20 :                     v,
    1218           20 :                     ctx,
    1219           20 :                     ExpandOpts {
    1220           20 :                         fragment: true,
    1221           20 :                         allow_null: true,
    1222           20 :                         ..Default::default()
    1223           20 :                     },
    1224              :                     1,
    1225            0 :                 )?;
    1226              :                 // 4.5.5.1: one Attribute name = one member of the fragment.
    1227           20 :                 if out.insert(iri.clone(), Value::Array(instances)).is_some() {
    1228            0 :                     return Err(bad(format!(
    1229            0 :                         "sub-attribute {k} expands to {iri}, which another \
    1230            0 :                          member of this fragment already defines (4.5.5.1)"
    1231            0 :                     )));
    1232           20 :                 }
    1233              :             }
    1234              :         }
    1235              :     }
    1236           90 :     Ok(Value::Object(out))
    1237          108 : }
    1238              : 
    1239              : /// 4.5.5.1: "There can only be one default Attribute instance for an
    1240              : /// Attribute with a given Attribute name in any request or response";
    1241              : /// datasetIds must be distinct per attribute (explicit "@none" is normalized
    1242              : /// to absent before this check, so absent + "@none" counts as two defaults).
    1243        18784 : fn validate_dataset_ids(name: &str, instances: &[Value]) -> Result<(), NgsiError> {
    1244              :     // A set, not a scanned list: the instance count is bounded only by the
    1245              :     // request body, so a linear scan per instance makes the check quadratic
    1246              :     // in what a client sends.
    1247        18784 :     let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
    1248        18784 :     let mut default_count = 0usize;
    1249        38804 :     for inst in instances {
    1250        38804 :         match inst.get("datasetId").and_then(Value::as_str) {
    1251        20046 :             Some(d) => {
    1252        20046 :                 if !seen.insert(d) {
    1253            6 :                     return Err(NgsiError::BadRequestData(format!(
    1254            6 :                         "attribute {name}: duplicate datasetId {d}"
    1255            6 :                     )));
    1256        20040 :                 }
    1257              :             }
    1258        18758 :             None => default_count += 1,
    1259              :         }
    1260              :     }
    1261        18778 :     if default_count > 1 {
    1262            2 :         return Err(NgsiError::BadRequestData(format!(
    1263            2 :             "attribute {name}: more than one instance without datasetId"
    1264            2 :         )));
    1265        18776 :     }
    1266        18776 :     Ok(())
    1267        18784 : }
    1268              : 
    1269              : /// RFC 7946 3.1.1: "A position is an array of numbers. There MUST be two or
    1270              : /// more elements. The first two elements are longitude and latitude \[…\]
    1271              : /// using decimal numbers", read in the coordinate reference system the
    1272              : /// format fixes: "a geographic coordinate reference system, using the World
    1273              : /// Geodetic System 1984 \[…\] datum, with longitude and latitude units of
    1274              : /// decimal degrees" (RFC 7946 4). 4.7.2 adds that the coordinates are "values
    1275              : /// of a JSON-LD floating point number data type".
    1276              : ///
    1277              : /// The range is not decoration: a latitude of 999 reaches PostGIS as a
    1278              : /// `::geography` cast that errors, so a single accepted write would break
    1279              : /// every later `near` query in that tenant.
    1280        10278 : fn check_position(p: &Value) -> Result<(), String> {
    1281        10278 :     let a = p.as_array().ok_or("position is not an array")?;
    1282        10252 :     if a.len() < 2 {
    1283           28 :         return Err(format!("position has {} elements (minimum 2)", a.len()));
    1284        10224 :     }
    1285        20454 :     let mut n = a.iter().map(|c| c.as_f64().filter(|f| f.is_finite()));
    1286        10224 :     let (Some(Some(lon)), Some(Some(lat))) = (n.next(), n.next()) else {
    1287           14 :         return Err("position holds a value that is not a number".into());
    1288              :     };
    1289        10210 :     if n.any(|c| c.is_none()) {
    1290            0 :         return Err("position holds a value that is not a number".into());
    1291        10210 :     }
    1292        10210 :     if !(-180.0..=180.0).contains(&lon) || !(-90.0..=90.0).contains(&lat) {
    1293           34 :         return Err(format!(
    1294           34 :             "position [{lon}, {lat}] is outside the WGS84 range [-180 -90, 180 90]"
    1295           34 :         ));
    1296        10176 :     }
    1297        10176 :     Ok(())
    1298        10278 : }
    1299              : 
    1300              : /// RFC 7946 3.1.4: a LineString is "two or more positions".
    1301           60 : fn check_line(v: &Value) -> Result<(), String> {
    1302           60 :     let a = v.as_array().ok_or("LineString is not an array")?;
    1303           60 :     if a.len() < 2 {
    1304           12 :         return Err(format!("LineString has {} positions (minimum 2)", a.len()));
    1305           48 :     }
    1306           48 :     a.iter().try_for_each(check_position)
    1307           60 : }
    1308              : 
    1309              : /// RFC 7946 3.1.6: a linear ring is "closed \[…\] with four or more positions",
    1310              : /// "the first and last positions \[…\] equivalent".
    1311          576 : fn check_ring(v: &Value) -> Result<(), String> {
    1312          576 :     let a = v.as_array().ok_or("linear ring is not an array")?;
    1313          576 :     if a.len() < 4 {
    1314           38 :         return Err(format!("linear ring has {} positions (minimum 4)", a.len()));
    1315          538 :     }
    1316          538 :     if a.first() != a.last() {
    1317            2 :         return Err("linear ring is not closed (first != last position)".into());
    1318          536 :     }
    1319          536 :     a.iter().try_for_each(check_position)
    1320          576 : }
    1321              : 
    1322          686 : fn each(v: &Value, what: &str, f: impl FnMut(&Value) -> Result<(), String>) -> Result<(), String> {
    1323          686 :     v.as_array()
    1324          686 :         .ok_or_else(|| format!("{what} is not an array"))?
    1325          686 :         .iter()
    1326          686 :         .try_for_each(f)
    1327          686 : }
    1328              : 
    1329              : /// The nesting RFC 7946 3.1 gives each geometry type its `coordinates`.
    1330              : /// Empty multi-geometries are geometries: only the shapes the RFC names a
    1331              : /// minimum for carry one.
    1332         1044 : pub fn check_geometry(gtype: &str, coords: &Value) -> Result<(), String> {
    1333         1044 :     match gtype {
    1334         1044 :         "Point" => check_position(coords),
    1335          318 :         "MultiPoint" => each(coords, "MultiPoint coordinates", check_position),
    1336          280 :         "LineString" => check_line(coords),
    1337          224 :         "MultiLineString" => each(coords, "MultiLineString coordinates", check_line),
    1338          206 :         "Polygon" => each(coords, "Polygon coordinates", check_ring),
    1339          426 :         "MultiPolygon" => each(coords, "MultiPolygon coordinates", |p| {
    1340          426 :             each(p, "MultiPolygon polygon", check_ring)
    1341          426 :         }),
    1342            2 :         _ => Err(format!("{gtype} is not a supported GeoJSON geometry type")),
    1343              :     }
    1344         1044 : }
    1345              : 
    1346              : /// 4.6.3: supported Value geometries are "All the GeoJSON Geometries \[8\]
    1347              : /// with the exception of GeometryCollection" — GEO_TYPES holds exactly that
    1348              : /// set. 4.7.2 accepts a geometry "if and only if \[…\] meeting the syntax and
    1349              : /// restrictions mandated by IETF RFC 7946 \[8\] when representing a valid
    1350              : /// Geometry of the type specified", so the shape of `coordinates` is checked
    1351              : /// against the declared type, not merely for being an array: a geometry that
    1352              : /// is not one must not reach storage, the 4.5.16 GeoJSON rendering path or a
    1353              : /// PostGIS cast.
    1354          370 : pub fn validate_geojson(name: &str, v: &Value) -> Result<(), NgsiError> {
    1355          370 :     let bad = |m: &str| NgsiError::BadRequestData(format!("attribute {name}: {m}"));
    1356          370 :     let shape = v.as_object().and_then(|o| {
    1357          364 :         let t = o.get("type").and_then(Value::as_str)?;
    1358          362 :         GEO_TYPES.contains(&t).then_some((t, o.get("coordinates")?))
    1359          364 :     });
    1360          370 :     let Some((gtype, coords)) = shape else {
    1361           18 :         return Err(bad("value is not a valid GeoJSON geometry"));
    1362              :     };
    1363          352 :     check_geometry(gtype, coords).map_err(|e| bad(&e))
    1364          370 : }
    1365              : 
    1366              : /// ISO 8601 DateTime check — 4.6.3, `YYYY-MM-DDThh:mm:ss[.ffffff]Z`.
    1367              : ///
    1368              : /// The clause is strict in three ways this used to get wrong:
    1369              : /// - "The trailing timestamp component … shall always be equal to the
    1370              : ///   character `Z`. Therefore, all timestamps shall be expressed in UTC" —
    1371              : ///   so `+HH:MM`/`-HH:MM` offsets are INVALID, not an alternative form.
    1372              : /// - "All the referred components shall appear in the string; reduced
    1373              : ///   representations are not permitted" — a bare 19-char form has no zone.
    1374              : /// - "The Seconds component may optionally contain a decimal fraction …
    1375              : ///   up to a maximum of six \[digits\]. … In requests, also a comma instead of a
    1376              : ///   decimal point may be used as separator for compatibility reasons."
    1377              : ///
    1378              : /// Digit-shape alone is not enough: `2026-13-45T00:00:00Z` is all digits in
    1379              : /// the right places, and letting it through let one write make every later
    1380              : /// temporal query in that tenant fail on the `::timestamptz` cast.
    1381         5264 : pub fn parse_datetime(s: &str) -> bool {
    1382         5264 :     let b = s.as_bytes();
    1383              :     // shortest legal form is 19 chars + the mandatory Z
    1384         5264 :     if b.len() < 20 || b.last() != Some(&b'Z') {
    1385          274 :         return false;
    1386         4990 :     }
    1387        29924 :     let digits = |r: std::ops::Range<usize>| b[r].iter().all(u8::is_ascii_digit);
    1388         4990 :     let shape = digits(0..4)
    1389         4988 :         && b[4] == b'-'
    1390         4988 :         && digits(5..7)
    1391         4988 :         && b[7] == b'-'
    1392         4988 :         && digits(8..10)
    1393         4988 :         && b[10] == b'T'
    1394         4986 :         && digits(11..13)
    1395         4986 :         && b[13] == b':'
    1396         4986 :         && digits(14..16)
    1397         4986 :         && b[16] == b':'
    1398         4986 :         && digits(17..19);
    1399         4990 :     if !shape {
    1400            4 :         return false;
    1401         4986 :     }
    1402              :     // between second 19 and the trailing Z: nothing, or a fraction of 1..=6
    1403         4986 :     let frac = &s[19..s.len() - 1];
    1404         4986 :     if !frac.is_empty() {
    1405         1774 :         let Some(rest) = frac.strip_prefix('.').or_else(|| frac.strip_prefix(',')) else {
    1406            2 :             return false;
    1407              :         };
    1408         5306 :         if rest.is_empty() || rest.len() > 6 || !rest.bytes().all(|c| c.is_ascii_digit()) {
    1409            6 :             return false;
    1410         1766 :         }
    1411         3212 :     }
    1412              :     // real calendar date/time, not just digits in the right slots
    1413         4978 :     let normalized = format!("{}Z", &s[..19]);
    1414         4978 :     chrono::DateTime::parse_from_rfc3339(&normalized).is_ok()
    1415         5264 : }
    1416              : 
    1417              : #[cfg(test)]
    1418              : mod tests {
    1419              :     use super::*;
    1420              :     use crate::loader::Loader;
    1421              :     use serde_json::json;
    1422              : 
    1423              :     /// 4.5.5.1: explicit "datasetId": "@none" designates the default
    1424              :     /// instance — normalized to absent, so it never appears in responses and
    1425              :     /// absent + "@none" in one request is two default instances (rejected).
    1426              :     #[test]
    1427            2 :     fn dataset_id_none_is_the_default_instance() {
    1428            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1429            2 :             "speed": {"type": "Property", "value": 1, "datasetId": "@none"}});
    1430            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1431            2 :             .expect("@none accepted");
    1432            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
    1433            2 :         assert!(inst.get("datasetId").is_none(), "@none must be dropped");
    1434              :         // absent + "@none" = two defaults → BadRequestData
    1435            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1436            2 :             "speed": [{"type": "Property", "value": 1},
    1437            2 :                       {"type": "Property", "value": 2, "datasetId": "@none"}]});
    1438            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1439            2 :     }
    1440              : 
    1441              :     /// 4.5.5.1: "there cannot be several Attribute instances with the same
    1442              :     /// datasetId" — two instances naming one datasetId are BadRequestData
    1443              :     /// wherever the repeat sits, and the instance count of one Attribute is
    1444              :     /// bounded by the request body alone, so the check may not scan the
    1445              :     /// instances it has already seen for each new one.
    1446              :     #[test]
    1447            2 :     fn clause_4_5_5_1_a_repeated_dataset_id_is_refused_at_any_position() {
    1448         8006 :         let inst = |i: usize| {
    1449         8006 :             json!({"type": "Property", "value": i,
    1450         8006 :                    "datasetId": format!("urn:ngsi-ld:Dataset:{i:05}")})
    1451         8006 :         };
    1452              :         const N: usize = 4000;
    1453            2 :         let distinct: Vec<Value> = (0..N).map(inst).collect();
    1454            2 :         let doc = json!({"id": "urn:ngsi-ld:X:1", "type": "T", "speed": distinct.clone()});
    1455            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1456            2 :             .expect("distinct datasetIds are legal however many there are");
    1457            2 :         assert_eq!(
    1458            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/speed"]
    1459            2 :                 .as_array()
    1460            2 :                 .expect("array")
    1461            2 :                 .len(),
    1462              :             N
    1463              :         );
    1464              :         // the repeat at the front, in the middle and at the end: a check that
    1465              :         // stops early, or one that only compares neighbours, misses two of them
    1466            6 :         for at in [1usize, N / 2, N - 1] {
    1467            6 :             let mut insts = distinct.clone();
    1468            6 :             insts[at] = inst(0);
    1469            6 :             let doc = json!({"id": "urn:ngsi-ld:X:1", "type": "T", "speed": insts});
    1470            6 :             let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
    1471            6 :             let Err(NgsiError::BadRequestData(msg)) = out else {
    1472            0 :                 panic!("a repeat at {at} must be BadRequestData: {out:?}");
    1473              :             };
    1474            6 :             assert!(msg.contains("duplicate datasetId"), "{msg}");
    1475              :         }
    1476            2 :     }
    1477              : 
    1478              :     /// 4.5.2.2 / C.11: "ngsildproof": a Property with the non-reified
    1479              :     /// subproperties "entityIdSealed" and "entityTypeSealed" as specified
    1480              :     /// in [35]; annex B maps entityIdSealed as a plain term and
    1481              :     /// entityTypeSealed with "@type": "@vocab" (the value expands like a
    1482              :     /// type name). Both must survive the expand→compact round trip — they
    1483              :     /// were RESERVED_MEMBERS with no explicit copy and silently vanished.
    1484              :     #[test]
    1485            2 :     fn ngsildproof_sealed_members_round_trip() {
    1486            2 :         let doc = json!({"id": "urn:ngsi-ld:Store:002", "type": "Store",
    1487            2 :             "ngsildproof": {"type": "Property",
    1488            2 :                 "entityIdSealed": "urn:ngsi-ld:Store:002",
    1489            2 :                 "entityTypeSealed": "Store",
    1490            2 :                 "value": {"type": "DataIntegrityProof",
    1491            2 :                           "cryptosuite": "eddsa-rdfc-2022",
    1492            2 :                           "created": "2025-01-27T21:02:24Z",
    1493            2 :                           "proofPurpose": "assertionMethod",
    1494            2 :                           "proofValue": "zQeVbY4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY"}}});
    1495            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1496            2 :             .expect("C.11-shaped ngsildproof is valid");
    1497            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/ngsildproof"][0];
    1498            2 :         assert_eq!(inst["entityIdSealed"], "urn:ngsi-ld:Store:002");
    1499            2 :         assert_eq!(
    1500            2 :             inst["entityTypeSealed"], "https://uri.etsi.org/ngsi-ld/default-context/Store",
    1501              :             "entityTypeSealed is @vocab-coerced (annex B)"
    1502              :         );
    1503              : 
    1504            2 :         let back = crate::compact::compact_entity(&out, &core());
    1505            2 :         let np = &back["ngsildproof"];
    1506            2 :         assert_eq!(np["entityIdSealed"], "urn:ngsi-ld:Store:002");
    1507            2 :         assert_eq!(np["entityTypeSealed"], "Store", "compacts back to the term");
    1508            2 :         assert_eq!(
    1509            2 :             np["value"]["proofValue"], "zQeVbY4oey5q2M3XKaxup3tmzN4DRFTLVqpLMweBrSxMY",
    1510              :             "the W3C proof structure is untouched"
    1511              :         );
    1512              :         // negative: non-reified means BARE strings — never Property objects,
    1513              :         // and never dropped
    1514            2 :         assert!(np["entityIdSealed"].is_string());
    1515            2 :         assert!(np["entityTypeSealed"].is_string());
    1516            2 :     }
    1517              : 
    1518              :     /// 4.5.2.2: the sealed members are strings ([35] seals the entity id and
    1519              :     /// type); "The value of its \"value\" element shall be an object
    1520              :     /// containing the W3C Data integrity \"proof\" structure" — a
    1521              :     /// non-object proof value is BadRequestData.
    1522              :     #[test]
    1523            2 :     fn ngsildproof_shapes_are_validated() {
    1524              :         // non-string sealed members
    1525            4 :         for bad_seal in [json!(42), json!({"type": "Property", "value": true})] {
    1526            4 :             let doc = json!({"id": "urn:x", "type": "Store",
    1527            4 :                 "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
    1528            4 :                     "entityIdSealed": bad_seal}});
    1529            4 :             assert!(
    1530            4 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1531              :                 "entityIdSealed must be a string"
    1532              :             );
    1533              :         }
    1534            2 :         let doc = json!({"id": "urn:x", "type": "Store",
    1535            2 :             "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
    1536            2 :                 "entityTypeSealed": ["Store"]}});
    1537            2 :         assert!(
    1538            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1539              :             "entityTypeSealed must be a string"
    1540              :         );
    1541              :         // the proof value shall be an object
    1542            2 :         let doc = json!({"id": "urn:x", "type": "Store",
    1543            2 :             "ngsildproof": {"type": "Property", "value": "not-a-proof"}});
    1544            2 :         assert!(
    1545            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1546              :             "ngsildproof value shall be an object (4.5.2.2)"
    1547              :         );
    1548              :         // sealed members on an ordinary attribute stay rejected (the
    1549              :         // existing 4.5.2.2 guard — pinned here as the negative pair)
    1550            2 :         let doc = json!({"id": "urn:x", "type": "Store",
    1551            2 :             "speed": {"type": "Property", "value": 1, "entityIdSealed": "urn:x"}});
    1552            2 :         assert!(
    1553            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1554              :             "sealed members only under ngsildproof"
    1555              :         );
    1556            2 :     }
    1557              : 
    1558              :     /// 4.15: the language filter augments the converted Property with "a
    1559              :     /// non-reified subproperty lang indicating the actual language
    1560              :     /// returned" — a langtag string (RFC 5646). The member is broker-
    1561              :     /// produced, and the clause is silent on a client supplying one, so it
    1562              :     /// is stored; it is not stored in a shape no consumer can read. Every
    1563              :     /// other non-reified member of an instance (unitCode, valueType,
    1564              :     /// datasetId, observedAt) is checked here, and lang was copied through
    1565              :     /// whatever its JSON type.
    1566              :     #[test]
    1567            2 :     fn clause_4_15_a_supplied_lang_member_is_a_string() {
    1568            8 :         for bad_lang in [json!({"en": "x"}), json!(["fr"]), json!(7), json!(true)] {
    1569            8 :             let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
    1570            8 :                 "street": {"type": "Property", "value": "Grand Place", "lang": bad_lang}});
    1571            8 :             let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
    1572            8 :             assert!(out.is_err(), "lang must be a langtag string: {out:?}");
    1573              :         }
    1574            2 :         let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle",
    1575            2 :             "street": {"type": "Property", "value": "Grand Place", "lang": "fr"}});
    1576            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1577            2 :             .expect("a langtag string is kept");
    1578            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/street"][0];
    1579            2 :         assert_eq!(inst["lang"], "fr");
    1580              :         // and it stays a member of the instance, never a reified
    1581              :         // sub-attribute of its own
    1582            2 :         assert!(
    1583            2 :             out.get("https://uri.etsi.org/ngsi-ld/default-context/lang")
    1584            2 :                 .is_none(),
    1585              :             "lang is non-reified: {out}"
    1586              :         );
    1587            2 :     }
    1588              : 
    1589              :     /// 4.5.3.2 Prohibited: on a Relationship "entityIdSealed" and
    1590              :     /// "entityTypeSealed" shall never be present — flat, with no exception.
    1591              :     /// 4.5.2.2 and 4.5.2.3 write the only exception there is as "unless the
    1592              :     /// PROPERTY name is ngsildproof", and the member itself is defined as
    1593              :     /// "a Property ... with the non-reified subproperties". The attribute
    1594              :     /// name alone is therefore not the test: an attribute called
    1595              :     /// ngsildproof that is not a Property seals nothing.
    1596              :     #[test]
    1597            2 :     fn clause_4_5_3_2_sealed_members_are_carried_by_a_property_only() {
    1598            8 :         for not_a_property in [
    1599            2 :             json!({"type": "Relationship", "object": "urn:ngsi-ld:Store:1",
    1600            2 :                    "entityIdSealed": "urn:ngsi-ld:Store:1"}),
    1601            2 :             json!({"type": "Relationship", "object": "urn:ngsi-ld:Store:1",
    1602            2 :                    "entityTypeSealed": "Store"}),
    1603            2 :             json!({"type": "LanguageProperty", "languageMap": {"en": "x"},
    1604            2 :                    "entityIdSealed": "urn:ngsi-ld:Store:1"}),
    1605            2 :             json!({"type": "ListRelationship", "objectList": ["urn:ngsi-ld:Store:1"],
    1606            2 :                    "entityTypeSealed": "Store"}),
    1607            2 :         ] {
    1608            8 :             let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
    1609            8 :                              "ngsildproof": not_a_property});
    1610            8 :             let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default());
    1611            8 :             assert!(
    1612            8 :                 out.is_err(),
    1613              :                 "a non-Property ngsildproof carries no sealed member: {out:?}"
    1614              :             );
    1615              :         }
    1616              :         // the concise form infers the attribute type, and the inference
    1617              :         // decides the same way: `object` makes this a Relationship
    1618            2 :         let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
    1619            2 :             "ngsildproof": {"object": "urn:ngsi-ld:Store:1",
    1620            2 :                             "entityIdSealed": "urn:ngsi-ld:Store:1"}});
    1621            2 :         assert!(
    1622            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1623              :             "the concise Relationship form is a Relationship (4.5.3.3)"
    1624              :         );
    1625              :         // and the Property form the clause does allow still round-trips
    1626            2 :         let doc = json!({"id": "urn:ngsi-ld:Store:1", "type": "Store",
    1627            2 :             "ngsildproof": {"type": "Property", "value": {"type": "DataIntegrityProof"},
    1628            2 :                             "entityIdSealed": "urn:ngsi-ld:Store:1",
    1629            2 :                             "entityTypeSealed": "Store"}});
    1630            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1631            2 :             .expect("the ngsildproof Property is the one carrier");
    1632            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/ngsildproof"][0];
    1633            2 :         assert_eq!(inst["entityIdSealed"], "urn:ngsi-ld:Store:1");
    1634            2 :     }
    1635              : 
    1636              :     /// 4.5.3.3: "type: If missing, Relationship can be inferred by the
    1637              :     /// presence of the object attribute" — and the shared prohibitions apply
    1638              :     /// to the inferred instance too.
    1639              :     #[test]
    1640            2 :     fn concise_relationship_inference() {
    1641            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1642            2 :             "isParked": {"object": "urn:ngsi-ld:P:1",
    1643            2 :                          "observedAt": "2026-01-01T00:00:00Z"}});
    1644            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1645            2 :             .expect("concise relationship");
    1646            2 :         let rel = &out["https://uri.etsi.org/ngsi-ld/default-context/isParked"][0];
    1647            2 :         assert_eq!(rel["type"], "Relationship");
    1648            2 :         assert_eq!(rel["object"], "urn:ngsi-ld:P:1");
    1649              :         // inferred Relationship still rejects a Property value member
    1650            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1651            2 :             "isParked": {"object": "urn:ngsi-ld:P:1", "value": 1}});
    1652            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1653            2 :     }
    1654              : 
    1655              :     /// 4.5.3.2: a normalized Relationship "shall never include" unitCode
    1656              :     /// ("Relationships are unitless") or the value-defining members of the
    1657              :     /// Property family — while objectType stays a legal optional member.
    1658              :     #[test]
    1659            2 :     fn relationship_prohibited_members_rejected() {
    1660           12 :         let mk = |extra: (&str, Value)| {
    1661           12 :             json!({
    1662           12 :                 "id": "urn:x", "type": "T",
    1663           12 :                 "isParked": {"type": "Relationship", "object": "urn:ngsi-ld:P:1",
    1664           12 :                              extra.0: extra.1}
    1665              :             })
    1666           12 :         };
    1667           10 :         for (m, v) in [
    1668            2 :             ("unitCode", json!("MTR")),
    1669            2 :             ("value", json!(1)),
    1670            2 :             ("languageMap", json!({"en": "x"})),
    1671            2 :             ("valueList", json!([1])),
    1672            2 :             ("previousObject", json!("urn:a")),
    1673            2 :         ] {
    1674           10 :             let doc = mk((m, v));
    1675           10 :             assert!(
    1676           10 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1677              :                 "{m} must be prohibited on a Relationship"
    1678              :             );
    1679              :         }
    1680            2 :         let ok = mk(("objectType", json!("Parking")));
    1681            2 :         assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
    1682            2 :     }
    1683              : 
    1684              :     /// 4.5.2.3: concise Property forms — a geometry-shaped value infers
    1685              :     /// GeoProperty (both as the whole object and as the value member); an
    1686              :     /// object carrying a "type" member is treated as normalized; a concise
    1687              :     /// object mixing value with another type's defining member rejects.
    1688              :     #[test]
    1689            2 :     fn concise_property_inference_rules() {
    1690            2 :         let geo = json!({"type": "Point", "coordinates": [1.0, 2.0]});
    1691              :         // whole object IS the geometry
    1692            2 :         let doc = json!({"id": "urn:x", "type": "T", "area": geo});
    1693            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1694            2 :             .expect("bare geometry");
    1695            2 :         assert_eq!(
    1696            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/area"][0]["type"],
    1697              :             "GeoProperty"
    1698              :         );
    1699              :         // geometry as the value member of a type-less object
    1700            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1701            2 :             "area": {"value": {"type": "Point", "coordinates": [1.0, 2.0]},
    1702            2 :                      "observedAt": "2026-01-01T00:00:00Z"}});
    1703            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1704            2 :             .expect("geometry value");
    1705            2 :         assert_eq!(
    1706            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/area"][0]["type"],
    1707              :             "GeoProperty"
    1708              :         );
    1709              :         // an object with a "type" member is normalized — unknown type rejects
    1710            2 :         let doc = json!({"id": "urn:x", "type": "T", "a": {"type": "Custom", "x": 1}});
    1711            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1712              :         // concise mix of value and a foreign defining member rejects
    1713            2 :         let doc = json!({"id": "urn:x", "type": "T",
    1714            2 :             "a": {"value": 1, "languageMap": {"en": "x"}}});
    1715            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1716            2 :     }
    1717              : 
    1718              :     /// 4.5.2.2: a normalized Property "shall never include" the value-defining
    1719              :     /// members of other attribute types, output-only members, or the sealed
    1720              :     /// members outside ngsildproof — and valueType coerces to a datatype URI.
    1721              :     #[test]
    1722            2 :     fn property_prohibited_members_rejected() {
    1723           22 :         let mk = |extra: (&str, Value)| {
    1724           22 :             json!({
    1725           22 :                 "id": "urn:x", "type": "T",
    1726           22 :                 "speed": {"type": "Property", "value": 1, extra.0: extra.1}
    1727              :             })
    1728           22 :         };
    1729           22 :         for (m, v) in [
    1730            2 :             ("object", json!("urn:ngsi-ld:other:1")),
    1731            2 :             ("languageMap", json!({"en": "hi"})),
    1732            2 :             ("json", json!({"k": 1})),
    1733            2 :             ("vocab", json!("term")),
    1734            2 :             ("valueList", json!([1, 2])),
    1735            2 :             ("objectList", json!(["urn:a"])),
    1736            2 :             ("entity", json!({"id": "urn:a", "type": "T"})),
    1737            2 :             ("entityList", json!([])),
    1738            2 :             ("previousValue", json!(0)),
    1739            2 :             ("previousObject", json!("urn:a")),
    1740            2 :             ("entityIdSealed", json!(true)),
    1741            2 :         ] {
    1742           22 :             let doc = mk((m, v));
    1743           22 :             assert!(
    1744           22 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1745              :                 "{m} must be prohibited on a Property"
    1746              :             );
    1747              :         }
    1748              :         // valueType is a legal optional member and coerces to a datatype URI
    1749            2 :         let doc = json!({
    1750            2 :             "id": "urn:x", "type": "T",
    1751            2 :             "speed": {"type": "Property", "value": 1.5, "valueType": "xsd:double"}
    1752              :         });
    1753            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1754            2 :             .expect("valueType is optional");
    1755            2 :         let attr = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
    1756            2 :         assert!(attr["valueType"]
    1757            2 :             .as_str()
    1758            2 :             .is_some_and(|s| s.contains("double")));
    1759            2 :     }
    1760              : 
    1761              :     /// 4.5.1: "Terms defined in the Core Context as non-reified Properties
    1762              :     /// (such as datasetId, instanceId, etc.) shall not be used as Attribute
    1763              :     /// names."
    1764              :     #[test]
    1765            2 :     fn core_non_reified_terms_rejected_as_attribute_names() {
    1766            8 :         for name in ["datasetId", "instanceId", "observedAt", "unitCode"] {
    1767            8 :             let doc = json!({
    1768            8 :                 "id": "urn:x", "type": "T",
    1769            8 :                 name: {"type": "Property", "value": 1}
    1770              :             });
    1771            8 :             assert!(
    1772            8 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1773              :                 "{name} must be rejected as an Attribute name"
    1774              :             );
    1775              :         }
    1776            2 :         let ok = json!({
    1777            2 :             "id": "urn:x", "type": "T",
    1778            2 :             "speed": {"type": "Property", "value": 1}
    1779              :         });
    1780            2 :         assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
    1781            2 :     }
    1782              : 
    1783              :     /// 4.5.1: "Attributes shall not contain any embedded @context" — 5.5.7:
    1784              :     /// such content "should result in an error of type BadRequestData".
    1785              :     #[test]
    1786            2 :     fn embedded_context_in_attribute_rejected() {
    1787            2 :         let doc = json!({
    1788            2 :             "id": "urn:x", "type": "T",
    1789            2 :             "speed": {"type": "Property", "value": 1,
    1790            2 :                       "@context": {"speed": "https://evil.example/speed"}}
    1791              :         });
    1792            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1793              :         // nested inside a sub-attribute as well
    1794            2 :         let doc = json!({
    1795            2 :             "id": "urn:x", "type": "T",
    1796            2 :             "speed": {"type": "Property", "value": 1,
    1797            2 :                       "source": {"type": "Property", "value": "s",
    1798            2 :                                  "@context": {"x": "https://e/x"}}}
    1799              :         });
    1800            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1801            2 :     }
    1802              : 
    1803          450 :     fn core() -> std::sync::Arc<Context> {
    1804          450 :         Loader::new().core()
    1805          450 :     }
    1806              : 
    1807              :     #[test]
    1808            2 :     fn expands_simple_entity() {
    1809            2 :         let doc = serde_json::json!({
    1810            2 :             "id": "urn:ngsi-ld:Building:1",
    1811            2 :             "type": "Building",
    1812            2 :             "name": {"type": "Property", "value": "Eiffel Tower"}
    1813              :         });
    1814            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1815            2 :             .expect("expand");
    1816            2 :         assert_eq!(out["id"], "urn:ngsi-ld:Building:1");
    1817            2 :         assert_eq!(
    1818            2 :             out["type"][0],
    1819              :             "https://uri.etsi.org/ngsi-ld/default-context/Building"
    1820              :         );
    1821            2 :         let name = &out["https://uri.etsi.org/ngsi-ld/default-context/name"];
    1822            2 :         assert_eq!(name[0]["value"], "Eiffel Tower");
    1823            2 :     }
    1824              : 
    1825              :     #[test]
    1826            2 :     fn expires_at_kept_as_meta_not_property() {
    1827              :         // 4.22: a top-level expiresAt must survive as a bare DateTime string
    1828              :         // (the shape the read-boundary filter / GC / expires_at column read),
    1829              :         // never as a Property under its IRI.
    1830            2 :         let doc = serde_json::json!({
    1831            2 :             "id": "urn:ngsi-ld:T:1",
    1832            2 :             "type": "T",
    1833            2 :             "expiresAt": "2020-01-01T00:00:00Z",
    1834            2 :             "foo": {"type": "Property", "value": 1}
    1835              :         });
    1836            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1837            2 :             .expect("expand");
    1838            2 :         assert_eq!(out["expiresAt"], "2020-01-01T00:00:00Z");
    1839            2 :         assert!(out.get("https://uri.etsi.org/ngsi-ld/expiresAt").is_none());
    1840              :         // a non-DateTime expiresAt is rejected
    1841            2 :         let bad = serde_json::json!({"id": "urn:ngsi-ld:T:2", "type": "T", "expiresAt": "soon"});
    1842            2 :         assert!(expand_entity(bad.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1843            2 :     }
    1844              : 
    1845              :     #[test]
    1846            2 :     fn concise_and_multi_instance() {
    1847            2 :         let doc = serde_json::json!({
    1848            2 :             "id": "urn:ngsi-ld:Vehicle:1",
    1849            2 :             "type": "Vehicle",
    1850            2 :             "speed": 55,
    1851            2 :             "brand": [
    1852            2 :                 {"type": "Property", "value": "Volvo", "datasetId": "urn:ngsi-ld:d:1"},
    1853            2 :                 {"type": "Property", "value": "Ford"}
    1854              :             ]
    1855              :         });
    1856            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    1857            2 :             .expect("expand");
    1858            2 :         let speed = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"];
    1859            2 :         assert_eq!(speed[0]["type"], "Property");
    1860            2 :         assert_eq!(speed[0]["value"], 55);
    1861            2 :         let brand = &out["https://uri.etsi.org/ngsi-ld/default-context/brand"];
    1862            2 :         assert_eq!(brand.as_array().unwrap().len(), 2);
    1863            2 :     }
    1864              : 
    1865              :     #[test]
    1866            2 :     fn rejects_missing_type() {
    1867            2 :         let doc = serde_json::json!({"id": "urn:ngsi-ld:Building:1"});
    1868            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1869            2 :     }
    1870              : 
    1871              :     /// 4.8: "Temporal Properties in NGSI-LD shall be represented based on
    1872              :     /// the DateTime data type as mandated by clause 4.6.3" and "a
    1873              :     /// TemporalProperty does not allow reification" — only a valid UTC
    1874              :     /// DateTime STRING is a legal observedAt.
    1875              :     #[test]
    1876            2 :     fn rejects_bad_observed_at() {
    1877            6 :         for bad in [
    1878            2 :             serde_json::json!("not-a-date"),
    1879            2 :             // 4.6.3: trailing component shall be Z — offsets are invalid
    1880            2 :             serde_json::json!("2026-08-10T12:00:00+02:00"),
    1881            2 :             // non-reified: a Property-shaped observedAt is not a DateTime
    1882            2 :             serde_json::json!({"type": "Property", "value": "2026-08-10T12:00:00Z"}),
    1883            2 :         ] {
    1884            6 :             let doc = serde_json::json!({
    1885            6 :                 "id": "urn:ngsi-ld:Building:1",
    1886            6 :                 "type": "Building",
    1887            6 :                 "a": {"type": "Property", "value": 1, "observedAt": bad}
    1888              :             });
    1889            6 :             assert!(
    1890            6 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1891              :                 "observedAt {bad} must be rejected (4.8/4.6.3)"
    1892              :             );
    1893              :         }
    1894            2 :     }
    1895              : 
    1896              :     #[test]
    1897            2 :     fn relationship_needs_uri_object() {
    1898            2 :         let doc = serde_json::json!({
    1899            2 :             "id": "urn:ngsi-ld:A:1",
    1900            2 :             "type": "T",
    1901            2 :             "rel": {"type": "Relationship", "object": "not a uri"}
    1902              :         });
    1903            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1904            2 :     }
    1905              : 
    1906              :     /// 4.2.2 Meta Model: "An NGSI-LD Property shall have a value, stated
    1907              :     /// through hasValue. An NGSI-LD Relationship shall have an object stated
    1908              :     /// through hasObject." The member is REQUIRED — a typed attribute
    1909              :     /// without it is rejected, per specialized type as well (5.2.32/5.2.38).
    1910              :     #[test]
    1911            2 :     fn meta_model_required_member_per_attribute_type() {
    1912           14 :         for (ty, wrong_member) in [
    1913            2 :             ("Property", "object"),
    1914            2 :             ("Relationship", "value"),
    1915            2 :             ("LanguageProperty", "value"),
    1916            2 :             ("JsonProperty", "value"),
    1917            2 :             ("VocabProperty", "value"),
    1918            2 :             ("ListProperty", "value"),
    1919            2 :             ("ListRelationship", "valueList"),
    1920            2 :         ] {
    1921           14 :             let doc = serde_json::json!({
    1922           14 :                 "id": "urn:ngsi-ld:A:1",
    1923           14 :                 "type": "T",
    1924           14 :                 "attr": {"type": ty, wrong_member: "x"}
    1925              :             });
    1926           14 :             assert!(
    1927           14 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1928              :                 "{ty} without its required member must be rejected (4.2.2)"
    1929              :             );
    1930              :         }
    1931            2 :     }
    1932              : 
    1933              :     /// 4.2.2: "An NGSI-LD Value shall be either a rdfs:Literal or a node
    1934              :     /// object" — every JSON literal, array and object is a legal value;
    1935              :     /// a bare JSON null is NOT (the null sentinel is the string form,
    1936              :     /// 4.5.2 / 057_03_02).
    1937              :     #[test]
    1938            2 :     fn meta_model_value_space() {
    1939           12 :         for v in [
    1940            2 :             serde_json::json!(17),
    1941            2 :             serde_json::json!(1.5),
    1942            2 :             serde_json::json!(true),
    1943            2 :             serde_json::json!("text"),
    1944            2 :             serde_json::json!([1, 2, 3]),
    1945            2 :             serde_json::json!({"nested": {"deep": [1]}}),
    1946            2 :         ] {
    1947           12 :             let doc = serde_json::json!({
    1948           12 :                 "id": "urn:ngsi-ld:A:1", "type": "T",
    1949           12 :                 "attr": {"type": "Property", "value": v}
    1950              :             });
    1951           12 :             assert!(
    1952           12 :                 expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok(),
    1953              :                 "literal/node-object value must be accepted (4.2.2): {v}"
    1954              :             );
    1955              :         }
    1956            2 :         let doc = serde_json::json!({
    1957            2 :             "id": "urn:ngsi-ld:A:1", "type": "T",
    1958            2 :             "attr": {"type": "Property", "value": null}
    1959              :         });
    1960            2 :         assert!(
    1961            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    1962              :             "bare JSON null is not an NGSI-LD Value (4.2.2/4.5.2)"
    1963              :         );
    1964            2 :     }
    1965              : 
    1966              :     #[test]
    1967            2 :     fn location_must_be_geo() {
    1968            2 :         let doc = serde_json::json!({
    1969            2 :             "id": "urn:ngsi-ld:A:1",
    1970            2 :             "type": "T",
    1971            2 :             "location": {"type": "Property", "value": 3}
    1972              :         });
    1973            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    1974            2 :         let ok = serde_json::json!({
    1975            2 :             "id": "urn:ngsi-ld:A:1",
    1976            2 :             "type": "T",
    1977            2 :             "location": {"type": "GeoProperty", "value": {"type": "Point", "coordinates": [1.0, 2.0]}}
    1978              :         });
    1979            2 :         assert!(expand_entity(ok.as_object().unwrap(), &core(), ExpandOpts::default()).is_ok());
    1980            2 :     }
    1981              : 
    1982              :     #[test]
    1983            2 :     fn datetime_validation() {
    1984              :         // 4.6.3, p.80-81. Accepted forms:
    1985            2 :         assert!(parse_datetime("2020-09-09T16:40:00Z"));
    1986            2 :         assert!(parse_datetime("2020-09-09T16:40:00.000Z"));
    1987            2 :         assert!(
    1988            2 :             parse_datetime("2020-09-09T16:40:00.123456Z"),
    1989              :             "6 fraction digits"
    1990              :         );
    1991              :         // "In requests, also a comma instead of a decimal point may be used as
    1992              :         // separator for compatibility reasons."
    1993            2 :         assert!(
    1994            2 :             parse_datetime("2020-09-09T16:40:00,123Z"),
    1995              :             "comma separator"
    1996              :         );
    1997            2 :         assert!(
    1998            2 :             parse_datetime("2020-02-29T00:00:00Z"),
    1999              :             "2020 is a leap year"
    2000              :         );
    2001              : 
    2002              :         // Rejected. NOTE: the offset case previously asserted the OPPOSITE —
    2003              :         // 4.6.3 is explicit that "the trailing timestamp component … shall
    2004              :         // always be equal to the character Z. Therefore, all timestamps shall
    2005              :         // be expressed in UTC", so an offset is invalid, not an alternative.
    2006            2 :         assert!(
    2007            2 :             !parse_datetime("2020-09-09T16:40:00+02:00"),
    2008              :             "offset forbidden"
    2009              :         );
    2010            2 :         assert!(
    2011            2 :             !parse_datetime("2020-09-09T16:40:00-05:00"),
    2012              :             "offset forbidden"
    2013              :         );
    2014              :         // "All the referred components shall appear in the string; reduced
    2015              :         // representations are not permitted."
    2016            2 :         assert!(!parse_datetime("2020-09-09T16:40:00"), "no zone");
    2017            2 :         assert!(!parse_datetime("2020-09-09"));
    2018            2 :         assert!(!parse_datetime("nope"));
    2019              :         // fraction bounds: 1..=6 digits, and a separator is required
    2020            2 :         assert!(!parse_datetime("2020-09-09T16:40:00.Z"), "empty fraction");
    2021            2 :         assert!(!parse_datetime("2020-09-09T16:40:00.1234567Z"), "7 digits");
    2022            2 :         assert!(!parse_datetime("2020-09-09T16:40:00123Z"), "no separator");
    2023              :         // calendar reality — digit-shape alone let this through, and one such
    2024              :         // write made every later temporal query in the tenant 500 on the
    2025              :         // ::timestamptz cast
    2026            2 :         assert!(!parse_datetime("2026-13-45T00:00:00Z"), "month 13, day 45");
    2027            2 :         assert!(
    2028            2 :             !parse_datetime("2021-02-29T00:00:00Z"),
    2029              :             "2021 is not a leap year"
    2030              :         );
    2031            2 :         assert!(!parse_datetime("2020-09-09T25:00:00Z"), "hour 25");
    2032            2 :     }
    2033              : 
    2034              :     /// 4.7.1/4.7.2/4.7.3 Geospatial Properties: location & co. must be
    2035              :     /// GeoProperties (4.7.1); a whole geometry MAY arrive as an encoded JSON
    2036              :     /// string, accepted "if and only if" it parses into a valid geometry of
    2037              :     /// the stated type (4.7.2, normalized to the object form); the concise
    2038              :     /// forms infer GeoProperty from a resolving geometry value (4.7.3).
    2039              :     #[test]
    2040            2 :     fn geo_property_rules() {
    2041           14 :         let ent = |attr: Value| {
    2042           14 :             let doc = json!({"id": "urn:x", "type": "T", "g": attr});
    2043           14 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2044           14 :         };
    2045              :         // 4.7.2: string-encoded geometry accepted and normalized to the object
    2046            2 :         let out = ent(json!({"type": "GeoProperty",
    2047            2 :             "value": "{\"type\": \"Point\", \"coordinates\": [17.1, 48.7]}"}))
    2048            2 :         .expect("string-encoded geometry");
    2049            2 :         let val = &out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["value"];
    2050            2 :         assert!(
    2051            2 :             !val.is_string(),
    2052              :             "value must be normalized, not stay a string"
    2053              :         );
    2054            2 :         assert_eq!(val["type"], "Point");
    2055            2 :         assert_eq!(val["coordinates"][0], 17.1);
    2056              :         // iff: unparseable, non-geometry and GeometryCollection strings → 400
    2057            6 :         for bad_s in [
    2058            2 :             "not json",
    2059            2 :             "{\"a\": 1}",
    2060            2 :             "{\"type\": \"GeometryCollection\", \"geometries\": []}",
    2061            2 :         ] {
    2062            6 :             let err = ent(json!({"type": "GeoProperty", "value": bad_s})).expect_err(bad_s);
    2063            6 :             assert!(matches!(err, NgsiError::BadRequestData(_)), "{bad_s}");
    2064              :         }
    2065              :         // 4.7.1: location shall be a GeoProperty
    2066            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2067            2 :             "location": {"type": "Property", "value": 1}});
    2068            2 :         assert!(
    2069            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2070              :             "location as plain Property"
    2071              :         );
    2072              :         // 4.7.3: concise inference — bare geometry and resolving value
    2073            2 :         let out = ent(json!({"type": "Point", "coordinates": [1.0, 2.0]}))
    2074            2 :             .expect("bare geometry concise form");
    2075            2 :         assert_eq!(
    2076            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
    2077              :             "GeoProperty"
    2078              :         );
    2079            2 :         let out = ent(json!({"value": {"type": "Point", "coordinates": [1.0, 2.0]}}))
    2080            2 :             .expect("resolving value");
    2081            2 :         assert_eq!(
    2082            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
    2083              :             "GeoProperty"
    2084              :         );
    2085              :         // an ordinary string value must NOT be inferred as GeoProperty
    2086            2 :         let out = ent(json!({"value": "Point"})).expect("plain string value");
    2087            2 :         assert_eq!(
    2088            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/g"][0]["type"],
    2089              :             "Property"
    2090              :         );
    2091            2 :     }
    2092              : 
    2093              :     /// 4.7.2: a geometry is accepted "if and only if" it meets "the syntax
    2094              :     /// and restrictions mandated by IETF RFC 7946 \[8\] when representing a
    2095              :     /// valid Geometry of the type specified", and its coordinates are "values
    2096              :     /// of a JSON-LD floating point number data type". The verbose, concise
    2097              :     /// and string-encoded forms are one Value and all three are held to it.
    2098              :     #[test]
    2099            2 :     fn geojson_geometries_meet_the_rfc_7946_restrictions() {
    2100          150 :         let ent = |attr: Value| {
    2101          150 :             let doc = json!({"id": "urn:x", "type": "T", "location": attr});
    2102          150 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2103          150 :         };
    2104            2 :         let bad_geoms = vec![
    2105              :             // RFC 7946 3.1.1: "A position is an array of numbers. There MUST
    2106              :             // be two or more elements."
    2107            2 :             (
    2108            2 :                 "point is not an array",
    2109            2 :                 json!({"type": "Point", "coordinates": 1}),
    2110            2 :             ),
    2111            2 :             (
    2112            2 :                 "point is an object",
    2113            2 :                 json!({"type": "Point", "coordinates": {"lon": 1}}),
    2114            2 :             ),
    2115            2 :             (
    2116            2 :                 "one element",
    2117            2 :                 json!({"type": "Point", "coordinates": [1.0]}),
    2118            2 :             ),
    2119            2 :             (
    2120            2 :                 "empty position",
    2121            2 :                 json!({"type": "Point", "coordinates": []}),
    2122            2 :             ),
    2123              :             // 4.7.2: coordinates are floating point numbers.
    2124            2 :             (
    2125            2 :                 "strings, not numbers",
    2126            2 :                 json!({"type": "Point", "coordinates": ["1", "2"]}),
    2127            2 :             ),
    2128            2 :             (
    2129            2 :                 "null coordinate",
    2130            2 :                 json!({"type": "Point", "coordinates": [1.0, null]}),
    2131            2 :             ),
    2132            2 :             (
    2133            2 :                 "nested where a position belongs",
    2134            2 :                 json!({"type": "Point", "coordinates": [[1.0, 2.0]]}),
    2135            2 :             ),
    2136              :             // RFC 7946 4: the CRS is WGS84 "with longitude and latitude units
    2137              :             // of decimal degrees" — 999 is not a latitude. Left through, it
    2138              :             // reaches PostGIS as a `::geography` cast that errors, so one
    2139              :             // write breaks every later `near` query in the tenant.
    2140            2 :             (
    2141            2 :                 "latitude past the pole",
    2142            2 :                 json!({"type": "Point", "coordinates": [0.0, 999.0]}),
    2143            2 :             ),
    2144            2 :             (
    2145            2 :                 "longitude past the antimeridian",
    2146            2 :                 json!({"type": "Point", "coordinates": [181.0, 0.0]}),
    2147            2 :             ),
    2148            2 :             (
    2149            2 :                 "latitude below the pole",
    2150            2 :                 json!({"type": "Point", "coordinates": [0.0, -90.5]}),
    2151            2 :             ),
    2152              :             // RFC 7946 3.1.4: a LineString needs "two or more positions".
    2153            2 :             (
    2154            2 :                 "one-position LineString",
    2155            2 :                 json!({"type": "LineString", "coordinates": [[1.0, 2.0]]}),
    2156            2 :             ),
    2157            2 :             (
    2158            2 :                 "LineString of numbers",
    2159            2 :                 json!({"type": "LineString", "coordinates": [1.0, 2.0]}),
    2160            2 :             ),
    2161              :             // RFC 7946 3.1.6: rings are closed and have four or more positions.
    2162            2 :             (
    2163            2 :                 "open ring",
    2164            2 :                 json!({"type": "Polygon", "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]]}),
    2165            2 :             ),
    2166            2 :             (
    2167            2 :                 "short ring",
    2168            2 :                 json!({"type": "Polygon", "coordinates": [[[0.0, 0.0], [1.0, 0.0], [0.0, 0.0]]]}),
    2169            2 :             ),
    2170            2 :             (
    2171            2 :                 "ring out of range",
    2172            2 :                 json!({"type": "Polygon",
    2173            2 :                 "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 91.0], [0.0, 0.0]]]}),
    2174            2 :             ),
    2175            2 :             (
    2176            2 :                 "polygon of positions",
    2177            2 :                 json!({"type": "Polygon", "coordinates": [[0.0, 0.0]]}),
    2178            2 :             ),
    2179            2 :             (
    2180            2 :                 "MultiPolygon nested one level short",
    2181            2 :                 json!({"type": "MultiPolygon",
    2182            2 :                 "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]}),
    2183            2 :             ),
    2184              :         ];
    2185           34 :         for (why, geom) in &bad_geoms {
    2186              :             // verbose form
    2187           34 :             let err = ent(json!({"type": "GeoProperty", "value": geom})).expect_err(why);
    2188           34 :             assert!(
    2189           34 :                 matches!(err, NgsiError::BadRequestData(_)),
    2190              :                 "{why}: {err:?}"
    2191              :             );
    2192              :             // 4.7.3 concise form — the same Value, the same restrictions
    2193           34 :             let err = ent(geom.clone()).expect_err(&format!("{why} (concise)"));
    2194           34 :             assert!(
    2195           34 :                 matches!(err, NgsiError::BadRequestData(_)),
    2196              :                 "{why} (concise)"
    2197              :             );
    2198              :             // 4.7.2 string-encoded form
    2199           34 :             let encoded = serde_json::to_string(geom).expect("encode");
    2200           34 :             let err = ent(json!({"type": "GeoProperty", "value": encoded}))
    2201           34 :                 .expect_err(&format!("{why} (encoded)"));
    2202           34 :             assert!(
    2203           34 :                 matches!(err, NgsiError::BadRequestData(_)),
    2204              :                 "{why} (encoded)"
    2205              :             );
    2206              :         }
    2207              :         // the shapes RFC 7946 does allow stay accepted, in all three forms
    2208            2 :         let good = vec![
    2209            2 :             json!({"type": "Point", "coordinates": [17.1, 48.7]}),
    2210            2 :             json!({"type": "Point", "coordinates": [-180.0, -90.0]}),
    2211            2 :             json!({"type": "Point", "coordinates": [180.0, 90.0]}),
    2212              :             // "Altitude or elevation MAY be included as an optional third element"
    2213            2 :             json!({"type": "Point", "coordinates": [1.0, 2.0, 300.0]}),
    2214            2 :             json!({"type": "MultiPoint", "coordinates": [[1.0, 2.0], [3.0, 4.0]]}),
    2215            2 :             json!({"type": "LineString", "coordinates": [[1.0, 2.0], [3.0, 4.0]]}),
    2216            2 :             json!({"type": "MultiLineString", "coordinates": [[[1.0, 2.0], [3.0, 4.0]]]}),
    2217            2 :             json!({"type": "Polygon",
    2218            2 :                 "coordinates": [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]}),
    2219            2 :             json!({"type": "Polygon", "coordinates": [
    2220              :                 [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 0.0]],
    2221              :                 [[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 1.0]]]}),
    2222            2 :             json!({"type": "MultiPolygon",
    2223            2 :                 "coordinates": [[[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]]]}),
    2224              :             // RFC 7946 3.1: an empty multi-geometry is still a geometry
    2225            2 :             json!({"type": "MultiPoint", "coordinates": []}),
    2226            2 :             json!({"type": "Polygon", "coordinates": []}),
    2227              :         ];
    2228           24 :         for geom in &good {
    2229           24 :             ent(json!({"type": "GeoProperty", "value": geom}))
    2230           24 :                 .unwrap_or_else(|e| panic!("{geom} rejected: {e:?}"));
    2231           24 :             ent(geom.clone()).unwrap_or_else(|e| panic!("{geom} concise rejected: {e:?}"));
    2232              :         }
    2233            2 :     }
    2234              : 
    2235              :     /// 5.5.4 General NGSI-LD validation: "urn:ngsi-ld:null" as a first-level
    2236              :     /// member value is BadRequestData outside partial-update/merge fragments;
    2237              :     /// as the value of a key inside a JSON object that is a Property's value
    2238              :     /// it is BadRequestData everywhere EXCEPT merge fragments (5.5.12).
    2239              :     #[test]
    2240            2 :     fn clause_5_5_4_null_placement() {
    2241            2 :         let e =
    2242           20 :             |doc: Value, opts: ExpandOpts| expand_entity(doc.as_object().unwrap(), &core(), opts);
    2243              :         // first-level member value — id and type are first-level members too
    2244            2 :         assert!(
    2245            2 :             e(
    2246            2 :                 json!({"id": "urn:ngsi-ld:null", "type": "T"}),
    2247            2 :                 ExpandOpts::default()
    2248            2 :             )
    2249            2 :             .is_err(),
    2250              :             "null URN as id must 400 on create"
    2251              :         );
    2252            2 :         assert!(
    2253            2 :             e(
    2254            2 :                 json!({"id": "urn:x", "type": "urn:ngsi-ld:null"}),
    2255            2 :                 ExpandOpts::default()
    2256            2 :             )
    2257            2 :             .is_err(),
    2258              :             "null URN as type must 400 on create"
    2259              :         );
    2260              :         // null inside a JSON object that is a Property value
    2261            2 :         let nested = json!({"id": "urn:x", "type": "T",
    2262            2 :             "p": {"type": "Property", "value": {"a": "urn:ngsi-ld:null"}}});
    2263            2 :         assert!(
    2264            2 :             e(nested.clone(), ExpandOpts::default()).is_err(),
    2265              :             "nested null in value object must 400 on create"
    2266              :         );
    2267              :         // partial update allows top-level nulls (allow_null) but the
    2268              :         // object-nested form is excepted for merge ONLY
    2269            2 :         assert!(
    2270            2 :             e(
    2271            2 :                 nested.clone(),
    2272            2 :                 ExpandOpts {
    2273            2 :                     fragment: true,
    2274            2 :                     allow_null: true,
    2275            2 :                     ..Default::default()
    2276            2 :                 }
    2277            2 :             )
    2278            2 :             .is_err(),
    2279              :             "nested null in value object must 400 on partial update"
    2280              :         );
    2281              :         // merge fragment: accepted and preserved for merge_into
    2282            2 :         let ok = e(
    2283            2 :             nested,
    2284            2 :             ExpandOpts {
    2285            2 :                 fragment: true,
    2286            2 :                 allow_null: true,
    2287            2 :                 merge: true,
    2288            2 :                 ..Default::default()
    2289            2 :             },
    2290            2 :         )
    2291            2 :         .expect("merge fragment keeps the nested null");
    2292            2 :         assert_eq!(
    2293            2 :             ok["https://uri.etsi.org/ngsi-ld/default-context/p"][0]["value"]["a"],
    2294              :             "urn:ngsi-ld:null"
    2295              :         );
    2296              :         // deep nesting (object in object, object in array) is caught too
    2297            2 :         let deep = json!({"id": "urn:x", "type": "T",
    2298            2 :             "p": {"type": "Property", "value": {"a": {"b": "urn:ngsi-ld:null"}}}});
    2299            2 :         assert!(
    2300            2 :             e(deep, ExpandOpts::default()).is_err(),
    2301              :             "deep nested null must 400"
    2302              :         );
    2303            2 :         let in_array = json!({"id": "urn:x", "type": "T",
    2304            2 :             "p": {"type": "Property", "value": [{"a": "urn:ngsi-ld:null"}]}});
    2305            2 :         assert!(
    2306            2 :             e(in_array, ExpandOpts::default()).is_err(),
    2307              :             "null in object in array must 400"
    2308              :         );
    2309              :         // negative: a benign object value passes and carries no null
    2310            2 :         let fine = e(
    2311            2 :             json!({"id": "urn:x", "type": "T",
    2312            2 :                 "p": {"type": "Property", "value": {"a": 1}}}),
    2313            2 :             ExpandOpts::default(),
    2314            2 :         )
    2315            2 :         .expect("plain object value stays legal");
    2316            2 :         assert!(
    2317            2 :             !fine.to_string().contains("urn:ngsi-ld:null"),
    2318              :             "no null leakage"
    2319              :         );
    2320              :         // top-level attribute null stays the fragment deletion form:
    2321              :         // rejected on create, accepted under allow_null (5.5.8/5.5.12)
    2322            2 :         let top = json!({"id": "urn:x", "type": "T", "p": "urn:ngsi-ld:null"});
    2323            2 :         assert!(e(top.clone(), ExpandOpts::default()).is_err());
    2324            2 :         e(
    2325            2 :             top,
    2326            2 :             ExpandOpts {
    2327            2 :                 fragment: true,
    2328            2 :                 allow_null: true,
    2329            2 :                 ..Default::default()
    2330            2 :             },
    2331            2 :         )
    2332            2 :         .expect("first-level null is the deletion form in fragments");
    2333            2 :     }
    2334              : 
    2335              :     /// 5.5.8: "A datasetId cannot be deleted by setting it to the value
    2336              :     /// urn:ngsi-ld:null" — such a fragment is rejected on every input,
    2337              :     /// including null-allowing (update/merge) ones.
    2338              :     #[test]
    2339            2 :     fn clause_5_5_8_dataset_id_null_rejected() {
    2340            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2341            2 :             "speed": {"type": "Property", "value": 1,
    2342            2 :                       "datasetId": "urn:ngsi-ld:null"}});
    2343            6 :         for opts in [
    2344            2 :             ExpandOpts::default(),
    2345            2 :             ExpandOpts {
    2346            2 :                 fragment: true,
    2347            2 :                 allow_null: true,
    2348            2 :                 ..Default::default()
    2349            2 :             },
    2350            2 :             ExpandOpts {
    2351            2 :                 fragment: true,
    2352            2 :                 allow_null: true,
    2353            2 :                 merge: true,
    2354            2 :                 ..Default::default()
    2355            2 :             },
    2356            2 :         ] {
    2357            6 :             assert!(
    2358            6 :                 expand_entity(doc.as_object().unwrap(), &core(), opts).is_err(),
    2359              :                 "datasetId null must be rejected (opts {opts:?})"
    2360              :             );
    2361              :         }
    2362              :         // the attribute-level fragment path (5.6.4) rejects it too
    2363            2 :         let frag = json!({"type": "Property", "value": 1,
    2364            2 :                           "datasetId": "urn:ngsi-ld:null"});
    2365            2 :         assert!(expand_attr_fragment(frag.as_object().unwrap(), &core()).is_err());
    2366              :         // a REAL datasetId still passes and is preserved
    2367            2 :         let ok = expand_entity(
    2368            2 :             json!({"id": "urn:x", "type": "T",
    2369            2 :                 "speed": {"type": "Property", "value": 1,
    2370            2 :                           "datasetId": "urn:ngsi-ld:Dataset:a"}})
    2371            2 :             .as_object()
    2372            2 :             .unwrap(),
    2373            2 :             &core(),
    2374            2 :             ExpandOpts::default(),
    2375              :         )
    2376            2 :         .expect("real datasetId");
    2377            2 :         assert_eq!(
    2378            2 :             ok["https://uri.etsi.org/ngsi-ld/default-context/speed"][0]["datasetId"],
    2379              :             "urn:ngsi-ld:Dataset:a"
    2380              :         );
    2381            2 :     }
    2382              : 
    2383              :     /// 4.6.5 Supported data types for LanguageMaps: keys are RFC 5646 tags
    2384              :     /// or "@none", values are strings or arrays of strings; the
    2385              :     /// {"@none": "urn:ngsi-ld:null"} form is ONLY the partial/merge-patch
    2386              :     /// deletion encoding — invalid in a create/append (no allow_null).
    2387              :     #[test]
    2388            2 :     fn language_map_data_types() {
    2389           10 :         let lp = |lm: Value, opts: ExpandOpts| {
    2390           10 :             let doc = json!({"id": "urn:x", "type": "T",
    2391           10 :                 "brandName": {"type": "LanguageProperty", "languageMap": lm}});
    2392           10 :             expand_entity(doc.as_object().unwrap(), &core(), opts)
    2393           10 :         };
    2394            2 :         let out = lp(
    2395            2 :             json!({"sk": "škola", "en": ["school", "academy"], "@none": "default"}),
    2396            2 :             ExpandOpts::default(),
    2397            2 :         )
    2398            2 :         .expect("strings and arrays of strings");
    2399            2 :         let m = &out["https://uri.etsi.org/ngsi-ld/default-context/brandName"][0]["languageMap"];
    2400            2 :         assert_eq!(m["en"][1], "academy");
    2401            2 :         assert!(m.get("urn:ngsi-ld:null").is_none(), "no null leakage");
    2402              :         // non-string value rejected
    2403            2 :         assert!(lp(json!({"en": 5}), ExpandOpts::default()).is_err());
    2404            2 :         assert!(lp(json!({"en": [5]}), ExpandOpts::default()).is_err());
    2405              :         // the null encoding is a deletion marker: rejected on create,
    2406              :         // accepted under allow_null (patch/merge)
    2407            2 :         assert!(
    2408            2 :             lp(json!({"@none": "urn:ngsi-ld:null"}), ExpandOpts::default()).is_err(),
    2409              :             "langmap null form invalid outside patch/merge"
    2410              :         );
    2411            2 :         lp(
    2412            2 :             json!({"@none": "urn:ngsi-ld:null"}),
    2413            2 :             ExpandOpts {
    2414            2 :                 allow_null: true,
    2415            2 :                 ..ExpandOpts::default()
    2416            2 :             },
    2417            2 :         )
    2418            2 :         .expect("deletion form under allow_null");
    2419            2 :     }
    2420              : 
    2421              :     /// 4.6.3 Supported data types for Values: "All the GeoJSON Geometries
    2422              :     /// [8] with the exception of GeometryCollection" — a GeoProperty value
    2423              :     /// of type GeometryCollection is BadRequestData; plain JSON values and
    2424              :     /// a bare JSON null follow 4.5.2 (null rejected outside merge-patch).
    2425              :     #[test]
    2426            2 :     fn value_data_types_rules() {
    2427            6 :         let geo = |val: Value| {
    2428            6 :             let doc = json!({"id": "urn:x", "type": "T",
    2429            6 :                 "location": {"type": "GeoProperty", "value": val}});
    2430            6 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2431            6 :         };
    2432            2 :         let out = geo(json!({"type": "Point", "coordinates": [17.1, 48.7]}))
    2433            2 :             .expect("Point is a supported geometry");
    2434            2 :         let loc = &out["https://uri.etsi.org/ngsi-ld/location"][0];
    2435            2 :         assert_eq!(loc["value"]["type"], "Point");
    2436            2 :         let err = geo(json!({"type": "GeometryCollection", "geometries": [
    2437            2 :             {"type": "Point", "coordinates": [1.0, 2.0]}]}))
    2438            2 :         .expect_err("GeometryCollection is excluded by 4.6.3");
    2439            2 :         assert!(matches!(err, NgsiError::BadRequestData(_)), "{err:?}");
    2440              :         // a geometry type must still carry coordinates
    2441            2 :         assert!(geo(json!({"type": "Point"})).is_err(), "no coordinates");
    2442              :         // bare JSON null is not a legal Value outside merge-patch (4.5.2)
    2443            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2444            2 :             "speed": {"type": "Property", "value": null}});
    2445            2 :         assert!(
    2446            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2447              :             "bare null value"
    2448              :         );
    2449            2 :     }
    2450              : 
    2451              :     /// 4.6.2 Supported names: name = unicodeLetter *(letter|number|_) —
    2452              :     /// Entity Type / Property / Relationship names with other characters are
    2453              :     /// BadRequestData; keys containing ':' (compact or absolute IRIs) are
    2454              :     /// out of the term grammar's scope.
    2455              :     #[test]
    2456            2 :     fn name_grammar_rules() {
    2457           20 :         let attr = |name: &str| {
    2458           20 :             let doc = json!({"id": "urn:x", "type": "T",
    2459           20 :                 name: {"type": "Property", "value": 1}});
    2460           20 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2461           20 :         };
    2462           10 :         for bad_name in ["my attr", "1temp", "temp-erature", "temp!", "_hidden"] {
    2463           10 :             let err = attr(bad_name).expect_err(bad_name);
    2464              :             // 4.6.2 names the error type: BadRequestData, nothing else
    2465           10 :             assert!(
    2466           10 :                 matches!(err, NgsiError::BadRequestData(_)),
    2467              :                 "{bad_name}: {err:?}"
    2468              :             );
    2469              :         }
    2470           10 :         for good in [
    2471            2 :             "teplota_1",
    2472            2 :             "Ωmega",
    2473            2 :             "výška",
    2474            2 :             "ns:temp",
    2475            2 :             "https://example.com/a b",
    2476           10 :         ] {
    2477           10 :             attr(good).expect(good);
    2478           10 :         }
    2479              :         // sub-attribute names obey the same grammar
    2480            2 :         let doc = json!({"id": "urn:x", "type": "T",
    2481            2 :             "speed": {"type": "Property", "value": 1,
    2482            2 :                 "bad sub": {"type": "Property", "value": 2}}});
    2483            2 :         assert!(
    2484            2 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err(),
    2485              :             "sub-attribute with space must be rejected"
    2486              :         );
    2487              :         // entity type names too; multi-type checks each entry
    2488            6 :         let ty = |t: Value| {
    2489            6 :             let doc = json!({"id": "urn:x", "type": t, "speed": {"type": "Property", "value": 1}});
    2490            6 :             expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2491            6 :         };
    2492            2 :         assert!(ty(json!("My Type")).is_err(), "type with space");
    2493            2 :         assert!(
    2494            2 :             ty(json!(["T", "9T"])).is_err(),
    2495              :             "type starting with a digit"
    2496              :         );
    2497            2 :         ty(json!("Škola")).expect("unicode-letter type");
    2498            2 :     }
    2499              : 
    2500              :     /// 4.5.24.2/4.5.24.3: JsonProperty — json is a raw object or array of
    2501              :     /// objects (kept verbatim), unitCode and value prohibited, concise
    2502              :     /// inference from json.
    2503              :     #[test]
    2504            2 :     fn json_property_rules() {
    2505           12 :         let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "tickets": attr});
    2506              :         // valid: object and array-of-objects, kept verbatim (no expansion)
    2507            2 :         let doc = mk(json!({"type": "JsonProperty", "json": {"id": "x", "value": 1}}));
    2508            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2509            2 :             .expect("valid json object");
    2510            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0];
    2511            2 :         assert_eq!(
    2512            2 :             inst["json"],
    2513            2 :             json!({"id": "x", "value": 1}),
    2514              :             "raw JSON kept verbatim"
    2515              :         );
    2516            2 :         let doc = mk(json!({"type": "JsonProperty", "json": [{"a": 1}, {"b": 2}]}));
    2517            2 :         expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2518            2 :             .expect("array of objects");
    2519              :         // concise inference
    2520            2 :         let doc = mk(json!({"json": {"a": 1}}));
    2521            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2522            2 :             .expect("concise");
    2523            2 :         assert_eq!(
    2524            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/tickets"][0]["type"],
    2525              :             "JsonProperty"
    2526              :         );
    2527              :         // scalar json rejected
    2528            2 :         let doc = mk(json!({"type": "JsonProperty", "json": 5}));
    2529            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2530              :         // unitCode prohibited
    2531            2 :         let doc = mk(json!({"type": "JsonProperty", "json": {"a": 1}, "unitCode": "MTR"}));
    2532            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2533              :         // value prohibited
    2534            2 :         let doc = mk(json!({"type": "JsonProperty", "json": {"a": 1}, "value": 1}));
    2535            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2536            2 :     }
    2537              : 
    2538              :     /// 4.5.21/4.5.22: ListProperty and ListRelationship — objectList accepts
    2539              :     /// bare URIs or {"object": URI} objects (normalized to bare URIs
    2540              :     /// internally), non-URIs rejected, [null] deletion form, value/object
    2541              :     /// prohibited, concise inference.
    2542              :     #[test]
    2543            2 :     fn list_property_and_relationship_rules() {
    2544           14 :         let mk = |name: &str, attr: Value| json!({"id": "urn:x", "type": "T", name: attr});
    2545              :         // ListProperty: ordered array of Property Values, value prohibited
    2546            2 :         let doc = mk(
    2547            2 :             "steps",
    2548            2 :             json!({"type": "ListProperty", "valueList": [1, "two", true]}),
    2549            2 :         );
    2550            2 :         expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).expect("valid");
    2551            2 :         let doc = mk(
    2552            2 :             "steps",
    2553            2 :             json!({"type": "ListProperty", "valueList": [1], "value": 2}),
    2554            2 :         );
    2555            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2556              :         // concise inference
    2557            2 :         let doc = mk("steps", json!({"valueList": [1]}));
    2558            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2559            2 :             .expect("concise");
    2560            2 :         assert_eq!(
    2561            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/steps"][0]["type"],
    2562              :             "ListProperty"
    2563              :         );
    2564              :         // ListRelationship: both entry forms normalize to bare URIs
    2565            2 :         let doc = mk(
    2566            2 :             "route",
    2567            2 :             json!({"type": "ListRelationship",
    2568            2 :             "objectList": ["urn:ngsi-ld:R:1", {"object": "urn:ngsi-ld:R:2"}]}),
    2569            2 :         );
    2570            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2571            2 :             .expect("both forms");
    2572            2 :         assert_eq!(
    2573            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/route"][0]["objectList"],
    2574            2 :             json!(["urn:ngsi-ld:R:1", "urn:ngsi-ld:R:2"])
    2575              :         );
    2576              :         // non-URI entry rejected
    2577            2 :         let doc = mk(
    2578            2 :             "route",
    2579            2 :             json!({"type": "ListRelationship", "objectList": ["not a uri"]}),
    2580            2 :         );
    2581            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2582              :         // object prohibited on ListRelationship
    2583            2 :         let doc = mk(
    2584            2 :             "route",
    2585            2 :             json!({"type": "ListRelationship",
    2586            2 :             "objectList": ["urn:ngsi-ld:R:1"], "object": "urn:ngsi-ld:R:2"}),
    2587            2 :         );
    2588            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2589              :         // [null] deletion form accepted under allow_null
    2590            2 :         let doc = mk(
    2591            2 :             "route",
    2592            2 :             json!({"type": "ListRelationship",
    2593            2 :             "objectList": ["urn:ngsi-ld:null"]}),
    2594            2 :         );
    2595            2 :         let opts = ExpandOpts {
    2596            2 :             allow_null: true,
    2597            2 :             fragment: true,
    2598            2 :             ..Default::default()
    2599            2 :         };
    2600            2 :         expand_entity(doc.as_object().unwrap(), &core(), opts).expect("deletion form");
    2601            2 :         assert!(is_ngsi_null_list(&json!(["urn:ngsi-ld:null"])));
    2602            2 :         assert!(!is_ngsi_null_list(&json!(["urn:ngsi-ld:null", "urn:x"])));
    2603            2 :     }
    2604              : 
    2605              :     /// 4.5.20.2/4.5.20.3: VocabProperty — vocab is a string or array of
    2606              :     /// strings coerced to IRIs; unitCode and value prohibited; concise
    2607              :     /// inference from vocab.
    2608              :     #[test]
    2609            2 :     fn vocab_property_rules() {
    2610           10 :         let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "category": attr});
    2611              :         // normalized: term expands to an IRI under the context
    2612            2 :         let doc = mk(json!({"type": "VocabProperty", "vocab": "non-commercial"}));
    2613            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2614            2 :             .expect("valid vocab");
    2615            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
    2616            2 :         assert_eq!(
    2617            2 :             inst["vocab"],
    2618              :             "https://uri.etsi.org/ngsi-ld/default-context/non-commercial"
    2619              :         );
    2620              :         // concise inference from vocab
    2621            2 :         let doc = mk(json!({"vocab": ["a", "b"]}));
    2622            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2623            2 :             .expect("concise");
    2624            2 :         assert_eq!(
    2625            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/category"][0]["type"],
    2626              :             "VocabProperty"
    2627              :         );
    2628              :         // non-string vocab rejected
    2629            2 :         let doc = mk(json!({"type": "VocabProperty", "vocab": 5}));
    2630            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2631              :         // unitCode prohibited
    2632            2 :         let doc = mk(json!({"type": "VocabProperty", "vocab": "x", "unitCode": "MTR"}));
    2633            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2634              :         // value prohibited
    2635            2 :         let doc = mk(json!({"type": "VocabProperty", "vocab": "x", "value": 1}));
    2636            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2637            2 :     }
    2638              : 
    2639              :     /// 4.5.18.2/4.5.18.3: LanguageProperty — non-empty language tags, unitCode
    2640              :     /// prohibited, value prohibited; concise inference from languageMap.
    2641              :     #[test]
    2642            2 :     fn language_property_rules() {
    2643           12 :         let mk = |attr: Value| json!({"id": "urn:x", "type": "T", "says": attr});
    2644              :         // valid normalized + "@none" tag
    2645            2 :         let doc = mk(json!({"type": "LanguageProperty",
    2646            2 :                             "languageMap": {"en": "hi", "@none": "hey"}}));
    2647            2 :         expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).expect("valid");
    2648              :         // concise inference from languageMap
    2649            2 :         let doc = mk(json!({"languageMap": {"en": "hi"}}));
    2650            2 :         let out = expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default())
    2651            2 :             .expect("concise");
    2652            2 :         assert_eq!(
    2653            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/says"][0]["type"],
    2654              :             "LanguageProperty"
    2655              :         );
    2656              :         // empty language tag rejected
    2657            2 :         let doc = mk(json!({"type": "LanguageProperty", "languageMap": {"": "hi"}}));
    2658            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2659              :         // unitCode prohibited
    2660            2 :         let doc = mk(json!({"type": "LanguageProperty",
    2661            2 :                             "languageMap": {"en": "hi"}, "unitCode": "MTR"}));
    2662            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2663              :         // value prohibited
    2664            2 :         let doc = mk(json!({"type": "LanguageProperty",
    2665            2 :                             "languageMap": {"en": "hi"}, "value": 1}));
    2666            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2667              :         // non-string languageMap values rejected
    2668            2 :         let doc = mk(json!({"type": "LanguageProperty", "languageMap": {"en": 5}}));
    2669            2 :         assert!(expand_entity(doc.as_object().unwrap(), &core(), ExpandOpts::default()).is_err());
    2670            2 :     }
    2671              : }
    2672              : 
    2673              : #[cfg(test)]
    2674              : mod bench {
    2675              :     use super::*;
    2676              : 
    2677              :     /// The go/no-go threshold for a hand-rolled JSON-LD processor was
    2678              :     /// ≥5k expansions/s/core. Antares hand-rolled its processor from day one
    2679              :     /// rather than forking a `json-ld` crate — this measures it. Run with
    2680              :     /// `cargo test -p antares-jsonld --release -- --ignored bench_expansion`.
    2681              :     #[test]
    2682              :     #[ignore = "benchmark — run explicitly in release"]
    2683            0 :     fn bench_expansion_rate() {
    2684            0 :         let loader = crate::Loader::new();
    2685            0 :         let ctx = loader.core();
    2686            0 :         let entity: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
    2687            0 :             r#"{
    2688            0 :             "id": "urn:ngsi-ld:Vehicle:bench-1", "type": "Vehicle",
    2689            0 :             "speed": {"type": "Property", "value": 55.1,
    2690            0 :                       "observedAt": "2026-08-04T12:00:00Z", "unitCode": "KMH",
    2691            0 :                       "source": {"type": "Property", "value": "GPS"}},
    2692            0 :             "heading": {"type": "Property", "value": 180},
    2693            0 :             "isParked": {"type": "Relationship",
    2694            0 :                           "object": "urn:ngsi-ld:OffStreetParking:p1",
    2695            0 :                           "providedBy": {"type": "Relationship",
    2696            0 :                                           "object": "urn:ngsi-ld:Person:bob"}},
    2697            0 :             "location": {"type": "GeoProperty",
    2698            0 :                           "value": {"type": "Point", "coordinates": [13.35, 52.51]}},
    2699            0 :             "name": {"type": "LanguageProperty",
    2700            0 :                       "languageMap": {"en": "car", "de": "Auto"}}
    2701            0 :         }"#,
    2702              :         )
    2703            0 :         .expect("entity");
    2704            0 :         let n = 20_000u32;
    2705            0 :         let start = std::time::Instant::now();
    2706            0 :         for _ in 0..n {
    2707            0 :             let out = expand_entity(&entity, &ctx, ExpandOpts::default()).expect("expand");
    2708            0 :             std::hint::black_box(out);
    2709            0 :         }
    2710            0 :         let secs = start.elapsed().as_secs_f64();
    2711            0 :         let rate = f64::from(n) / secs;
    2712            0 :         eprintln!("expansion rate: {rate:.0}/s/core ({n} iterations in {secs:.2}s)");
    2713            0 :         assert!(
    2714            0 :             rate >= 5_000.0,
    2715              :             "expansion rate {rate:.0}/s is below the 5k/s/core phase-0 gate"
    2716              :         );
    2717            0 :     }
    2718              : }
    2719              : 
    2720              : #[cfg(test)]
    2721              : mod clause_4_18 {
    2722              :     use super::*;
    2723              :     use crate::loader::Loader;
    2724              :     use serde_json::json;
    2725              : 
    2726           28 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    2727           28 :         expand_entity(
    2728           28 :             doc.as_object().expect("obj"),
    2729           28 :             &Loader::new().core(),
    2730           28 :             ExpandOpts::default(),
    2731              :         )
    2732           28 :     }
    2733              : 
    2734           34 :     fn with_scope(s: serde_json::Value) -> serde_json::Value {
    2735           34 :         json!({"id": "urn:x", "type": "T", "scope": s})
    2736           34 :     }
    2737              : 
    2738              :     /// 4.18 Scope grammar: [/] ScopeLevel *(/ScopeLevel), ScopeLevel =
    2739              :     /// unicodeLetter *(letter/number/_). EXAMPLES 1-4 must pass.
    2740              :     #[test]
    2741            2 :     fn scope_grammar_accepts_the_examples() {
    2742            8 :         for s in [
    2743            2 :             "/Madrid",
    2744            2 :             "Madrid",
    2745            2 :             "/Madrid/Gardens/ParqueNorte",
    2746            2 :             "/CompanyA/OrganizationB/UnitC",
    2747            2 :         ] {
    2748            8 :             assert!(expand(with_scope(json!(s))).is_ok(), "{s} must be valid");
    2749              :         }
    2750            2 :         let out = expand(with_scope(json!(["/A", "B/C_2"]))).expect("multi scope");
    2751            2 :         assert_eq!(out["scope"], json!(["/A", "B/C_2"]));
    2752            2 :     }
    2753              : 
    2754              :     /// 4.18: levels start with a letter, carry only letters/digits/_, no
    2755              :     /// empty levels; "urn:ngsi-ld:null" "shall be only used and only appear
    2756              :     /// in case of deleted scopes" — never creatable.
    2757              :     #[test]
    2758            2 :     fn scope_grammar_rejects_malformed_values() {
    2759           14 :         for s in [
    2760            2 :             "9bad",  // level starts with a digit
    2761            2 :             "/a//b", // empty level
    2762            2 :             "a-b",   // '-' not a ScopeLevelChar
    2763            2 :             "/",     // no level at all
    2764            2 :             "",      // empty
    2765            2 :             "/a/b/", // trailing empty level
    2766            2 :             "a b",   // space
    2767            2 :         ] {
    2768           14 :             assert!(
    2769           14 :                 expand(with_scope(json!(s))).is_err(),
    2770              :                 "{s:?} must be rejected"
    2771              :             );
    2772              :         }
    2773            2 :         assert!(
    2774            2 :             expand(with_scope(json!("urn:ngsi-ld:null"))).is_err(),
    2775              :             "the NGSI-LD Null scope is only for deletions, not creation"
    2776              :         );
    2777            2 :         assert!(
    2778            2 :             expand(with_scope(json!(["/ok", "9bad"]))).is_err(),
    2779              :             "one bad entry poisons the array"
    2780              :         );
    2781            2 :     }
    2782              : 
    2783              :     /// 5.5.12 deletes a member whose value IS an NGSI-LD Null, so on the
    2784              :     /// inputs that admit one the sentinel is the whole scope. Beside a real
    2785              :     /// scope it would be stored as one, and 4.18 spells no such scope.
    2786              :     #[test]
    2787            2 :     fn a_null_scope_is_the_whole_value_or_none_of_it() {
    2788            6 :         let merge = |s: serde_json::Value| {
    2789            6 :             expand_entity(
    2790            6 :                 with_scope(s).as_object().expect("obj"),
    2791            6 :                 &Loader::new().core(),
    2792            6 :                 ExpandOpts {
    2793            6 :                     allow_null: true,
    2794            6 :                     ..ExpandOpts::default()
    2795            6 :                 },
    2796              :             )
    2797            6 :         };
    2798            2 :         let out = merge(json!("urn:ngsi-ld:null")).expect("the deletion form");
    2799            2 :         assert_eq!(out["scope"], json!(["urn:ngsi-ld:null"]));
    2800            2 :         assert_eq!(
    2801            2 :             merge(json!(["urn:ngsi-ld:null"])).expect("array form")["scope"],
    2802            2 :             json!(["urn:ngsi-ld:null"])
    2803              :         );
    2804            2 :         assert!(
    2805            2 :             merge(json!(["/Madrid", "urn:ngsi-ld:null"])).is_err(),
    2806              :             "a deletion mixed with a scope is neither"
    2807              :         );
    2808            2 :     }
    2809              : }
    2810              : 
    2811              : #[cfg(test)]
    2812              : mod clause_5_2_1 {
    2813              :     use super::*;
    2814              :     use crate::loader::Loader;
    2815              :     use serde_json::json;
    2816              : 
    2817            6 :     fn expand_create(doc: serde_json::Value) -> Result<Value, NgsiError> {
    2818            6 :         expand_entity(
    2819            6 :             doc.as_object().expect("obj"),
    2820            6 :             &Loader::new().core(),
    2821            6 :             ExpandOpts::default(), // create: allow_null = false
    2822              :         )
    2823            6 :     }
    2824              : 
    2825              :     /// 5.2.1: outside partial-update/merge inputs, "implementations shall
    2826              :     /// raise an error of type BadRequestData if an NGSI-LD Null value is
    2827              :     /// encountered" — Property value, Relationship object, sub-attribute.
    2828              :     #[test]
    2829            2 :     fn ngsi_ld_null_is_rejected_outside_merge_inputs() {
    2830            6 :         for doc in [
    2831            2 :             json!({"id": "urn:x", "type": "T",
    2832            2 :                 "p": {"type": "Property", "value": "urn:ngsi-ld:null"}}),
    2833            2 :             json!({"id": "urn:x", "type": "T",
    2834            2 :                 "r": {"type": "Relationship", "object": "urn:ngsi-ld:null"}}),
    2835            2 :             json!({"id": "urn:x", "type": "T",
    2836            2 :                 "p": {"type": "Property", "value": 1,
    2837            2 :                     "sub": {"type": "Property", "value": "urn:ngsi-ld:null"}}}),
    2838            2 :         ] {
    2839            6 :             let e = expand_create(doc).expect_err("NGSI-LD Null on create must be rejected");
    2840            6 :             assert!(
    2841            6 :                 matches!(e, NgsiError::BadRequestData(_)),
    2842              :                 "must be BadRequestData, got {e:?}"
    2843              :             );
    2844              :         }
    2845            2 :     }
    2846              : 
    2847              :     /// 5.2.1: the deletion marker stays legal on null-allowing inputs
    2848              :     /// (merge/partial-update, 5.5.8/5.5.12).
    2849              :     #[test]
    2850            2 :     fn ngsi_ld_null_survives_on_merge_inputs() {
    2851            2 :         let doc = json!({"p": {"type": "Property", "value": "urn:ngsi-ld:null"}});
    2852            2 :         let out = expand_entity(
    2853            2 :             doc.as_object().expect("obj"),
    2854            2 :             &Loader::new().core(),
    2855            2 :             ExpandOpts {
    2856            2 :                 fragment: true,
    2857            2 :                 allow_null: true,
    2858            2 :                 ..ExpandOpts::default()
    2859            2 :             },
    2860              :         )
    2861            2 :         .expect("merge fragment expands");
    2862            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
    2863            2 :         assert!(
    2864            2 :             is_deletion_instance(inst),
    2865              :             "the marker must remain recognizable: {inst}"
    2866              :         );
    2867            2 :     }
    2868              : 
    2869              :     /// 5.5.4: the marker is legal only in a Fragment used in a partial update
    2870              :     /// or merge. A temporal import allows nulls for 4.5.7 tombstones, but its
    2871              :     /// document is a whole Entity — an entity-level expiresAt carrying the
    2872              :     /// marker there is BadRequestData, not a stored lifetime of
    2873              :     /// "urn:ngsi-ld:null".
    2874              :     #[test]
    2875            2 :     fn the_entity_expires_at_marker_is_only_a_fragment_form() {
    2876            2 :         let doc = json!({
    2877            2 :             "id": "urn:ngsi-ld:Vehicle:1",
    2878            2 :             "type": "Vehicle",
    2879            2 :             "expiresAt": "urn:ngsi-ld:null",
    2880            2 :             "speed": [{"type": "Property", "value": 1}]
    2881              :         });
    2882            2 :         let e = expand_entity(
    2883            2 :             doc.as_object().expect("obj"),
    2884            2 :             &Loader::new().core(),
    2885            2 :             ExpandOpts {
    2886            2 :                 allow_null: true,
    2887            2 :                 temporal: true,
    2888            2 :                 sys: true,
    2889            2 :                 ..ExpandOpts::default()
    2890            2 :             },
    2891              :         )
    2892            2 :         .expect_err("a whole entity cannot ask for the removal");
    2893            2 :         assert!(matches!(e, NgsiError::BadRequestData(_)), "{e:?}");
    2894              : 
    2895              :         // the fragment form still asks for the removal
    2896            2 :         let frag = json!({"expiresAt": "urn:ngsi-ld:null"});
    2897            2 :         let out = expand_entity(
    2898            2 :             frag.as_object().expect("obj"),
    2899            2 :             &Loader::new().core(),
    2900            2 :             ExpandOpts {
    2901            2 :                 fragment: true,
    2902            2 :                 allow_null: true,
    2903            2 :                 ..ExpandOpts::default()
    2904            2 :             },
    2905              :         )
    2906            2 :         .expect("merge fragment expands");
    2907            2 :         assert_eq!(out["expiresAt"], "urn:ngsi-ld:null");
    2908            2 :     }
    2909              : }
    2910              : 
    2911              : #[cfg(test)]
    2912              : mod clause_5_2_4 {
    2913              :     use super::*;
    2914              :     use crate::loader::Loader;
    2915              :     use serde_json::json;
    2916              : 
    2917           24 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    2918           24 :         expand_entity(
    2919           24 :             doc.as_object().expect("obj"),
    2920           24 :             &Loader::new().core(),
    2921           24 :             ExpandOpts::default(),
    2922              :         )
    2923           24 :     }
    2924              : 
    2925              :     /// Table 5.2.4-1: id must be a valid URI; type accepts a short name, a
    2926              :     /// URI, or an array of either; expiresAt must be a 4.6.3 DateTime.
    2927              :     #[test]
    2928            2 :     fn entity_member_table_restrictions() {
    2929            2 :         assert!(expand(json!({"id": "not a uri", "type": "T"})).is_err());
    2930            2 :         assert!(expand(json!({"id": "urn:x", "type": "T"})).is_ok());
    2931            2 :         assert!(expand(json!({"id": "urn:x", "type": ["T", "https://ex.org/U"]})).is_ok());
    2932            2 :         assert!(expand(json!({"id": "urn:x", "type": 5})).is_err());
    2933            2 :         assert!(
    2934            2 :             expand(json!({"id": "urn:x", "type": "T", "expiresAt": "2020-01-01"})).is_err(),
    2935              :             "expiresAt must be a DateTime, not a Date"
    2936              :         );
    2937            2 :         assert!(
    2938            2 :             expand(json!({"id": "urn:x", "type": "T", "expiresAt": "2030-01-01T00:00:00Z"}))
    2939            2 :                 .is_ok()
    2940              :         );
    2941            2 :     }
    2942              : 
    2943              :     /// Table 5.2.4-1: location/observationSpace/operationSpace are
    2944              :     /// GeoProperties (5.2.7) — a plain Property under those names is a
    2945              :     /// violation (4.7.1).
    2946              :     #[test]
    2947            2 :     fn default_geo_names_must_be_geoproperties() {
    2948            6 :         for name in ["location", "observationSpace", "operationSpace"] {
    2949            6 :             let doc = json!({"id": "urn:x", "type": "T",
    2950            6 :                 name: {"type": "Property", "value": 3}});
    2951            6 :             assert!(expand(doc).is_err(), "{name} as a plain Property must 400");
    2952            6 :             let ok = json!({"id": "urn:x", "type": "T",
    2953            6 :                 name: {"type": "GeoProperty",
    2954            6 :                        "value": {"type": "Point", "coordinates": [8, 40]}}});
    2955            6 :             assert!(expand(ok).is_ok(), "{name} as a GeoProperty is fine");
    2956              :         }
    2957            2 :     }
    2958              : }
    2959              : 
    2960              : #[cfg(test)]
    2961              : mod clause_5_2_5 {
    2962              :     use super::*;
    2963              :     use crate::loader::Loader;
    2964              :     use serde_json::json;
    2965              : 
    2966           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    2967           14 :         expand_entity(
    2968           14 :             doc.as_object().expect("obj"),
    2969           14 :             &Loader::new().core(),
    2970           14 :             ExpandOpts::default(),
    2971              :         )
    2972           14 :     }
    2973              : 
    2974           14 :     fn with_p(p: serde_json::Value) -> serde_json::Value {
    2975           14 :         json!({"id": "urn:x", "type": "T", "p": p})
    2976           14 :     }
    2977              : 
    2978              :     /// Table 5.2.5-1: value mandatory (any JSON value), datasetId a URI,
    2979              :     /// observedAt/expiresAt DateTimes, unitCode a string, sub-attributes
    2980              :     /// nest per their own tables.
    2981              :     #[test]
    2982            2 :     fn property_member_table_restrictions() {
    2983            2 :         assert!(
    2984            2 :             expand(with_p(json!({"type": "Property"}))).is_err(),
    2985              :             "value mandatory"
    2986              :         );
    2987            2 :         assert!(
    2988            2 :             expand(with_p(json!({"type": "Property", "value": {"k": [1, "x"]},
    2989            2 :             "datasetId": "urn:ds:1", "observedAt": "2020-09-09T16:40:00Z",
    2990            2 :             "unitCode": "CEL",
    2991            2 :             "sub": {"type": "Relationship", "object": "urn:o:1"}})))
    2992            2 :             .is_ok()
    2993              :         );
    2994            2 :         assert!(expand(with_p(json!({"type": "Property", "value": 1,
    2995            2 :             "datasetId": "not a uri"})))
    2996            2 :         .is_err());
    2997            2 :         assert!(expand(with_p(json!({"type": "Property", "value": 1,
    2998            2 :             "observedAt": "2020-09-09"})))
    2999            2 :         .is_err());
    3000            2 :         assert!(expand(with_p(json!({"type": "Property", "value": 1,
    3001            2 :             "unitCode": 7})))
    3002            2 :         .is_err());
    3003            2 :     }
    3004              : 
    3005              :     /// 5.2.5: in the concise representation type="Property" is inferred from
    3006              :     /// `value` — but a GeoJSON-object value "would be interpreted as a
    3007              :     /// GeoProperty" and so infers GeoProperty, not Property.
    3008              :     #[test]
    3009            2 :     fn concise_inference_and_the_geojson_value_carveout() {
    3010            2 :         let out = expand(with_p(json!({"value": 42}))).expect("concise Property");
    3011            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
    3012            2 :         assert_eq!(inst["type"], "Property");
    3013            2 :         let out = expand(with_p(json!({"value":
    3014            2 :             {"type": "Point", "coordinates": [8, 40]}})))
    3015            2 :         .expect("concise geo value");
    3016            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/p"][0];
    3017            2 :         assert_eq!(
    3018            2 :             inst["type"], "GeoProperty",
    3019              :             "a GeoJSON object value infers GeoProperty (5.2.5/5.2.7)"
    3020              :         );
    3021            2 :     }
    3022              : }
    3023              : 
    3024              : #[cfg(test)]
    3025              : mod clause_5_2_6 {
    3026              :     use super::*;
    3027              :     use crate::loader::Loader;
    3028              :     use serde_json::json;
    3029              : 
    3030           12 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3031           12 :         expand_entity(
    3032           12 :             doc.as_object().expect("obj"),
    3033           12 :             &Loader::new().core(),
    3034           12 :             ExpandOpts::default(),
    3035              :         )
    3036           12 :     }
    3037              : 
    3038           12 :     fn with_r(r: serde_json::Value) -> serde_json::Value {
    3039           12 :         json!({"id": "urn:x", "type": "T", "r": r})
    3040           12 :     }
    3041              : 
    3042              :     /// Table 5.2.6-1: object mandatory — a URI or an array of URIs; datasetId
    3043              :     /// a URI; objectType coerced; concise inference from `object`; unitCode
    3044              :     /// prohibited (4.5.3.2).
    3045              :     #[test]
    3046            2 :     fn relationship_member_table_restrictions() {
    3047            2 :         assert!(
    3048            2 :             expand(with_r(json!({"type": "Relationship"}))).is_err(),
    3049              :             "object mandatory"
    3050              :         );
    3051            2 :         assert!(expand(with_r(
    3052            2 :             json!({"type": "Relationship", "object": "not a uri"})
    3053            2 :         ))
    3054            2 :         .is_err());
    3055            2 :         assert!(
    3056            2 :             expand(with_r(json!({"type": "Relationship",
    3057            2 :             "object": ["urn:a", "urn:b"], "datasetId": "urn:ds:1",
    3058            2 :             "objectType": "Device"})))
    3059            2 :             .is_ok(),
    3060              :             "array of URIs is legal"
    3061              :         );
    3062            2 :         assert!(
    3063            2 :             expand(with_r(json!({"type": "Relationship",
    3064            2 :             "object": ["urn:a", "not a uri"]})))
    3065            2 :             .is_err(),
    3066              :             "every array entry must be a URI"
    3067              :         );
    3068            2 :         assert!(
    3069            2 :             expand(with_r(json!({"type": "Relationship", "object": "urn:a",
    3070            2 :             "unitCode": "C62"})))
    3071            2 :             .is_err(),
    3072              :             "Relationships are unitless"
    3073              :         );
    3074              :         // concise inference from the object member
    3075            2 :         let out = expand(with_r(json!({"object": "urn:o:1"}))).expect("concise");
    3076            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/r"][0];
    3077            2 :         assert_eq!(inst["type"], "Relationship");
    3078            2 :     }
    3079              : }
    3080              : 
    3081              : #[cfg(test)]
    3082              : mod clause_5_2_32 {
    3083              :     use super::*;
    3084              :     use crate::loader::Loader;
    3085              :     use serde_json::json;
    3086              : 
    3087           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3088           14 :         expand_entity(
    3089           14 :             doc.as_object().expect("obj"),
    3090           14 :             &Loader::new().core(),
    3091           14 :             ExpandOpts::default(),
    3092              :         )
    3093           14 :     }
    3094              : 
    3095           14 :     fn with_lp(lp: serde_json::Value) -> serde_json::Value {
    3096           14 :         json!({"id": "urn:x", "type": "T", "greeting": lp})
    3097           14 :     }
    3098              : 
    3099              :     /// Table 5.2.32-1: languageMap keys are non-empty language tags mapping
    3100              :     /// to strings or string arrays; valueType, when present, shall be equal
    3101              :     /// to "langString"; datasetId is a URI; observedAt a DateTime.
    3102              :     #[test]
    3103            2 :     fn language_property_member_table_restrictions() {
    3104            2 :         let ok = expand(with_lp(json!({"type": "LanguageProperty",
    3105            2 :             "languageMap": {"en": "hello", "sk": ["ahoj", "servus"]},
    3106            2 :             "valueType": "langString"})))
    3107            2 :         .expect("conformant LanguageProperty");
    3108            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/greeting"][0];
    3109            2 :         assert_eq!(attr["valueType"], "langString");
    3110            2 :         assert!(attr.get("value").is_none(), "languageMap, not value");
    3111            2 :         assert!(
    3112            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3113            2 :                 "languageMap": {"en": "x"}, "valueType": "xsd:string"})))
    3114            2 :             .is_err(),
    3115              :             "valueType shall be equal to langString"
    3116              :         );
    3117            2 :         assert!(
    3118            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3119            2 :                 "languageMap": {"en": 5}})))
    3120            2 :             .is_err(),
    3121              :             "languageMap values are strings or string arrays"
    3122              :         );
    3123            2 :         assert!(
    3124            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3125            2 :                 "languageMap": {"": "x"}})))
    3126            2 :             .is_err(),
    3127              :             "empty language tag"
    3128              :         );
    3129            2 :         assert!(
    3130            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3131            2 :                 "languageMap": {"en": ["a", 5]}})))
    3132            2 :             .is_err(),
    3133              :             "array entries must all be strings"
    3134              :         );
    3135            2 :         assert!(
    3136            2 :             expand(with_lp(json!({"type": "LanguageProperty"}))).is_err(),
    3137              :             "languageMap is mandatory"
    3138              :         );
    3139            2 :         assert!(
    3140            2 :             expand(with_lp(json!({"type": "LanguageProperty",
    3141            2 :                 "languageMap": {"en": "x"}, "observedAt": "not-a-date"})))
    3142            2 :             .is_err(),
    3143              :             "observedAt must be a DateTime"
    3144              :         );
    3145            2 :     }
    3146              : }
    3147              : 
    3148              : #[cfg(test)]
    3149              : mod clause_5_2_35 {
    3150              :     use super::*;
    3151              :     use crate::loader::Loader;
    3152              :     use serde_json::json;
    3153              : 
    3154           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3155           14 :         expand_entity(
    3156           14 :             doc.as_object().expect("obj"),
    3157           14 :             &Loader::new().core(),
    3158           14 :             ExpandOpts::default(),
    3159              :         )
    3160           14 :     }
    3161              : 
    3162           14 :     fn with_vp(vp: serde_json::Value) -> serde_json::Value {
    3163           14 :         json!({"id": "urn:x", "type": "T", "category": vp})
    3164           14 :     }
    3165              : 
    3166              :     /// Table 5.2.35-1: vocab is a String or String[] type-coerced to URIs
    3167              :     /// under the @context; unitCode is prohibited (4.5.20.2); concise form
    3168              :     /// infers VocabProperty from the vocab member.
    3169              :     #[test]
    3170            2 :     fn vocab_property_member_table_restrictions() {
    3171            2 :         let ok = expand(with_vp(json!({"type": "VocabProperty", "vocab": "term"})))
    3172            2 :             .expect("conformant VocabProperty");
    3173            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
    3174            2 :         assert_eq!(
    3175            2 :             attr["vocab"], "https://uri.etsi.org/ngsi-ld/default-context/term",
    3176              :             "vocab is term-expanded"
    3177              :         );
    3178            2 :         assert!(attr.get("value").is_none(), "vocab, not value");
    3179            2 :         let ok = expand(with_vp(
    3180            2 :             json!({"type": "VocabProperty", "vocab": ["a", "b"]}),
    3181              :         ))
    3182            2 :         .expect("string[] form");
    3183            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
    3184            2 :         assert_eq!(attr["vocab"].as_array().map(Vec::len), Some(2));
    3185            2 :         assert!(
    3186            2 :             expand(with_vp(json!({"type": "VocabProperty", "vocab": ["a", 5]}))).is_err(),
    3187              :             "vocab array entries must be strings"
    3188              :         );
    3189            2 :         assert!(
    3190            2 :             expand(with_vp(json!({"type": "VocabProperty", "vocab": 5}))).is_err(),
    3191              :             "vocab must be a string or string array"
    3192              :         );
    3193            2 :         assert!(
    3194            2 :             expand(with_vp(json!({"type": "VocabProperty"}))).is_err(),
    3195              :             "vocab is mandatory"
    3196              :         );
    3197            2 :         assert!(
    3198            2 :             expand(with_vp(
    3199            2 :                 json!({"type": "VocabProperty", "vocab": "t", "unitCode": "C"})
    3200            2 :             ))
    3201            2 :             .is_err(),
    3202              :             "unitCode prohibited (4.5.20.2)"
    3203              :         );
    3204              :         // concise: the vocab member alone infers VocabProperty
    3205            2 :         let ok = expand(with_vp(json!({"vocab": "term"}))).expect("concise inference");
    3206            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/category"][0];
    3207            2 :         assert_eq!(attr["type"], "VocabProperty");
    3208            2 :     }
    3209              : }
    3210              : 
    3211              : #[cfg(test)]
    3212              : mod clause_5_2_36 {
    3213              :     use super::*;
    3214              :     use crate::loader::Loader;
    3215              :     use serde_json::json;
    3216              : 
    3217            8 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3218            8 :         expand_entity(
    3219            8 :             doc.as_object().expect("obj"),
    3220            8 :             &Loader::new().core(),
    3221            8 :             ExpandOpts::default(),
    3222              :         )
    3223            8 :     }
    3224              : 
    3225            8 :     fn with_list(lp: serde_json::Value) -> serde_json::Value {
    3226            8 :         json!({"id": "urn:x", "type": "T", "readings": lp})
    3227            8 :     }
    3228              : 
    3229              :     /// Table 5.2.36-1: valueList is a mandatory ordered array of JSON
    3230              :     /// values; concise form infers ListProperty from the valueList member.
    3231              :     #[test]
    3232            2 :     fn list_property_member_table_restrictions() {
    3233            2 :         let ok = expand(with_list(json!({"type": "ListProperty",
    3234            2 :             "valueList": [1, "a", {"o": 2}]})))
    3235            2 :         .expect("conformant ListProperty");
    3236            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/readings"][0];
    3237            2 :         assert_eq!(attr["valueList"], json!([1, "a", {"o": 2}]), "order kept");
    3238            2 :         assert!(attr.get("value").is_none(), "valueList, not value");
    3239            2 :         assert!(
    3240            2 :             expand(with_list(json!({"type": "ListProperty", "valueList": 5}))).is_err(),
    3241              :             "valueList must be an array"
    3242              :         );
    3243            2 :         assert!(
    3244            2 :             expand(with_list(json!({"type": "ListProperty"}))).is_err(),
    3245              :             "valueList is mandatory"
    3246              :         );
    3247            2 :         let ok = expand(with_list(json!({"valueList": [1, 2]}))).expect("concise inference");
    3248            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/readings"][0];
    3249            2 :         assert_eq!(attr["type"], "ListProperty");
    3250            2 :     }
    3251              : }
    3252              : 
    3253              : #[cfg(test)]
    3254              : mod clause_5_2_37 {
    3255              :     use super::*;
    3256              :     use crate::loader::Loader;
    3257              :     use serde_json::json;
    3258              : 
    3259           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3260           14 :         expand_entity(
    3261           14 :             doc.as_object().expect("obj"),
    3262           14 :             &Loader::new().core(),
    3263           14 :             ExpandOpts::default(),
    3264              :         )
    3265           14 :     }
    3266              : 
    3267           14 :     fn with_lr(lr: serde_json::Value) -> serde_json::Value {
    3268           14 :         json!({"id": "urn:x", "type": "T", "route" : lr})
    3269           14 :     }
    3270              : 
    3271              :     /// Table 5.2.37-1: objectList is a mandatory array of URIs — accepted
    3272              :     /// both as bare URI strings and as {"object": URI} entries (4.5.22.2);
    3273              :     /// invalid URIs rejected; unitCode prohibited; concise form infers
    3274              :     /// ListRelationship from the objectList member.
    3275              :     #[test]
    3276            2 :     fn list_relationship_member_table_restrictions() {
    3277            4 :         for form in [
    3278            2 :             json!(["urn:a", "urn:b"]),
    3279            2 :             json!([{"object": "urn:a"}, {"object": "urn:b"}]),
    3280            2 :         ] {
    3281            4 :             let ok = expand(with_lr(
    3282            4 :                 json!({"type": "ListRelationship", "objectList": form}),
    3283              :             ))
    3284            4 :             .expect("conformant ListRelationship");
    3285            4 :             let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
    3286            4 :             let list = attr["objectList"].as_array().expect("objectList");
    3287            4 :             assert_eq!(list.len(), 2);
    3288            4 :             assert!(attr.get("object").is_none(), "objectList, not object");
    3289              :         }
    3290            2 :         assert!(
    3291            2 :             expand(with_lr(
    3292            2 :                 json!({"type": "ListRelationship", "objectList": ["not a uri"]})
    3293            2 :             ))
    3294            2 :             .is_err(),
    3295              :             "objectList entries must be URIs"
    3296              :         );
    3297            2 :         assert!(
    3298            2 :             expand(with_lr(
    3299            2 :                 json!({"type": "ListRelationship", "objectList": "urn:a"})
    3300            2 :             ))
    3301            2 :             .is_err(),
    3302              :             "objectList must be an array"
    3303              :         );
    3304            2 :         assert!(
    3305            2 :             expand(with_lr(json!({"type": "ListRelationship"}))).is_err(),
    3306              :             "objectList is mandatory"
    3307              :         );
    3308            2 :         assert!(
    3309            2 :             expand(with_lr(
    3310            2 :                 json!({"type": "ListRelationship", "objectList": ["urn:a"],
    3311            2 :                 "unitCode": "C"})
    3312            2 :             ))
    3313            2 :             .is_err(),
    3314              :             "unitCode prohibited on a ListRelationship"
    3315              :         );
    3316            2 :         let ok = expand(with_lr(json!({"objectList": ["urn:a"]}))).expect("concise inference");
    3317            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/route"][0];
    3318            2 :         assert_eq!(attr["type"], "ListRelationship");
    3319            2 :     }
    3320              : }
    3321              : 
    3322              : #[cfg(test)]
    3323              : mod clause_5_2_38 {
    3324              :     use super::*;
    3325              :     use crate::loader::Loader;
    3326              :     use serde_json::json;
    3327              : 
    3328           14 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3329           14 :         expand_entity(
    3330           14 :             doc.as_object().expect("obj"),
    3331           14 :             &Loader::new().core(),
    3332           14 :             ExpandOpts::default(),
    3333              :         )
    3334           14 :     }
    3335              : 
    3336           14 :     fn with_jp(jp: serde_json::Value) -> serde_json::Value {
    3337           14 :         json!({"id": "urn:x", "type": "T", "payload": jp})
    3338           14 :     }
    3339              : 
    3340              :     /// Table 5.2.38-1: json is a mandatory raw JSON object or array of
    3341              :     /// objects, never expanded; unitCode prohibited (4.5.24.2); concise form
    3342              :     /// infers JsonProperty from the json member.
    3343              :     #[test]
    3344            2 :     fn json_property_member_table_restrictions() {
    3345            2 :         let ok = expand(with_jp(json!({"type": "JsonProperty",
    3346            2 :             "json": {"type": "kept-verbatim", "en": 1}})))
    3347            2 :         .expect("conformant JsonProperty");
    3348            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
    3349            2 :         assert_eq!(
    3350            2 :             attr["json"],
    3351            2 :             json!({"type": "kept-verbatim", "en": 1}),
    3352              :             "raw JSON kept verbatim, no expansion"
    3353              :         );
    3354            2 :         assert!(attr.get("value").is_none(), "json, not value");
    3355            2 :         let ok = expand(with_jp(
    3356            2 :             json!({"type": "JsonProperty", "json": [{"a": 1}, {"b": 2}]}),
    3357              :         ))
    3358            2 :         .expect("array-of-objects form");
    3359            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
    3360            2 :         assert_eq!(attr["json"].as_array().map(Vec::len), Some(2));
    3361            2 :         assert!(
    3362            2 :             expand(with_jp(json!({"type": "JsonProperty", "json": 5}))).is_err(),
    3363              :             "json must be an object or array of objects"
    3364              :         );
    3365            2 :         assert!(
    3366            2 :             expand(with_jp(json!({"type": "JsonProperty", "json": [1, 2]}))).is_err(),
    3367              :             "array entries must be objects"
    3368              :         );
    3369            2 :         assert!(
    3370            2 :             expand(with_jp(json!({"type": "JsonProperty"}))).is_err(),
    3371              :             "json is mandatory"
    3372              :         );
    3373            2 :         assert!(
    3374            2 :             expand(with_jp(
    3375            2 :                 json!({"type": "JsonProperty", "json": {"a": 1}, "unitCode": "C"})
    3376            2 :             ))
    3377            2 :             .is_err(),
    3378              :             "unitCode prohibited on a JsonProperty"
    3379              :         );
    3380            2 :         let ok = expand(with_jp(json!({"json": {"a": 1}}))).expect("concise inference");
    3381            2 :         let attr = &ok["https://uri.etsi.org/ngsi-ld/default-context/payload"][0];
    3382            2 :         assert_eq!(attr["type"], "JsonProperty");
    3383            2 :     }
    3384              : }
    3385              : 
    3386              : #[cfg(test)]
    3387              : mod clause_5_5_4 {
    3388              :     use super::*;
    3389              :     use crate::loader::Loader;
    3390              :     use serde_json::json;
    3391              : 
    3392           12 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3393           12 :         expand_entity(
    3394           12 :             doc.as_object().expect("obj"),
    3395           12 :             &Loader::new().core(),
    3396           12 :             ExpandOpts::default(),
    3397              :         )
    3398           12 :     }
    3399              : 
    3400              :     /// 5.5.4: outside fragments/notifications, "urn:ngsi-ld:null" is
    3401              :     /// BadRequestData as a first-level member value, as a Property value /
    3402              :     /// Relationship object, as the languageMap {"@none": null} form, AND as
    3403              :     /// a key value inside a JSON object that is a Property value.
    3404              :     #[test]
    3405            2 :     fn ngsi_null_rejected_everywhere_on_create() {
    3406           10 :         let with = |attr: serde_json::Value| json!({"id": "urn:x", "type": "T", "a": attr});
    3407            2 :         assert!(
    3408            2 :             expand(json!({"id": "urn:x", "type": "T", "scope": "urn:ngsi-ld:null"})).is_err(),
    3409              :             "first-level member value"
    3410              :         );
    3411            2 :         assert!(
    3412            2 :             expand(with(
    3413            2 :                 json!({"type": "Property", "value": "urn:ngsi-ld:null"})
    3414            2 :             ))
    3415            2 :             .is_err(),
    3416              :             "Property value"
    3417              :         );
    3418            2 :         assert!(
    3419            2 :             expand(with(
    3420            2 :                 json!({"type": "Relationship", "object": "urn:ngsi-ld:null"})
    3421            2 :             ))
    3422            2 :             .is_err(),
    3423              :             "Relationship object"
    3424              :         );
    3425            2 :         assert!(
    3426            2 :             expand(with(json!({"type": "LanguageProperty",
    3427            2 :                 "languageMap": {"@none": "urn:ngsi-ld:null"}})))
    3428            2 :             .is_err(),
    3429              :             "languageMap null form"
    3430              :         );
    3431            2 :         assert!(
    3432            2 :             expand(with(json!({"type": "Property",
    3433            2 :                 "value": {"nested": "urn:ngsi-ld:null"}})))
    3434            2 :             .is_err(),
    3435              :             "null inside a compound Property value"
    3436              :         );
    3437              :         // control: an ordinary compound value stays creatable
    3438            2 :         assert!(expand(with(json!({"type": "Property", "value": {"nested": 1}}))).is_ok());
    3439            2 :     }
    3440              : }
    3441              : 
    3442              : #[cfg(test)]
    3443              : mod clause_5_2_7 {
    3444              :     use super::*;
    3445              :     use crate::loader::Loader;
    3446              :     use serde_json::json;
    3447              : 
    3448           10 :     fn expand(doc: serde_json::Value) -> Result<Value, NgsiError> {
    3449           10 :         expand_entity(
    3450           10 :             doc.as_object().expect("obj"),
    3451           10 :             &Loader::new().core(),
    3452           10 :             ExpandOpts::default(),
    3453              :         )
    3454           10 :     }
    3455              : 
    3456           10 :     fn with_g(g: serde_json::Value) -> serde_json::Value {
    3457           10 :         json!({"id": "urn:x", "type": "T", "g": g})
    3458           10 :     }
    3459              : 
    3460              :     /// Table 5.2.7-1: value must be a 4.7 GeoJSON geometry object (a plain
    3461              :     /// number/string is a violation), GeometryCollection excluded (4.6.3);
    3462              :     /// unitCode prohibited — GeoProperties carry coordinates, not units.
    3463              :     #[test]
    3464            2 :     fn geoproperty_member_table_restrictions() {
    3465            2 :         assert!(expand(with_g(json!({"type": "GeoProperty", "value": 5}))).is_err());
    3466            2 :         assert!(expand(with_g(json!({"type": "GeoProperty",
    3467            2 :             "value": {"type": "Nonsense", "coordinates": [1, 2]}})))
    3468            2 :         .is_err());
    3469            2 :         assert!(expand(with_g(json!({"type": "GeoProperty",
    3470            2 :             "value": {"type": "GeometryCollection", "geometries": []}})))
    3471            2 :         .is_err());
    3472            2 :         assert!(expand(with_g(json!({"type": "GeoProperty",
    3473            2 :             "value": {"type": "Point", "coordinates": [8, 40]},
    3474            2 :             "datasetId": "urn:ds:1"})))
    3475            2 :         .is_ok());
    3476            2 :         assert!(expand(with_g(json!({"type": "GeoProperty",
    3477            2 :             "value": {"type": "LineString",
    3478            2 :                       "coordinates": [[8, 40], [9, 41]]}})))
    3479            2 :         .is_ok());
    3480            2 :     }
    3481              : }
    3482              : 
    3483              : #[cfg(test)]
    3484              : mod reserved_member_guards {
    3485              :     use super::*;
    3486              :     use crate::loader::Loader;
    3487              :     use serde_json::json;
    3488              : 
    3489              :     /// 4.5.1: an Attribute name is expanded against the @context, and the
    3490              :     /// user @context is merged BEFORE the core one — so a term whose "@id"
    3491              :     /// is a bare word stays a RELATIVE IRI. Expanding an attribute onto
    3492              :     /// "id" must not be allowed to replace the Entity id (5.5.4
    3493              :     /// BadRequestData), the same rule expand_types applies to type names.
    3494              :     #[tokio::test]
    3495            2 :     async fn attribute_name_must_expand_to_an_absolute_iri() {
    3496            2 :         let ctx = Loader::new()
    3497            2 :             .resolve_quiet(&json!({"hostile": {"@id": "id"}}))
    3498            2 :             .await
    3499            2 :             .expect("inline @context");
    3500            2 :         assert_eq!(ctx.expand_key("hostile"), "id", "term maps to a bare word");
    3501              : 
    3502            2 :         let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "T",
    3503            2 :             "hostile": {"type": "Property", "value": 1}});
    3504            2 :         let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default());
    3505            2 :         assert!(
    3506            2 :             out.is_err(),
    3507              :             "an attribute name that does not expand to an absolute IRI is BadRequestData, got {out:?}"
    3508              :         );
    3509              :         // negative: the Entity id must still be its own URI string — never
    3510              :         // the attribute's instance array.
    3511            2 :         if let Ok(v) = &out {
    3512            2 :             assert_eq!(v["id"], "urn:ngsi-ld:Vehicle:1");
    3513            2 :         }
    3514            2 :     }
    3515              : 
    3516              :     /// 4.5.1/5.5.4: the same at sub-attribute level — a term expanding onto
    3517              :     /// "observedAt" would replace the validated DateTime with an instance
    3518              :     /// array, bypassing the 4.6.3 DateTime check.
    3519              :     #[tokio::test]
    3520            2 :     async fn sub_attribute_name_must_not_overwrite_observed_at() {
    3521            2 :         let ctx = Loader::new()
    3522            2 :             .resolve_quiet(&json!({"hostile": {"@id": "observedAt"}}))
    3523            2 :             .await
    3524            2 :             .expect("inline @context");
    3525            2 :         assert_eq!(ctx.expand_key("hostile"), "observedAt");
    3526              : 
    3527            2 :         let doc = json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "T",
    3528            2 :             "speed": {"type": "Property", "value": 1,
    3529            2 :                       "observedAt": "2026-01-01T00:00:00Z",
    3530            2 :                       "hostile": {"type": "Property", "value": "x"}}});
    3531            2 :         let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default());
    3532            2 :         assert!(out.is_err(), "expected BadRequestData, got {out:?}");
    3533              : 
    3534              :         // negative: with a well-behaved @context the sub-attribute lands on
    3535              :         // its own IRI and observedAt still holds the DateTime string.
    3536            2 :         let ctx = Loader::new()
    3537            2 :             .resolve_quiet(&json!({"hostile": {"@id": "https://example.org/hostile"}}))
    3538            2 :             .await
    3539            2 :             .expect("inline @context");
    3540            2 :         let out = expand_entity(doc.as_object().expect("obj"), &ctx, ExpandOpts::default())
    3541            2 :             .expect("absolute IRI is fine");
    3542            2 :         let inst = &out["https://uri.etsi.org/ngsi-ld/default-context/speed"][0];
    3543            2 :         assert_eq!(inst["observedAt"], "2026-01-01T00:00:00Z");
    3544            2 :         assert!(inst["https://example.org/hostile"].is_array());
    3545            2 :     }
    3546              : 
    3547              :     /// 4.5.5.1/5.5.8: "datasetId" is a URI string in a partial-update
    3548              :     /// fragment exactly as in a full instance — a non-string one is copied
    3549              :     /// onto the target instance and hides its default slot from every
    3550              :     /// datasetId-absent lookup.
    3551              :     #[test]
    3552            2 :     fn fragment_dataset_id_must_be_a_uri_string() {
    3553           10 :         for bad in [
    3554            2 :             json!(42),
    3555            2 :             json!(["urn:ngsi-ld:Dataset:a"]),
    3556            2 :             json!({"object": "urn:ngsi-ld:Dataset:a"}),
    3557            2 :             json!(true),
    3558            2 :             json!("not a uri"),
    3559            2 :         ] {
    3560           10 :             let frag = json!({"type": "Property", "value": 1, "datasetId": bad});
    3561           10 :             let out = expand_attr_fragment(frag.as_object().expect("obj"), &core());
    3562           10 :             assert!(
    3563           10 :                 out.is_err(),
    3564              :                 "datasetId {bad} must be rejected, got {out:?}"
    3565              :             );
    3566              :             // negative: no non-string datasetId ever reaches the output.
    3567            0 :             if let Ok(Value::Object(m)) = &out {
    3568            0 :                 assert!(m.get("datasetId").is_none_or(Value::is_string));
    3569           10 :             }
    3570              :         }
    3571            2 :         let frag = json!({"type": "Property", "value": 1, "datasetId": "urn:ngsi-ld:Dataset:a"});
    3572            2 :         let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
    3573            2 :             .expect("a URI datasetId is valid");
    3574            2 :         assert_eq!(out["datasetId"], "urn:ngsi-ld:Dataset:a");
    3575            2 :     }
    3576              : 
    3577              :     /// 5.2.1: "In all other cases, implementations shall raise an error of
    3578              :     /// type BadRequestData if an NGSI-LD Null value is encountered" — the
    3579              :     /// concise forms (a bare value, a bare object value) must not be a way
    3580              :     /// around it, or a plain append deletes the instance it targets.
    3581              :     #[test]
    3582            2 :     fn concise_values_do_not_smuggle_the_ngsi_null() {
    3583            8 :         let create = |attr: serde_json::Value| -> Result<Value, NgsiError> {
    3584            8 :             let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T", "a": attr});
    3585            8 :             expand_entity(
    3586            8 :                 doc.as_object().expect("obj"),
    3587            8 :                 &core(),
    3588            8 :                 ExpandOpts::default(),
    3589              :             )
    3590            8 :         };
    3591            8 :         for attr in [
    3592            2 :             json!(["urn:ngsi-ld:null"]),
    3593            2 :             json!({"foo": "urn:ngsi-ld:null"}),
    3594            2 :             json!({"type": "Property", "value": "urn:ngsi-ld:null"}),
    3595            2 :             json!({"value": ["urn:ngsi-ld:null"]}),
    3596            2 :         ] {
    3597            8 :             let out = create(attr.clone());
    3598            8 :             assert!(out.is_err(), "{attr} must be BadRequestData, got {out:?}");
    3599              :         }
    3600              :         // negative: the same documents stay legal on a merge fragment
    3601              :         // (5.5.12), and a create with no sentinel keeps its value intact.
    3602            2 :         let doc = json!({"a": {"foo": "urn:ngsi-ld:null"}});
    3603            2 :         assert!(expand_entity(
    3604            2 :             doc.as_object().expect("obj"),
    3605            2 :             &core(),
    3606            2 :             ExpandOpts {
    3607            2 :                 fragment: true,
    3608            2 :                 allow_null: true,
    3609            2 :                 merge: true,
    3610            2 :                 ..Default::default()
    3611            2 :             }
    3612            2 :         )
    3613            2 :         .is_ok());
    3614            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T", "a": {"foo": "bar"}});
    3615            2 :         let out = expand_entity(
    3616            2 :             doc.as_object().expect("obj"),
    3617            2 :             &core(),
    3618            2 :             ExpandOpts::default(),
    3619              :         )
    3620            2 :         .expect("plain compound value");
    3621            2 :         assert_eq!(
    3622            2 :             out["https://uri.etsi.org/ngsi-ld/default-context/a"][0]["value"]["foo"],
    3623              :             "bar"
    3624              :         );
    3625            2 :     }
    3626              : 
    3627              :     /// 5.2.5 Table 5.2.5-2 preamble: the output-only members "shall not be
    3628              :     /// provided by Context Producers. In the event that they are provided (in
    3629              :     /// update or create operations) NGSI-LD implementations shall ignore
    3630              :     /// them." 4.5.2.2 Prohibited adds "shall never include" for entity,
    3631              :     /// entityList and the previous* family, and entityIdSealed/
    3632              :     /// entityTypeSealed "unless the Property name is ngsildproof".
    3633              :     #[test]
    3634            2 :     fn fragment_ignores_output_only_and_prohibited_members() {
    3635            2 :         let frag = json!({
    3636            2 :             "type": "Property",
    3637            2 :             "value": 5,
    3638            2 :             "previousValue": 999,
    3639            2 :             "previousObject": "urn:ngsi-ld:Other:1",
    3640            2 :             "previousLanguageMap": {"en": "x"},
    3641            2 :             "previousJson": {"a": 1},
    3642            2 :             "previousVocab": "x",
    3643            2 :             "previousValueList": [1],
    3644            2 :             "previousObjectList": ["urn:ngsi-ld:Other:1"],
    3645            2 :             "entity": {"id": "urn:evil", "type": "T"},
    3646            2 :             "entityList": [{"id": "urn:evil", "type": "T"}],
    3647            2 :             "entityIdSealed": "urn:evil",
    3648            2 :             "entityTypeSealed": "T",
    3649            2 :             "deletedAt": "2026-01-01T00:00:00Z",
    3650              :         });
    3651            2 :         let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
    3652            2 :             .expect("the ignored members must not make the fragment invalid");
    3653            2 :         let m = out.as_object().expect("object");
    3654           24 :         for k in [
    3655            2 :             "previousValue",
    3656            2 :             "previousObject",
    3657            2 :             "previousLanguageMap",
    3658            2 :             "previousJson",
    3659            2 :             "previousVocab",
    3660            2 :             "previousValueList",
    3661            2 :             "previousObjectList",
    3662            2 :             "entity",
    3663            2 :             "entityList",
    3664            2 :             "entityIdSealed",
    3665            2 :             "entityTypeSealed",
    3666            2 :             "deletedAt",
    3667            2 :         ] {
    3668           24 :             assert!(
    3669           24 :                 !m.contains_key(k),
    3670              :                 "{k} must be ignored on input, got {out:#}"
    3671              :             );
    3672              :         }
    3673              :         // negative: the members the fragment IS allowed to carry survive.
    3674            2 :         assert_eq!(m["value"], 5);
    3675            2 :         assert_eq!(m["type"], "Property");
    3676            2 :     }
    3677              : 
    3678              :     /// 4.6.3/4.22: expiresAt is an ISO 8601 DateTime in a partial-update
    3679              :     /// fragment exactly as in a full instance — an unparsable one must not
    3680              :     /// reach the transient-entity boundary check.
    3681              :     #[test]
    3682            2 :     fn fragment_expires_at_is_a_datetime() {
    3683            2 :         let frag = json!({"type": "Property", "value": 1, "expiresAt": "soon"});
    3684            2 :         let out = expand_attr_fragment(frag.as_object().expect("obj"), &core());
    3685            2 :         assert!(
    3686            2 :             matches!(out, Err(NgsiError::BadRequestData(_))),
    3687              :             "an invalid expiresAt is BadRequestData, got {out:?}"
    3688              :         );
    3689              :         // negative: no unvalidated expiresAt ever reaches the output.
    3690            0 :         if let Ok(Value::Object(m)) = &out {
    3691            0 :             assert!(m.get("expiresAt").is_none());
    3692            2 :         }
    3693            2 :         let frag = json!({"type": "Property", "value": 1, "expiresAt": "2026-01-01T00:00:00Z"});
    3694            2 :         let out = expand_attr_fragment(frag.as_object().expect("obj"), &core())
    3695            2 :             .expect("a valid DateTime is kept");
    3696            2 :         assert_eq!(out["expiresAt"], "2026-01-01T00:00:00Z");
    3697            2 :     }
    3698              : 
    3699              :     /// 4.5.1: "Terms defined in the Core Context as non-reified Properties
    3700              :     /// (such as datasetId, instanceId, etc.) shall not be used as Attribute
    3701              :     /// names." createdAt/modifiedAt/deletedAt/expiresAt/scope map 1:1 onto
    3702              :     /// their core IRI, so the fully-qualified spelling would compact straight
    3703              :     /// back onto the Entity's own system member.
    3704              :     #[test]
    3705            2 :     fn core_system_member_iris_cannot_be_attribute_names() {
    3706           10 :         for term in ["createdAt", "modifiedAt", "deletedAt", "expiresAt", "scope"] {
    3707           10 :             let mut doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T"});
    3708           10 :             doc.as_object_mut().expect("obj").insert(
    3709           10 :                 format!("https://uri.etsi.org/ngsi-ld/{term}"),
    3710           10 :                 json!({"type": "Property", "value": "pwned"}),
    3711              :             );
    3712           10 :             let out = expand_entity(
    3713           10 :                 doc.as_object().expect("obj"),
    3714           10 :                 &core(),
    3715           10 :                 ExpandOpts::default(),
    3716              :             );
    3717           10 :             assert!(
    3718           10 :                 matches!(out, Err(NgsiError::BadRequestData(_))),
    3719              :                 "{term} as an Attribute name is BadRequestData, got {out:?}"
    3720              :             );
    3721              :             // negative: the poisoned value never reaches the expanded entity.
    3722           10 :             if let Ok(v) = &out {
    3723            0 :                 assert_ne!(v[term], "pwned");
    3724           10 :             }
    3725              :         }
    3726              :         // negative: the system members themselves still expand normally.
    3727            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    3728            2 :             "expiresAt": "2026-01-01T00:00:00Z", "scope": "/a"});
    3729            2 :         let out = expand_entity(
    3730            2 :             doc.as_object().expect("obj"),
    3731            2 :             &core(),
    3732            2 :             ExpandOpts::default(),
    3733              :         )
    3734            2 :         .expect("plain system members");
    3735            2 :         assert_eq!(out["expiresAt"], "2026-01-01T00:00:00Z");
    3736            2 :     }
    3737              : 
    3738              :     /// Table 5.2.6-1: objectType is "String or String[]" and "Both short hand
    3739              :     /// string(s) (type name) or URI(s) are allowed" — both shapes are
    3740              :     /// @vocab-coerced, so the two spellings of one target type cannot be
    3741              :     /// stored differently.
    3742              :     #[test]
    3743            2 :     fn object_type_expands_in_both_string_and_array_form() {
    3744            2 :         let expanded = "https://uri.etsi.org/ngsi-ld/default-context/Device";
    3745           10 :         let rel = |ot: serde_json::Value| -> Result<Value, NgsiError> {
    3746           10 :             let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    3747           10 :                 "r": {"type": "Relationship", "object": "urn:ngsi-ld:D:1", "objectType": ot}});
    3748           10 :             expand_entity(
    3749           10 :                 doc.as_object().expect("obj"),
    3750           10 :                 &core(),
    3751           10 :                 ExpandOpts::default(),
    3752              :             )
    3753           10 :         };
    3754            2 :         let scalar = rel(json!("Device")).expect("scalar objectType");
    3755            2 :         let array = rel(json!(["Device"])).expect("array objectType");
    3756            6 :         let at = |v: &Value| {
    3757            6 :             v["https://uri.etsi.org/ngsi-ld/default-context/r"][0]["objectType"].clone()
    3758            6 :         };
    3759            2 :         assert_eq!(at(&scalar), json!(expanded));
    3760            2 :         assert_eq!(at(&array), json!([expanded]));
    3761              :         // negative: the bare term must never survive unexpanded.
    3762            2 :         assert_ne!(at(&array), json!(["Device"]));
    3763            6 :         for bad in [json!(42), json!({"a": 1}), json!([1])] {
    3764            6 :             let out = rel(bad.clone());
    3765            6 :             assert!(
    3766            6 :                 matches!(out, Err(NgsiError::BadRequestData(_))),
    3767              :                 "objectType {bad} must be rejected, got {out:?}"
    3768              :             );
    3769              :         }
    3770            2 :     }
    3771              : 
    3772              :     /// 4.6.3/Table 5.2.7-1: a GeoProperty value is a clause 4.7 GeoJSON
    3773              :     /// geometry, whose "coordinates" is an array — a scalar or object one is
    3774              :     /// not a geometry and must not reach the GeoJSON rendering path.
    3775              :     #[test]
    3776            2 :     fn geojson_coordinates_must_be_an_array() {
    3777            8 :         for bad in [json!("boom"), json!(42), json!({"lat": 1}), json!(null)] {
    3778            8 :             let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    3779            8 :                 "location": {"type": "GeoProperty",
    3780            8 :                              "value": {"type": "Point", "coordinates": bad}}});
    3781            8 :             let out = expand_entity(
    3782            8 :                 doc.as_object().expect("obj"),
    3783            8 :                 &core(),
    3784            8 :                 ExpandOpts::default(),
    3785              :             );
    3786            8 :             assert!(
    3787            8 :                 matches!(out, Err(NgsiError::BadRequestData(_))),
    3788              :                 "coordinates {bad} must be rejected, got {out:?}"
    3789              :             );
    3790              :         }
    3791              :         // negative: a real geometry still passes untouched.
    3792            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    3793            2 :             "location": {"type": "GeoProperty",
    3794            2 :                          "value": {"type": "Point", "coordinates": [1.0, 2.0]}}});
    3795            2 :         let out = expand_entity(
    3796            2 :             doc.as_object().expect("obj"),
    3797            2 :             &core(),
    3798            2 :             ExpandOpts::default(),
    3799              :         )
    3800            2 :         .expect("valid Point");
    3801            2 :         assert_eq!(
    3802            2 :             out["https://uri.etsi.org/ngsi-ld/location"][0]["value"]["coordinates"],
    3803            2 :             json!([1.0, 2.0])
    3804              :         );
    3805            2 :     }
    3806              : 
    3807              :     /// 4.5.5.1: "There can only be one default Attribute instance for an
    3808              :     /// Attribute with a given Attribute name in any request or response" — a
    3809              :     /// term and its own expanded IRI are one Attribute name, so accepting
    3810              :     /// both would silently discard one client's data.
    3811              :     #[test]
    3812            2 :     fn two_names_expanding_to_one_iri_are_rejected() {
    3813            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    3814            2 :             "temperature": {"type": "Property", "value": 1},
    3815            2 :             "https://uri.etsi.org/ngsi-ld/default-context/temperature":
    3816            2 :                 {"type": "Property", "value": 2}});
    3817            2 :         let out = expand_entity(
    3818            2 :             doc.as_object().expect("obj"),
    3819            2 :             &core(),
    3820            2 :             ExpandOpts::default(),
    3821              :         );
    3822            2 :         assert!(
    3823            2 :             matches!(out, Err(NgsiError::BadRequestData(_))),
    3824              :             "duplicate expanded Attribute name is BadRequestData, got {out:?}"
    3825              :         );
    3826              :         // negative: neither value may survive alone as the single instance.
    3827            2 :         if let Ok(v) = &out {
    3828            0 :             assert!(
    3829            0 :                 v["https://uri.etsi.org/ngsi-ld/default-context/temperature"]
    3830            0 :                     .as_array()
    3831            0 :                     .is_none_or(|a| a.len() != 1)
    3832              :             );
    3833            2 :         }
    3834              :         // the same collision one level down, on sub-attributes.
    3835            2 :         let doc = json!({"id": "urn:ngsi-ld:V:1", "type": "T",
    3836            2 :             "speed": {"type": "Property", "value": 1,
    3837            2 :                 "accuracy": {"type": "Property", "value": 1},
    3838            2 :                 "https://uri.etsi.org/ngsi-ld/default-context/accuracy":
    3839            2 :                     {"type": "Property", "value": 2}}});
    3840            2 :         let out = expand_entity(
    3841            2 :             doc.as_object().expect("obj"),
    3842            2 :             &core(),
    3843            2 :             ExpandOpts::default(),
    3844              :         );
    3845            2 :         assert!(
    3846            2 :             matches!(out, Err(NgsiError::BadRequestData(_))),
    3847              :             "duplicate expanded sub-Attribute name is BadRequestData, got {out:?}"
    3848              :         );
    3849            2 :     }
    3850              : 
    3851           82 :     fn core() -> std::sync::Arc<Context> {
    3852           82 :         Loader::new().core()
    3853           82 :     }
    3854              : 
    3855              :     /// `expand_instance` dispatches on `attr_type` and closes the match with
    3856              :     /// `unreachable!()`. That arm is reachable exactly when `ATTR_TYPES` — a
    3857              :     /// `pub` list, and the gate a CLIENT-supplied `"type"` passes through —
    3858              :     /// carries a member the match does not: 4.5.2 gives Attribute types a
    3859              :     /// closed set, but a later edition adding one to the list without an arm
    3860              :     /// turns `{"type": "<new>"}` into a panic on the request path instead of
    3861              :     /// a Table 6.3.2-1 error.
    3862              :     ///
    3863              :     /// So every member of the list is walked here. A type may legitimately
    3864              :     /// refuse an instance that carries the wrong members (BadRequestData is a
    3865              :     /// fine answer); it may not panic, and it may not answer with an error
    3866              :     /// that is not an NGSI-LD one.
    3867              :     #[test]
    3868            2 :     fn every_declarable_attribute_type_is_dispatched_not_unreachable() {
    3869           16 :         for t in ATTR_TYPES {
    3870           16 :             let doc = serde_json::json!({
    3871           16 :                 "id": "urn:ngsi-ld:Vehicle:dispatch",
    3872           16 :                 "type": "Vehicle",
    3873              :                 // Bare on purpose: 4.5.2.2 lets a value-defining member
    3874              :                 // appear only on its own type, so an instance carrying them
    3875              :                 // all is refused BEFORE the dispatch and would prove nothing.
    3876           16 :                 "attr": {"type": t},
    3877              :             });
    3878              :             // Whatever the answer is, reaching one is the assertion: an
    3879              :             // unhandled type would have panicked before returning.
    3880           16 :             let got = expand_entity(
    3881           16 :                 doc.as_object().expect("object"),
    3882           16 :                 &core(),
    3883           16 :                 ExpandOpts::default(),
    3884              :             );
    3885           16 :             if let Err(e) = got {
    3886           16 :                 assert_eq!(
    3887           16 :                     e.status(),
    3888              :                     400,
    3889              :                     "{t}: an instance carrying the wrong members is BadRequestData, not {e}"
    3890              :                 );
    3891            0 :             }
    3892              :         }
    3893            2 :     }
    3894              : }
        

Generated by: LCOV version 2.0-1