imsd/tests/Engine/main.cpp

530 lines
28 KiB
C++
Raw Permalink Normal View History

// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Engine unit tests — the call state machine driven through recorded
// network dialogs. Outgoing: reliable-18x PRACK, 2xx ACK + media start,
// media-plane far-end hangup, local hang-up (CANCEL before answer, BYE
// after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180
// on the INVITE (reliable when the caller requires 100rel), Accept -> 200
// with the SDP answer at the offer's payload types + media, ACK silence,
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488, ring
// timeout -> 480, BYE both ways. Pure reducer, no I/O.
import std;
import Imsd;
using namespace imsd::engine;
using imsd::msg::Context;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
Context MakeCtx() {
Context c;
c.id = imsd::aka::MakeIdentity("001010123456789", "001", "01");
c.local = "2001:db8::db43";
c.route = "<sip:[2001:db8::105]:6000;lr>";
c.ppi = "tel:+31611111111";
c.securityServer = "ipsec-3gpp; spi-c=1; spi-s=2; port-c=3; port-s=4";
return c;
}
bool Has(const std::vector<Action>& a, Action::Type t) {
for (const auto& x : a) if (x.type == t) return true;
return false;
}
const Action* Find(const std::vector<Action>& a, Action::Type t) {
for (const auto& x : a) if (x.type == t) return &x;
return nullptr;
}
// First SendClient whose text starts with `method `.
const Action* Sent(const std::vector<Action>& a, std::string_view method) {
for (const auto& x : a)
if (x.type == Action::Type::SendClient && x.text.starts_with(method)) return &x;
return nullptr;
}
// Build a response with the machine's own Call-ID (so dialog matching in a
// real shell would hold; the reducer itself doesn't re-check).
std::string R183(std::string_view callid) {
return std::format(
"SIP/2.0 183 Session Progress\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Record-Route: <sip:[2001:db8::105]:6000;lr>\r\n"
"Require: 100rel\r\n"
"RSeq: 1\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 120\r\n\r\n"
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
}
std::string R200(std::string_view callid) {
return std::format(
"SIP/2.0 200 OK\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 120\r\n\r\n"
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
}
std::string Final(std::string_view callid, int code, std::string_view text) {
return std::format(
"SIP/2.0 {} {}\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n",
code, text, callid);
}
// First SendResponse whose text starts with `prefix`.
const Action* Responded(const std::vector<Action>& a, std::string_view prefix) {
for (const auto& x : a)
if (x.type == Action::Type::SendResponse && x.text.starts_with(prefix)) return &x;
return nullptr;
}
// An inbound (terminating) INVITE the way KPN's P-CSCF delivers one:
// two Vias, a Record-Route pair, P-Asserted-Identity, an AMR-WB offer
// at non-default payload types (104/105) with telephone-event.
std::string MtInvite(std::string_view extraHeaders = "") {
std::string sdp =
"v=0\r\no=- 55 55 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27864 RTP/AVP 104 105\r\n"
"a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=fmtp:104 octet-align=1;mode-change-capability=2\r\n"
"a=rtpmap:105 telephone-event/16000\r\n"
"a=fmtp:105 0-15\r\n"
"a=ptime:20\r\na=sendrecv\r\n";
return std::format(
"INVITE sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
"Via: SIP/2.0/UDP [2001:db8::9]:5060;branch=z9hG4bKscscf1\r\n"
"Record-Route: <sip:[2001:db8::105]:5100;lr>\r\n"
"Record-Route: <sip:[2001:db8::9]:5060;lr>\r\n"
"Max-Forwards: 68\r\n"
"From: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"
"Call-ID: mt-call-abc123\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
"P-Asserted-Identity: <tel:+31612345678>\r\n"
"Session-Expires: 1800\r\n"
"{}"
"Content-Type: application/sdp\r\n"
"Content-Length: {}\r\n\r\n{}",
extraHeaders, sdp.size(), sdp);
}
}
int main() {
Context c = MakeCtx();
// ---- full answered call: INVITE -> 100 -> 183(rel)+PRACK -> 200 -> ACK
// + media -> far-end hangup
{
imsd::util::Rng rng(42);
CallMachine m(c, rng, "ims-call-1", "1233", 50004, false);
auto start = m.Start();
Check(Sent(start, "INVITE ") != nullptr, "Start sends INVITE");
Check(Has(start, Action::Type::SetDeadline), "Start arms ring timeout");
Check(m.State() == CallState::Dialing, "starts dialing");
// 100 Trying: nothing changes
auto a100 = m.OnInviteResponse(Final(m.CallId(), 100, "Trying"));
Check(a100.empty(), "100 Trying is a no-op");
Check(m.State() == CallState::Dialing, "still dialing after 100");
// 183 with reliable 100rel + SDP: -> ringing, PRACK
auto a183 = m.OnInviteResponse(R183(m.CallId()));
Check(m.State() == CallState::Ringing, "183 -> ringing");
const Action* stAct = Find(a183, Action::Type::State);
Check(stAct && stAct->state == "ringing" && stAct->reason == "outgoing", "183 emits ringing/outgoing");
const Action* prack = Sent(a183, "PRACK ");
Check(prack != nullptr, "reliable 183 triggers PRACK");
Check(prack && prack->text.contains("RAck: 1 1 INVITE\r\n"), "PRACK RAck");
Check(prack && prack->text.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq 2");
// PRACK targets the remote Contact and routes via the Record-Route
Check(prack && prack->text.starts_with("PRACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "PRACK targets remote Contact");
Check(prack && prack->text.contains("Route: <sip:[2001:db8::105]:6000;lr>\r\n"), "PRACK routes via reversed Record-Route");
// a second identical 183 must NOT re-PRACK the same RSeq
auto a183b = m.OnInviteResponse(R183(m.CallId()));
Check(Sent(a183b, "PRACK ") == nullptr, "duplicate RSeq not re-PRACKed");
// 200 OK: ACK + StartMedia + active
auto a200 = m.OnInviteResponse(R200(m.CallId()));
const Action* ack = Sent(a200, "ACK ");
Check(ack != nullptr, "200 -> ACK");
Check(ack && ack->text.starts_with("ACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "ACK targets remote Contact");
Check(ack && ack->text.contains("To: <sip:1233@ims>;tag=remote7\r\n"), "ACK carries dialog To tag");
const Action* media = Find(a200, Action::Type::StartMedia);
Check(media != nullptr, "200 starts media");
Check(media && media->media.Valid(), "media leg valid");
Check(media && media->media.remoteIp == "2001:db8::9", "media remote ip");
Check(media && media->media.remotePort == 6004, "media remote port");
Check(media && media->media.payloadType == 97, "media pt");
Check(media && media->media.codec == "AMR-WB", "media codec");
const Action* active = Find(a200, Action::Type::State);
Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted");
Check(m.State() == CallState::Active, "state active");
// media leg exits code 3 (downlink dried up) -> BYE + terminated
auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE");
const Action* term = Find(amx, Action::Type::State);
Check(term && term->state == "terminated" && term->reason == "remote-hangup", "media exit -> terminated/remote-hangup");
Check(Has(amx, Action::Type::StopMedia), "stops media");
Check(Has(amx, Action::Type::Deleted), "emits deleted");
Check(m.Terminated(), "terminated");
}
// ---- remote BYE mid-call
{
imsd::util::Rng rng(7);
CallMachine m(c, rng, "ims-call-2", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R200(m.CallId()));
Check(m.State() == CallState::Active, "answered");
std::string bye = std::format(
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/TCP [x]:1;branch=z9hG4bKb\r\n"
"From: <sip:them>;tag=remote7\r\nTo: <sip:me>;tag=it\r\n"
"Reason: SIP;cause=503;text=\"PT: Session timer expired.\"\r\n"
"Call-ID: {}\r\nCSeq: 9 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ab = m.OnRequest(bye);
const Action* resp = Find(ab, Action::Type::SendResponse);
Check(resp && resp->text.starts_with("SIP/2.0 200 OK"), "remote BYE answered 200");
Check(resp && resp->text.contains("CSeq: 9 BYE\r\n"), "200 echoes BYE CSeq");
const Action* t = Find(ab, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
const Action* lg = Find(ab, Action::Type::Log);
Check(lg && lg->text.contains("ended: remote-hangup") && lg->text.contains("Reason: SIP;cause=503"), "teardown log names the peer's Reason");
Check(m.Terminated(), "terminated after remote BYE");
}
// ---- local hang-up before answer sends CANCEL; the 200/487 race
{
imsd::util::Rng rng(9);
CallMachine m(c, rng, "ims-call-3", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R183(m.CallId())); // ringing
auto ah = m.OnHangup();
Check(Sent(ah, "CANCEL ") != nullptr, "hangup while ringing sends CANCEL");
Check(Has(ah, Action::Type::SetDeadline), "arms 487 timeout after CANCEL");
Check(!m.Terminated(), "not terminated until 487");
// a late 200 wins the race: ACK, then BYE, then terminate local-hangup
auto ar = m.OnInviteResponse(R200(m.CallId()));
Check(Sent(ar, "ACK ") != nullptr, "race 200 is ACKed");
Check(Sent(ar, "BYE ") != nullptr, "race 200 then BYE (we cancelled)");
const Action* t = Find(ar, Action::Type::State);
Check(t && t->reason == "local-hangup", "cancel race -> local-hangup");
// no media for a cancelled call
Check(!Has(ar, Action::Type::StartMedia), "cancelled call starts no media");
}
// ---- 487 after CANCEL
{
imsd::util::Rng rng(11);
CallMachine m(c, rng, "ims-call-4", "1233", 50004, false);
m.Start();
m.OnHangup(); // CANCEL while dialing
auto a487 = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
Check(Sent(a487, "ACK ") != nullptr, "487 is ACKed (non-2xx)");
const Action* t = Find(a487, Action::Type::State);
Check(t && t->reason == "local-hangup", "487 -> local-hangup");
Check(m.Terminated(), "terminated on 487");
}
// ---- busy / declined finals
{
for (auto [code, want] : std::vector<std::pair<int, std::string>>{
{486, "refused-or-busy"}, {480, "refused-or-busy"},
{603, "refused-or-busy"}, {500, "error"}, {403, "error"}}) {
imsd::util::Rng rng(code);
CallMachine m(c, rng, "u", "1233", 50004, false);
m.Start();
auto a = m.OnInviteResponse(Final(m.CallId(), code, "x"));
Check(Sent(a, "ACK ") != nullptr, std::format("{} ACKed", code));
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == want, std::format("{} -> {}", code, want));
Check(m.Terminated(), std::format("{} terminates", code));
}
}
// ---- BYE after answer (local hangup during active call)
{
imsd::util::Rng rng(13);
CallMachine m(c, rng, "ims-call-5", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R200(m.CallId()));
auto a = m.OnHangup();
const Action* bye = Sent(a, "BYE ");
Check(bye != nullptr, "active hangup sends BYE");
Check(bye && bye->text.contains("CSeq: 3 BYE\r\n"), "BYE CSeq after ACK=1");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
}
// ---- setup timeout -> CANCEL, then error
{
imsd::util::Rng rng(15);
CallMachine m(c, rng, "ims-call-6", "1233", 50004, false);
m.Start();
auto a1 = m.OnDeadline(); // ring timeout
Check(Sent(a1, "CANCEL ") != nullptr, "ring timeout sends CANCEL");
Check(!m.Terminated(), "not terminated after first timeout");
auto a2 = m.OnDeadline(); // 487 never came
const Action* t = Find(a2, Action::Type::State);
Check(t && t->reason == "error", "second timeout -> error");
Check(m.Terminated(), "terminated on second timeout");
}
// ================= incoming (terminating / UAS) =======================
// ---- full accepted incoming call: INVITE -> 100+180, Accept -> 200 with
// the answer at the offer's payload types + media, ACK, remote BYE
{
imsd::util::Rng rng(21);
CallMachine m(c, rng, "ims-call-7", IncomingInvite{MtInvite()}, 50004);
Check(m.Dir() == Direction::Incoming, "direction incoming");
Check(m.State() == CallState::Incoming, "starts incoming");
Check(m.Number() == "+31612345678", "caller id from P-Asserted-Identity");
Check(m.CallId() == "mt-call-abc123", "call id from INVITE");
auto ai = m.OnInvite();
const Action* trying = Responded(ai, "SIP/2.0 100 Trying");
Check(trying != nullptr, "INVITE -> 100 Trying");
Check(trying && trying->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"), "100 leaves To untagged");
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
Check(ringing != nullptr, "INVITE -> 180 Ringing");
Check(ringing && !ringing->text.contains("RSeq:"), "180 unreliable when 100rel not required");
Check(Has(ai, Action::Type::SetDeadline), "arms ring timeout");
// both Vias echoed, in order
Check(ringing && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::105]:5100") && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::9]:5060"), "180 echoes all Vias");
Check(ringing && ringing->text.find("[2001:db8::105]:5100") < ringing->text.find("[2001:db8::9]:5060"), "180 Via order preserved");
Check(ringing && ringing->text.contains("Record-Route: <sip:[2001:db8::105]:5100;lr>"), "180 returns the Record-Route set");
Check(ringing && ringing->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001." "3gppnetwork.org;user=phone>;tag="), "180 mints a To tag");
// retransmitted INVITE (lost provisional): repeat the 180
auto art = m.OnRequest(MtInvite());
Check(Responded(art, "SIP/2.0 180 Ringing") != nullptr, "INVITE retransmit re-sends the 180");
Check(m.State() == CallState::Incoming, "still incoming");
auto aa = m.OnAccept();
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
Check(ok != nullptr, "Accept -> 200 OK");
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 104 105\r\n"), "answer uses the offer's payload types");
Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt");
Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align");
Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event");
Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes");
Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact");
// the 180 and 200 carry the SAME To tag (one dialog)
if (ringing && ok) {
auto tag = [](const std::string& s) {
std::size_t at = s.find(";tag=", s.find("To:"));
return s.substr(at, s.find('\r', at) - at);
};
Check(tag(ringing->text) == tag(ok->text), "180/200 share the To tag");
}
const Action* media = Find(aa, Action::Type::StartMedia);
Check(media != nullptr, "Accept starts media");
Check(media && media->media.remoteIp == "2001:db8::30c", "media ip from offer");
Check(media && media->media.remotePort == 27864, "media port from offer");
Check(media && media->media.payloadType == 104, "media pt from offer");
const Action* st = Find(aa, Action::Type::State);
Check(st && st->state == "active" && st->reason == "accepted", "Accept emits active/accepted");
Check(m.State() == CallState::Active, "active after Accept");
// a second Accept is a no-op
Check(m.OnAccept().empty(), "double Accept is a no-op");
// the caller's ACK is consumed silently
std::string ack = std::format(
"ACK sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKack\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 1 ACK\r\nContent-Length: 0\r\n\r\n", m.CallId());
Check(m.OnRequest(ack).empty(), "ACK gets no response");
// session-refresh UPDATE without a body: bodyless 200
std::string upd = std::format(
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 2 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto au = m.OnRequest(upd);
const Action* u200 = Responded(au, "SIP/2.0 200 OK");
Check(u200 != nullptr, "bodyless UPDATE answered 200");
Check(u200 && u200->text.contains("Content-Length: 0"), "bodyless UPDATE gets a bodyless 200");
Check(u200 && !u200->text.contains("Session-Expires"), "no Session-Expires in the UPDATE -> none invented in the 200");
// session-audit UPDATE with Session-Expires (KPN B2BUA, ~55 s into
// every call): the 200 MUST echo it (RFC 4028 §9) or the network
// releases with "Session timer expired"
std::string se = std::format(
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu2\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Min-SE: 90\r\nSession-Expires: 1800;refresher=uac\r\n"
"Supported: timer\r\n"
"Call-ID: {}\r\nCSeq: 3 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ase = m.OnRequest(se);
const Action* se200 = Responded(ase, "SIP/2.0 200 OK");
Check(se200 != nullptr, "refresh UPDATE answered 200");
Check(se200 && se200->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires");
Check(se200 && se200->text.contains("Require: timer\r\n"), "200 carries Require: timer");
// remote BYE ends it
std::string bye = std::format(
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKb\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 4 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ab = m.OnRequest(bye);
Check(Responded(ab, "SIP/2.0 200 OK") != nullptr, "remote BYE answered");
const Action* t = Find(ab, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
Check(m.Terminated(), "terminated");
}
// ---- reliable 180 when the caller requires 100rel; PRACK answered
{
imsd::util::Rng rng(23);
CallMachine m(c, rng, "ims-call-8", IncomingInvite{MtInvite("Require: 100rel\r\n")}, 50004);
auto ai = m.OnInvite();
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
Check(ringing && ringing->text.contains("Require: 100rel\r\n") && ringing->text.contains("RSeq: 1\r\n"), "Require: 100rel makes the 180 reliable");
std::string prack = std::format(
"PRACK sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKp\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 2 PRACK\r\nRAck: 1 1 INVITE\r\n"
"Content-Length: 0\r\n\r\n", m.CallId());
auto ap = m.OnRequest(prack);
const Action* p200 = Responded(ap, "SIP/2.0 200 OK");
Check(p200 != nullptr, "PRACK answered 200");
Check(p200 && p200->text.contains("CSeq: 2 PRACK\r\n"), "PRACK 200 echoes CSeq");
Check(m.State() == CallState::Incoming, "PRACK leaves state incoming");
}
// ---- local reject: HangUp while ringing -> 486
{
imsd::util::Rng rng(25);
CallMachine m(c, rng, "ims-call-9", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
auto ah = m.OnHangup();
const Action* busy = Responded(ah, "SIP/2.0 486 Busy Here");
Check(busy != nullptr, "reject sends 486");
Check(busy && busy->text.contains("CSeq: 1 INVITE\r\n"), "486 answers the INVITE");
const Action* t = Find(ah, Action::Type::State);
Check(t && t->reason == "local-hangup", "reject -> local-hangup");
Check(m.Terminated(), "terminated after reject");
}
// ---- remote CANCEL while ringing: 200 the CANCEL, 487 the INVITE
{
imsd::util::Rng rng(27);
CallMachine m(c, rng, "ims-call-10", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
std::string cancel = std::format(
"CANCEL sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: {}\r\nCSeq: 1 CANCEL\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ac = m.OnRequest(cancel);
const Action* c200 = Responded(ac, "SIP/2.0 200 OK");
Check(c200 && c200->text.contains("CSeq: 1 CANCEL\r\n"), "CANCEL answered 200 on its own transaction");
const Action* r487 = Responded(ac, "SIP/2.0 487 Request Terminated");
Check(r487 && r487->text.contains("CSeq: 1 INVITE\r\n"), "the INVITE transaction gets the 487");
const Action* t = Find(ac, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote CANCEL -> remote-hangup");
Check(m.Terminated(), "terminated after remote CANCEL");
}
// ---- offer we cannot serve (no AMR-WB): 488 before ringing
{
imsd::util::Rng rng(29);
std::string sdp =
"v=0\r\no=- 5 5 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27864 RTP/AVP 8\r\na=rtpmap:8 PCMA/8000\r\n";
std::string inv = std::format(
"INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: mt-pcma\r\nCSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\nContent-Length: {}\r\n\r\n{}",
sdp.size(), sdp);
CallMachine m(c, rng, "ims-call-11", IncomingInvite{inv}, 50004);
auto ai = m.OnInvite();
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "non-AMR-WB offer refused with 488");
Check(Responded(ai, "SIP/2.0 180 Ringing") == nullptr, "no 180 for a 488");
Check(m.Terminated(), "terminated on 488");
}
// ---- ring timeout: 480, missed call
{
imsd::util::Rng rng(31);
CallMachine m(c, rng, "ims-call-12", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
auto ad = m.OnDeadline();
Check(Responded(ad, "SIP/2.0 480 Temporarily Unavailable") != nullptr, "ring timeout sends 480");
const Action* t = Find(ad, Action::Type::State);
Check(t && t->reason == "remote-hangup", "timeout reads as missed");
Check(m.Terminated(), "terminated on ring timeout");
}
// ---- our BYE after accepting: UAS dialog fields hold
{
imsd::util::Rng rng(33);
CallMachine m(c, rng, "ims-call-13", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
m.OnAccept();
auto ah = m.OnHangup();
const Action* bye = Sent(ah, "BYE ");
Check(bye != nullptr, "hangup after accept sends BYE");
Check(bye && bye->text.starts_with("BYE sip:origgw@[2001:db8::9]:5060 SIP/2.0"), "BYE targets the caller's Contact");
Check(bye && bye->text.contains("CSeq: 1 BYE\r\n"), "our UAS CSeq space starts at 1");
Check(bye && bye->text.contains("To: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>" ";tag=caller1\r\n"), "BYE To is the caller (their tag)");
Check(bye && bye->text.contains("From: <sip:001010123456789@ims.mnc001.mcc001." "3gppnetwork.org>;tag="), "BYE From is us (our tag)");
// UAS route set: Record-Route in RECEIVED order
Check(bye && bye->text.contains("Route: <sip:[2001:db8::105]:5100;lr>, " "<sip:[2001:db8::9]:5060;lr>\r\n"), "BYE routes via Record-Route in received order");
const Action* t = Find(ah, Action::Type::State);
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
}
// ---- media death after accept releases the call
{
imsd::util::Rng rng(35);
CallMachine m(c, rng, "ims-call-14", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
m.OnAccept();
auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE (incoming too)");
Check(m.Terminated(), "terminated");
}
if (Failures == 0) std::println("Engine: all tests passed");
return Failures;
}