fix: sync account freeze peer visibility
This commit is contained in:
parent
d9875b5caa
commit
eba402946a
26 changed files with 1034 additions and 19 deletions
|
|
@ -168,7 +168,7 @@ func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) {
|
|||
|
||||
func (s *AdminStore) GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
FROM account_restrictions
|
||||
WHERE user_id = $1`, userID)
|
||||
r, err := scanAccountFreeze(row)
|
||||
|
|
@ -181,13 +181,80 @@ WHERE user_id = $1`, userID)
|
|||
return r, true, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) GetAccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
out := make(map[int64]domain.AccountFreeze)
|
||||
if s == nil || s.db == nil || len(userIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
FROM account_restrictions
|
||||
WHERE user_id = ANY($1::bigint[]) AND frozen = true`, userIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get account freezes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
freeze, err := scanAccountFreeze(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account freeze: %w", err)
|
||||
}
|
||||
out[freeze.UserID] = freeze
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account freezes: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return setAccountFreezeRow(ctx, s.db, freeze)
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("begin set account freeze: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
out, err := setAccountFreezeRow(ctx, tx, freeze)
|
||||
if err != nil {
|
||||
return domain.AccountFreeze{}, err
|
||||
}
|
||||
if err := enqueueAccountFreezeNotifications(ctx, tx, out); err != nil {
|
||||
return domain.AccountFreeze{}, err
|
||||
}
|
||||
// User visibility participates in the same cache/version invalidation spine
|
||||
// as profile and dialog changes. These functions emit cross-instance NOTIFY
|
||||
// events only after the surrounding transaction commits.
|
||||
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_contact_accounts_for_user($1)`, out.UserID); err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("bump frozen user contact projections: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_private_dialog_light_for_user($1)`, out.UserID); err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("bump frozen user dialog projections: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `SELECT telesrv_bump_read_model_version('user_visibility', 0, 'user', $1)`, out.UserID); err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("bump frozen user visibility: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.AccountFreeze{}, fmt.Errorf("commit set account freeze: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func setAccountFreezeRow(ctx context.Context, db sqlcgen.DBTX, freeze domain.AccountFreeze) (domain.AccountFreeze, error) {
|
||||
var since, until any
|
||||
if freeze.Frozen {
|
||||
since = freeze.Since
|
||||
until = freeze.Until
|
||||
}
|
||||
row := s.db.QueryRow(ctx, `
|
||||
row := db.QueryRow(ctx, `
|
||||
INSERT INTO account_restrictions (
|
||||
user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at
|
||||
)
|
||||
|
|
@ -200,8 +267,9 @@ ON CONFLICT (user_id) DO UPDATE SET
|
|||
reason = EXCLUDED.reason,
|
||||
actor = EXCLUDED.actor,
|
||||
command_id = EXCLUDED.command_id,
|
||||
version = account_restrictions.version + 1,
|
||||
updated_at = now()
|
||||
RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
|
||||
RETURNING user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`,
|
||||
freeze.UserID, freeze.Frozen, since, until, freeze.AppealURL, freeze.Reason, freeze.Actor, freeze.CommandID,
|
||||
)
|
||||
out, err := scanAccountFreeze(row)
|
||||
|
|
@ -211,12 +279,16 @@ RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) {
|
||||
type accountFreezeScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanAccountFreeze(row accountFreezeScanner) (domain.AccountFreeze, error) {
|
||||
var r domain.AccountFreeze
|
||||
var since, until pgtype.Timestamptz
|
||||
var updated time.Time
|
||||
if err := row.Scan(
|
||||
&r.UserID, &r.Frozen, &since, &until, &r.AppealURL,
|
||||
&r.UserID, &r.Frozen, &r.Version, &since, &until, &r.AppealURL,
|
||||
&r.Reason, &r.Actor, &r.CommandID, &updated,
|
||||
); err != nil {
|
||||
return domain.AccountFreeze{}, err
|
||||
|
|
@ -230,3 +302,84 @@ func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) {
|
|||
r.UpdatedAt = updated
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func enqueueAccountFreezeNotifications(ctx context.Context, tx pgx.Tx, freeze domain.AccountFreeze) error {
|
||||
const maxAccountFreezeNotificationAudience = 4096
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO account_freeze_notifications (target_user_id, frozen_user_id, version, frozen)
|
||||
SELECT audience.user_id, $1, $2, $3
|
||||
FROM (
|
||||
SELECT user_id
|
||||
FROM (
|
||||
SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
|
||||
FROM contacts WHERE user_id = $1
|
||||
UNION ALL
|
||||
SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
|
||||
UNION ALL
|
||||
SELECT peer_id, 1, top_message_date
|
||||
FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
|
||||
UNION ALL
|
||||
SELECT user_id, 1, top_message_date
|
||||
FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
|
||||
) candidates
|
||||
GROUP BY user_id
|
||||
ORDER BY min(priority), max(activity) DESC, user_id
|
||||
LIMIT $4
|
||||
) audience
|
||||
JOIN users u ON u.id = audience.user_id
|
||||
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
|
||||
ON CONFLICT (target_user_id, frozen_user_id) DO UPDATE SET
|
||||
version = EXCLUDED.version,
|
||||
frozen = EXCLUDED.frozen,
|
||||
status = 'pending',
|
||||
attempts = 0,
|
||||
next_attempt_at = now(),
|
||||
lease_until = NULL,
|
||||
last_error = '',
|
||||
updated_at = now()`, freeze.UserID, freeze.Version, freeze.Frozen, maxAccountFreezeNotificationAudience)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enqueue account freeze notifications: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminStore) ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error) {
|
||||
if s == nil || s.db == nil || limit <= 0 || lease <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH claim AS (
|
||||
SELECT id FROM account_freeze_notifications
|
||||
WHERE (status = 'pending' AND next_attempt_at <= $1)
|
||||
OR (status = 'dispatching' AND lease_until <= $1)
|
||||
ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
|
||||
)
|
||||
UPDATE account_freeze_notifications n
|
||||
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
|
||||
FROM claim WHERE n.id = claim.id
|
||||
RETURNING n.id, n.target_user_id, n.frozen_user_id, n.version, n.frozen, n.attempts`, now, limit, now.Add(lease))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("claim account freeze notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountFreezeNotification, 0)
|
||||
for rows.Next() {
|
||||
var n domain.AccountFreezeNotification
|
||||
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.FrozenUserID, &n.Version, &n.Frozen, &n.Attempts); err != nil {
|
||||
return nil, fmt.Errorf("scan account freeze notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *AdminStore) CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE account_freeze_notifications
|
||||
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $3
|
||||
WHERE id = $1 AND version = $2`, id, version, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete account freeze notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,11 +33,12 @@ func TestAccountFreezeMigrationAndStoreRoundTrip(t *testing.T) {
|
|||
const (
|
||||
frozenUserID = int64(1999999881)
|
||||
activeUserID = int64(1999999882)
|
||||
observerID = int64(1999999883)
|
||||
)
|
||||
for _, user := range []struct {
|
||||
id int64
|
||||
phone string
|
||||
}{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}} {
|
||||
}{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}, {observerID, "1999999883"}} {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, first_name)
|
||||
VALUES ($1, $1, $2, 'Freeze migration test')`, user.id, user.phone); err != nil {
|
||||
|
|
@ -60,10 +61,32 @@ VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, l
|
|||
t.Fatalf("GetAccountFreeze migrated = %+v found=%v err=%v", migrated, found, err)
|
||||
}
|
||||
if !migrated.Frozen || !migrated.Since.Equal(legacyUpdatedAt) ||
|
||||
!migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" {
|
||||
!migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" || migrated.Version != 1 {
|
||||
t.Fatalf("migrated freeze = %+v", migrated)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO contacts (user_id, contact_user_id, contact_first_name)
|
||||
VALUES ($1, $2, 'Visible frozen peer')`, observerID, activeUserID); err != nil {
|
||||
t.Fatalf("insert observer contact: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO dialogs (user_id, peer_type, peer_id, top_message_id, top_message_date)
|
||||
VALUES ($1, 'user', $2, 1, 100)`, observerID, activeUserID); err != nil {
|
||||
t.Fatalf("insert observer dialog: %v", err)
|
||||
}
|
||||
var contactVersionBefore, dialogVersionBefore int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, observerID).Scan(&contactVersionBefore); err != nil {
|
||||
t.Fatalf("read initial contact projection version: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2`, observerID, activeUserID).Scan(&dialogVersionBefore); err != nil {
|
||||
t.Fatalf("read initial dialog projection version: %v", err)
|
||||
}
|
||||
|
||||
since := time.Date(2026, 7, 15, 2, 0, 0, 0, time.UTC)
|
||||
want := domain.AccountFreeze{
|
||||
UserID: activeUserID,
|
||||
|
|
@ -75,21 +98,80 @@ VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, l
|
|||
Actor: "ops",
|
||||
CommandID: "freeze-round-trip",
|
||||
}
|
||||
if _, err := store.SetAccountFreeze(ctx, want); err != nil {
|
||||
updated, err := store.SetAccountFreeze(ctx, want)
|
||||
if err != nil {
|
||||
t.Fatalf("SetAccountFreeze active: %v", err)
|
||||
}
|
||||
if updated.Version != 1 {
|
||||
t.Fatalf("first freeze version = %d, want 1", updated.Version)
|
||||
}
|
||||
got, found, err := store.GetAccountFreeze(ctx, activeUserID)
|
||||
if err != nil || !found || !got.Frozen || !got.Since.Equal(want.Since) ||
|
||||
!got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL {
|
||||
!got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL || got.Version != 1 {
|
||||
t.Fatalf("active round trip = %+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
if _, err := store.SetAccountFreeze(ctx, domain.AccountFreeze{
|
||||
var contactVersionAfter, dialogVersionAfter, visibilityVersion int64
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, observerID).Scan(&contactVersionAfter); err != nil {
|
||||
t.Fatalf("read frozen contact projection version: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2`, observerID, activeUserID).Scan(&dialogVersionAfter); err != nil {
|
||||
t.Fatalf("read frozen dialog projection version: %v", err)
|
||||
}
|
||||
if contactVersionAfter <= contactVersionBefore || dialogVersionAfter <= dialogVersionBefore {
|
||||
t.Fatalf("projection versions contact %d->%d dialog %d->%d, want increments",
|
||||
contactVersionBefore, contactVersionAfter, dialogVersionBefore, dialogVersionAfter)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT version FROM read_model_versions
|
||||
WHERE model = 'user_visibility' AND owner_user_id = 0 AND peer_type = 'user' AND peer_id = $1`, activeUserID).Scan(&visibilityVersion); err != nil || visibilityVersion != 1 {
|
||||
t.Fatalf("user visibility version = %d err=%v, want 1", visibilityVersion, err)
|
||||
}
|
||||
|
||||
claimAt := time.Now().UTC().Add(time.Minute)
|
||||
claimed, err := store.ClaimAccountFreezeNotifications(ctx, claimAt, 10, time.Minute)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim frozen notification = %+v err=%v, want one", claimed, err)
|
||||
}
|
||||
oldNotification := claimed[0]
|
||||
if oldNotification.TargetUserID != observerID || oldNotification.FrozenUserID != activeUserID || !oldNotification.Frozen || oldNotification.Version != 1 {
|
||||
t.Fatalf("frozen notification = %+v", oldNotification)
|
||||
}
|
||||
|
||||
updated, err = store.SetAccountFreeze(ctx, domain.AccountFreeze{
|
||||
UserID: activeUserID, Reason: "appeal accepted", Actor: "ops", CommandID: "unfreeze-round-trip",
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetAccountFreeze inactive: %v", err)
|
||||
}
|
||||
if updated.Version != 2 {
|
||||
t.Fatalf("unfreeze version = %d, want 2", updated.Version)
|
||||
}
|
||||
// A worker that claimed v1 before the unfreeze cannot acknowledge the
|
||||
// coalesced v2 row and suppress its online refresh.
|
||||
if err := store.CompleteAccountFreezeNotification(ctx, oldNotification.ID, oldNotification.Version, claimAt); err != nil {
|
||||
t.Fatalf("complete stale notification: %v", err)
|
||||
}
|
||||
claimed, err = store.ClaimAccountFreezeNotifications(ctx, claimAt.Add(time.Minute), 10, time.Minute)
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("claim unfreeze notification = %+v err=%v, want one", claimed, err)
|
||||
}
|
||||
newNotification := claimed[0]
|
||||
if newNotification.ID != oldNotification.ID || newNotification.Version != 2 || newNotification.Frozen {
|
||||
t.Fatalf("coalesced unfreeze notification = %+v, previous=%+v", newNotification, oldNotification)
|
||||
}
|
||||
if err := store.CompleteAccountFreezeNotification(ctx, newNotification.ID, newNotification.Version, claimAt.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("complete unfreeze notification: %v", err)
|
||||
}
|
||||
var notificationStatus string
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM account_freeze_notifications WHERE id = $1`, newNotification.ID).Scan(¬ificationStatus); err != nil || notificationStatus != "delivered" {
|
||||
t.Fatalf("notification status = %q err=%v, want delivered", notificationStatus, err)
|
||||
}
|
||||
got, found, err = store.GetAccountFreeze(ctx, activeUserID)
|
||||
if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" {
|
||||
if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" || got.Version != 2 {
|
||||
t.Fatalf("inactive round trip = %+v found=%v err=%v", got, found, err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -348,6 +348,15 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
|
|||
l.caches.BotProfiles.InvalidateBotProfileReadModel(evt.PeerID)
|
||||
}
|
||||
}
|
||||
case "user_visibility":
|
||||
if evt.PeerType == "user" && evt.PeerID != 0 {
|
||||
if l.caches.RPCProjections != nil {
|
||||
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.PeerID)
|
||||
}
|
||||
if l.caches.Stories != nil {
|
||||
l.caches.Stories.InvalidateStoryReadModelPeer(domain.Peer{Type: domain.PeerTypeUser, ID: evt.PeerID})
|
||||
}
|
||||
}
|
||||
case "bot_full":
|
||||
// bot 资料(name/about/description/commands/menu_button)变更经 bot_info_version
|
||||
// bump 触发(迁移 0013)。channelFullBotInfoCache 按 (viewer,channel) 键、无法按 botID
|
||||
|
|
|
|||
|
|
@ -17,6 +17,19 @@ type fakeStoryReadModelCache struct {
|
|||
flushes int
|
||||
}
|
||||
|
||||
type fakeRPCProjectionReadModelCache struct {
|
||||
users []int64
|
||||
}
|
||||
|
||||
func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForViewer(int64) {}
|
||||
func (f *fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForUser(id int64) {
|
||||
f.users = append(f.users, id)
|
||||
}
|
||||
func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForPeer(int64, domain.Peer) {
|
||||
}
|
||||
func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForChannel(int64) {}
|
||||
func (*fakeRPCProjectionReadModelCache) FlushRPCProjectionReadModel() {}
|
||||
|
||||
func (f *fakeStoryReadModelCache) InvalidateStoryReadModelViewers(ids ...int64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -78,6 +91,29 @@ func TestReadModelChangeListenerRoutesStoryPeer(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReadModelChangeListenerRoutesUserVisibility(t *testing.T) {
|
||||
stories := &fakeStoryReadModelCache{}
|
||||
rpcProjections := &fakeRPCProjectionReadModelCache{}
|
||||
listener := NewReadModelChangeListener("", ReadModelCacheSet{
|
||||
Stories: stories,
|
||||
RPCProjections: rpcProjections,
|
||||
}, nil)
|
||||
|
||||
listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"user","peer_id":777,"version":2}`)
|
||||
if len(rpcProjections.users) != 1 || rpcProjections.users[0] != 777 {
|
||||
t.Fatalf("RPC projection invalidations = %v, want [777]", rpcProjections.users)
|
||||
}
|
||||
if peers := stories.peersSnapshot(); len(peers) != 1 || peers[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 777}) {
|
||||
t.Fatalf("story projection invalidations = %+v, want user 777", peers)
|
||||
}
|
||||
|
||||
listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"channel","peer_id":888,"version":3}`)
|
||||
listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"user","peer_id":0,"version":4}`)
|
||||
if len(rpcProjections.users) != 1 || len(stories.peersSnapshot()) != 1 {
|
||||
t.Fatalf("invalid visibility events were not ignored: users=%v peers=%+v", rpcProjections.users, stories.peersSnapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite 验证 0135 触发器:写 stories /
|
||||
// story_hidden_peers → story_peer bump → 统一 read-model NOTIFY → 按 owner peer 失效故事投影。
|
||||
func TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue