1- // Integration test: real SQLite delivery queue + mock adapter.
2- // Verifies the patch end-to-end at the queue layer: when a required-mode
3- // batch send fails mid-batch after an earlier payload already succeeded,
4- // the queue entry advances to recovery_state=unknown_after_send (not left
5- // in send_attempt_started), so reconnect-drain routes it through
6- // reconcileUnknownQueuedDelivery instead of blind replay.
71import { describe , expect , it , vi , beforeAll , beforeEach } from "vitest" ;
82import type { OpenClawConfig } from "../../config/config.js" ;
93import { drainPendingDeliveries , type DeliverFn , loadPendingDeliveries } from "./delivery-queue.js" ;
@@ -14,7 +8,6 @@ import {
148
159let deliverOutboundPayloads : typeof import ( "./deliver.js" ) . deliverOutboundPayloads ;
1610
17- // Minimal reconnect drain helper (no adapter → reconcileUnknownQueuedDelivery returns null).
1811async function drainMatrixReconnect ( opts : { deliver : DeliverFn ; stateDir : string } ) : Promise < void > {
1912 await drainPendingDeliveries ( {
2013 drainKey : "matrix:reconnect-test" ,
@@ -27,6 +20,27 @@ async function drainMatrixReconnect(opts: { deliver: DeliverFn; stateDir: string
2720 } ) ;
2821}
2922
23+ function createPartialSendFailure ( ) {
24+ return vi
25+ . fn ( )
26+ . mockResolvedValueOnce ( { messageId : "m1" } )
27+ . mockRejectedValueOnce ( new Error ( "second payload send failed" ) ) ;
28+ }
29+
30+ async function deliverPartialMatrixBatch ( sendMatrix : ReturnType < typeof vi . fn > , tmpDir : string ) {
31+ process . env . OPENCLAW_STATE_DIR = tmpDir ;
32+ await expect (
33+ deliverOutboundPayloads ( {
34+ cfg : { } as OpenClawConfig ,
35+ channel : "matrix" ,
36+ to : "!room:example" ,
37+ payloads : [ { text : "first" } , { text : "second" } ] ,
38+ deps : { matrix : sendMatrix } ,
39+ queuePolicy : "required" ,
40+ } ) ,
41+ ) . rejects . toThrow ( "second payload send failed" ) ;
42+ }
43+
3044describe ( "deliverOutboundPayloads queue integration: mid-batch failure with send evidence" , ( ) => {
3145 const fixtures = installDeliveryQueueTmpDirHooks ( ) ;
3246 let tmpDir : string ;
@@ -40,79 +54,36 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send
4054 } ) ;
4155
4256 it ( "advances queued entry to unknown_after_send when a later payload fails after an earlier one succeeded" , async ( ) => {
43- process . env . OPENCLAW_STATE_DIR = tmpDir ;
44- // First payload succeeds (send evidence), second payload throws.
45- const sendMatrix = vi
46- . fn ( )
47- . mockResolvedValueOnce ( { messageId : "m1" } )
48- . mockRejectedValueOnce ( new Error ( "second payload send failed" ) ) ;
57+ const sendMatrix = createPartialSendFailure ( ) ;
4958
50- await expect (
51- deliverOutboundPayloads ( {
52- cfg : { } as OpenClawConfig ,
53- channel : "matrix" ,
54- to : "!room:example" ,
55- payloads : [ { text : "first" } , { text : "second" } ] ,
56- deps : { matrix : sendMatrix } ,
57- queuePolicy : "required" ,
58- } ) ,
59- ) . rejects . toThrow ( "second payload send failed" ) ;
59+ await deliverPartialMatrixBatch ( sendMatrix , tmpDir ) ;
6060
61- // The entry must exist in the real SQLite queue and be in unknown_after_send.
62- const entries = await import ( "./delivery-queue.js" ) . then ( ( m ) =>
63- m . loadPendingDeliveries ( tmpDir ) ,
64- ) ;
61+ const entries = await loadPendingDeliveries ( tmpDir ) ;
6562 expect ( entries ) . toHaveLength ( 1 ) ;
6663 const entry = entries [ 0 ] ;
6764 expect ( entry . recoveryState ) . toBe ( "unknown_after_send" ) ;
6865 expect ( entry . retryCount ) . toBe ( 0 ) ;
6966 expect ( entry . lastError ) . toBeUndefined ( ) ;
70- // Sanity: the send actually happened for the first payload.
7167 expect ( sendMatrix ) . toHaveBeenCalledTimes ( 2 ) ;
7268 } ) ;
7369
7470 it ( "drain does not replay an unknown_after_send entry when no adapter reconciliation is available" , async ( ) => {
75- // Regression guard for the recovery/drain semantics: an entry in
76- // unknown_after_send (written by the patch above) must NOT be blindly
77- // replayed when the channel adapter cannot reconcile the unknown send.
78- // Without the patch the entry would stay in send_attempt_started, which
79- // has the same drain behaviour — but this test pins the contract so that
80- // any future regression that accidentally advances the state in a way that
81- // re-enables blind replay is caught.
82- process . env . OPENCLAW_STATE_DIR = tmpDir ;
83- const sendMatrix = vi
84- . fn ( )
85- . mockResolvedValueOnce ( { messageId : "m1" } )
86- . mockRejectedValueOnce ( new Error ( "second payload send failed" ) ) ;
71+ const sendMatrix = createPartialSendFailure ( ) ;
8772
88- // Drive the patch: entry lands in unknown_after_send.
89- await expect (
90- deliverOutboundPayloads ( {
91- cfg : { } as OpenClawConfig ,
92- channel : "matrix" ,
93- to : "!room:example" ,
94- payloads : [ { text : "first" } , { text : "second" } ] ,
95- deps : { matrix : sendMatrix } ,
96- queuePolicy : "required" ,
97- } ) ,
98- ) . rejects . toThrow ( "second payload send failed" ) ;
73+ await deliverPartialMatrixBatch ( sendMatrix , tmpDir ) ;
9974
10075 const beforeDrain = await loadPendingDeliveries ( tmpDir ) ;
10176 expect ( beforeDrain [ 0 ] ?. recoveryState ) . toBe ( "unknown_after_send" ) ;
10277
103- // Reconnect drain with no adapter (cfg={}) — reconcileUnknownQueuedDelivery
104- // returns null → "refusing blind replay" branch → deliver is never called.
10578 const deliver = vi . fn < DeliverFn > ( async ( ) => { } ) ;
10679 await drainMatrixReconnect ( { deliver, stateDir : tmpDir } ) ;
10780
10881 expect ( deliver ) . not . toHaveBeenCalled ( ) ;
109- // The entry is moved to failed (not re-queued as pending), closing the drain loop.
11082 expect ( await loadPendingDeliveries ( tmpDir ) ) . toHaveLength ( 0 ) ;
11183 } ) ;
11284
113- it ( "leaves entry for retry (failDelivery, recovery_state stays null) when no send evidence" , async ( ) => {
85+ it ( "leaves entry for retry in send_attempt_started when no send evidence exists " , async ( ) => {
11486 process . env . OPENCLAW_STATE_DIR = tmpDir ;
115- // First (and only) payload fails immediately — no send evidence.
11687 const sendMatrix = vi . fn ( ) . mockRejectedValueOnce ( new Error ( "first payload send failed" ) ) ;
11788
11889 await expect (
@@ -131,7 +102,6 @@ describe("deliverOutboundPayloads queue integration: mid-batch failure with send
131102 ) ;
132103 expect ( entries ) . toHaveLength ( 1 ) ;
133104 const entry = entries [ 0 ] ;
134- // No send evidence -> failDelivery path: retryCount bumped, recovery_state not advanced.
135105 expect ( entry . retryCount ) . toBe ( 1 ) ;
136106 expect ( entry . recoveryState ) . toBe ( "send_attempt_started" ) ;
137107 expect ( entry . lastError ) . toContain ( "first payload send failed" ) ;
0 commit comments