2121//!
2222//! - **Cursor pagination on every table** (`pageToken` in, top-level
2323//! `$.pageToken` out; page size 100 — im/tasks maximum — or 50, the
24- //! wiki maximum). Termination is complete on the null-token spelling
25- //! the executors normalize to; `hasMore` is redundant with the null
26- //! token and deliberately unused. A repeated token fails as
27- //! `PaginationLoop`, a non-string token as `PaginationCursorInvalid` —
28- //! never a silent truncation. No feishu executor filters fetched pages,
29- //! so the cursor's termination signal is undamaged.
24+ //! wiki maximum), with `$.hasMore` declared as the AUTHORITATIVE
25+ //! termination signal (`has_more_path`). That is a live-verification
26+ //! correction, not a nicety: Feishu's wiki space listing answers its
27+ //! final page with `has_more: false` beside a NON-empty `page_token`
28+ //! (`"0||…"`, captured 2026-08-04), so the null-token spelling alone
29+ //! would refetch a finished scan and die as `PaginationLoop`. With the
30+ //! signal declared, `hasMore: true` without a usable token is contract
31+ //! drift (`PaginationCursorInvalid`), a non-boolean signal is
32+ //! `PaginationHasMoreInvalid` — never a silent truncation. No feishu
33+ //! executor filters fetched pages, so the signals are undamaged.
3034//! - **Orderings are pinned for cursor stability**: `chats` and
3135//! `messages` pin `sortType: ByCreateTimeAsc` because the API's
3236//! activity-ordered default reshuffles rows mid-scan (skips and
4145//! `endTime` is deliberately unmapped: it is EXCLUSIVE, and flooring
4246//! an upper bound drops rows.
4347//! - **`tasks` pins `type: my_tasks`** (the executor default, declared
44- //! rather than inherited) and omits `completed` by default — Feishu's
45- //! state=all. The `completed` equality pushdown stays `Inexact` like
46- //! every enum-ish push.
48+ //! rather than inherited) and omits the action's `completed` input —
49+ //! Feishu's state=all. Live rows (2026-08-04) carry NO `completed`
50+ //! boolean: completion is `status` (todo|done) plus `completed_at`,
51+ //! whose "not completed" spelling is the digit string "0" (epoch-zero
52+ //! sentinel, documented at the column). The `completed` input is
53+ //! therefore deliberately unmapped — a `status` string cannot render
54+ //! into a boolean input without a value transform the filter engine
55+ //! deliberately lacks, and a mapping on an always-NULL draft column
56+ //! would have re-trimmed every row to zero.
4757//! - **`wiki_nodes` lists ONE level** (children of `parentNodeToken`,
4858//! space root when omitted) — the action's own shape; full-tree
4959//! traversal is client-side recursion, documented in the pack doc.
5565//! item schema LOOSE (`additionalProperties: true`, zero declared
5666//! properties) — so ALL mapped columns ride passthrough outside the
5767//! fingerprint gate, and the coverage-gap pin records that honestly.
58- //! Column truth is therefore settled ONLY by real rows: the sets
59- //! below are drafted from Feishu's API reference and MUST be
60- //! reconciled against a live workspace (skill phase 4) before this
61- //! pack is submission-ready; the fixtures are synthetic drafts to be
62- //! re-derived as redacted live captures in that phase.
68+ //! Column truth is therefore settled ONLY by real rows. `tasks`,
69+ //! `wiki_spaces`, and `wiki_nodes` are reconciled against a live
70+ //! workspace (2026-08-04; their fixtures are redacted live captures).
71+ //! `chats` / `messages` / `chat_members` remain API-reference drafts
72+ //! with synthetic fixtures: Feishu gates the im APIs on the app's BOT
73+ //! capability (error 232025) independently of OAuth scopes, and the
74+ //! live pass completes once the test app has it enabled.
6375
6476use std:: sync:: OnceLock ;
6577
@@ -210,35 +222,46 @@ mod tests {
210222 }
211223
212224 #[ test]
213- fn tasks_fixture_converts_booleans_and_empty_lists ( ) {
225+ fn tasks_fixture_converts_status_and_the_epoch_zero_sentinel ( ) {
226+ // Redacted live capture (2026-08-04): completion is `status`
227+ // (todo|done) plus `completed_at` — there is NO `completed`
228+ // boolean on the wire, which is why that draft column is gone.
214229 let batch = convert_fixture ( table ( "tasks" ) , include_str ! ( "fixtures/feishu/tasks.json" ) ) ;
215- assert_eq ! ( batch. num_rows( ) , 2 ) ;
216- assert ! ( !boolean( & batch, "completed" ) . value( 0 ) ) ;
217- assert ! ( boolean( & batch, "completed" ) . value( 1 ) ) ;
218- assert ! ( utf8( & batch, "summary" ) . is_null( 1 ) , "JSON null → SQL NULL" ) ;
219- // JSON null due is SQL NULL; the empty members array is the JSON
220- // text `[]`, not NULL — an empty list is data.
221- assert ! ( utf8( & batch, "due" ) . is_null( 1 ) ) ;
222- assert_eq ! ( utf8( & batch, "members" ) . value( 1 ) , "[]" ) ;
230+ assert_eq ! ( batch. num_rows( ) , 3 ) ;
231+ assert_eq ! ( utf8( & batch, "status" ) . value( 0 ) , "todo" ) ;
232+ assert_eq ! ( utf8( & batch, "status" ) . value( 2 ) , "done" ) ;
233+ // Feishu spells "not completed" as "0" — the epoch-zero sentinel
234+ // the YAML documents; a done task carries a real instant.
235+ assert_eq ! ( millis( & batch, "completed_at" ) . value( 0 ) , 0 ) ;
236+ assert_eq ! ( millis( & batch, "completed_at" ) . value( 2 ) , 1_780_322_529_638 ) ;
237+ assert_eq ! ( millis( & batch, "created_at" ) . value( 0 ) , 1_775_202_331_888 ) ;
238+ // A present JSON null `due` is SQL NULL; members survive as
239+ // opaque JSON.
240+ assert ! ( utf8( & batch, "due" ) . is_null( 0 ) ) ;
241+ let members: Value =
242+ serde_json:: from_str ( utf8 ( & batch, "members" ) . value ( 0 ) ) . expect ( "valid JSON" ) ;
243+ assert ! ( members. is_array( ) ) ;
223244 }
224245
225246 #[ test]
226247 fn wiki_nodes_fixture_converts_epoch_second_strings ( ) {
248+ // Redacted live capture (2026-08-04).
227249 let batch = convert_fixture (
228250 table ( "wiki_nodes" ) ,
229251 include_str ! ( "fixtures/feishu/wiki_nodes.json" ) ,
230252 ) ;
231- assert_eq ! ( batch. num_rows( ) , 2 ) ;
253+ assert_eq ! ( batch. num_rows( ) , 3 ) ;
232254 // Epoch-SECONDS digit strings scale to millis.
233255 assert_eq ! (
234256 millis( & batch, "obj_create_time" ) . value( 0 ) ,
235- 1_735_689_600_000
257+ 1_636_114_726_000
236258 ) ;
237- assert ! ( millis( & batch, "obj_create_time" ) . is_null( 1 ) ) ;
238- assert ! ( boolean( & batch, "has_child" ) . value( 0 ) ) ;
259+ assert ! ( !boolean( & batch, "has_child" ) . value( 0 ) ) ;
239260 // Feishu spells "no parent" as the empty string — that is data,
240261 // preserved verbatim, not coerced to NULL.
241262 assert_eq ! ( utf8( & batch, "parent_node_token" ) . value( 0 ) , "" ) ;
263+ assert ! ( utf8( & batch, "creator" ) . value( 0 ) . starts_with( "ou_" ) ) ;
264+ assert ! ( utf8( & batch, "url" ) . value( 0 ) . starts_with( "https://" ) ) ;
242265 }
243266
244267 #[ test]
@@ -255,9 +278,13 @@ mod tests {
255278 table ( "wiki_spaces" ) ,
256279 include_str ! ( "fixtures/feishu/wiki_spaces.json" ) ,
257280 ) ;
258- assert_eq ! ( spaces. num_rows( ) , 2 ) ;
259- assert_eq ! ( utf8( & spaces, "space_id" ) . value( 0 ) , "70001" ) ;
260- assert ! ( utf8( & spaces, "description" ) . is_null( 1 ) ) ;
281+ // Redacted live capture (2026-08-04): note the envelope carries a
282+ // NON-empty pageToken beside hasMore:false — the shape the
283+ // has_more_path termination exists for, pinned end to end by
284+ // `wiki_spaces_terminate_on_has_more_false_despite_a_token`.
285+ assert_eq ! ( spaces. num_rows( ) , 1 ) ;
286+ assert_eq ! ( utf8( & spaces, "space_id" ) . value( 0 ) , "700000038" ) ;
287+ assert_eq ! ( utf8( & spaces, "open_sharing" ) . value( 0 ) , "closed" ) ;
261288 }
262289
263290 #[ test]
@@ -583,7 +610,11 @@ bindings:
583610 }
584611
585612 #[ tokio:: test]
586- async fn tasks_pin_their_population_and_push_completed_equality ( ) {
613+ async fn tasks_pin_their_population_and_keep_status_predicates_local ( ) {
614+ // The action HAS a `completed` boolean input, but real rows carry
615+ // `status` (todo|done) and no boolean — so nothing is mapped and a
616+ // status predicate must run entirely in DataFusion while the
617+ // request carries exactly the pins and pagination, nothing else.
587618 let gateway = MockGateway :: start ( |req| {
588619 if req. method == "GET" && req. path == "/v1/health" {
589620 return MockResponse :: ok ( "{}" ) ;
@@ -594,8 +625,8 @@ bindings:
594625 if req. method == "POST" && req. path == "/v1/actions/feishu.list_tasks" {
595626 return MockResponse :: ok ( & envelope_ok (
596627 & json ! ( { "items" : [
597- { "guid" : "t-1" , "completed " : true } ,
598- { "guid" : "t-2" , "completed " : false } ] ,
628+ { "guid" : "t-1" , "status " : "done" } ,
629+ { "guid" : "t-2" , "status " : "todo" } ] ,
599630 "pageToken" : null, "hasMore" : false } )
600631 . to_string ( ) ,
601632 ) ) ;
@@ -606,11 +637,7 @@ bindings:
606637 let ( gateway, ctx) =
607638 setup_with_gateway ( gateway, "SKARDI_TEST_OC_FEISHU_TASKS" , "tasks" ) . await ;
608639
609- let batches = collect (
610- & ctx,
611- "SELECT guid FROM saas.ws.tasks WHERE completed = true" ,
612- )
613- . await ;
640+ let batches = collect ( & ctx, "SELECT guid FROM saas.ws.tasks WHERE status = 'done'" ) . await ;
614641 let guids: Vec < String > = batches
615642 . iter ( )
616643 . flat_map ( |b| {
@@ -624,11 +651,59 @@ bindings:
624651 ( 0 ..col. len ( ) ) . map ( move |i| col. value ( i) . to_string ( ) )
625652 } )
626653 . collect ( ) ;
627- assert_eq ! ( guids, vec![ "t-1" ] , "Inexact push re-trimmed locally" ) ;
654+ assert_eq ! ( guids, vec![ "t-1" ] , "status filtering happened locally" ) ;
628655
629656 let input = & execute_inputs ( & gateway) [ 0 ] ;
630657 assert_eq ! ( input[ "type" ] , "my_tasks" , "population pin" ) ;
631- assert_eq ! ( input[ "completed" ] , true , "boolean equality pushed" ) ;
658+ let mut keys: Vec < & str > = input
659+ . as_object ( )
660+ . expect ( "input object" )
661+ . keys ( )
662+ . map ( String :: as_str)
663+ . collect ( ) ;
664+ keys. sort_unstable ( ) ;
665+ assert_eq ! (
666+ keys,
667+ vec![ "pageSize" , "type" ] ,
668+ "the status predicate is not pushed and no `completed` input is invented"
669+ ) ;
670+ }
671+
672+ #[ tokio:: test]
673+ async fn wiki_spaces_terminate_on_has_more_false_despite_a_token ( ) {
674+ // The live wire shape this pack's has_more_path exists for
675+ // (captured 2026-08-04): Feishu wiki answers the FINAL page with
676+ // hasMore:false but a NON-empty pageToken ("0||…"). Null-token
677+ // termination alone would refetch that token and fail as a
678+ // PaginationLoop; the has-more signal must end the scan after one
679+ // request.
680+ let gateway = MockGateway :: start ( |req| {
681+ if req. method == "GET" && req. path == "/v1/health" {
682+ return MockResponse :: ok ( "{}" ) ;
683+ }
684+ if req. method == "GET" && req. path . starts_with ( "/v1/actions/" ) {
685+ return feishu_discovery ( & req. path ) ;
686+ }
687+ if req. method == "POST" && req. path == "/v1/actions/feishu.list_wiki_spaces" {
688+ return MockResponse :: ok ( & envelope_ok (
689+ & json ! ( { "items" : [ { "space_id" : "700000038" , "name" : "s" } ] ,
690+ "pageToken" : "0||7000000000000000001" , "hasMore" : false } )
691+ . to_string ( ) ,
692+ ) ) ;
693+ }
694+ MockResponse :: new ( 404 , "{}" )
695+ } )
696+ . await ;
697+ let ( gateway, ctx) =
698+ setup_with_gateway ( gateway, "SKARDI_TEST_OC_FEISHU_WIKI_HM" , "wiki_spaces" ) . await ;
699+
700+ let batches = collect ( & ctx, "SELECT space_id FROM saas.ws.wiki_spaces" ) . await ;
701+ assert_eq ! ( batches. iter( ) . map( RecordBatch :: num_rows) . sum:: <usize >( ) , 1 ) ;
702+ assert_eq ! (
703+ execute_inputs( & gateway) . len( ) ,
704+ 1 ,
705+ "hasMore:false ended the scan; the non-empty token was never refetched"
706+ ) ;
632707 }
633708
634709 #[ tokio:: test]
0 commit comments