@@ -25,6 +25,11 @@ import (
2525 "github.com/bsv-blockchain/arcade/teranode"
2626)
2727
28+ // testBlockHash is a synthetic block hash reused across the table-style tests
29+ // in this file. Lifted to a constant to satisfy goconst now that the
30+ // regression tests for issue #87 push the literal count past the threshold.
31+ const testBlockHash = "aabbccdd00000000000000000000000000000000000000000000000000000000"
32+
2833// --- Mock Store ---
2934
3035type mockStore struct {
@@ -43,8 +48,9 @@ type mockStore struct {
4348}
4449
4550type minedCall struct {
46- blockHash string
47- txids []string
51+ blockHash string
52+ blockHeight uint64
53+ txids []string
4854}
4955
5056func newMockStore () * mockStore {
@@ -85,20 +91,21 @@ func (m *mockStore) InsertBUMP(_ context.Context, blockHash string, _ uint64, bu
8591 return nil
8692}
8793
88- func (m * mockStore ) SetMinedByTxIDs (_ context.Context , blockHash string , txids []string ) ([]* models.TransactionStatus , error ) {
94+ func (m * mockStore ) SetMinedByTxIDs (_ context.Context , blockHash string , blockHeight uint64 , txids []string ) ([]* models.TransactionStatus , error ) {
8995 m .mu .Lock ()
9096 defer m .mu .Unlock ()
9197 if m .setMinedErr != nil {
9298 return nil , m .setMinedErr
9399 }
94- m .minedCalls = append (m .minedCalls , minedCall {blockHash , txids })
100+ m .minedCalls = append (m .minedCalls , minedCall {blockHash , blockHeight , txids })
95101 var statuses []* models.TransactionStatus
96102 for _ , txid := range txids {
97103 statuses = append (statuses , & models.TransactionStatus {
98- TxID : txid ,
99- Status : models .StatusMined ,
100- BlockHash : blockHash ,
101- Timestamp : time .Now (),
104+ TxID : txid ,
105+ Status : models .StatusMined ,
106+ BlockHash : blockHash ,
107+ BlockHeight : blockHeight ,
108+ Timestamp : time .Now (),
102109 })
103110 }
104111 return statuses , nil
@@ -757,3 +764,217 @@ func searchStr(s, substr string) bool {
757764 }
758765 return false
759766}
767+
768+ // --- Publisher mock + block-height regression tests for issue #87 / F-029 ---
769+
770+ // recordingPublisher captures every TransactionStatus the builder publishes
771+ // downstream so tests can assert on what SSE / webhook subscribers would see.
772+ type recordingPublisher struct {
773+ mu sync.Mutex
774+ published []* models.TransactionStatus
775+ }
776+
777+ func (p * recordingPublisher ) Publish (_ context.Context , status * models.TransactionStatus ) error {
778+ p .mu .Lock ()
779+ defer p .mu .Unlock ()
780+ // Copy so later mutation by the builder (or the store) cannot retroactively
781+ // repair a height we want to assert was missing at publish time.
782+ cp := * status
783+ p .published = append (p .published , & cp )
784+ return nil
785+ }
786+
787+ func (p * recordingPublisher ) Subscribe (context.Context ) (<- chan * models.TransactionStatus , error ) {
788+ return nil , errors .New ("recordingPublisher: Subscribe not used in tests" )
789+ }
790+
791+ func (p * recordingPublisher ) Close () error { return nil }
792+
793+ func (p * recordingPublisher ) snapshot () []* models.TransactionStatus {
794+ p .mu .Lock ()
795+ defer p .mu .Unlock ()
796+ out := make ([]* models.TransactionStatus , len (p .published ))
797+ copy (out , p .published )
798+ return out
799+ }
800+
801+ // makeMinimalSTUMPAtHeight builds a single-leaf STUMP for txidHex at the given
802+ // block height. Uses go-sdk's MerklePath.Bytes() so the height is encoded as a
803+ // proper BRC-74 varint (the hand-rolled makeMinimalSTUMP only supports heights
804+ // < 0xfd because it writes a single raw byte).
805+ func makeMinimalSTUMPAtHeight (t * testing.T , txidHex string , blockHeight uint32 ) []byte {
806+ t .Helper ()
807+ txHash := mustHash (t , txidHex )
808+ isTxid := true
809+ mp := transaction .NewMerklePath (blockHeight , [][]* transaction.PathElement {
810+ {{Offset : 0 , Hash : & txHash , Txid : & isTxid }},
811+ })
812+ return mp .Bytes ()
813+ }
814+
815+ // TestBuilder_HandleMessage_PublishesMinedStatusWithBlockHeight is the
816+ // regression test for issue #87 / F-029: the builder must thread the
817+ // compound BUMP's block height all the way through SetMinedByTxIDs and
818+ // onto the TransactionStatus that gets published. A previous code path
819+ // dropped the height before publish, leaving downstream SSE/webhook
820+ // consumers with BlockHash but BlockHeight=0.
821+ func TestBuilder_HandleMessage_PublishesMinedStatusWithBlockHeight (t * testing.T ) {
822+ const wantHeight uint32 = 850123
823+ ms := newMockStore ()
824+ pub := & recordingPublisher {}
825+
826+ blockHash := testBlockHash
827+ txidHex := "1111111111111111111111111111111111111111111111111111111111111111"
828+
829+ stumpData := makeMinimalSTUMPAtHeight (t , txidHex , wantHeight )
830+ ms .addStump (blockHash , 0 , stumpData )
831+
832+ subtreeHash := mustHash (t , txidHex )
833+ root := expectedCompoundRoot (t ,
834+ []* models.Stump {{BlockHash : blockHash , SubtreeIndex : 0 , StumpData : stumpData }},
835+ []chainhash.Hash {subtreeHash }, nil )
836+ datahub := newDatahubServer (root , []chainhash.Hash {subtreeHash })
837+ defer datahub .Close ()
838+
839+ b := newTestBuilder (ms , datahub .URL )
840+ b .publisher = pub
841+
842+ if err := b .handleMessage (context .Background (), makeBlockProcessedMsg (blockHash )); err != nil {
843+ t .Fatalf ("handleMessage: %v" , err )
844+ }
845+
846+ // 1) The store call itself must receive the height — without this, no
847+ // backend can persist it even if the publish path were correct.
848+ ms .mu .Lock ()
849+ if len (ms .minedCalls ) != 1 {
850+ ms .mu .Unlock ()
851+ t .Fatalf ("expected 1 SetMinedByTxIDs call, got %d" , len (ms .minedCalls ))
852+ }
853+ got := ms .minedCalls [0 ]
854+ ms .mu .Unlock ()
855+ if got .blockHeight != uint64 (wantHeight ) {
856+ t .Errorf ("SetMinedByTxIDs got blockHeight=%d, want %d" , got .blockHeight , wantHeight )
857+ }
858+ if got .blockHash != blockHash {
859+ t .Errorf ("SetMinedByTxIDs got blockHash=%q, want %q" , got .blockHash , blockHash )
860+ }
861+
862+ // 2) The published TransactionStatus must carry both fields. This is the
863+ // contract SSE / webhook subscribers see; F-029 was that BlockHeight
864+ // was zero here even though BlockHash was set.
865+ emitted := pub .snapshot ()
866+ if len (emitted ) != 1 {
867+ t .Fatalf ("expected 1 published status, got %d" , len (emitted ))
868+ }
869+ st := emitted [0 ]
870+ if st .Status != models .StatusMined {
871+ t .Errorf ("published status = %q, want MINED" , st .Status )
872+ }
873+ if st .BlockHash != blockHash {
874+ t .Errorf ("published BlockHash=%q, want %q" , st .BlockHash , blockHash )
875+ }
876+ if st .BlockHeight != uint64 (wantHeight ) {
877+ t .Errorf ("published BlockHeight=%d, want %d" , st .BlockHeight , wantHeight )
878+ }
879+ }
880+
881+ // TestBuilder_HandleMessage_PublishedHeightIsNeverZero is the narrow
882+ // regression guard: anyone refactoring SetMinedByTxIDs or the publish
883+ // loop and zero-valuing BlockHeight will fail this test even if every
884+ // other assertion happens to pass (e.g. a future test that compares
885+ // published statuses to mock-returned statuses without checking height).
886+ func TestBuilder_HandleMessage_PublishedHeightIsNeverZero (t * testing.T ) {
887+ ms := newMockStore ()
888+ pub := & recordingPublisher {}
889+
890+ blockHash := testBlockHash
891+ txidHex := "1111111111111111111111111111111111111111111111111111111111111111"
892+
893+ // Use the existing 0x01 minimal STUMP — it encodes blockHeight=1, which is
894+ // non-zero, so any code path that zero-values the height will be caught.
895+ stumpData := makeMinimalSTUMP (txidHex )
896+ ms .addStump (blockHash , 0 , stumpData )
897+
898+ subtreeHash := mustHash (t , txidHex )
899+ root := expectedCompoundRoot (t ,
900+ []* models.Stump {{BlockHash : blockHash , SubtreeIndex : 0 , StumpData : stumpData }},
901+ []chainhash.Hash {subtreeHash }, nil )
902+ datahub := newDatahubServer (root , []chainhash.Hash {subtreeHash })
903+ defer datahub .Close ()
904+
905+ b := newTestBuilder (ms , datahub .URL )
906+ b .publisher = pub
907+
908+ if err := b .handleMessage (context .Background (), makeBlockProcessedMsg (blockHash )); err != nil {
909+ t .Fatalf ("handleMessage: %v" , err )
910+ }
911+
912+ emitted := pub .snapshot ()
913+ if len (emitted ) == 0 {
914+ t .Fatal ("expected at least one published status" )
915+ }
916+ for i , st := range emitted {
917+ if st .BlockHeight == 0 {
918+ t .Errorf ("published status %d has BlockHeight=0; F-029 regression: %+v" , i , st )
919+ }
920+ }
921+ }
922+
923+ // TestBuilder_HandleMessage_DefensivelyRestoresHeightIfStoreDropsIt
924+ // asserts the safety net the builder added on top of SetMinedByTxIDs:
925+ // if a backend regresses and forgets to populate BlockHeight on its
926+ // returned status, the publish path repairs it from the compound BUMP's
927+ // height before fanning out. This guards against a partial revert that
928+ // undoes only the store change.
929+ func TestBuilder_HandleMessage_DefensivelyRestoresHeightIfStoreDropsIt (t * testing.T ) {
930+ ms := newMockStore ()
931+ pub := & recordingPublisher {}
932+
933+ blockHash := testBlockHash
934+ txidHex := "1111111111111111111111111111111111111111111111111111111111111111"
935+
936+ stumpData := makeMinimalSTUMP (txidHex )
937+ ms .addStump (blockHash , 0 , stumpData )
938+
939+ subtreeHash := mustHash (t , txidHex )
940+ root := expectedCompoundRoot (t ,
941+ []* models.Stump {{BlockHash : blockHash , SubtreeIndex : 0 , StumpData : stumpData }},
942+ []chainhash.Hash {subtreeHash }, nil )
943+ datahub := newDatahubServer (root , []chainhash.Hash {subtreeHash })
944+ defer datahub .Close ()
945+
946+ b := newTestBuilder (ms , datahub .URL )
947+ b .publisher = pub
948+
949+ // Wrap the mock so SetMinedByTxIDs returns BlockHeight=0 — simulating a
950+ // backend that regresses on the persistence side. The builder must still
951+ // publish a non-zero height by falling back to the compound's height.
952+ heightDroppingStore := & heightDroppingMockStore {mockStore : ms }
953+ b .store = heightDroppingStore
954+
955+ if err := b .handleMessage (context .Background (), makeBlockProcessedMsg (blockHash )); err != nil {
956+ t .Fatalf ("handleMessage: %v" , err )
957+ }
958+
959+ emitted := pub .snapshot ()
960+ if len (emitted ) != 1 {
961+ t .Fatalf ("expected 1 published status, got %d" , len (emitted ))
962+ }
963+ if emitted [0 ].BlockHeight == 0 {
964+ t .Errorf ("publish path failed to defensively restore BlockHeight: %+v" , emitted [0 ])
965+ }
966+ }
967+
968+ // heightDroppingMockStore wraps mockStore and zeroes BlockHeight on every
969+ // returned status to simulate a buggy backend.
970+ type heightDroppingMockStore struct {
971+ * mockStore
972+ }
973+
974+ func (h * heightDroppingMockStore ) SetMinedByTxIDs (ctx context.Context , blockHash string , blockHeight uint64 , txids []string ) ([]* models.TransactionStatus , error ) {
975+ statuses , err := h .mockStore .SetMinedByTxIDs (ctx , blockHash , blockHeight , txids )
976+ for _ , s := range statuses {
977+ s .BlockHeight = 0
978+ }
979+ return statuses , err
980+ }
0 commit comments