Ctrl K
ComunidadGuías…

internal/store/postgres/channel_send_idempotency_integration_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"bytes"
	"context"
	"crypto/sha256"
	"errors"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"

	"telesrv/deploy"
	"telesrv/internal/domain"
	"telesrv/internal/store"
)

func TestChannelSendFingerprintReplayPostgres(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	suffix := randomSuffix(t)

	users := NewUserStore(pool)
	owner, err := users.Create(ctx, domain.User{AccessHash: 181, Phone: "+1781" + suffix + "01", FirstName: "ChannelReplayOwner"})
	if err != nil {
		t.Fatalf("create owner: %v", err)
	}
	var channelID int64
	t.Cleanup(func() {
		if channelID != 0 {
			_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id = $1`, channelID)
		}
		_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID)
	})

	channels := NewChannelStore(pool)
	created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
		CreatorUserID: owner.ID,
		Title:         "Channel replay " + suffix,
		Megagroup:     true,
		Date:          1700100000,
	})
	if err != nil {
		t.Fatalf("create channel: %v", err)
	}
	channelID = created.Channel.ID
	base := domain.SendChannelMessageRequest{
		UserID:    owner.ID,
		ChannelID: channelID,
		RandomID:  781001,
		Message:   "immutable original",
		Entities: []domain.MessageEntity{{
			Type:   domain.MessageEntityBold,
			Offset: 0,
			Length: 9,
		}},
		Date: 1700100001,
	}
	wantFingerprint, err := store.ChannelSendFingerprint(base)
	if err != nil {
		t.Fatalf("fingerprint base: %v", err)
	}
	first, err := channels.SendChannelMessage(ctx, base)
	if err != nil {
		t.Fatalf("first send: %v", err)
	}
	var storedFingerprint []byte
	if err := pool.QueryRow(ctx, `SELECT request_fingerprint FROM channel_messages WHERE channel_id = $1 AND id = $2`, channelID, first.Message.ID).Scan(&storedFingerprint); err != nil {
		t.Fatalf("load fingerprint: %v", err)
	}
	if !bytes.Equal(storedFingerprint, wantFingerprint) {
		t.Fatalf("stored fingerprint = %x, want %x", storedFingerprint, wantFingerprint)
	}

	type durableState struct {
		pts    int
		events int
		rows   int
	}
	loadState := func(randomID int64) durableState {
		t.Helper()
		var state durableState
		if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&state.pts); err != nil {
			t.Fatalf("load channel pts: %v", err)
		}
		if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&state.events); err != nil {
			t.Fatalf("count channel events: %v", err)
		}
		if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id = $1 AND sender_user_id = $2 AND random_id = $3`, channelID, owner.ID, randomID).Scan(&state.rows); err != nil {
			t.Fatalf("count random receipt: %v", err)
		}
		return state
	}

	before := loadState(base.RandomID)
	exact := base
	exact.Date += 100 // execution time is not part of immutable intent
	replay, err := channels.SendChannelMessage(ctx, exact)
	if err != nil {
		t.Fatalf("exact replay: %v", err)
	}
	if !replay.Duplicate || replay.Message.ID != first.Message.ID || replay.Event.Pts != first.Event.Pts {
		t.Fatalf("exact replay = %+v, want first id=%d pts=%d", replay, first.Message.ID, first.Event.Pts)
	}
	if after := loadState(base.RandomID); after != before {
		t.Fatalf("exact replay mutated state = %+v, want %+v", after, before)
	}

	conflicts := []struct {
		name   string
		mutate func(*domain.SendChannelMessageRequest)
	}{
		{name: "body", mutate: func(req *domain.SendChannelMessageRequest) { req.Message = "changed body" }},
		{name: "media", mutate: func(req *domain.SendChannelMessageRequest) {
			req.Media = &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 781, AccessHash: 782, DCID: 2}}
		}},
		{name: "reply", mutate: func(req *domain.SendChannelMessageRequest) {
			req.ReplyTo = &domain.MessageReply{MessageID: first.Message.ID}
		}},
		{name: "group", mutate: func(req *domain.SendChannelMessageRequest) { req.GroupedID = 781003 }},
	}
	for _, tc := range conflicts {
		t.Run("conflict_"+tc.name, func(t *testing.T) {
			changed := base
			tc.mutate(&changed)
			if _, err := channels.SendChannelMessage(ctx, changed); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
				t.Fatalf("changed %s replay err = %v, want ErrMessageRandomIDDuplicate", tc.name, err)
			}
			if after := loadState(base.RandomID); after != before {
				t.Fatalf("changed %s replay mutated state = %+v, want %+v", tc.name, after, before)
			}
		})
	}

	if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
		UserID: owner.ID, ChannelID: channelID, ID: first.Message.ID, Message: "edited current", EditDate: 1700100002,
	}); err != nil {
		t.Fatalf("edit channel message: %v", err)
	}
	editedReplay, err := channels.SendChannelMessage(ctx, exact)
	if err != nil {
		t.Fatalf("replay edited message: %v", err)
	}
	if !editedReplay.Duplicate || editedReplay.Message.Body != "edited current" || editedReplay.Event.Pts != first.Event.Pts {
		t.Fatalf("edited replay = %+v, want current projection with first pts", editedReplay)
	}
	deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
		UserID: owner.ID, ChannelID: channelID, IDs: []int{first.Message.ID}, Date: 1700100003,
	})
	if err != nil {
		t.Fatalf("delete channel message: %v", err)
	}
	deletedReplay, err := channels.SendChannelMessage(ctx, exact)
	if err != nil {
		t.Fatalf("replay deleted message: %v", err)
	}
	if !deletedReplay.Duplicate || deletedReplay.Message.Body != base.Message || deletedReplay.Message.ID != first.Message.ID || deletedReplay.ReplayDeleteEvent == nil || deletedReplay.ReplayDeleteEvent.Pts != deleted.Event.Pts {
		t.Fatalf("deleted replay = %+v, want immutable first snapshot + delete receipt %+v", deletedReplay, deleted.Event)
	}

	// A raw request-boundary fingerprint must be stored byte-for-byte rather
	// than replaced with the domain fallback.
	raw := sha256.Sum256([]byte("raw channel TL intent"))
	rawReq := domain.SendChannelMessageRequest{
		UserID: owner.ID, ChannelID: channelID, RandomID: 781002, Message: "raw fingerprint", Date: 1700100010,
		IdempotencyFingerprint: raw[:],
	}
	rawSent, err := channels.SendChannelMessage(ctx, rawReq)
	if err != nil {
		t.Fatalf("raw fingerprint send: %v", err)
	}
	storedFingerprint = nil
	if err := pool.QueryRow(ctx, `SELECT request_fingerprint FROM channel_messages WHERE channel_id = $1 AND id = $2`, channelID, rawSent.Message.ID).Scan(&storedFingerprint); err != nil {
		t.Fatalf("load raw fingerprint: %v", err)
	}
	if !bytes.Equal(storedFingerprint, raw[:]) {
		t.Fatalf("stored raw fingerprint = %x, want %x", storedFingerprint, raw)
	}

	// Simulate a rolling old writer that omits the new column. The empty
	// default keeps the write compatible, but it is never accepted as replay.
	legacyID := rawSent.Message.ID + 100
	legacyRandomID := int64(781099)
	if _, err := pool.Exec(ctx, `
INSERT INTO channel_messages (channel_id, id, random_id, sender_user_id, from_peer_id, message_date, pts, body)
VALUES ($1,$2,$3,$4,$4,$5,$6,$7)`, channelID, legacyID, legacyRandomID, owner.ID, 1700100020, rawSent.Event.Pts+100, "legacy unknown intent"); err != nil {
		t.Fatalf("old-writer insert without fingerprint: %v", err)
	}
	var legacyFingerprint []byte
	if err := pool.QueryRow(ctx, `SELECT request_fingerprint FROM channel_messages WHERE channel_id=$1 AND id=$2`, channelID, legacyID).Scan(&legacyFingerprint); err != nil {
		t.Fatalf("load legacy fingerprint: %v", err)
	}
	if len(legacyFingerprint) != 0 {
		t.Fatalf("legacy fingerprint length = %d, want empty", len(legacyFingerprint))
	}
	legacyReq := domain.SendChannelMessageRequest{UserID: owner.ID, ChannelID: channelID, RandomID: legacyRandomID, Message: "legacy unknown intent", Date: 1700100021}
	legacyExpected, err := store.ChannelSendFingerprint(legacyReq)
	if err != nil {
		t.Fatalf("fingerprint legacy retry: %v", err)
	}
	if _, _, err := channels.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
		ChannelID: channelID, SenderUserID: owner.ID, RandomID: legacyRandomID, IdempotencyFingerprint: legacyExpected,
	}); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
		t.Fatalf("legacy empty lookup err = %v, want ErrMessageRandomIDDuplicate", err)
	}
	if _, err := channels.SendChannelMessage(ctx, legacyReq); !errors.Is(err, domain.ErrMessageRandomIDDuplicate) {
		t.Fatalf("legacy empty send err = %v, want ErrMessageRandomIDDuplicate", err)
	}
}

