mirror of
https://github.com/logos-co/logos-logoscore-cli.git
synced 2026-08-30 12:21:08 +00:00
`dispatchRejection` matched the single literal "dispatch_failed", so the
refusal `logosctl call` runs into most often went straight through it. Providers
answer a wrong argument COUNT with "invalid_args" — logos-cpp-sdk's cdylib
dispatch and logos-rust-sdk's args::invalid_args both do, and always have — and
no detector anywhere matched it. Measured on this very command:
logosctl call test_basic_module isPositive (missing required argument)
-> exit 0, status:"ok",
result {"code":"invalid_args","message":"expected 1 arguments, got 0",
"origin":"test_basic_module"}
An arity error reported as a successful call that returned a map. It now reports
METHOD_FAILED with error.code "invalid_args", and `logosctl call` exits 4
instead of 0 (call_command.cpp maps any status:"error" that is not a
MODULE_NOT_* to 4).
The parent commit made the envelope read the error CHANNEL instead of judging by
the value; this makes the in-band half of the same split as complete. Both are
one question — "did this call succeed?" — and answering it from one of the two
places was the whole defect.
`code` is now matched against a CLOSED SET, in one named array so it cannot
drift from the rest of the function: dispatch_failed, invalid_args,
unknown_method. Nothing emits "unknown_method" yet and it is listed anyway,
because a detector can be widened compatibly on its own while a new provider
code cannot — one shipped against narrow detectors would arrive as data.
Closed, not open. A method may legitimately return a {code, message, origin}
map of its own, so matching the SHAPE would turn its data into an error. The
three guards (exactly three keys, all present, all strings) are untouched, and
the tests now pin that as behaviour: eight unrecognised codes including
"DISPATCH_FAILED", "dispatch_failed " and "invalid_argument", 2- and 4-key
objects, and a non-string in each of the three slots all stay DATA — plus an
end-to-end case where an unrecognised three-string map comes back as "result".
ONE THING LEFT DELIBERATELY UNDONE, recorded at the declaration: when a provider
eventually emits "unknown_method", it will fold to METHOD_FAILED here rather
than to the METHOD_NOT_FOUND envelope callEnvelope already builds from
introspection. Choosing between those two belongs with the provider-contract
change, not with widening a detector, and any routing written now would be
untested against a real provider.
Verified: 13/13 test_call_envelope, and a negative control — the two new
positive tests FAIL against the pre-change detector and pass against this one,
while the narrowness tests pass against both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
15 KiB
C++
316 lines
15 KiB
C++
// The proxied-call envelope: ok vs METHOD_FAILED vs METHOD_NOT_FOUND.
|
|
//
|
|
// The whole point of this file is ONE distinction. `logosctl call` used to
|
|
// report METHOD_FAILED whenever the module answered null, because
|
|
// core_service_impl.cpp reached for the invokeRemoteMethod overload that has no
|
|
// logos::CallError* parameter and therefore had nothing but the value to judge
|
|
// by. A method that legitimately returns nothing was indistinguishable from a
|
|
// call that never ran.
|
|
//
|
|
// The error channel already carried that distinction everywhere else — lp_invoke
|
|
// branches on callErr.ok() and never on the value — so these tests pin the two
|
|
// answers apart at the layer that used to conflate them:
|
|
//
|
|
// transport reported a failure -> METHOD_FAILED (+ error.code)
|
|
// provider RAN and refused -> METHOD_FAILED (+ its rejection code)
|
|
// module has no such method -> METHOD_NOT_FOUND (+ available_methods)
|
|
// method exists and answered null -> ok, result null <- the change
|
|
//
|
|
// No Qt, no transport, no daemon: callEnvelope is pure, and the introspection
|
|
// hop it needs for the third case arrives as a callback these tests supply.
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include "core_service/call_envelope.h"
|
|
|
|
using core_service::CallFailure;
|
|
using core_service::callEnvelope;
|
|
using core_service::dispatchRejection;
|
|
|
|
namespace {
|
|
|
|
// An introspection hook that records whether it was consulted, so the tests can
|
|
// assert the ordinary path does NOT pay for the extra round-trip.
|
|
struct Lister {
|
|
std::vector<std::string> names;
|
|
mutable int calls = 0;
|
|
|
|
core_service::MethodLister fn() const {
|
|
return [this]() { ++calls; return names; };
|
|
}
|
|
};
|
|
|
|
const Lister kBasicModule{{"returnTrue", "returnNothing", "echo"}};
|
|
|
|
} // namespace
|
|
|
|
// ── The behaviour change: null is a VALUE, not a failure ────────────────────
|
|
|
|
TEST(CallEnvelope, NullFromAKnownMethodIsOk)
|
|
{
|
|
const LogosMap env = callEnvelope("test_basic_module", "returnNothing",
|
|
nlohmann::json(), CallFailure{},
|
|
kBasicModule.fn());
|
|
|
|
EXPECT_EQ(env.value("status", std::string{}), "ok");
|
|
EXPECT_EQ(env.value("module", std::string{}), "test_basic_module");
|
|
EXPECT_EQ(env.value("method", std::string{}), "returnNothing");
|
|
ASSERT_TRUE(env.contains("result"));
|
|
EXPECT_TRUE(env["result"].is_null())
|
|
<< "a null return must survive as a null RESULT, not become an error";
|
|
EXPECT_FALSE(env.contains("code"));
|
|
}
|
|
|
|
TEST(CallEnvelope, FalseyValuesAreOkToo)
|
|
{
|
|
// These all used to be safe (only `null` tripped the old check), but they
|
|
// are the neighbours of the case that broke, so pin them.
|
|
for (const nlohmann::json v : {nlohmann::json(false), nlohmann::json(0),
|
|
nlohmann::json(""), nlohmann::json::array(),
|
|
nlohmann::json::object()}) {
|
|
const LogosMap env = callEnvelope("m", "echo", v, CallFailure{},
|
|
kBasicModule.fn());
|
|
EXPECT_EQ(env.value("status", std::string{}), "ok") << v.dump();
|
|
EXPECT_EQ(env.value("result", nlohmann::json()), v) << v.dump();
|
|
}
|
|
}
|
|
|
|
TEST(CallEnvelope, OrdinaryCallNeverIntrospects)
|
|
{
|
|
Lister lister{{"echo"}};
|
|
callEnvelope("m", "echo", nlohmann::json("hi"), CallFailure{}, lister.fn());
|
|
EXPECT_EQ(lister.calls, 0)
|
|
<< "a non-null result must cost exactly one round-trip";
|
|
}
|
|
|
|
// ── A genuinely failed call still reports METHOD_FAILED ─────────────────────
|
|
|
|
TEST(CallEnvelope, TransportFailureIsMethodFailed)
|
|
{
|
|
// Every code logos::CallError can carry. All of them are failures of the
|
|
// call, and all of them keep the single documented METHOD_FAILED code.
|
|
for (const char* code : {"object_unavailable", "timeout", "transport_error",
|
|
"call_failed", "unauthorized"}) {
|
|
Lister lister{{"returnTrue"}};
|
|
const LogosMap env = callEnvelope(
|
|
"test_basic_module", "returnTrue", nlohmann::json(),
|
|
CallFailure{code, "the transport said so", "test_basic_module"},
|
|
lister.fn());
|
|
|
|
EXPECT_EQ(env.value("status", std::string{}), "error") << code;
|
|
EXPECT_EQ(env.value("code", std::string{}), "METHOD_FAILED") << code;
|
|
// The specific failure stays machine-readable rather than being
|
|
// flattened into prose.
|
|
ASSERT_TRUE(env.contains("error")) << code;
|
|
EXPECT_EQ(env["error"].value("code", std::string{}), code);
|
|
EXPECT_EQ(env["error"].value("origin", std::string{}), "test_basic_module");
|
|
EXPECT_NE(env.value("message", std::string{}).find(code), std::string::npos)
|
|
<< "the human message should name the underlying failure";
|
|
EXPECT_EQ(lister.calls, 0)
|
|
<< "a failed call must not go back to a module that just failed";
|
|
}
|
|
}
|
|
|
|
TEST(CallEnvelope, FailureWinsOverAValue)
|
|
{
|
|
// A transport failure can still hand back a value; the error channel is
|
|
// what decides, in both directions.
|
|
const LogosMap env = callEnvelope("m", "echo", nlohmann::json("leftover"),
|
|
CallFailure{"timeout", "took too long", "m"},
|
|
kBasicModule.fn());
|
|
EXPECT_EQ(env.value("code", std::string{}), "METHOD_FAILED");
|
|
}
|
|
|
|
// ── A provider that RAN and refused ─────────────────────────────────────────
|
|
|
|
TEST(CallEnvelope, DispatchRejectionIsMethodFailed)
|
|
{
|
|
const nlohmann::json refusal{{"code", "dispatch_failed"},
|
|
{"message", "wrong argument count"},
|
|
{"origin", "test_basic_module"}};
|
|
|
|
const LogosMap env = callEnvelope("test_basic_module", "isPositive", refusal,
|
|
CallFailure{}, kBasicModule.fn());
|
|
|
|
EXPECT_EQ(env.value("status", std::string{}), "error");
|
|
EXPECT_EQ(env.value("code", std::string{}), "METHOD_FAILED");
|
|
ASSERT_TRUE(env.contains("error"));
|
|
EXPECT_EQ(env["error"].value("code", std::string{}), "dispatch_failed");
|
|
EXPECT_EQ(env["error"].value("message", std::string{}), "wrong argument count");
|
|
}
|
|
|
|
// The LIVE bug this widening fixes. A provider answers an arity error with
|
|
// {"code":"invalid_args", ...} as its RESULT — logos-cpp-sdk's cdylib dispatch
|
|
// and logos-rust-sdk's args::invalid_args both do, and have all along. Before
|
|
// the detector matched a closed SET rather than the single literal
|
|
// "dispatch_failed", this envelope came back status "ok" with the refusal as
|
|
// the value, and `logosctl call` exited 0. Measured, on
|
|
// `logosctl call test_basic_module isPositive` with the argument missing.
|
|
TEST(CallEnvelope, InvalidArgsIsMethodFailed)
|
|
{
|
|
const nlohmann::json refusal{{"code", "invalid_args"},
|
|
{"message", "expected 1 arguments, got 0"},
|
|
{"origin", "test_basic_module"}};
|
|
|
|
const LogosMap env = callEnvelope("test_basic_module", "isPositive", refusal,
|
|
CallFailure{}, kBasicModule.fn());
|
|
|
|
EXPECT_EQ(env.value("status", std::string{}), "error");
|
|
EXPECT_EQ(env.value("code", std::string{}), "METHOD_FAILED");
|
|
ASSERT_TRUE(env.contains("error"));
|
|
EXPECT_EQ(env["error"].value("code", std::string{}), "invalid_args");
|
|
EXPECT_EQ(env["error"].value("message", std::string{}),
|
|
"expected 1 arguments, got 0");
|
|
// The refusal must NOT also survive as a value: an envelope carrying both
|
|
// would let a caller keep reading it as data.
|
|
EXPECT_FALSE(env.contains("result"));
|
|
}
|
|
|
|
// Every code in the closed set folds, not just dispatch_failed. "unknown_method"
|
|
// is here before any provider emits it — that readiness is the point of doing
|
|
// the detectors first.
|
|
TEST(CallEnvelope, EveryRejectionCodeIsMethodFailed)
|
|
{
|
|
for (const char* code : {"dispatch_failed", "invalid_args", "unknown_method"}) {
|
|
CallFailure out;
|
|
EXPECT_TRUE(dispatchRejection(nlohmann::json{{"code", code},
|
|
{"message", "m"},
|
|
{"origin", "o"}}, out))
|
|
<< code;
|
|
EXPECT_EQ(out.code, code);
|
|
EXPECT_EQ(out.message, "m");
|
|
EXPECT_EQ(out.origin, "o");
|
|
|
|
const LogosMap env = callEnvelope("test_basic_module", "isPositive",
|
|
nlohmann::json{{"code", code},
|
|
{"message", "m"},
|
|
{"origin", "o"}},
|
|
CallFailure{}, kBasicModule.fn());
|
|
EXPECT_EQ(env.value("code", std::string{}), "METHOD_FAILED") << code;
|
|
}
|
|
}
|
|
|
|
// The NEGATIVES. Widening one literal into a set is one careless edit away from
|
|
// "any object with a code", which would hand every method that legitimately
|
|
// returns a three-string map to the error channel. These are what keep the
|
|
// match closed.
|
|
TEST(CallEnvelope, DispatchRejectionMatchStaysNarrow)
|
|
{
|
|
CallFailure out;
|
|
// Right shape, right code.
|
|
EXPECT_TRUE(dispatchRejection(nlohmann::json{{"code", "dispatch_failed"},
|
|
{"message", "m"},
|
|
{"origin", "o"}}, out));
|
|
|
|
// A code OUTSIDE the closed set stays DATA, however plausible. This is the
|
|
// difference between a closed set and an open shape match.
|
|
for (const char* code : {"", "ok", "not_found", "user_error",
|
|
"DISPATCH_FAILED", "dispatch_failed ",
|
|
"invalid_argument", "unknown_methods"}) {
|
|
EXPECT_FALSE(dispatchRejection(nlohmann::json{{"code", code},
|
|
{"message", "m"},
|
|
{"origin", "o"}}, out))
|
|
<< code;
|
|
}
|
|
|
|
// Wrong key COUNT, for every code in the set — 2 keys and 4 keys.
|
|
for (const char* code : {"dispatch_failed", "invalid_args", "unknown_method"}) {
|
|
EXPECT_FALSE(dispatchRejection(nlohmann::json{{"code", code},
|
|
{"message", "m"}}, out))
|
|
<< code;
|
|
EXPECT_FALSE(dispatchRejection(nlohmann::json{{"code", code},
|
|
{"message", "m"},
|
|
{"origin", "o"},
|
|
{"extra", 1}}, out))
|
|
<< code;
|
|
// A non-string in any of the three slots.
|
|
EXPECT_FALSE(dispatchRejection(nlohmann::json{{"code", 7},
|
|
{"message", "m"},
|
|
{"origin", "o"}}, out))
|
|
<< code;
|
|
EXPECT_FALSE(dispatchRejection(nlohmann::json{{"code", code},
|
|
{"message", 7},
|
|
{"origin", "o"}}, out))
|
|
<< code;
|
|
EXPECT_FALSE(dispatchRejection(nlohmann::json{{"code", code},
|
|
{"message", "m"},
|
|
{"origin", nullptr}}, out))
|
|
<< code;
|
|
}
|
|
|
|
EXPECT_FALSE(dispatchRejection(nlohmann::json::array(), out));
|
|
EXPECT_FALSE(dispatchRejection(nlohmann::json(), out));
|
|
|
|
// And end-to-end: an unrecognised three-string map is a RESULT, not an error.
|
|
const nlohmann::json userMap{{"code", "amber"},
|
|
{"message", "hello"},
|
|
{"origin", "sensor"}};
|
|
const LogosMap env = callEnvelope("test_basic_module", "describe", userMap,
|
|
CallFailure{}, kBasicModule.fn());
|
|
EXPECT_EQ(env.value("status", std::string{}), "ok");
|
|
EXPECT_EQ(env["result"], userMap);
|
|
}
|
|
|
|
// ── An unknown method: the one case the wire cannot report ──────────────────
|
|
|
|
TEST(CallEnvelope, UnknownMethodIsMethodNotFound)
|
|
{
|
|
// Providers answer an unknown method with a bare null and no error — see
|
|
// logos_protocol.h and lidl_gen_cdylib.cpp's `return nullptr; // unknown
|
|
// method`. Only the module's own method list can settle it.
|
|
Lister lister{{"returnTrue", "echo"}};
|
|
const LogosMap env = callEnvelope("test_basic_module", "noSuchMethod",
|
|
nlohmann::json(), CallFailure{}, lister.fn());
|
|
|
|
EXPECT_EQ(env.value("status", std::string{}), "error");
|
|
EXPECT_EQ(env.value("code", std::string{}), "METHOD_NOT_FOUND");
|
|
EXPECT_EQ(env.value("message", std::string{}),
|
|
"Method 'noSuchMethod' not found on module 'test_basic_module'.");
|
|
ASSERT_TRUE(env.contains("available_methods"));
|
|
EXPECT_EQ(env["available_methods"],
|
|
nlohmann::json::array({"returnTrue", "echo"}));
|
|
EXPECT_EQ(lister.calls, 1) << "introspection must be consulted exactly once";
|
|
}
|
|
|
|
TEST(CallEnvelope, UnprovableMissingMethodStaysOk)
|
|
{
|
|
// Introspection unavailable (module wedged, transport dropped, provider
|
|
// that publishes nothing). We cannot prove the method is missing, so we do
|
|
// not claim it — that would be the old null-means-failure guess wearing a
|
|
// better name.
|
|
Lister lister{{}};
|
|
const LogosMap env = callEnvelope("m", "whoKnows", nlohmann::json(),
|
|
CallFailure{}, lister.fn());
|
|
|
|
EXPECT_EQ(env.value("status", std::string{}), "ok");
|
|
ASSERT_TRUE(env.contains("result"));
|
|
EXPECT_TRUE(env["result"].is_null());
|
|
EXPECT_EQ(lister.calls, 1);
|
|
}
|
|
|
|
TEST(CallEnvelope, NoListerAtAllStaysOk)
|
|
{
|
|
const LogosMap env = callEnvelope("m", "whoKnows", nlohmann::json(),
|
|
CallFailure{}, nullptr);
|
|
EXPECT_EQ(env.value("status", std::string{}), "ok");
|
|
}
|
|
|
|
// ── The distinction, stated as one assertion ────────────────────────────────
|
|
|
|
TEST(CallEnvelope, EmptyAndFailedAreDistinguishable)
|
|
{
|
|
const LogosMap empty = callEnvelope("m", "returnNothing", nlohmann::json(),
|
|
CallFailure{}, kBasicModule.fn());
|
|
const LogosMap failed = callEnvelope(
|
|
"m", "returnNothing", nlohmann::json(),
|
|
CallFailure{"object_unavailable", "module is gone", "m"},
|
|
kBasicModule.fn());
|
|
|
|
// Same method, same null on the wire, opposite answers — which is the
|
|
// entire point of reading the error channel.
|
|
EXPECT_NE(empty.value("status", std::string{}),
|
|
failed.value("status", std::string{}));
|
|
EXPECT_EQ(empty.value("status", std::string{}), "ok");
|
|
EXPECT_EQ(failed.value("code", std::string{}), "METHOD_FAILED");
|
|
}
|