// test_linkset_json.mbt — Tests for the RFC 9264 `application/linkset+json`
// format (linkset_json.mbt).
///|
/// A one-relation link carrying an explicit anchor context.
fn anchor_link(target : String, rel : String, anchor : String) -> WebLink {
{
target,
relations: [Registered(rel)],
anchor: Some(anchor),
hreflang: [],
media: None,
title: None,
title_star: None,
media_type: None,
extensions: [],
}
}
///|
test "serialize_linkset_json emits the canonical document shape" {
// One minimal link: a single context object with one relation member.
let link = web_link("/page")
link.relations.push(Registered("next"))
let set = LinkSet::from_links([link])
assert_str_eq(
serialize_linkset_json(set),
"{\n \"linkset\": [\n {\n \"next\": [\n {\n \"href\": \"/page\"\n }\n ]\n }\n ]\n}",
)
// A link with an anchor and target attributes groups under the anchor
// context and carries the attributes on the target object.
let rich : WebLink = {
target: "https://example.com/next",
relations: [Registered("next")],
anchor: Some("https://example.com/"),
hreflang: ["en"],
media: Some("print"),
title: Some("Go next"),
title_star: None,
media_type: Some("text/html"),
extensions: [{ name: "x-note", value: Some("v1"), quoted: false }],
}
let rich_json = serialize_linkset_json(LinkSet::from_links([rich]))
assert_true(rich_json.contains("\"anchor\": \"https://example.com/\""))
assert_true(rich_json.contains("\"href\": \"https://example.com/next\""))
assert_true(rich_json.contains("\"hreflang\": [\"en\"]"))
assert_true(rich_json.contains("\"media\": \"print\""))
assert_true(rich_json.contains("\"title\": \"Go next\""))
assert_true(rich_json.contains("\"type\": \"text/html\""))
assert_true(rich_json.contains("\"x-note\": [\"v1\"]"))
// Two links sharing the anchor appear under a single context object.
let a = anchor_link("https://example.com/a", "next", "https://example.com/")
let b = anchor_link("https://example.com/b", "prev", "https://example.com/")
let two_json = serialize_linkset_json(LinkSet::from_links([a, b]))
assert_true(two_json.contains("\"anchor\": \"https://example.com/\""))
assert_true(two_json.contains("\"next\": ["))
assert_true(two_json.contains("\"prev\": ["))
}
///|
test "parse_linkset_json builds the model and round-trips" {
// A minimal document parses to one link with one relation.
let set = unwrap_ok(
parse_linkset_json(
"{\"linkset\": [{\"next\": [{\"href\": \"/page\"}]}]}",
Limits::default(),
),
)
assert_true(set.link_count() == 1)
assert_str_eq(set.links()[0].target(), "/page")
assert_true(set.links()[0].has_relation("next"))
assert_str_eq(serialize_link_header(set.links()), "; rel=\"next\"")
// The canonical serialization of the parsed model is a fixed point.
let canonical = serialize_linkset_json(set)
let reparsed = unwrap_ok(parse_linkset_json(canonical, Limits::default()))
assert_str_eq(serialize_linkset_json(reparsed), canonical)
// A multi-relation link is emitted under each relation member and
// normalizes back to one link per relation type.
let multi = web_link("/p")
multi.relations.push(Registered("next"))
multi.relations.push(Registered("prev"))
let multi_json = serialize_linkset_json(LinkSet::from_links([multi]))
assert_true(multi_json.contains("\"next\": ["))
assert_true(multi_json.contains("\"prev\": ["))
let back = unwrap_ok(parse_linkset_json(multi_json, Limits::default()))
assert_true(back.link_count() == 2)
assert_true(back.links()[0].has_relation("next"))
assert_true(back.links()[1].has_relation("prev"))
// title*, hreflang and extension attributes survive a round trip.
let parsed = unwrap_ok(
parse_linkset_json(
"{\"linkset\":[{\"https://rel.example/x\":[{\"href\":\"/d\",\"title*\":[{\"value\":\"café\",\"language\":\"fr\"}],\"hreflang\":[\"fr\",\"de\"],\"x-tag\":[\"v1\"]}]}]}",
Limits::default(),
),
)
let l = parsed.links()[0]
assert_true(l.has_relation("https://rel.example/x"))
assert_true(l.hreflang().length() == 2)
match l.title_star() {
Some(ev) => {
assert_str_eq(ev.value(), "café")
match ev.language() {
Some(lang) => assert_str_eq(lang, "fr")
None => fail("language missing")
}
}
None => fail("title* missing")
}
assert_true(l.extensions().length() == 1)
assert_str_eq(l.extensions()[0].name(), "x-tag")
}
///|
test "structural and JSON errors are reported as LinkError" {
// Invalid JSON syntax.
let r1 = parse_linkset_json("{\"linkset\": [", Limits::default())
match r1 {
Ok(_) => fail("expected invalid JSON error")
Err(e) => assert_true(e.kind() == InvalidJson)
}
// Top-level value is not an object.
expect_err_kind(
parse_linkset_json("[1, 2]", Limits::default()),
InvalidJsonShape,
)
// Missing "linkset" member.
expect_err_kind(
parse_linkset_json("{\"foo\": []}", Limits::default()),
InvalidJsonShape,
)
// "linkset" is not the sole member.
expect_err_kind(
parse_linkset_json("{\"linkset\": [], \"extra\": 1}", Limits::default()),
InvalidJsonShape,
)
// "linkset" value is not an array.
expect_err_kind(
parse_linkset_json("{\"linkset\": {}}", Limits::default()),
InvalidJsonShape,
)
// A link target object is missing "href".
expect_err_kind(
parse_linkset_json("{\"linkset\": [{\"next\": [{}]}]}", Limits::default()),
InvalidJsonShape,
)
// "href" is not a string.
expect_err_kind(
parse_linkset_json(
"{\"linkset\": [{\"next\": [{\"href\": 5}]}]}",
Limits::default(),
),
InvalidJsonShape,
)
// A relation member is not an array.
expect_err_kind(
parse_linkset_json(
"{\"linkset\": [{\"next\": {\"href\": \"/x\"}}]}",
Limits::default(),
),
InvalidJsonShape,
)
// "anchor" is not a string.
expect_err_kind(
parse_linkset_json(
"{\"linkset\": [{\"anchor\": 5, \"next\": [{\"href\": \"/x\"}]}]}",
Limits::default(),
),
InvalidJsonShape,
)
// A small document parses fine under the default byte bound.
let r10 = parse_linkset_json(
"{\"linkset\": [{\"next\": [{\"href\": \"/x\"}]}]}",
Limits::default(),
)
match r10 {
Ok(_) => ()
Err(_) => fail("expected a small JSON document to parse within limits")
}
}