func TestChannelSendFingerprintConcurrentRacePostgres(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	suffix := randomSuffix(t)
	users := NewUserStore(pool)
	owner, err := users.Create(ctx, domain.User{AccessHash: 191, Phone: "+1781" + suffix + "11", FirstName: "ChannelRaceOwner"})
	if err != nil {
		t.Fatalf("create owner: %v", err)
	}
	var channelIDs []int64
	t.Cleanup(func() {
		if len(channelIDs) != 0 {
			_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id = ANY($1::bigint[])`, channelIDs)
		}
		_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID)
	})
	newChannel := func(title string) int64 {
		t.Helper()
		created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: title + suffix, Megagroup: true, Date: 1700110000})
		if err != nil {
			t.Fatalf("create %s channel: %v", title, err)
		}
		channelIDs = append(channelIDs, created.Channel.ID)
		return created.Channel.ID
	}

	run := func(reqs [2]domain.SendChannelMessageRequest) ([2]domain.SendChannelMessageResult, [2]error) {
		t.Helper()
		var results [2]domain.SendChannelMessageResult
		var errs [2]error
		start := make(chan struct{})
		var wg sync.WaitGroup
		for i := range reqs {
			wg.Add(1)
			go func(i int) {
				defer wg.Done()
				<-start
				results[i], errs[i] = NewChannelStore(pool).SendChannelMessage(ctx, reqs[i])
			}(i)
		}
		close(start)
		wg.Wait()
		return results, errs
	}

	exactChannelID := newChannel("exact race ")
	exactReq := domain.SendChannelMessageRequest{
		UserID: owner.ID, ChannelID: exactChannelID, RandomID: 791001, Message: "same intent", Date: 1700110001,
		IdempotencyPreflighted: true,
	}
	exactResults, exactErrs := run([2]domain.SendChannelMessageRequest{exactReq, exactReq})
	for i, err := range exactErrs {
		if err != nil {
			t.Fatalf("exact race result[%d] err = %v", i, err)
		}
	}
	if exactResults[0].Message.ID != exactResults[1].Message.ID || exactResults[0].Duplicate == exactResults[1].Duplicate {
		t.Fatalf("exact race results = %+v / %+v, want same id and one duplicate", exactResults[0], exactResults[1])
	}
	assertChannelRandomReceiptCount(t, ctx, pool, exactChannelID, owner.ID, exactReq.RandomID, 1)

	conflictChannelID := newChannel("conflict race ")
	conflictA := domain.SendChannelMessageRequest{
		UserID: owner.ID, ChannelID: conflictChannelID, RandomID: 791002, Message: "intent A", Date: 1700110010,
		IdempotencyPreflighted: true,
	}
	conflictB := conflictA
	conflictB.Message = "intent B"
	conflictResults, conflictErrs := run([2]domain.SendChannelMessageRequest{conflictA, conflictB})
	nilCount, duplicateErrCount := 0, 0
	for _, err := range conflictErrs {
		switch {
		case err == nil:
			nilCount++
		case errors.Is(err, domain.ErrMessageRandomIDDuplicate):
			duplicateErrCount++
		default:
			t.Fatalf("conflicting race unexpected err = %v; results=%+v", err, conflictResults)
		}
	}
	if nilCount != 1 || duplicateErrCount != 1 {
		t.Fatalf("conflicting race errors = %v, want one success and one duplicate", conflictErrs)
	}
	assertChannelRandomReceiptCount(t, ctx, pool, conflictChannelID, owner.ID, conflictA.RandomID, 1)
}

func TestChannelSendFingerprintSingleConnectionConflictLookupPostgres(t *testing.T) {
	dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
	if dsn == "" {
		t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
	}
	setupPool := testPool(t)
	setupCtx := context.Background()
	suffix := randomSuffix(t)
	users := NewUserStore(setupPool)
	owner, err := users.Create(setupCtx, domain.User{AccessHash: 192, Phone: "+1781" + suffix + "21", FirstName: "OneConnectionOwner"})
	if err != nil {
		t.Fatalf("create owner: %v", err)
	}
	var channelIDs []int64
	t.Cleanup(func() {
		cleanupCtx := context.Background()
		if len(channelIDs) != 0 {
			_, _ = setupPool.Exec(cleanupCtx, `DELETE FROM channels WHERE id = ANY($1::bigint[])`, channelIDs)
		}
		_, _ = setupPool.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, owner.ID)
	})

	setupChannels := NewChannelStore(setupPool)
	created, err := setupChannels.CreateChannel(setupCtx, domain.CreateChannelRequest{
		CreatorUserID: owner.ID, Title: "one connection " + suffix, Megagroup: true, Date: 1700120000,
	})
	if err != nil {
		t.Fatalf("create ordinary channel: %v", err)
	}
	channelIDs = append(channelIDs, created.Channel.ID)
	ordinaryReq := domain.SendChannelMessageRequest{
		UserID: owner.ID, ChannelID: created.Channel.ID, RandomID: 792001, Message: "single pool exact", Date: 1700120001,
		IdempotencyPreflighted: true,
	}
	broadcast, err := setupChannels.CreateChannel(setupCtx, domain.CreateChannelRequest{
		CreatorUserID: owner.ID, Title: "one connection mono " + suffix, Broadcast: true, Date: 1700120010,
	})
	if err != nil {
		t.Fatalf("create broadcast: %v", err)
	}
	channelIDs = append(channelIDs, broadcast.Channel.ID)
	enabled, err := setupChannels.SetPaidMessagesPrice(setupCtx, owner.ID, broadcast.Channel.ID, 0, true)
	if err != nil {
		t.Fatalf("enable monoforum: %v", err)
	}
	monoID := enabled.Channel.LinkedMonoforumID
	channelIDs = append(channelIDs, monoID)

	// Fixture creation itself has legacy allocator paths that require more than
	// one connection. Constrain only the send/replay path under test.
	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil {
		t.Fatalf("parse postgres config: %v", err)
	}
	cfg.MaxConns = 1
	cfg.MinConns = 0
	pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
	if err != nil {
		t.Fatalf("open one-connection pool: %v", err)
	}
	t.Cleanup(pool.Close)
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	t.Cleanup(cancel)
	msgIDs := &singleConnectionMessageIDAllocator{current: make(map[int64]int)}
	for _, id := range []int64{created.Channel.ID, monoID} {
		var current int
		if err := setupPool.QueryRow(setupCtx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id=$1`, id).Scan(&current); err != nil {
			t.Fatalf("seed message allocator for channel %d: %v", id, err)
		}
		msgIDs.current[id] = current
	}
	oneConnectionStore := func() *ChannelStore {
		return NewChannelStore(pool, WithChannelAllocators(nil, msgIDs))
	}

	var ordinaryResults [2]domain.SendChannelMessageResult
	var ordinaryErrs [2]error
	start := make(chan struct{})
	var wg sync.WaitGroup
	for i := range ordinaryResults {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			<-start
			ordinaryResults[i], ordinaryErrs[i] = oneConnectionStore().SendChannelMessage(ctx, ordinaryReq)
		}(i)
	}
	close(start)
	wg.Wait()
	for i, err := range ordinaryErrs {
		if err != nil {
			t.Fatalf("one-connection ordinary result[%d] err = %v", i, err)
		}
	}
	if ordinaryResults[0].Message.ID != ordinaryResults[1].Message.ID || ordinaryResults[0].Duplicate == ordinaryResults[1].Duplicate {
		t.Fatalf("one-connection ordinary results = %+v / %+v, want same id and one duplicate", ordinaryResults[0], ordinaryResults[1])
	}

	monoReq := domain.SendMonoforumMessageRequest{
		MonoforumID: monoID, SenderUserID: owner.ID,
		SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID},
		RandomID:  792002, Message: "single pool mono exact", Date: 1700120011,
		IdempotencyPreflighted: true,
	}
	var monoResults [2]domain.SendChannelMessageResult
	var monoErrs [2]error
	start = make(chan struct{})
	for i := range monoResults {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			<-start
			monoResults[i], monoErrs[i] = oneConnectionStore().SendMonoforumMessage(ctx, monoReq)
		}(i)
	}
	close(start)
	wg.Wait()
	for i, err := range monoErrs {
		if err != nil {
			t.Fatalf("one-connection monoforum result[%d] err = %v", i, err)
		}
	}
	if monoResults[0].Message.ID != monoResults[1].Message.ID || monoResults[0].Duplicate == monoResults[1].Duplicate {
		t.Fatalf("one-connection monoforum results = %+v / %+v, want same id and one duplicate", monoResults[0], monoResults[1])
	}
}

