feat: sync ephemeral transient messages
Sync telesrv 570ccf8 (feat(ephemeral): implement Layer 228 transient messages). Skipped telesrv docs changes per public sync rules; normalized the public appearance seed label.
This commit is contained in:
parent
3f78eaa2c6
commit
f49c817def
53 changed files with 5793 additions and 112 deletions
|
|
@ -1,8 +1,11 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
|
@ -291,6 +294,7 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.
|
|||
var callbackInlineDCID, callbackInlineMessageID int
|
||||
var callbackInlineOwnerID, callbackInlineAccessHash int64
|
||||
var callbackData []byte
|
||||
var ephemeralPayload []byte
|
||||
if req.Callback != nil {
|
||||
callbackQueryID = req.Callback.ID
|
||||
callbackUserID = req.Callback.UserID
|
||||
|
|
@ -303,13 +307,21 @@ func (s *BotAPIUpdateStore) EnqueueBotAPIUpdate(ctx context.Context, req domain.
|
|||
callbackInlineAccessHash = req.Callback.InlineMessage.AccessHash
|
||||
}
|
||||
}
|
||||
if req.Ephemeral != nil {
|
||||
var err error
|
||||
ephemeralPayload, err = json.Marshal(req.Ephemeral)
|
||||
if err != nil {
|
||||
return domain.BotAPIUpdate{}, false, fmt.Errorf("marshal bot api ephemeral payload: %w", err)
|
||||
}
|
||||
}
|
||||
row, err := s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
|
||||
WITH inserted AS (
|
||||
INSERT INTO bot_api_updates (
|
||||
bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
) SELECT $1, $2::varchar(32), $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM bot_api_update_states
|
||||
|
|
@ -320,7 +332,8 @@ WHERE NOT EXISTS (
|
|||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
), wake_webhook AS (
|
||||
UPDATE bot_api_webhooks
|
||||
SET next_attempt_at = now(), updated_at = now()
|
||||
|
|
@ -329,11 +342,12 @@ WHERE NOT EXISTS (
|
|||
)
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM inserted
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, req.Date,
|
||||
callbackQueryID, callbackUserID, callbackChatInstance, callbackData,
|
||||
callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash))
|
||||
callbackInlineDCID, callbackInlineOwnerID, callbackInlineMessageID, callbackInlineAccessHash, ephemeralPayload))
|
||||
if err == nil {
|
||||
return row, true, nil
|
||||
}
|
||||
|
|
@ -343,16 +357,20 @@ FROM inserted
|
|||
row, err = s.scanBotAPIUpdate(s.db.QueryRow(ctx, `
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
AND update_kind = $2
|
||||
AND (
|
||||
(update_kind = 'callback_query' AND callback_query_id = $7)
|
||||
OR
|
||||
(update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND source_pts = $6)
|
||||
(update_kind <> 'callback_query' AND peer_type = $3 AND peer_id = $4 AND message_id = $5 AND (
|
||||
(ephemeral_payload IS NULL AND $8::jsonb IS NULL AND source_pts = $6)
|
||||
OR (ephemeral_payload = $8::jsonb)
|
||||
))
|
||||
)
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID))
|
||||
`, req.BotUserID, string(req.Kind), string(req.Peer.Type), req.Peer.ID, req.MessageID, req.SourcePts, callbackQueryID, ephemeralPayload))
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return domain.BotAPIUpdate{}, false, nil
|
||||
|
|
@ -372,11 +390,13 @@ func (s *BotAPIUpdateStore) ListTailBotAPIUpdates(ctx context.Context, botUserID
|
|||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM (
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1
|
||||
AND id > COALESCE((SELECT confirmed_update_id FROM bot_api_update_states WHERE bot_user_id = $1), 0)
|
||||
|
|
@ -417,7 +437,8 @@ func (s *BotAPIUpdateStore) ListBotAPIUpdates(ctx context.Context, botUserID, fr
|
|||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts, date,
|
||||
callback_query_id, callback_user_id, callback_chat_instance, callback_data,
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash
|
||||
callback_inline_dc_id, callback_inline_owner_id, callback_inline_message_id, callback_inline_access_hash,
|
||||
ephemeral_payload
|
||||
FROM bot_api_updates
|
||||
WHERE bot_user_id = $1 AND id >= $2
|
||||
ORDER BY id
|
||||
|
|
@ -619,9 +640,11 @@ func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error)
|
|||
var callbackInlineDCID, callbackInlineMessageID int
|
||||
var callbackInlineOwnerID, callbackInlineAccessHash int64
|
||||
var callbackData []byte
|
||||
var ephemeralPayload []byte
|
||||
if err := row.Scan(&item.ID, &item.BotUserID, &kind, &peerType, &item.Peer.ID, &item.MessageID, &item.SourcePts, &item.Date,
|
||||
&callbackQueryID, &callbackUserID, &callbackChatInstance, &callbackData,
|
||||
&callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash); err != nil {
|
||||
&callbackInlineDCID, &callbackInlineOwnerID, &callbackInlineMessageID, &callbackInlineAccessHash,
|
||||
&ephemeralPayload); err != nil {
|
||||
return domain.BotAPIUpdate{}, err
|
||||
}
|
||||
item.Kind = domain.BotAPIUpdateKind(kind)
|
||||
|
|
@ -640,6 +663,21 @@ func scanBotAPIUpdateRows(row botAPIUpdateScanner) (domain.BotAPIUpdate, error)
|
|||
item.Callback.InlineMessage = &domain.BotInlineMessageID{DCID: callbackInlineDCID, OwnerID: callbackInlineOwnerID, ID: callbackInlineMessageID, AccessHash: callbackInlineAccessHash}
|
||||
}
|
||||
}
|
||||
if len(ephemeralPayload) != 0 {
|
||||
var payload domain.BotAPIEphemeralPayload
|
||||
decoder := json.NewDecoder(bytes.NewReader(ephemeralPayload))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return domain.BotAPIUpdate{}, fmt.Errorf("decode bot api ephemeral payload: trailing JSON")
|
||||
}
|
||||
if err := validateBotAPIEphemeralPayload(item.BotUserID, item.Kind, item.Peer, item.MessageID, item.SourcePts, item.Date, &payload); err != nil {
|
||||
return domain.BotAPIUpdate{}, err
|
||||
}
|
||||
item.Ephemeral = &payload
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
|
|
@ -662,6 +700,9 @@ func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
|
|||
default:
|
||||
return fmt.Errorf("invalid bot api update peer type %q", req.Peer.Type)
|
||||
}
|
||||
if err := validateBotAPIEphemeralPayload(req.BotUserID, req.Kind, req.Peer, req.MessageID, req.SourcePts, req.Date, req.Ephemeral); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Kind == domain.BotAPIUpdateCallbackQuery {
|
||||
cb := req.Callback
|
||||
if cb == nil || cb.ID == 0 || cb.BotUserID != req.BotUserID || cb.UserID <= 0 ||
|
||||
|
|
@ -681,3 +722,27 @@ func validateBotAPIUpdateRequest(req domain.EnqueueBotAPIUpdateRequest) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBotAPIEphemeralPayload(botUserID int64, kind domain.BotAPIUpdateKind, peer domain.Peer, messageID, sourcePts, date int, payload *domain.BotAPIEphemeralPayload) error {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
message := payload.Message
|
||||
if payload.Validate() != nil || peer.Type != domain.PeerTypeChannel || message.ID != messageID || message.Peer != peer ||
|
||||
message.Expired(time.Unix(int64(date), 0)) || sourcePts != 0 {
|
||||
return fmt.Errorf("invalid bot api ephemeral update")
|
||||
}
|
||||
if kind == domain.BotAPIUpdateCallbackQuery {
|
||||
if message.SenderUserID != botUserID {
|
||||
return fmt.Errorf("invalid bot api ephemeral callback target")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if kind != domain.BotAPIUpdateMessage && kind != domain.BotAPIUpdateEditedMessage {
|
||||
return fmt.Errorf("invalid bot api ephemeral update kind")
|
||||
}
|
||||
if message.ReceiverUserID != botUserID {
|
||||
return fmt.Errorf("invalid bot api ephemeral receiver")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -406,3 +406,70 @@ ON CONFLICT (bot_user_id) DO NOTHING`, u.ID); err != nil {
|
|||
t.Fatalf("post-retention list = %+v, want only unconfirmed fresh row %d", items, unconfirmedFresh.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotAPIEphemeralEnvelopeRoundTrip(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
bot, err := users.Create(ctx, domain.User{AccessHash: 941, Phone: "+1941" + suffix + "01", FirstName: "EphemeralQueueBot"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
human, err := users.Create(ctx, domain.User{AccessHash: 942, Phone: "+1942" + suffix + "02", FirstName: "EphemeralHuman"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $1, 'ephemeral-secret')`, bot.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_updates WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_api_update_states WHERE bot_user_id = $1", bot.ID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", bot.ID)
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 3901}
|
||||
message := domain.EphemeralMessage{
|
||||
ID: 81, Peer: peer, SenderUserID: human.ID, ReceiverUserID: bot.ID,
|
||||
Date: int(now.Unix()), RandomID: 11, Content: domain.EphemeralContent{Message: "/private"},
|
||||
Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
|
||||
}
|
||||
store := NewBotAPIUpdateStore(pool)
|
||||
request := domain.EnqueueBotAPIUpdateRequest{
|
||||
BotUserID: bot.ID, Kind: domain.BotAPIUpdateMessage, Peer: peer,
|
||||
MessageID: message.ID, Date: message.Date,
|
||||
Ephemeral: domain.NewBotAPIEphemeralPayload(message),
|
||||
}
|
||||
first, created, err := store.EnqueueBotAPIUpdate(ctx, request)
|
||||
if err != nil || !created || first.Ephemeral == nil {
|
||||
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
|
||||
}
|
||||
var leakedPrivateRoutingState bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT (ephemeral_payload -> 'Message') ?| ARRAY[
|
||||
'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted'
|
||||
]
|
||||
FROM bot_api_updates WHERE id = $1`, first.ID).Scan(&leakedPrivateRoutingState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if leakedPrivateRoutingState {
|
||||
t.Fatal("durable Bot API envelope contains private ephemeral routing fields")
|
||||
}
|
||||
if replay, created, err := store.EnqueueBotAPIUpdate(ctx, request); err != nil || created || replay.ID != first.ID {
|
||||
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
|
||||
}
|
||||
message.Version, message.EditDate, message.Content.Message = 2, message.Date+1, "edited"
|
||||
request.Kind = domain.BotAPIUpdateEditedMessage
|
||||
request.Ephemeral = domain.NewBotAPIEphemeralPayload(message)
|
||||
second, created, err := store.EnqueueBotAPIUpdate(ctx, request)
|
||||
if err != nil || !created || second.ID <= first.ID {
|
||||
t.Fatalf("second=%+v created=%v err=%v", second, created, err)
|
||||
}
|
||||
rows, err := store.ListBotAPIUpdates(ctx, bot.ID, first.ID, 100)
|
||||
if err != nil || len(rows) != 2 || rows[0].SourcePts != 0 || rows[0].Ephemeral == nil ||
|
||||
rows[0].Ephemeral.Message.Content.Message != "/private" || rows[1].Ephemeral.Message.Content.Message != "edited" {
|
||||
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
internal/store/postgres/ephemeral_report.go
Normal file
49
internal/store/postgres/ephemeral_report.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// EphemeralReportStore persists the low-volume abuse-review evidence path.
|
||||
// The hot ephemeral send/edit/delete path remains entirely in Redis.
|
||||
type EphemeralReportStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
func NewEphemeralReportStore(db sqlcgen.DBTX) *EphemeralReportStore {
|
||||
return &EphemeralReportStore{db: db}
|
||||
}
|
||||
|
||||
func (s *EphemeralReportStore) CreateEphemeralReport(ctx context.Context, report domain.EphemeralAbuseReport) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, fmt.Errorf("ephemeral report store is not configured")
|
||||
}
|
||||
if err := report.Validate(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
evidence, err := json.Marshal(report.Evidence)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("marshal ephemeral report evidence: %w", err)
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO ephemeral_abuse_reports (
|
||||
reporter_user_id, channel_id, ephemeral_message_id, sender_user_id,
|
||||
receiver_user_id, report_option, report_comment, comment_hash,
|
||||
payload_hash, evidence, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)
|
||||
ON CONFLICT (
|
||||
reporter_user_id, channel_id, ephemeral_message_id, report_option, comment_hash
|
||||
) DO NOTHING
|
||||
`, report.ReporterUserID, report.Evidence.Peer.ID, report.Evidence.MessageID,
|
||||
report.Evidence.SenderUserID, report.Evidence.ReceiverUserID,
|
||||
report.Option, report.Comment, report.CommentHash[:], report.Evidence.PayloadHash[:], evidence, report.CreatedAt)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert ephemeral abuse report: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
60
internal/store/postgres/ephemeral_report_integration_test.go
Normal file
60
internal/store/postgres/ephemeral_report_integration_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestEphemeralReportStoreDurableEvidenceAndIdempotency(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
reporter := now.UnixNano()&0x3fffffff + 1000
|
||||
sender := reporter + 1
|
||||
messageID := int(now.UnixNano()&0x3fffffff) + 1
|
||||
message := domain.EphemeralMessage{
|
||||
ID: messageID, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: reporter + 2},
|
||||
SenderUserID: sender, ReceiverUserID: reporter, Date: int(now.Unix()), RandomID: 99,
|
||||
Content: domain.EphemeralContent{Message: "abuse evidence"},
|
||||
OriginDevice: domain.EphemeralDevice{UserID: reporter, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44},
|
||||
Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
|
||||
}
|
||||
report := domain.NewEphemeralAbuseReport(reporter, "spam", "review this", message, now)
|
||||
store := NewEphemeralReportStore(pool)
|
||||
created, err := store.CreateEphemeralReport(ctx, report)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("create=%v err=%v", created, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM ephemeral_abuse_reports WHERE reporter_user_id = $1", reporter)
|
||||
})
|
||||
if created, err := store.CreateEphemeralReport(ctx, report); err != nil || created {
|
||||
t.Fatalf("retry create=%v err=%v", created, err)
|
||||
}
|
||||
var evidenceRaw []byte
|
||||
var count int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT evidence, count(*) OVER ()
|
||||
FROM ephemeral_abuse_reports
|
||||
WHERE reporter_user_id = $1 AND channel_id = $2 AND ephemeral_message_id = $3
|
||||
`, reporter, message.Peer.ID, message.ID).Scan(&evidenceRaw, &count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("rows=%d", count)
|
||||
}
|
||||
var evidence map[string]any
|
||||
if err := json.Unmarshal(evidenceRaw, &evidence); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if evidence["MessageID"] != float64(message.ID) || evidence["Content"] == nil {
|
||||
t.Fatalf("evidence=%s", evidenceRaw)
|
||||
}
|
||||
if _, leaked := evidence["OriginDevice"]; leaked {
|
||||
t.Fatalf("device identity leaked into report evidence: %s", evidenceRaw)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||
}
|
||||
if status.Dirty || status.Empty || status.Version != 119 {
|
||||
t.Fatalf("migration status = %+v, want clean version 119", status)
|
||||
if status.Dirty || status.Empty || status.Version != 121 {
|
||||
t.Fatalf("migration status = %+v, want clean version 121", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue