Files
MyGoNavi/internal/synccdc/mongodb_stream_test.go
Syngnat e40cdbc016 feat(synccdc): 新增 MongoDB 变更流适配器
- 建立 CDC 适配器注册、探测与安全游标契约
- 支持 MongoDB change stream、恢复令牌和批量读取
- 覆盖权限探测、取消关闭与流位置恢复测试
2026-08-08 19:06:36 +08:00

277 lines
10 KiB
Go

package synccdc
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type bufferedFakeMongoCursor struct {
*fakeMongoCursor
}
func (cursor *bufferedFakeMongoCursor) TryNext(ctx context.Context) bool {
return cursor.fakeMongoCursor.Next(ctx)
}
func (cursor *bufferedFakeMongoCursor) RemainingBatchLength() int {
cursor.mu.Lock()
defer cursor.mu.Unlock()
return len(cursor.events) - cursor.index
}
func TestMongoStreamMapsInsertReplaceUpdateAndDelete(t *testing.T) {
objectID := bson.NewObjectID()
sessionID := mustMongoRaw(t, bson.D{{Key: "id", Value: bson.Binary{Subtype: 4, Data: []byte("0123456789abcdef")}}})
txnNumber := int64(9)
operations := []string{"insert", "replace", "update", "delete"}
events := make([]bson.Raw, 0, len(operations))
tokens := make([]bson.Raw, 0, len(operations))
for index, operation := range operations {
document := bson.D{
{Key: "_id", Value: bson.D{{Key: "_data", Value: operation}}},
{Key: "operationType", Value: operation},
{Key: "ns", Value: bson.D{{Key: "db", Value: "app"}, {Key: "coll", Value: "orders"}}},
{Key: "documentKey", Value: bson.D{{Key: "_id", Value: objectID}}},
{Key: "clusterTime", Value: bson.Timestamp{T: uint32(1_725_000_000 + index), I: 1}},
{Key: "lsid", Value: sessionID},
{Key: "txnNumber", Value: txnNumber},
}
if operation != "delete" {
document = append(document, bson.E{Key: "fullDocument", Value: bson.D{
{Key: "_id", Value: objectID},
{Key: "amount", Value: int64(9_007_199_254_740_993)},
{Key: "ratio", Value: 1.25},
}})
}
events = append(events, mustMongoRaw(t, document))
tokens = append(tokens, mustMongoRaw(t, bson.D{{Key: "_data", Value: "token-" + operation}}))
}
cursor := &fakeMongoCursor{events: events, tokens: tokens}
connection := &fakeMongoConnection{cursor: cursor}
stream := newMongoDBStream(connection, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
var previousPosition Position
for _, expectedOperation := range operations {
transaction, err := stream.Next(context.Background())
if err != nil {
t.Fatalf("next %s: %v", expectedOperation, err)
}
if len(transaction.Events) != 1 || transaction.Events[0].Operation != expectedOperation {
t.Fatalf("unexpected transaction: %+v", transaction)
}
event := transaction.Events[0]
if event.Object.Database != "app" || event.Object.Name != "orders" {
t.Fatalf("unexpected namespace: %+v", event.Object)
}
if event.Key["_id"] == nil {
t.Fatalf("document key was not preserved: %+v", event.Key)
}
objectIDJSON, ok := event.Key["_id"].(map[string]interface{})
if !ok || objectIDJSON["$oid"] != objectID.Hex() {
t.Fatalf("ObjectID was not preserved as canonical Extended JSON: %+v", event.Key["_id"])
}
if expectedOperation == "delete" {
if event.After != nil {
t.Fatalf("delete after image = %+v", event.After)
}
} else {
amountJSON, ok := event.After["amount"].(map[string]interface{})
if !ok || amountJSON["$numberLong"] != "9007199254740993" {
t.Fatalf("64-bit integer was not preserved as canonical Extended JSON: %+v", event.After)
}
if event.After["ratio"] != float64(1.25) {
t.Fatalf("ordinary double must match snapshot conversion semantics: %+v", event.After)
}
}
if !strings.HasPrefix(event.SourceTxID, "mongo-tx-") {
t.Fatalf("source transaction identity missing: %q", event.SourceTxID)
}
if previousPosition.Adapter != "" {
if err := stream.Acknowledge(context.Background(), previousPosition); err == nil {
t.Fatalf("stale acknowledgement before %s must be rejected", expectedOperation)
}
}
if err := stream.Acknowledge(context.Background(), transaction.Position); err != nil {
t.Fatalf("acknowledge %s: %v", expectedOperation, err)
}
if err := stream.Acknowledge(context.Background(), transaction.Position); err != nil {
t.Fatalf("idempotent acknowledge %s: %v", expectedOperation, err)
}
previousPosition = transaction.Position
}
}
func TestMongoStreamDrainsBufferedEventsAndAcknowledgesFinalPosition(t *testing.T) {
events := make([]bson.Raw, 0, 3)
tokens := make([]bson.Raw, 0, 3)
for index := 1; index <= 3; index++ {
events = append(events, mustMongoRaw(t, bson.D{
{Key: "operationType", Value: "insert"},
{Key: "ns", Value: bson.D{{Key: "db", Value: "app"}, {Key: "coll", Value: "orders"}}},
{Key: "documentKey", Value: bson.D{{Key: "_id", Value: index}}},
{Key: "fullDocument", Value: bson.D{{Key: "_id", Value: index}, {Key: "value", Value: index}}},
{Key: "clusterTime", Value: bson.Timestamp{T: uint32(100 + index), I: 1}},
}))
tokens = append(tokens, mustMongoRaw(t, bson.D{{Key: "_data", Value: fmt.Sprintf("token-%d", index)}}))
}
cursor := &bufferedFakeMongoCursor{fakeMongoCursor: &fakeMongoCursor{events: events, tokens: tokens}}
stream := newMongoDBStream(&fakeMongoConnection{cursor: cursor}, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
transaction, err := stream.Next(context.Background())
if err != nil {
t.Fatalf("next buffered transaction: %v", err)
}
if len(transaction.Events) != 3 {
t.Fatalf("buffered events = %d, want 3", len(transaction.Events))
}
for index, event := range transaction.Events {
encodedID, ok := event.Key["_id"].(map[string]interface{})
if !ok || encodedID["$numberInt"] != fmt.Sprint(index+1) {
t.Fatalf("event %d key = %#v", index, event.Key)
}
}
intermediate, err := mongoResumeTokenPosition("scope-1", tokens[0])
if err != nil {
t.Fatalf("build intermediate position: %v", err)
}
if err := stream.Acknowledge(context.Background(), intermediate); err == nil {
t.Fatal("buffered delivery accepted a non-final position")
}
if err := stream.Acknowledge(context.Background(), transaction.Position); err != nil {
t.Fatalf("acknowledge final buffered position: %v", err)
}
expectedPosition, err := mongoResumeTokenPosition("scope-1", tokens[2])
if err != nil {
t.Fatalf("build final position: %v", err)
}
expectedIdentity, _ := mongoPositionIdentity(expectedPosition)
actualIdentity, _ := mongoPositionIdentity(transaction.Position)
if actualIdentity != expectedIdentity {
t.Fatalf("transaction position = %s, want final %s", actualIdentity, expectedIdentity)
}
}
func TestMongoStreamRejectsOutOfScopeEvent(t *testing.T) {
tests := []struct {
name string
raw bson.Raw
error string
}{
{
name: "out of scope",
raw: mustMongoRaw(t, bson.D{
{Key: "operationType", Value: "delete"},
{Key: "ns", Value: bson.D{{Key: "db", Value: "other"}, {Key: "coll", Value: "orders"}}},
{Key: "documentKey", Value: bson.D{{Key: "_id", Value: 1}}},
{Key: "clusterTime", Value: bson.Timestamp{T: 100, I: 1}},
}),
error: "out-of-scope",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cursor := &fakeMongoCursor{
events: []bson.Raw{test.raw},
tokens: []bson.Raw{mustMongoRaw(t, bson.D{{Key: "_data", Value: "token"}})},
}
stream := newMongoDBStream(&fakeMongoConnection{}, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
if _, err := stream.Next(context.Background()); err == nil || !strings.Contains(err.Error(), test.error) {
t.Fatalf("error = %v, want substring %q", err, test.error)
}
})
}
}
func TestMongoStreamDeliversUpdateLookupNullWithCheckpoint(t *testing.T) {
cursor := &fakeMongoCursor{
events: []bson.Raw{mustMongoRaw(t, bson.D{
{Key: "operationType", Value: "update"},
{Key: "ns", Value: bson.D{{Key: "db", Value: "app"}, {Key: "coll", Value: "orders"}}},
{Key: "documentKey", Value: bson.D{{Key: "_id", Value: 1}}},
{Key: "clusterTime", Value: bson.Timestamp{T: 100, I: 1}},
})},
tokens: []bson.Raw{mustMongoRaw(t, bson.D{{Key: "_data", Value: "update-without-full-document"}})},
}
stream := newMongoDBStream(&fakeMongoConnection{}, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
transaction, err := stream.Next(context.Background())
if err != nil {
t.Fatalf("deliver updateLookup-null event: %v", err)
}
if len(transaction.Events) != 1 || transaction.Events[0].Operation != "update" || transaction.Events[0].After != nil {
t.Fatalf("unexpected tombstone event: %+v", transaction)
}
if transaction.Position.Adapter == "" {
t.Fatal("updateLookup-null event must carry a resumable checkpoint")
}
if err := stream.Acknowledge(context.Background(), transaction.Position); err != nil {
t.Fatalf("acknowledge updateLookup-null checkpoint: %v", err)
}
}
func TestMongoStreamNextIsCancelableAndCloseIsIdempotent(t *testing.T) {
cursor := &fakeMongoCursor{block: true, nextStarted: make(chan struct{}, 1)}
connection := &fakeMongoConnection{}
stream := newMongoDBStream(connection, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
result := make(chan error, 1)
go func() {
_, err := stream.Next(context.Background())
result <- err
}()
select {
case <-cursor.nextStarted:
case <-time.After(time.Second):
t.Fatal("Next did not reach the change-stream cursor")
}
if err := stream.Close(); err != nil {
t.Fatalf("close stream: %v", err)
}
select {
case err := <-result:
if !errors.Is(err, ErrMongoDBStreamClosed) {
t.Fatalf("Next error after Close = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close did not cancel blocked Next")
}
if err := stream.Close(); err != nil {
t.Fatalf("second close: %v", err)
}
cursor.mu.Lock()
closeCount := cursor.closed
cursor.mu.Unlock()
connection.mu.Lock()
disconnectCount := connection.disconnects
connection.mu.Unlock()
if closeCount != 1 || disconnectCount != 1 {
t.Fatalf("close count=%d disconnect count=%d", closeCount, disconnectCount)
}
}
func TestMongoStreamCallerCancellationAndAcknowledgementValidation(t *testing.T) {
cursor := &fakeMongoCursor{block: true}
stream := newMongoDBStream(&fakeMongoConnection{}, cursor, []mongoNamespace{{Database: "app", Collection: "orders"}}, "scope-1")
defer stream.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := stream.Next(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled Next error = %v", err)
}
foreign, err := mongoOperationTimePosition("other-scope", bson.Timestamp{T: 1, I: 1})
if err != nil {
t.Fatalf("build foreign position: %v", err)
}
if err := stream.Acknowledge(context.Background(), foreign); err == nil || !strings.Contains(err.Error(), "different source") {
t.Fatalf("foreign acknowledgement error = %v", err)
}
}