type singleConnectionMessageIDAllocator struct {
	mu      sync.Mutex
	current map[int64]int
}

func (a *singleConnectionMessageIDAllocator) NextChannelMessageID(_ context.Context, channelID int64) (int, error) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.current[channelID]++
	return a.current[channelID], nil
}

func (a *singleConnectionMessageIDAllocator) CurrentChannelMessageID(_ context.Context, channelID int64) (int, error) {
	a.mu.Lock()
	defer a.mu.Unlock()
	return a.current[channelID], nil
}

func assertChannelRandomReceiptCount(t *testing.T, ctx context.Context, pool *pgxpool.Pool, channelID, senderUserID, randomID int64, want int) {
	t.Helper()
	var got int
	if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id=$1 AND sender_user_id=$2 AND random_id=$3`, channelID, senderUserID, randomID).Scan(&got); err != nil {
		t.Fatalf("count channel random receipt: %v", err)
	}
	if got != want {
		t.Fatalf("channel random receipt count = %d, want %d", got, want)
	}
}

func TestChannelSendFingerprintMigrationRoundTripPostgres(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	downSQL, err := deploy.Migrations.ReadFile("migrations/0078_channel_send_fingerprint.down.sql")
	if err != nil {
		t.Fatalf("read 0078 down: %v", err)
	}
	upSQL, err := deploy.Migrations.ReadFile("migrations/0078_channel_send_fingerprint.up.sql")
	if err != nil {
		t.Fatalf("read 0078 up: %v", err)
	}
	tx, err := pool.Begin(ctx)
	if err != nil {
		t.Fatalf("begin 0078 round trip: %v", err)
	}
	defer func() { _ = tx.Rollback(context.Background()) }()
	if _, err := tx.Exec(ctx, string(downSQL)); err != nil {
		t.Fatalf("0078 down: %v", err)
	}
	if _, err := tx.Exec(ctx, string(upSQL)); err != nil {
		t.Fatalf("0078 up: %v", err)
	}
	var defaultExpr string
	var constraintExists bool
	if err := tx.QueryRow(ctx, `
SELECT column_default,
       EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'channel_messages_request_fingerprint_size')
FROM information_schema.columns
WHERE table_schema='public' AND table_name='channel_messages' AND column_name='request_fingerprint'`).Scan(&defaultExpr, &constraintExists); err != nil {
		t.Fatalf("inspect 0078: %v", err)
	}
	if !strings.Contains(defaultExpr, `\x`) || !constraintExists {
		t.Fatalf("0078 default=%q constraint=%v, want empty bytea rolling default + size constraint", defaultExpr, constraintExists)
	}
}