diff --git a/CMakeLists.txt b/CMakeLists.txt index 4c28a53..49299e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1917,6 +1917,16 @@ if(WOLFRAM_BUILD_TESTS) test_video_typed_httpd PRIVATE test ${MICROHTTPD_INCLUDE_DIR} ${cjson_SOURCE_DIR}) add_test(NAME video_typed_httpd COMMAND test_video_typed_httpd) + + add_executable(test_actor_status_typed_httpd + test/test_actor_status_typed_httpd.c test/mock_pds.c) + target_link_libraries(test_actor_status_typed_httpd + PRIVATE wolfram ${MICROHTTPD_LIBRARY}) + target_include_directories( + test_actor_status_typed_httpd PRIVATE test ${MICROHTTPD_INCLUDE_DIR} + ${cjson_SOURCE_DIR}) + add_test(NAME actor_status_typed_httpd + COMMAND test_actor_status_typed_httpd) endif() endif() diff --git a/docs/roadmap.md b/docs/roadmap.md index 209e3aa..7a511ee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -137,11 +137,16 @@ tested). For what's still ahead, see [Next planned work](#next-planned-work). and getThread (all delegating to the existing `wf_agent_*` API). Tested. 39. `app.bsky.actor.status` typed wrappers (`actor_status_typed.h`) — owned parsers + a record builder for status records/views following the - labeler/actor ownership model. The `getActorStatus`/`getStatus`/`putStatus` - agent wrappers are honest stubs (`WF_ERR_INVALID_ARG` + `TODO`) because the - local lexicon snapshot lacks generated bindings for those endpoints. - Replace these stubs with real implementations in the next major 0.x.0 - update when the upstream lexicon adds query/procedure defs. Tested. + labeler/actor ownership model. `getActorStatus`/`getStatus`/`putStatus` + are not real endpoints in the reference (confirmed against + bluesky-social/atproto and bluesky-social/social-app: no such RPCs + exist anywhere, only the `app.bsky.actor.status` record type), so the + agent wrappers read the status embedded in a getProfile(s) response and + write it via a plain `com.atproto.repo.putRecord`, matching how + social-app's liveNow feature actually does it. `wf_actor_status_parse_view` + also had a bug fixed alongside this: createdAt/durationMinutes live inside + statusView's opaque `record` field, not at the top level, so real + responses always left both unset. Tested. 40. `tools.ozone.*` typed coverage (`ozone.h`/`ozone.c`) — initial batch of typed convenience wrappers for moderation (queryStatuses, getLabelDefs, emitEvent, queryEvents, getEvent, getReporterStats, getSubjects), diff --git a/include/wolfram/actor_status_typed.h b/include/wolfram/actor_status_typed.h index 5a7b3cf..b4262c2 100644 --- a/include/wolfram/actor_status_typed.h +++ b/include/wolfram/actor_status_typed.h @@ -116,17 +116,21 @@ void wf_actor_status_view_free(wf_actor_status_view *v); void wf_actor_status_put_result_free(wf_actor_status_put_result *r); /* ---- Agent convenience wrappers ---- - * Each issues the corresponding lex call against the agent's primary XRPC - * client (after syncing auth) and parses the body into `out`. On success `out` - * is owned by the caller (free with the matching `_free`); on error it is left - * reset. Required inputs are validated and return WF_ERR_INVALID_ARG when - * NULL/empty. + * On success `out` is owned by the caller (free with the matching `_free`); + * on error it is left reset. Required inputs are validated and return + * WF_ERR_INVALID_ARG when NULL/empty. * - * NOTE: the atproto lexicon corpus currently provides no generated call helper - * for app.bsky.actor.status query/procedure endpoints - * (getActorStatus/getStatus/putStatus), so these are honest stubs returning - * WF_ERR_INVALID_ARG with a TODO until the generated lex bindings exist. The - * parsers/builder above are fully functional and network-independent. */ + * getActorStatus/getStatus/putStatus are not real endpoints -- neither the + * local lexicon corpus nor the reference implementation (bluesky-social/ + * atproto, bluesky-social/social-app) defines dedicated RPCs for + * app.bsky.actor.status. The actual feature is read from the "status" field + * of an ordinary getProfile(s) response and written with a plain + * com.atproto.repo.putRecord (collection app.bsky.actor.status, rkey "self"). + * These wrappers do exactly that: wf_agent_get_actor_status fetches and + * decodes the named actor's profile (WF_ERR_NOT_FOUND when it has no live + * status), wf_agent_get_status is the same for the logged-in agent's own + * profile, and wf_agent_put_status builds the record via + * wf_actor_status_build_record and calls wf_agent_put_record. */ wf_status wf_agent_get_actor_status(wf_agent *agent, const char *actor, wf_actor_status_view *out); wf_status wf_agent_get_status(wf_agent *agent, wf_actor_status_view *out); diff --git a/src/agent/actor_status_typed.c b/src/agent/actor_status_typed.c index 750dc0e..be970e6 100644 --- a/src/agent/actor_status_typed.c +++ b/src/agent/actor_status_typed.c @@ -6,10 +6,10 @@ * * Mirrors labeler_typed.c / actor_typed.c: static strdup/set_string/reset * helpers, owned strings, detached `extra` cJSON subtrees where shapes are - * open/unbounded, and full cleanup on the first error. The agent wrappers call - * the generated lex wrappers directly after syncing auth via - * wf_agent_sync_auth; because those helpers are absent for this namespace they - * are honest stubs (see the notes in the header). + * open/unbounded, and full cleanup on the first error. The agent wrappers + * route through the generic getProfile/putRecord primitives rather than a + * dedicated lex call -- see the notes in the header and above + * wf_agent_get_actor_status below. */ #include "wolfram/actor_status_typed.h" @@ -223,15 +223,29 @@ wf_status wf_actor_status_parse_view(const char *json, size_t json_len, if (status == WF_OK) { status = wf_actor_status_take_string(root, "status", &out->status); } + /* + * createdAt/durationMinutes are NOT top-level statusView fields -- + * lexicons/app/bsky/actor/defs.json defines statusView.record as + * {"type": "unknown"}, the raw, unvalidated app.bsky.actor.status + * record the status references. That record is where createdAt and + * durationMinutes actually live; a real AppView's statusView never + * carries them at the top level, so reading them from `root` directly + * always left these two fields unset. Pull them out of `record` + * instead, then discard the rest of it -- wf_actor_status_view has no + * field for the raw record itself, only these two derived values. + */ if (status == WF_OK) { - status = - wf_actor_status_take_string(root, "createdAt", &out->created_at); - } - if (status == WF_OK) { - wf_actor_status_take_int(root, "durationMinutes", - &out->has_duration_minutes, - &out->duration_minutes); - cJSON_DetachItemFromObject(root, "durationMinutes"); + cJSON *record = wf_actor_status_take_object(root, "record"); + if (record) { + status = wf_actor_status_take_string(record, "createdAt", + &out->created_at); + if (status == WF_OK) { + wf_actor_status_take_int(record, "durationMinutes", + &out->has_duration_minutes, + &out->duration_minutes); + } + cJSON_Delete(record); + } } if (status == WF_OK) { status = @@ -328,37 +342,65 @@ wf_status wf_actor_status_build_record(const char *created_at, } /* ---- Agent convenience wrappers ---- - * The atproto lexicon corpus for app.bsky.actor.status only ships a `main` - * record type and a `live` token; it does NOT define the getActorStatus / - * getStatus / putStatus query/procedure endpoints. Because the lexicon - * generator only emits `*_main_call` transport helpers for query/procedure - * `main` defs, no wf_lex_app_bsky_actor_status_{getActorStatus,getStatus, - * putStatus}_main_call bindings exist. These wrappers are therefore honest - * stubs: they validate required inputs and return WF_ERR_INVALID_ARG with a - * TODO explaining what is missing until the upstream lexicon adds those - * endpoints. (Re-running the regenerated lexgen does not change this.) */ + * The atproto lexicon corpus for app.bsky.actor.status ships only a `main` + * record type and a `live` token -- it does NOT define getActorStatus / + * getStatus / putStatus as query/procedure endpoints, and searching the + * reference implementation (bluesky-social/atproto, bluesky-social/social-app) + * confirms those endpoints do not exist there either. The real feature has no + * bespoke RPCs at all: social-app's liveNow feature (src/features/liveNow/ + * index.tsx) reads the status from the `status` field embedded in + * app.bsky.actor.defs#profileView(Detailed) (i.e. an ordinary getProfile/ + * getProfiles response) and writes it with a plain com.atproto.repo.putRecord + * to collection "app.bsky.actor.status", rkey "self" -- exactly the generic + * primitives wf_agent_get_profile_raw and wf_agent_put_record already wrap. + * These wrappers therefore go through those, not a nonexistent dedicated + * call. */ wf_status wf_agent_get_actor_status(wf_agent *agent, const char *actor, wf_actor_status_view *out) { if (!agent || !actor || !actor[0] || !out) { return WF_ERR_INVALID_ARG; } - /* TODO: once wf_lex_app_bsky_actor_status_get_actor_status_main_call and - * its _main_output_decode_json/_free exist, sync auth, call the wrapper, - * decode res.body into `out`, and free the response. The atproto lexicon - * does not yet ship this endpoint's generated binding. */ - return WF_ERR_INVALID_ARG; + wf_response res = {0}; + wf_status status = wf_agent_get_profile_raw(agent, actor, &res); + if (status != WF_OK) { + wf_response_free(&res); + return status; + } + cJSON *root = cJSON_ParseWithLength(res.body, res.body_len); + wf_response_free(&res); + if (!root) { + return WF_ERR_PARSE; + } + /* statusView is embedded under the profile's "status" key; its absence + * means the actor simply has no live status right now, not a parse + * failure. */ + cJSON *status_view = cJSON_GetObjectItemCaseSensitive(root, "status"); + if (!cJSON_IsObject(status_view)) { + cJSON_Delete(root); + memset(out, 0, sizeof(*out)); + return WF_ERR_NOT_FOUND; + } + char *status_json = cJSON_PrintUnformatted(status_view); + cJSON_Delete(root); + if (!status_json) { + return WF_ERR_ALLOC; + } + wf_status parse_status = + wf_actor_status_parse_view(status_json, strlen(status_json), out); + free(status_json); + return parse_status; } wf_status wf_agent_get_status(wf_agent *agent, wf_actor_status_view *out) { if (!agent || !out) { return WF_ERR_INVALID_ARG; } - /* TODO: once wf_lex_app_bsky_actor_status_get_status_main_call and its - * _main_output_decode_json/_free exist, sync auth, call the wrapper, - * decode res.body into `out`, and free the response. The atproto lexicon - * does not yet ship this endpoint's generated binding. */ - return WF_ERR_INVALID_ARG; + const char *did = wf_agent_get_did(agent); + if (!did || !did[0]) { + return WF_ERR_INVALID_ARG; + } + return wf_agent_get_actor_status(agent, did, out); } wf_status wf_agent_put_status(wf_agent *agent, const wf_actor_status *in, @@ -366,11 +408,22 @@ wf_status wf_agent_put_status(wf_agent *agent, const wf_actor_status *in, if (!agent || !in || !out) { return WF_ERR_INVALID_ARG; } - /* TODO: once wf_lex_app_bsky_actor_status_put_status_main_call and its - * _main_output_decode_json/_free exist, build the record via - * wf_actor_status_build_record, sync auth, call the wrapper with the record - * input, decode res.body into `out` (uri/cid/value), and free the - * response. The atproto lexicon does not yet ship this endpoint's - * generated binding. */ - return WF_ERR_INVALID_ARG; + char *record_json = NULL; + wf_status status = wf_actor_status_build_record(in->created_at, in->status, + in->embed, &record_json); + if (status != WF_OK) { + return status; + } + wf_agent_post_result post = {0}; + status = wf_agent_put_record(agent, "app.bsky.actor.status", "self", + record_json, &post); + if (status != WF_OK) { + free(record_json); + return status; + } + memset(out, 0, sizeof(*out)); + out->uri = post.uri; /* transferred */ + out->cid = post.cid; /* transferred */ + out->value = record_json; /* transferred */ + return WF_OK; } diff --git a/test/test_actor_status_typed.c b/test/test_actor_status_typed.c index ca9c597..d6b19cb 100644 --- a/test/test_actor_status_typed.c +++ b/test/test_actor_status_typed.c @@ -22,13 +22,18 @@ static const char *k_record_json = "\"customField\":\"kept\"}"; /* A statusView (app.bsky.actor.defs#statusView) plus task-described envelope - * fields when present. */ + * fields when present. Per lexicons/app/bsky/actor/defs.json, createdAt and + * durationMinutes are NOT top-level statusView fields -- they live inside the + * opaque `record` (the raw app.bsky.actor.status record being referenced). + * `embed` at the top level is the *view*-shaped embed (distinct from the + * record's raw embed input), which is why both appear below. */ static const char *k_view_json = "{\"uri\":\"at://did:plc:abc/app.bsky.actor.status/self\"," "\"cid\":\"bafyreigh\"," "\"status\":\"app.bsky.actor.status#live\"," - "\"createdAt\":\"2024-06-01T12:00:00Z\"," - "\"durationMinutes\":30," + "\"record\":{\"$type\":\"app.bsky.actor.status\"," + "\"status\":\"app.bsky.actor.status#live\"," + "\"createdAt\":\"2024-06-01T12:00:00Z\",\"durationMinutes\":30}," "\"expiresAt\":\"2024-06-01T12:30:00Z\"," "\"isActive\":true,\"isDisabled\":false," "\"actor\":\"did:plc:abc\",\"lastUpdated\":\"2024-06-01T12:00:05Z\"," @@ -144,30 +149,13 @@ int main(void) { WF_CHECK(wf_actor_status_build_record("2024-06-01T00:00:00Z", "x", NULL, NULL) == WF_ERR_INVALID_ARG); - /* agent wrappers: honest stubs return WF_ERR_INVALID_ARG. - * NULL-required-input validation returns the same code. */ + /* agent wrappers: NULL-required-input validation. (Behavioral + * coverage against a real agent lives in + * test_actor_status_typed_httpd.c.) */ WF_CHECK(wf_agent_get_actor_status(NULL, "did:plc:abc", &v) == WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_get_actor_status((wf_agent *)1, NULL, &v) == - WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_get_actor_status((wf_agent *)1, "did:plc:abc", - NULL) == WF_ERR_INVALID_ARG); - /* even with valid inputs the generated binding is absent -> stub */ - WF_CHECK(wf_agent_get_actor_status((wf_agent *)1, "did:plc:abc", &v) == - WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_get_status(NULL, &v) == WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_get_status((wf_agent *)1, NULL) == - WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_get_status((wf_agent *)1, &v) == WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_put_status(NULL, &rec, &res) == WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_put_status((wf_agent *)1, NULL, &res) == - WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_put_status((wf_agent *)1, &rec, NULL) == - WF_ERR_INVALID_ARG); - WF_CHECK(wf_agent_put_status((wf_agent *)1, &rec, &res) == - WF_ERR_INVALID_ARG); wf_actor_status_free(&rec); wf_actor_status_view_free(&v); diff --git a/test/test_actor_status_typed_httpd.c b/test/test_actor_status_typed_httpd.c new file mode 100644 index 0000000..fda530b --- /dev/null +++ b/test/test_actor_status_typed_httpd.c @@ -0,0 +1,183 @@ +/* + * test_actor_status_typed_httpd.c — offline integration tests for the + * app.bsky.actor.status agent wrappers (actor_status_typed.c). Drives a real + * local libmicrohttpd mock PDS (mock_pds.c) end-to-end: getActorStatus/ + * getStatus read the "status" field of a getProfile response, and putStatus + * writes a com.atproto.repo.putRecord — there is no dedicated RPC for any of + * the three, so this is the only way to exercise them against a real + * transport. + * + * Built only when WOLFRAM_BUILD_TEST_HTTPD=ON. + */ + +#include "wolfram/actor_status_typed.h" +#include "wolfram/agent.h" + +#include "mock_pds.h" +#include "test.h" + +#include + +#include +#include + +/* Assert a string field `key` inside object `obj` (NULL = top level) of the + * JSON `body` equals `expect`. */ +static int json_field_eq(const char *body, const char *obj, const char *key, + const char *expect) { + cJSON *root = cJSON_Parse(body); + if (root == NULL) { + return 0; + } + cJSON *cur = root; + if (obj) { + cJSON *o = cJSON_GetObjectItemCaseSensitive(root, obj); + if (!cJSON_IsObject(o)) { + cJSON_Delete(root); + return 0; + } + cur = o; + } + cJSON *f = cJSON_GetObjectItemCaseSensitive(cur, key); + int ok = cJSON_IsString(f) && strcmp(f->valuestring, expect) == 0; + cJSON_Delete(root); + return ok; +} + +int main(void) { + wf_mock_pds *pds = NULL; + int port = 0; + WF_CHECK(wf_mock_pds_start(&pds, &port) == WF_OK); + WF_CHECK(pds != NULL); + WF_CHECK(port > 0); + + const char *session_json = + "{\"did\":\"did:plc:abc123\",\"handle\":\"alice.test\"," + "\"accessJwt\":\"eyJ.fake.access\",\"refreshJwt\":\"eyJ.fake.refresh\"," + "\"active\":true}"; + /* getProfile with an embedded live statusView: createdAt/durationMinutes + * live inside `record`, matching the real defs.json shape (see the + * wf_actor_status_parse_view fix). */ + const char *profile_with_status_json = + "{\"did\":\"did:plc:abc123\",\"handle\":\"alice.test\"," + "\"status\":{\"uri\":\"at://did:plc:abc123/app.bsky.actor.status/" + "self\",\"cid\":\"bafyreigh\",\"status\":\"app.bsky.actor.status#" + "live\",\"record\":{\"$type\":\"app.bsky.actor.status\"," + "\"status\":\"app.bsky.actor.status#live\"," + "\"createdAt\":\"2024-06-01T12:00:00Z\",\"durationMinutes\":30}," + "\"isActive\":true}}"; + const char *profile_no_status_json = + "{\"did\":\"did:plc:abc123\",\"handle\":\"alice.test\"}"; + const char *put_json = + "{\"uri\":\"at://did:plc:abc123/app.bsky.actor.status/self\"," + "\"cid\":\"bafyreigput\"}"; + + WF_CHECK(wf_mock_pds_register(pds, "com.atproto.server.refreshSession", + session_json) == WF_OK); + WF_CHECK(wf_mock_pds_register(pds, "app.bsky.actor.getProfile", + profile_with_status_json) == WF_OK); + WF_CHECK(wf_mock_pds_register(pds, "com.atproto.repo.putRecord", + put_json) == WF_OK); + + char base_url[64]; + snprintf(base_url, sizeof(base_url), "http://127.0.0.1:%d", port); + + wf_agent *agent = wf_agent_new(base_url); + WF_CHECK(agent != NULL); + + wf_session_data data; + memset(&data, 0, sizeof(data)); + data.did = "did:plc:abc123"; + data.handle = "alice.test"; + data.access_jwt = "eyJ.fake.access"; + data.refresh_jwt = "eyJ.fake.refresh"; + data.active = 1; + WF_CHECK(wf_agent_resume(agent, &data) == WF_OK); + + /* ---- getActorStatus: profile has a live status ---- */ + { + wf_actor_status_view v = {0}; + WF_CHECK(wf_agent_get_actor_status(agent, "did:plc:abc123", &v) == + WF_OK); + WF_CHECK(v.uri && strcmp(v.uri, "at://did:plc:abc123/" + "app.bsky.actor.status/self") == 0); + WF_CHECK(v.cid && strcmp(v.cid, "bafyreigh") == 0); + WF_CHECK(v.status && + strcmp(v.status, "app.bsky.actor.status#live") == 0); + /* The bug this test guards against: created_at/duration_minutes + * come from the nested `record`, not the top level. */ + WF_CHECK(v.created_at && + strcmp(v.created_at, "2024-06-01T12:00:00Z") == 0); + WF_CHECK(v.has_duration_minutes && v.duration_minutes == 30); + WF_CHECK(v.has_is_active && v.is_active); + wf_actor_status_view_free(&v); + } + + /* ---- getStatus: same path, using the agent's own DID ---- */ + { + wf_actor_status_view v = {0}; + WF_CHECK(wf_agent_get_status(agent, &v) == WF_OK); + WF_CHECK(v.status && + strcmp(v.status, "app.bsky.actor.status#live") == 0); + WF_CHECK(v.created_at && + strcmp(v.created_at, "2024-06-01T12:00:00Z") == 0); + wf_actor_status_view_free(&v); + } + + /* ---- getActorStatus: profile has no live status -> NOT_FOUND ---- */ + { + WF_CHECK(wf_mock_pds_register(pds, "app.bsky.actor.getProfile", + profile_no_status_json) == WF_OK); + wf_actor_status_view v = {0}; + WF_CHECK(wf_agent_get_actor_status(agent, "did:plc:abc123", &v) == + WF_ERR_NOT_FOUND); + wf_actor_status_view_free(&v); /* safe on a reset struct */ + /* restore for any later use */ + WF_CHECK(wf_mock_pds_register(pds, "app.bsky.actor.getProfile", + profile_with_status_json) == WF_OK); + } + + /* ---- putStatus: builds the record and calls putRecord ---- */ + { + wf_actor_status in = {0}; + in.status = strdup("app.bsky.actor.status#live"); + in.created_at = strdup("2024-06-01T12:00:00Z"); + in.has_duration_minutes = true; + in.duration_minutes = 15; + + wf_actor_status_put_result out = {0}; + WF_CHECK(wf_agent_put_status(agent, &in, &out) == WF_OK); + WF_CHECK(out.uri && strcmp(out.uri, "at://did:plc:abc123/" + "app.bsky.actor.status/self") == 0); + WF_CHECK(out.cid && strcmp(out.cid, "bafyreigput") == 0); + WF_CHECK(out.value != NULL); + + const char *last_nsid = NULL; + const char *last_method = NULL; + const char *last_body = NULL; + WF_CHECK(wf_mock_pds_get_last_request(pds, &last_nsid, &last_method, + &last_body) == WF_OK); + WF_CHECK(last_nsid && + strcmp(last_nsid, "com.atproto.repo.putRecord") == 0); + WF_CHECK(last_body != NULL); + if (last_body) { + WF_CHECK(json_field_eq(last_body, NULL, "repo", "did:plc:abc123")); + WF_CHECK(json_field_eq(last_body, NULL, "collection", + "app.bsky.actor.status")); + WF_CHECK(json_field_eq(last_body, NULL, "rkey", "self")); + WF_CHECK(json_field_eq(last_body, "record", "status", + "app.bsky.actor.status#live")); + WF_CHECK(json_field_eq(last_body, "record", "createdAt", + "2024-06-01T12:00:00Z")); + } + + wf_actor_status_free(&in); + wf_actor_status_put_result_free(&out); + } + + wf_agent_free(agent); + wf_mock_pds_free(pds); + + WF_TEST_SUMMARY(); + return 0; /* unreachable */ +}