Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

@ -0,0 +1,392 @@
package memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
// Default page sizes for the rating reads, so an unset limit resolves to a
// finite page the way the PostgreSQL LIMIT does.
const (
defaultAccountRatingListLimit = 50
defaultAccountRatingEventLimit = 50
defaultAccountRatingStaleLimit = 50
)
// AccountRatingStore is the in-memory implementation of store.AccountRatingStore.
// It reproduces the invariants migration 0151 encodes:
//
// - account_rating is keyed by user_id, so one projection row per user.
// - the version CHECK plus optimistic concurrency: a write is applied only when
// it carries the successor of the stored version, which is exactly what
// domain.ResolveAccountRatingPending produces.
// - the pending pair CHECK: a pending delta and its date exist together or not
// at all.
// - the component CHECKs: stars/activity/penalty components and level are
// non-negative, and next_level_stars is either absent or above
// current_level_stars.
// - account_rating_events_command_idx: a replayed command key never appends a
// second adjustment.
type AccountRatingStore struct {
mu sync.Mutex
nextID int64
// ratings is the account_rating read model.
ratings map[int64]domain.AccountRating
// events is the append-only contribution ledger in insertion order.
events []domain.AccountRatingEvent
// commands maps an adjustment command key onto the ledger row it created.
commands map[string]int64
// signals holds the raw contribution snapshot per user.
//
// PostgreSQL aggregates it from stars_transactions, message counts, saved
// gifts and moderation cases. In memory those live in unrelated store types
// (StarsStore, MessageStore, StarGiftStore, ModerationReportStore) that this
// store has no handle on, and wiring them in would make the rating depend on
// which stores a test happens to construct. The snapshot is therefore
// injected -- deterministic, and identical for a unit test and for the
// recompute worker, which is what domain.AccountRatingSignals promises. Only
// the manual total is derived here, from the ledger, because the ledger is
// this store's own data.
signals map[int64]domain.AccountRatingSignals
// accounts is the account universe UnratedAccounts seeds from, in declaration
// order.
//
// PostgreSQL reads it from the users table. This store has no users table and
// inventing one from whichever ids happen to appear in the ledger would be
// circular -- an account with no rating and no adjustment is exactly the case
// seeding exists for. So the universe is declared, like signals above.
accounts []int64
}
// NewAccountRatingStore creates an empty rating store.
func NewAccountRatingStore() *AccountRatingStore {
return &AccountRatingStore{
nextID: 1,
ratings: make(map[int64]domain.AccountRating),
commands: make(map[string]int64),
signals: make(map[int64]domain.AccountRatingSignals),
}
}
// SeedAccountRatingSignals installs raw contribution snapshots for tests in other
// packages; see the signals field for why they are injected rather than derived.
func (s *AccountRatingStore) SeedAccountRatingSignals(signals ...domain.AccountRatingSignals) {
for _, item := range signals {
s.setAccountRatingSignals(item)
}
}
// setAccountRatingSignals is the same hook for this package's tests.
func (s *AccountRatingStore) setAccountRatingSignals(signals domain.AccountRatingSignals) {
if signals.UserID <= 0 {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.signals[signals.UserID] = signals
}
// AccountRating returns the stored projection, or domain.ErrAccountRatingNotFound
// when the user was never computed.
func (s *AccountRatingStore) AccountRating(_ context.Context, userID int64) (domain.AccountRating, error) {
s.mu.Lock()
defer s.mu.Unlock()
rating, ok := s.ratings[userID]
if !ok {
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
}
return rating, nil
}
// AccountRatingBatch resolves several users at once; users without a row are
// absent from the map.
func (s *AccountRatingStore) AccountRatingBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
out := make(map[int64]domain.AccountRating, len(userIDs))
if len(userIDs) == 0 {
return out, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if rating, ok := s.ratings[userID]; ok {
out[userID] = rating
}
}
return out, nil
}
// SaveAccountRating upserts the projection under optimistic concurrency: the
// incoming Version must be the successor of the stored one, which is what
// domain.ResolveAccountRatingPending computes. A stale or missing version leaves
// the stored row untouched and reports changed=false, so a caller that lost a
// race can re-read and retry.
func (s *AccountRatingStore) SaveAccountRating(_ context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
if rating.UserID <= 0 {
// account_rating.user_id references users(id): there is no row to write.
return domain.AccountRating{}, false, domain.ErrAccountRatingNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
stored, exists := s.ratings[rating.UserID]
if rating.Version != stored.Version+1 {
if !exists {
return domain.AccountRating{}, false, nil
}
return stored, false, nil
}
next := normalizeAccountRating(rating)
s.ratings[next.UserID] = next
return next, true, nil
}
// AccountRatingSignals returns the injected raw snapshot with the manual total
// taken from the ledger, mirroring the PostgreSQL aggregate. A user with no
// contributions reports zeros rather than an error, because the aggregate has no
// "missing row" state.
func (s *AccountRatingStore) AccountRatingSignals(_ context.Context, userID int64) (domain.AccountRatingSignals, error) {
if userID <= 0 {
return domain.AccountRatingSignals{}, domain.ErrAccountRatingNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
signals := s.signals[userID]
signals.UserID = userID
signals.Manual += s.manualTotalLocked(userID)
return signals, nil
}
// AdjustAccountRating appends a manual adjustment. A replayed command key returns
// the recorded event with applied=false and appends nothing.
func (s *AccountRatingStore) AdjustAccountRating(_ context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
if err := req.Validate(); err != nil {
return domain.AccountRatingEvent{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if req.CommandKey != "" {
if id, ok := s.commands[req.CommandKey]; ok {
for _, event := range s.events {
if event.ID == id {
return event, false, nil
}
}
}
}
event := domain.AccountRatingEvent{
ID: s.nextID,
UserID: req.UserID,
Kind: domain.AccountRatingEventManual,
Amount: req.Amount,
Reason: req.Reason,
Actor: req.Actor,
CommandKey: req.CommandKey,
CreatedAt: time.Now().UTC(),
}
s.nextID++
s.events = append(s.events, event)
if event.CommandKey != "" {
s.commands[event.CommandKey] = event.ID
}
return event, true, nil
}
// ListAccountRatings is the admin leaderboard: level desc, stars desc, user id
// asc, matching account_rating_leaderboard_idx. BeforeID is the keyset cursor and
// names the last row of the previous page.
func (s *AccountRatingStore) ListAccountRatings(_ context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
limit := filter.Limit
if limit <= 0 {
limit = defaultAccountRatingListLimit
}
s.mu.Lock()
defer s.mu.Unlock()
cursor, hasCursor := s.ratings[filter.BeforeID]
out := make([]domain.AccountRating, 0, len(s.ratings))
for _, rating := range s.ratings {
if filter.MinLevel > 0 && rating.Level < filter.MinLevel {
continue
}
if filter.UserID > 0 && rating.UserID != filter.UserID {
continue
}
switch {
case filter.BeforeID <= 0:
case hasCursor:
// Keyset paging over the leaderboard order.
if !accountRatingLess(cursor, rating) {
continue
}
default:
// The cursor row is gone; fall back to the id tiebreak alone so paging
// still terminates.
if rating.UserID <= filter.BeforeID {
continue
}
}
out = append(out, rating)
}
sort.Slice(out, func(i, j int) bool { return accountRatingLess(out[i], out[j]) })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// AccountRatingEvents returns the ledger for one user, newest first.
func (s *AccountRatingStore) AccountRatingEvents(_ context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
if limit <= 0 {
limit = defaultAccountRatingEventLimit
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.AccountRatingEvent, 0, limit)
for i := len(s.events) - 1; i >= 0 && len(out) < limit; i-- {
if s.events[i].UserID != userID {
continue
}
out = append(out, s.events[i])
}
return out, nil
}
// StaleAccountRatings returns the users whose projection predates the horizon,
// oldest first, which is the order account_rating_stale_idx serves.
func (s *AccountRatingStore) StaleAccountRatings(_ context.Context, olderThanUnix int64, limit int) ([]int64, error) {
if limit <= 0 {
limit = defaultAccountRatingStaleLimit
}
horizon := time.Unix(olderThanUnix, 0).UTC()
s.mu.Lock()
defer s.mu.Unlock()
stale := make([]domain.AccountRating, 0, len(s.ratings))
for _, rating := range s.ratings {
if rating.ComputedAt.Before(horizon) {
stale = append(stale, rating)
}
}
sort.Slice(stale, func(i, j int) bool {
if !stale[i].ComputedAt.Equal(stale[j].ComputedAt) {
return stale[i].ComputedAt.Before(stale[j].ComputedAt)
}
return stale[i].UserID < stale[j].UserID
})
if len(stale) > limit {
stale = stale[:limit]
}
out := make([]int64, 0, len(stale))
for _, rating := range stale {
out = append(out, rating.UserID)
}
return out, nil
}
// SeedAccounts declares the account universe UnratedAccounts walks. Repeating an
// id is a no-op, so a test can declare accounts as it creates them.
func (s *AccountRatingStore) SeedAccounts(userIDs ...int64) {
s.mu.Lock()
defer s.mu.Unlock()
known := make(map[int64]struct{}, len(s.accounts))
for _, id := range s.accounts {
known[id] = struct{}{}
}
for _, id := range userIDs {
if id <= 0 {
continue
}
if _, ok := known[id]; ok {
continue
}
known[id] = struct{}{}
s.accounts = append(s.accounts, id)
}
}
// UnratedAccounts returns declared accounts that have no projection yet, in
// declaration order -- the memory stand-in for PostgreSQL's oldest-account-first
// walk. A store nobody seeded reports no candidates rather than erroring: the
// worker treats that as "nothing to seed", which is the truth.
func (s *AccountRatingStore) UnratedAccounts(_ context.Context, limit int) ([]int64, error) {
if limit <= 0 {
limit = defaultAccountRatingStaleLimit
}
s.mu.Lock()
defer s.mu.Unlock()
out := make([]int64, 0, limit)
for _, id := range s.accounts {
if _, rated := s.ratings[id]; rated {
continue
}
out = append(out, id)
if len(out) == limit {
break
}
}
return out, nil
}
// manualTotalLocked sums the manual ledger rows, the only kind that survives a
// recompute.
func (s *AccountRatingStore) manualTotalLocked(userID int64) int64 {
var total int64
for _, event := range s.events {
if event.UserID == userID && event.Kind == domain.AccountRatingEventManual {
total += event.Amount
}
}
return total
}
// normalizeAccountRating makes the rows the table's CHECK constraints forbid
// unrepresentable. PostgreSQL raises an opaque constraint error there rather than
// a domain error, so the memory store folds the impossible shapes onto the
// closest representable one instead of inventing an error the RPC layer would
// then have to handle only in tests.
func normalizeAccountRating(rating domain.AccountRating) domain.AccountRating {
if rating.Level < 0 {
rating.Level = 0
}
if rating.Level > domain.MaxAccountRatingLevel {
rating.Level = domain.MaxAccountRatingLevel
}
if rating.CurrentLevelStars < 0 {
rating.CurrentLevelStars = 0
}
if rating.StarsComponent < 0 {
rating.StarsComponent = 0
}
if rating.ActivityComponent < 0 {
rating.ActivityComponent = 0
}
if rating.PenaltyComponent < 0 {
rating.PenaltyComponent = 0
}
if !rating.HasNextLevel || rating.NextLevelStars <= rating.CurrentLevelStars {
rating.HasNextLevel = false
rating.NextLevelStars = 0
}
// The pending delta and its date only exist together.
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
rating.PendingStars = 0
rating.PendingDate = time.Time{}
}
return rating
}
// accountRatingLess is the leaderboard order: highest level first, then the
// larger score, then the lower user id as a stable tiebreak.
func accountRatingLess(a, b domain.AccountRating) bool {
if a.Level != b.Level {
return a.Level > b.Level
}
if a.Stars != b.Stars {
return a.Stars > b.Stars
}
return a.UserID < b.UserID
}

View file

@ -0,0 +1,425 @@
package memory
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
// accountRatingFixture builds a projection the table's CHECK constraints accept.
func accountRatingFixture(userID, stars, version int64, computedAt time.Time) domain.AccountRating {
level, current, next, hasNext := domain.AccountRatingLevelForStars(stars)
return domain.AccountRating{
UserID: userID,
Level: level,
Stars: stars,
CurrentLevelStars: current,
NextLevelStars: next,
HasNextLevel: hasNext,
StarsComponent: stars,
ComputedAt: computedAt,
UpdatedAt: computedAt,
Version: version,
}
}
func TestSaveAccountRating(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
tests := []struct {
name string
seed []domain.AccountRating
input domain.AccountRating
wantErr error
wantChanged bool
check func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating)
}{
{
name: "insert",
input: accountRatingFixture(11, 450, 1, now),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Level != 2 || stored.Stars != 450 || stored.Version != 1 ||
stored.CurrentLevelStars != 400 || stored.NextLevelStars != 900 || !stored.HasNextLevel {
t.Fatalf("stored=%+v", stored)
}
read, err := s.AccountRating(ctx, 11)
if err != nil || read != stored {
t.Fatalf("read=%+v err=%v", read, err)
}
},
},
{
name: "successor version applied",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 1000, 2, now.Add(time.Minute)),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Version != 2 || stored.Stars != 1000 || stored.Level != 3 {
t.Fatalf("stored=%+v", stored)
}
},
},
{
name: "replayed version is stale",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 9999, 1, now.Add(time.Minute)),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
// The loser of the race gets the current row back, untouched.
if stored.Stars != 450 || stored.Version != 1 {
t.Fatalf("stored=%+v", stored)
}
read, err := s.AccountRating(ctx, 11)
if err != nil || read.Stars != 450 || read.Version != 1 {
t.Fatalf("read=%+v err=%v", read, err)
}
},
},
{
name: "version from the future is rejected",
seed: []domain.AccountRating{accountRatingFixture(11, 450, 1, now)},
input: accountRatingFixture(11, 9999, 7, now.Add(time.Minute)),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Stars != 450 || stored.Version != 1 {
t.Fatalf("stored=%+v", stored)
}
},
},
{
name: "insert must carry version one",
input: accountRatingFixture(11, 450, 3, now),
wantChanged: false,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored != (domain.AccountRating{}) {
t.Fatalf("stored=%+v", stored)
}
if _, err := s.AccountRating(ctx, 11); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("row was written: %v", err)
}
},
},
{
name: "no user",
input: accountRatingFixture(0, 450, 1, now),
wantErr: domain.ErrAccountRatingNotFound,
},
{
name: "pending delta without a date is dropped",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.PendingStars = 120
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.PendingStars != 0 || !stored.PendingDate.IsZero() {
t.Fatalf("stored=%+v", stored)
}
if _, ok := stored.PendingLevel(); ok {
t.Fatalf("pending projection survived: %+v", stored)
}
},
},
{
name: "pending pair is kept",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.PendingStars = 500
rating.PendingDate = now.Add(time.Hour)
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
pending, ok := stored.PendingLevel()
if !ok || pending.Stars != 950 || pending.Level != 3 {
t.Fatalf("pending=%+v ok=%v", pending, ok)
}
},
},
{
name: "impossible components are folded",
input: func() domain.AccountRating {
rating := accountRatingFixture(11, 450, 1, now)
rating.Level = -3
rating.StarsComponent = -10
rating.ActivityComponent = -1
rating.PenaltyComponent = -7
rating.CurrentLevelStars = -5
rating.NextLevelStars = -9
rating.HasNextLevel = true
return rating
}(),
wantChanged: true,
check: func(t *testing.T, s *AccountRatingStore, stored domain.AccountRating) {
if stored.Level != 0 || stored.StarsComponent != 0 || stored.ActivityComponent != 0 ||
stored.PenaltyComponent != 0 || stored.CurrentLevelStars != 0 {
t.Fatalf("stored=%+v", stored)
}
if stored.HasNextLevel || stored.NextLevelStars != 0 {
t.Fatalf("next level survived: %+v", stored)
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewAccountRatingStore()
for _, seed := range tc.seed {
if _, changed, err := s.SaveAccountRating(ctx, seed); err != nil || !changed {
t.Fatalf("seed changed=%v err=%v", changed, err)
}
}
stored, changed, err := s.SaveAccountRating(ctx, tc.input)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
if tc.wantErr != nil {
return
}
if tc.check != nil {
tc.check(t, s, stored)
}
})
}
}
func TestAccountRatingReadsAndLeaderboard(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
s := NewAccountRatingStore()
for _, rating := range []domain.AccountRating{
accountRatingFixture(11, 2500, 1, now),
accountRatingFixture(12, 450, 1, now),
accountRatingFixture(13, 2500, 1, now),
accountRatingFixture(14, 0, 1, now),
} {
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
}
}
batch, err := s.AccountRatingBatch(ctx, []int64{11, 13, 99, 0, 11})
if err != nil || len(batch) != 2 {
t.Fatalf("batch=%+v err=%v", batch, err)
}
if batch[11].Stars != 2500 || batch[13].Stars != 2500 {
t.Fatalf("batch=%+v", batch)
}
if empty, err := s.AccountRatingBatch(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("empty batch=%+v err=%v", empty, err)
}
// level desc, stars desc, user id asc.
board, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{})
if err != nil || len(board) != 4 {
t.Fatalf("board=%+v err=%v", board, err)
}
want := []int64{11, 13, 12, 14}
for i, userID := range want {
if board[i].UserID != userID {
t.Fatalf("board order=%+v want %v", board, want)
}
}
page, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{Limit: 2})
if err != nil || len(page) != 2 || page[1].UserID != 13 {
t.Fatalf("page=%+v err=%v", page, err)
}
next, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{
BeforeID: page[len(page)-1].UserID, Limit: 2,
})
if err != nil || len(next) != 2 || next[0].UserID != 12 || next[1].UserID != 14 {
t.Fatalf("next=%+v err=%v", next, err)
}
filtered, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 5})
if err != nil || len(filtered) != 2 {
t.Fatalf("filtered=%+v err=%v", filtered, err)
}
single, err := s.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: 12})
if err != nil || len(single) != 1 || single[0].UserID != 12 {
t.Fatalf("single=%+v err=%v", single, err)
}
if _, err := s.AccountRating(ctx, 99); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("unknown user err=%v", err)
}
}
func TestAdjustAccountRating(t *testing.T) {
ctx := context.Background()
s := NewAccountRatingStore()
req := domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 750, Reason: "contest prize", Actor: "admin", CommandKey: "cmd-adjust",
}
event, applied, err := s.AdjustAccountRating(ctx, req)
if err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
if event.ID == 0 || event.Kind != domain.AccountRatingEventManual || event.Amount != 750 ||
event.Reason != "contest prize" || event.Actor != "admin" || event.CreatedAt.IsZero() {
t.Fatalf("event=%+v", event)
}
// Replaying the command key returns the recorded row and appends nothing.
replay, applied, err := s.AdjustAccountRating(ctx, req)
if err != nil || applied {
t.Fatalf("replay applied=%v err=%v", applied, err)
}
if replay != event {
t.Fatalf("replay=%+v want %+v", replay, event)
}
ledger, err := s.AccountRatingEvents(ctx, 11, 10)
if err != nil || len(ledger) != 1 {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
second := req
second.Amount = -200
second.CommandKey = "cmd-adjust-2"
if _, applied, err := s.AdjustAccountRating(ctx, second); err != nil || !applied {
t.Fatalf("second adjust applied=%v err=%v", applied, err)
}
// Newest first, and other users are not mixed in.
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 12, Amount: 5, CommandKey: "cmd-other",
}); err != nil || !applied {
t.Fatalf("other user applied=%v err=%v", applied, err)
}
ledger, err = s.AccountRatingEvents(ctx, 11, 10)
if err != nil || len(ledger) != 2 || ledger[0].Amount != -200 || ledger[1].Amount != 750 {
t.Fatalf("ledger=%+v err=%v", ledger, err)
}
if capped, err := s.AccountRatingEvents(ctx, 11, 1); err != nil || len(capped) != 1 ||
capped[0].Amount != -200 {
t.Fatalf("capped=%+v err=%v", capped, err)
}
// An unkeyed adjustment is always appended.
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 10,
}); err != nil || !applied {
t.Fatalf("unkeyed applied=%v err=%v", applied, err)
}
for _, invalid := range []domain.AdjustAccountRatingRequest{
{UserID: 11, Amount: 0, CommandKey: "cmd-zero"},
{UserID: 0, Amount: 5, CommandKey: "cmd-nouser"},
} {
if _, applied, err := s.AdjustAccountRating(ctx, invalid); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) || applied {
t.Fatalf("invalid adjust applied=%v err=%v", applied, err)
}
}
// The manual total is carried out of the ledger into the signal snapshot.
signals, err := s.AccountRatingSignals(ctx, 11)
if err != nil || signals.UserID != 11 || signals.Manual != 560 {
t.Fatalf("signals=%+v err=%v", signals, err)
}
}
func TestAccountRatingSignals(t *testing.T) {
ctx := context.Background()
s := NewAccountRatingStore()
// A user with no contributions reports zeros rather than an error.
signals, err := s.AccountRatingSignals(ctx, 11)
if err != nil || signals != (domain.AccountRatingSignals{UserID: 11}) {
t.Fatalf("signals=%+v err=%v", signals, err)
}
if _, err := s.AccountRatingSignals(ctx, 0); !errors.Is(err, domain.ErrAccountRatingNotFound) {
t.Fatalf("missing user err=%v", err)
}
s.setAccountRatingSignals(domain.AccountRatingSignals{
UserID: 11, StarsReceived: 4000, StarsSpent: 2000, MessagesSent: 300,
AccountAgeDays: 100, GiftsReceived: 4, ModerationCases: 1,
})
if _, applied, err := s.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
UserID: 11, Amount: 250, CommandKey: "cmd-bonus",
}); err != nil || !applied {
t.Fatalf("adjust applied=%v err=%v", applied, err)
}
signals, err = s.AccountRatingSignals(ctx, 11)
if err != nil || signals.StarsReceived != 4000 || signals.MessagesSent != 300 ||
signals.ModerationCases != 1 || signals.Manual != 250 {
t.Fatalf("signals=%+v err=%v", signals, err)
}
// The snapshot feeds the domain formula, and the result round-trips.
now := time.Unix(1700000000, 0).UTC()
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
stored, changed, err := s.SaveAccountRating(ctx, computed)
if err != nil || !changed {
t.Fatalf("save changed=%v err=%v", changed, err)
}
if stored.Stars != computed.Stars || stored.Level != computed.Level ||
stored.ManualComponent != 250 {
t.Fatalf("stored=%+v computed=%+v", stored, computed)
}
// A recompute uses the pending resolution the domain owns.
s.setAccountRatingSignals(domain.AccountRatingSignals{UserID: 11, StarsReceived: 40000})
signals, err = s.AccountRatingSignals(ctx, 11)
if err != nil {
t.Fatal(err)
}
recomputed := domain.ResolveAccountRatingPending(stored,
domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now.Add(time.Hour)),
24*time.Hour, now.Add(time.Hour))
saved, changed, err := s.SaveAccountRating(ctx, recomputed)
if err != nil || !changed || saved.Version != stored.Version+1 {
t.Fatalf("saved=%+v changed=%v err=%v", saved, changed, err)
}
if saved.PendingStars <= 0 || saved.PendingDate.IsZero() {
t.Fatalf("pending was not parked: %+v", saved)
}
}
func TestStaleAccountRatings(t *testing.T) {
ctx := context.Background()
now := time.Unix(1700000000, 0).UTC()
s := NewAccountRatingStore()
for _, rating := range []domain.AccountRating{
accountRatingFixture(11, 100, 1, now.Add(-3*time.Hour)),
accountRatingFixture(12, 100, 1, now.Add(-2*time.Hour)),
accountRatingFixture(13, 100, 1, now.Add(-time.Hour)),
accountRatingFixture(14, 100, 1, now),
} {
if _, changed, err := s.SaveAccountRating(ctx, rating); err != nil || !changed {
t.Fatalf("seed %d changed=%v err=%v", rating.UserID, changed, err)
}
}
stale, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
if err != nil || len(stale) != 2 || stale[0] != 11 || stale[1] != 12 {
t.Fatalf("stale=%v err=%v", stale, err)
}
if limited, err := s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 1); err != nil ||
len(limited) != 1 || limited[0] != 11 {
t.Fatalf("limited=%v err=%v", limited, err)
}
if none, err := s.StaleAccountRatings(ctx, now.Add(-4*time.Hour).Unix(), 10); err != nil || len(none) != 0 {
t.Fatalf("none=%v err=%v", none, err)
}
// A recompute refreshes computed_at and takes the row out of the horizon.
refreshed := accountRatingFixture(11, 100, 2, now)
if _, changed, err := s.SaveAccountRating(ctx, refreshed); err != nil || !changed {
t.Fatalf("refresh changed=%v err=%v", changed, err)
}
stale, err = s.StaleAccountRatings(ctx, now.Add(-90*time.Minute).Unix(), 10)
if err != nil || len(stale) != 1 || stale[0] != 12 {
t.Fatalf("stale=%v err=%v", stale, err)
}
}

View file

@ -461,8 +461,9 @@ func (s *AuthorizationStore) DeleteByHash(_ context.Context, userID, hash int64)
return domain.Authorization{}, false, nil
}
// RevokeByHash mirrors PostgreSQL's protocol-key revocation boundary when this
// authorization projection is linked to an in-memory auth-key authority.
// RevokeByHash removes only the business authorization. The protocol auth key
// and any temp binding stay usable for MTProto decryption so a kicked client can
// reconnect and receive AUTH_KEY_UNREGISTERED from the RPC gate.
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
s.linkMu.RLock()
defer s.linkMu.RUnlock()
@ -471,27 +472,17 @@ func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int6
}
s.authKeys.mu.Lock()
s.mu.Lock()
var (
targetID [8]byte
target domain.Authorization
found bool
)
for id, a := range s.m {
if a.UserID == userID && a.Hash == hash {
targetID, target, found = id, a, true
break
delete(s.m, id)
s.mu.Unlock()
s.authKeys.mu.Unlock()
return a, true, nil
}
}
if !found {
s.mu.Unlock()
s.authKeys.mu.Unlock()
return domain.Authorization{}, false, nil
}
deletedIDs := s.authKeys.deleteProtocolAuthKeyLocked(targetID)
s.authKeys.deleteAuthorizationMirrorsWithHeldLocked(deletedIDs, s)
s.mu.Unlock()
s.authKeys.mu.Unlock()
return target, true, nil
return domain.Authorization{}, false, nil
}
func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
@ -517,18 +508,12 @@ func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int6
s.authKeys.mu.Lock()
s.mu.Lock()
out := make([]domain.Authorization, 0)
targets := make([][8]byte, 0)
for id, a := range s.m {
if a.UserID == userID && id != keepAuthKeyID {
out = append(out, a)
targets = append(targets, id)
delete(s.m, id)
}
}
deletedIDs := make([][8]byte, 0, len(targets))
for _, id := range targets {
deletedIDs = append(deletedIDs, s.authKeys.deleteProtocolAuthKeyLocked(id)...)
}
s.authKeys.deleteAuthorizationMirrorsWithHeldLocked(deletedIDs, s)
s.mu.Unlock()
s.authKeys.mu.Unlock()
return out, nil

View file

@ -0,0 +1,83 @@
package memory
import (
"context"
"sync"
"time"
"telesrv/internal/domain"
)
type AuthDeliveryReportStore struct {
mu sync.Mutex
nextID int64
byFingerprint map[[32]byte]domain.AuthDeliveryReport
}
func NewAuthDeliveryReportStore() *AuthDeliveryReportStore {
return &AuthDeliveryReportStore{
nextID: 1, byFingerprint: make(map[[32]byte]domain.AuthDeliveryReport),
}
}
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(_ context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
if err := report.Validate(); err != nil {
return domain.AuthDeliveryReport{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.byFingerprint[report.Fingerprint]; ok {
return existing, false, nil
}
var hourly, phoneDaily int
hourAgo := report.CreatedAt.Add(-time.Hour)
dayAgo := report.CreatedAt.Add(-24 * time.Hour)
for _, existing := range s.byFingerprint {
if existing.CreatedAt.After(report.CreatedAt) {
continue
}
if existing.AuthKeyID == report.AuthKeyID && !existing.CreatedAt.Before(hourAgo) {
hourly++
}
if existing.PhoneHash == report.PhoneHash && !existing.CreatedAt.Before(dayAgo) {
phoneDaily++
}
}
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
}
report.ID = s.nextID
s.nextID++
s.byFingerprint[report.Fingerprint] = report
return report, true, nil
}
func (s *AuthDeliveryReportStore) Reports() []domain.AuthDeliveryReport {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.AuthDeliveryReport, 0, len(s.byFingerprint))
for _, report := range s.byFingerprint {
out = append(out, report)
}
return out
}
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrAuthDeliveryReportInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
deleted := 0
for fingerprint, report := range s.byFingerprint {
if deleted >= limit {
break
}
if report.CreatedAt.Before(olderThan) {
delete(s.byFingerprint, fingerprint)
deleted++
}
}
return deleted, nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,851 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
)
func botVerificationUserPeer(id int64) domain.Peer {
return domain.Peer{Type: domain.PeerTypeUser, ID: id}
}
func botVerificationChannelPeer(id int64) domain.Peer {
return domain.Peer{Type: domain.PeerTypeChannel, ID: id}
}
// botVerificationTestVerifier grants verifier status the way the admin edge does.
func botVerificationTestVerifier(t *testing.T, s *BotVerificationStore, botID, iconDocumentID int64) domain.BotVerifierSettings {
t.Helper()
settings, err := s.UpsertBotVerifierSettings(context.Background(), domain.BotVerifierSettings{
BotID: botID,
IconDocumentID: iconDocumentID,
CompanyName: fmt.Sprintf("Verifier %d", botID),
DefaultDescription: "verified by the test verifier",
CanModifyCustomDescription: true,
Enabled: true,
GrantedBy: "operator",
GrantReason: "test fixture",
})
if err != nil {
t.Fatalf("grant verifier %d: %v", botID, err)
}
return settings
}
// botVerificationTestRequest is an application that clears domain validation.
func botVerificationTestRequest(verifier, applicant int64, peer domain.Peer, username string) domain.CustomVerificationRequest {
return domain.CustomVerificationRequest{
VerifierBotID: verifier,
ApplicantUserID: applicant,
Peer: peer,
PeerTitle: "Target " + username,
PeerUsername: username,
Reason: "we run the official account for this brand",
RequestedDescription: "official brand account",
CorrelationID: fmt.Sprintf("corr-%d", peer.ID),
}
}
// TestBotVerificationIconCatalogueMemory covers the catalogue: an entry is keyed
// by document id, retiring one keeps it readable, and the listing pages newest
// first with the activeOnly filter honoured.
func TestBotVerificationIconCatalogueMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
first, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5001, Name: " Blue check ", Active: true,
})
if err != nil {
t.Fatalf("upsert icon: %v", err)
}
if first.ID == 0 || first.Name != "Blue check" || !first.Active || first.OwnerBotID != 0 {
t.Fatalf("stored icon = %+v", first)
}
if first.CreatedAt.IsZero() || first.UpdatedAt.Before(first.CreatedAt) {
t.Fatalf("icon timestamps = %v / %v", first.CreatedAt, first.UpdatedAt)
}
second, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5002, OwnerBotID: 777, Name: "Reserved", Active: true,
})
if err != nil {
t.Fatalf("upsert second icon: %v", err)
}
// document_id is the identity of an entry: the second upsert edits in place.
edited, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{
DocumentID: 5001, OwnerBotID: 42, Name: "Blue check v2", Active: true,
})
if err != nil {
t.Fatalf("re-upsert icon: %v", err)
}
if edited.ID != first.ID || edited.Name != "Blue check v2" || edited.OwnerBotID != 42 {
t.Fatalf("edited icon = %+v, want id %d", edited, first.ID)
}
if !edited.CreatedAt.Equal(first.CreatedAt) || !edited.UpdatedAt.After(first.UpdatedAt) {
t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt)
}
if _, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{DocumentID: 0, Name: "bad"}); !errors.Is(err, domain.ErrVerificationIconInvalid) {
t.Fatalf("upsert without document err = %v, want ErrVerificationIconInvalid", err)
}
if _, err := s.UpsertVerificationIcon(ctx, domain.VerificationIcon{DocumentID: 9, Name: " "}); !errors.Is(err, domain.ErrVerificationIconInvalid) {
t.Fatalf("upsert without name err = %v, want ErrVerificationIconInvalid", err)
}
retired, err := s.SetVerificationIconActive(ctx, second.ID, false)
if err != nil {
t.Fatalf("retire icon: %v", err)
}
if retired.Active || retired.ID != second.ID {
t.Fatalf("retired icon = %+v", retired)
}
if _, err := s.SetVerificationIconActive(ctx, second.ID+1000, false); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("retire unknown err = %v, want ErrVerificationIconNotFound", err)
}
byDocument, err := s.VerificationIconByDocument(ctx, 5002)
if err != nil {
t.Fatalf("read icon by document: %v", err)
}
if byDocument.ID != second.ID || byDocument.Active {
t.Fatalf("icon by document = %+v", byDocument)
}
if _, err := s.VerificationIconByDocument(ctx, 999999); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("unknown document err = %v, want ErrVerificationIconNotFound", err)
}
byID, err := s.VerificationIcon(ctx, first.ID)
if err != nil || byID.DocumentID != 5001 {
t.Fatalf("read icon by id = %+v err=%v", byID, err)
}
if _, err := s.VerificationIcon(ctx, 0); !errors.Is(err, domain.ErrVerificationIconNotFound) {
t.Fatalf("icon id 0 err = %v, want ErrVerificationIconNotFound", err)
}
all, err := s.ListVerificationIcons(ctx, false, 0)
if err != nil {
t.Fatalf("list icons: %v", err)
}
if len(all) != 2 || all[0].ID != second.ID || all[1].ID != first.ID {
t.Fatalf("catalogue order = %+v, want newest first", all)
}
active, err := s.ListVerificationIcons(ctx, true, 0)
if err != nil {
t.Fatalf("list active icons: %v", err)
}
if len(active) != 1 || active[0].ID != first.ID {
t.Fatalf("active catalogue = %+v", active)
}
if page, err := s.ListVerificationIcons(ctx, false, 1); err != nil || len(page) != 1 {
t.Fatalf("icon page = %+v err=%v", page, err)
}
}
// TestBotVerifierSettingsLifecycleMemory covers verifier status: optimistic
// locking on version, the idempotent kill switch and the cascade that takes the
// marks with the verifier row.
func TestBotVerifierSettingsLifecycleMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
if _, err := s.BotVerifierSettings(ctx, 4242); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("unknown verifier err = %v, want ErrVerifierNotFound", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 7, CompanyName: "Acme", Version: 3,
}); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("versioned upsert of missing row err = %v, want ErrVerifierNotFound", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 0, CompanyName: "Acme",
}); !errors.Is(err, domain.ErrVerifierSettingsInvalid) {
t.Fatalf("iconless upsert err = %v, want ErrVerifierSettingsInvalid", err)
}
created := botVerificationTestVerifier(t, s, 4242, 5001)
if created.Version != 1 || !created.Enabled || created.CompanyName != "Verifier 4242" {
t.Fatalf("created verifier = %+v", created)
}
// Version 0 means "there is no row yet", so it loses against the stored row.
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 5001, CompanyName: "Acme",
}); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("re-create err = %v, want ErrCustomVerificationVersionConflict", err)
}
if _, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242, IconDocumentID: 5001, CompanyName: "Acme", Version: 99,
}); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("stale upsert err = %v, want ErrCustomVerificationVersionConflict", err)
}
edited, err := s.UpsertBotVerifierSettings(ctx, domain.BotVerifierSettings{
BotID: 4242,
IconDocumentID: 5002,
CompanyName: "Acme Media",
DefaultDescription: "checked by Acme",
Enabled: true,
Version: created.Version,
})
if err != nil {
t.Fatalf("edit verifier: %v", err)
}
if edited.Version != 2 || edited.IconDocumentID != 5002 || edited.CanModifyCustomDescription {
t.Fatalf("edited verifier = %+v", edited)
}
if !edited.CreatedAt.Equal(created.CreatedAt) || !edited.UpdatedAt.After(created.UpdatedAt) {
t.Fatalf("edited timestamps = %v / %v", edited.CreatedAt, edited.UpdatedAt)
}
disabled, err := s.SetBotVerifierEnabled(ctx, 4242, false)
if err != nil {
t.Fatalf("disable verifier: %v", err)
}
if disabled.Enabled || disabled.Version != edited.Version+1 {
t.Fatalf("disabled verifier = %+v", disabled)
}
again, err := s.SetBotVerifierEnabled(ctx, 4242, false)
if err != nil {
t.Fatalf("re-disable verifier: %v", err)
}
if again.Version != disabled.Version {
t.Fatalf("re-disable bumped version to %d", again.Version)
}
if _, err := s.SetBotVerifierEnabled(ctx, 777777, false); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("disable unknown err = %v, want ErrVerifierNotFound", err)
}
// A disabled verifier is still readable: the admin panel renders the switch.
stored, err := s.BotVerifierSettings(ctx, 4242)
if err != nil || stored.Enabled {
t.Fatalf("read disabled verifier = %+v err=%v", stored, err)
}
other := botVerificationTestVerifier(t, s, 1042, 5001)
batch, err := s.BotVerifierSettingsBatch(ctx, []int64{4242, 1042, 999999, 0})
if err != nil {
t.Fatalf("batch verifiers: %v", err)
}
if len(batch) != 2 || batch[4242].Enabled || !batch[1042].Enabled {
t.Fatalf("verifier batch = %+v", batch)
}
if _, absent := batch[999999]; absent {
t.Fatal("batch invented a verifier")
}
listed, err := s.ListBotVerifiers(ctx, false, 0)
if err != nil {
t.Fatalf("list verifiers: %v", err)
}
if len(listed) != 2 || listed[0].BotID != other.BotID || listed[1].BotID != 4242 {
t.Fatalf("verifier list = %+v, want bot id order", listed)
}
enabledOnly, err := s.ListBotVerifiers(ctx, true, 0)
if err != nil {
t.Fatalf("list enabled verifiers: %v", err)
}
if len(enabledOnly) != 1 || enabledOnly[0].BotID != other.BotID {
t.Fatalf("enabled verifier list = %+v", enabledOnly)
}
// Marks cascade with the verifier row; applications do not.
peer := botVerificationChannelPeer(9001)
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: other.BotID, Peer: peer, Description: "cascade me",
}); err != nil {
t.Fatalf("grant before delete: %v", err)
}
req, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(other.BotID, 31337, peer, "CascadeChannel"))
if err != nil {
t.Fatalf("create request before delete: %v", err)
}
removed, err := s.DeleteBotVerifierSettings(ctx, other.BotID)
if err != nil || !removed {
t.Fatalf("delete verifier: removed=%v err=%v", removed, err)
}
if removed, err := s.DeleteBotVerifierSettings(ctx, other.BotID); err != nil || removed {
t.Fatalf("repeated delete: removed=%v err=%v", removed, err)
}
if _, err := s.CustomVerification(ctx, other.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("mark after cascade err = %v, want ErrCustomVerificationNotFound", err)
}
if count, err := s.CountCustomVerifications(ctx, other.BotID); err != nil || count != 0 {
t.Fatalf("mark count after cascade = %d err=%v", count, err)
}
if kept, err := s.CustomVerificationRequest(ctx, req.ID); err != nil || kept.ID != req.ID {
t.Fatalf("application after cascade = %+v err=%v", kept, err)
}
}
// TestCustomVerificationGrantAndProjectionMemory is the projection contract:
// exactly one mark exists per peer, a later verifier replaces the former one,
// a disabled verifier projects nothing while its row survives, and a repeated
// grant updates the mark in place.
func TestCustomVerificationGrantAndProjectionMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
alpha := botVerificationTestVerifier(t, s, 101, 5001)
beta := botVerificationTestVerifier(t, s, 102, 5002)
peer := botVerificationUserPeer(7001)
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: 999, Peer: peer,
}); !errors.Is(err, domain.ErrVerifierNotFound) {
t.Fatalf("grant by non-verifier err = %v, want ErrVerifierNotFound", err)
}
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: domain.Peer{Type: domain.PeerTypeCommunity, ID: 5},
}); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("grant on community err = %v, want ErrCustomVerificationTargetInvalid", err)
}
// The icon is denormalised from the verifier when the caller leaves it unset.
alphaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: peer, Description: "checked by alpha",
GrantedByUserID: 555,
})
if err != nil || !created {
t.Fatalf("grant alpha mark: created=%v err=%v", created, err)
}
if alphaMark.IconDocumentID != alpha.IconDocumentID || alphaMark.Version != 1 {
t.Fatalf("alpha mark = %+v", alphaMark)
}
if alphaMark.CreatedAt.IsZero() || !alphaMark.UpdatedAt.Equal(alphaMark.CreatedAt) {
t.Fatalf("alpha mark timestamps = %v / %v", alphaMark.CreatedAt, alphaMark.UpdatedAt)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != alphaMark.ID {
t.Fatalf("projection with one mark = %+v err=%v", got, err)
}
betaMark, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: beta.BotID, Peer: peer, Description: "checked by beta",
})
if err != nil || !created {
t.Fatalf("grant beta mark: created=%v err=%v", created, err)
}
if betaMark.ID != alphaMark.ID || betaMark.Version != alphaMark.Version+1 {
t.Fatalf("replacement mark = %+v, want id %d v%d", betaMark, alphaMark.ID, alphaMark.Version+1)
}
if _, err := s.CustomVerification(ctx, alpha.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("replaced alpha mark err = %v, want ErrCustomVerificationNotFound", err)
}
if count, err := s.CountCustomVerifications(ctx, alpha.BotID); err != nil || count != 0 {
t.Fatalf("alpha mark count after replacement = %d err=%v", count, err)
}
// One peer has one wire-visible mark, irrespective of how often it is read.
for i := 0; i < 3; i++ {
got, err := s.PeerVerification(ctx, peer)
if err != nil {
t.Fatalf("projection after replacement: %v", err)
}
if got.ID != betaMark.ID || got.VerifierBotID != beta.BotID {
t.Fatalf("projection = %+v, want replacement mark %d", got, betaMark.ID)
}
if got.Projection().Icon != beta.IconDocumentID {
t.Fatalf("projected icon = %d, want %d", got.Projection().Icon, beta.IconDocumentID)
}
}
// Kill switch: disabling the current verifier hides the badge. The replaced
// alpha mark must not silently reappear.
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil {
t.Fatalf("disable beta: %v", err)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after disabling beta err=%v, want ErrCustomVerificationNotFound", err)
}
if stored, err := s.CustomVerification(ctx, beta.BotID, peer); err != nil || stored.ID != betaMark.ID {
t.Fatalf("disabled verifier lost its mark: %+v err=%v", stored, err)
}
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil {
t.Fatalf("re-enable beta: %v", err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != betaMark.ID {
t.Fatalf("projection after re-enabling beta = %+v err=%v", got, err)
}
// Granting through alpha replaces beta's mark on the same peer.
regranted, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: peer, IconDocumentID: 5099,
Description: "checked by alpha, again",
})
if err != nil || !created {
t.Fatalf("re-grant alpha mark: created=%v err=%v", created, err)
}
if regranted.ID != betaMark.ID || regranted.Version != betaMark.Version+1 {
t.Fatalf("re-granted mark = %+v, want id %d v%d", regranted, betaMark.ID, betaMark.Version+1)
}
if regranted.IconDocumentID != 5099 || regranted.Description != "checked by alpha, again" {
t.Fatalf("re-granted payload = %+v", regranted)
}
if regranted.CreatedAt.Before(betaMark.CreatedAt) || !regranted.UpdatedAt.After(betaMark.UpdatedAt) {
t.Fatalf("re-granted timestamps = %v / %v", regranted.CreatedAt, regranted.UpdatedAt)
}
if count, err := s.CountCustomVerifications(ctx, alpha.BotID); err != nil || count != 1 {
t.Fatalf("alpha mark count = %d err=%v", count, err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.VerifierBotID != alpha.BotID {
t.Fatalf("projection after re-grant = %+v err=%v", got, err)
}
// The batch form resolves several peers at once, with the same rules.
second := botVerificationChannelPeer(7002)
third := botVerificationUserPeer(7003)
unmarked := botVerificationChannelPeer(7004)
secondMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: alpha.BotID, Peer: second, Description: "second",
})
if err != nil {
t.Fatalf("grant second: %v", err)
}
thirdMark, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: beta.BotID, Peer: third, Description: "third",
})
if err != nil {
t.Fatalf("grant third: %v", err)
}
batch, err := s.PeerVerificationBatch(ctx,
[]domain.Peer{peer, second, third, unmarked, peer, {Type: domain.PeerTypeUser, ID: 0}})
if err != nil {
t.Fatalf("batch projection: %v", err)
}
if len(batch) != 3 {
t.Fatalf("batch projection = %+v, want 3 peers", batch)
}
if batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID ||
batch[third].ID != thirdMark.ID {
t.Fatalf("batch projection picked %+v", batch)
}
if _, present := batch[unmarked]; present {
t.Fatal("batch projected an unmarked peer")
}
if empty, err := s.PeerVerificationBatch(ctx, nil); err != nil || len(empty) != 0 {
t.Fatalf("empty batch = %+v err=%v", empty, err)
}
// Disabling a verifier drops its peers from the batch too.
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, false); err != nil {
t.Fatalf("disable beta again: %v", err)
}
batch, err = s.PeerVerificationBatch(ctx, []domain.Peer{peer, second, third})
if err != nil {
t.Fatalf("batch projection after disable: %v", err)
}
if len(batch) != 2 || batch[peer].ID != regranted.ID || batch[second].ID != secondMark.ID {
t.Fatalf("batch after disable = %+v", batch)
}
if _, present := batch[third]; present {
t.Fatal("disabled verifier still projects in the batch")
}
if _, err := s.SetBotVerifierEnabled(ctx, beta.BotID, true); err != nil {
t.Fatalf("re-enable beta again: %v", err)
}
// Listing and paging over the marks.
all, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{})
if err != nil || len(all) != 3 {
t.Fatalf("list marks = %+v err=%v", all, err)
}
if all[0].ID != thirdMark.ID {
t.Fatalf("mark list order = %+v, want newest first", all)
}
page, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("mark page = %+v err=%v", page, err)
}
next, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
Limit: 2, BeforeID: page[len(page)-1].ID,
})
if err != nil || len(next) != 1 || next[0].ID >= page[len(page)-1].ID {
t.Fatalf("mark keyset page = %+v err=%v", next, err)
}
mine, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
VerifierBotID: alpha.BotID,
})
if err != nil || len(mine) != 2 {
t.Fatalf("verifier-filtered marks = %+v err=%v", mine, err)
}
channels, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: domain.PeerTypeChannel,
})
if err != nil || len(channels) != 1 || channels[0].Peer != second {
t.Fatalf("channel-filtered marks = %+v err=%v", channels, err)
}
byPeer, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: peer.Type, PeerID: peer.ID,
})
if err != nil || len(byPeer) != 1 {
t.Fatalf("peer-filtered marks = %+v err=%v", byPeer, err)
}
byQuery, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
Query: fmt.Sprintf("%d", second.ID),
})
if err != nil || len(byQuery) != 1 || byQuery[0].ID != secondMark.ID {
t.Fatalf("numeric mark query = %+v err=%v", byQuery, err)
}
byText, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{Query: "AGAIN"})
if err != nil || len(byText) != 1 || byText[0].ID != regranted.ID {
t.Fatalf("text mark query = %+v err=%v", byText, err)
}
if _, err := s.ListCustomVerifications(ctx, domain.CustomVerificationFilter{
PeerType: domain.PeerTypeFolder,
}); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("bad peer-type filter err = %v, want ErrCustomVerificationTargetInvalid", err)
}
// A replaced verifier cannot revoke the current mark.
revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer)
if err != nil || revoked {
t.Fatalf("revoke replaced beta mark: revoked=%v err=%v", revoked, err)
}
if revoked, err := s.RevokeCustomVerification(ctx, beta.BotID, peer); err != nil || revoked {
t.Fatalf("repeated revoke: revoked=%v err=%v", revoked, err)
}
if got, err := s.PeerVerification(ctx, peer); err != nil || got.ID != regranted.ID {
t.Fatalf("projection after rejected revoke = %+v err=%v", got, err)
}
if revoked, err := s.RevokeCustomVerification(ctx, alpha.BotID, peer); err != nil || !revoked {
t.Fatalf("revoke alpha mark: revoked=%v err=%v", revoked, err)
}
if _, err := s.RevokeCustomVerification(ctx, 0, peer); !errors.Is(err, domain.ErrCustomVerificationTargetInvalid) {
t.Fatalf("revoke without verifier err = %v, want ErrCustomVerificationTargetInvalid", err)
}
}
// TestCustomVerificationLimitMemory pins the per-verifier bound: a new mark is
// refused at the limit while an existing one can still be re-described.
func TestCustomVerificationLimitMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
verifier := botVerificationTestVerifier(t, s, 303, 5001)
for i := 1; i <= domain.MaxCustomVerificationsPerVerifier; i++ {
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationChannelPeer(int64(i)),
}); err != nil || !created {
t.Fatalf("grant %d: created=%v err=%v", i, created, err)
}
}
if count, err := s.CountCustomVerifications(ctx, verifier.BotID); err != nil ||
count != domain.MaxCustomVerificationsPerVerifier {
t.Fatalf("mark count = %d err=%v", count, err)
}
if _, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(424242),
}); !errors.Is(err, domain.ErrCustomVerificationLimit) {
t.Fatalf("grant past the limit err = %v, want ErrCustomVerificationLimit", err)
}
// The bound is on creating marks, not on editing them.
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationChannelPeer(7),
Description: "still editable at the limit",
}); err != nil || created {
t.Fatalf("re-grant at the limit: created=%v err=%v", created, err)
}
// Freeing one slot lets the next grant through.
if revoked, err := s.RevokeCustomVerification(ctx, verifier.BotID,
botVerificationChannelPeer(1)); err != nil || !revoked {
t.Fatalf("free a slot: revoked=%v err=%v", revoked, err)
}
if _, created, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: verifier.BotID, Peer: botVerificationUserPeer(424242),
}); err != nil || !created {
t.Fatalf("grant into the freed slot: created=%v err=%v", created, err)
}
}
// TestCustomVerificationRequestQueueMemory covers the application queue: one
// pending application per (verifier, peer), the decision status machine, and the
// transaction that keeps an approved application and its mark together.
func TestCustomVerificationRequestQueueMemory(t *testing.T) {
ctx := context.Background()
s := NewBotVerificationStore()
verifier := botVerificationTestVerifier(t, s, 501, 5001)
peer := botVerificationChannelPeer(8001)
applicant := int64(6001)
grant := func(ctx context.Context, req domain.CustomVerificationRequest) error {
_, _, err := s.GrantCustomVerification(ctx, domain.CustomVerification{
VerifierBotID: req.VerifierBotID,
Peer: req.Peer,
Description: req.RequestedDescription,
GrantedByUserID: req.ApplicantUserID,
})
return err
}
revoke := func(ctx context.Context, req domain.CustomVerificationRequest) error {
_, err := s.RevokeCustomVerification(ctx, req.VerifierBotID, req.Peer)
return err
}
filed, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews"))
if err != nil {
t.Fatalf("file application: %v", err)
}
if filed.Status != domain.CustomVerificationPending || filed.Version != 1 {
t.Fatalf("filed application = %s v%d", filed.Status, filed.Version)
}
if !filed.ApprovedAt.IsZero() || !filed.RejectedAt.IsZero() || filed.DecidedBy != "" {
t.Fatalf("filed application carries a decision: %+v", filed)
}
// custom_verification_requests_pending_idx: one live application per pair.
if _, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, peer, "AcmeNews")); !errors.Is(err, domain.ErrCustomVerificationRequestExists) {
t.Fatalf("duplicate pending err = %v, want ErrCustomVerificationRequestExists", err)
}
if _, err := s.CreateCustomVerificationRequest(ctx, domain.CustomVerificationRequest{
VerifierBotID: verifier.BotID, ApplicantUserID: applicant, Peer: peer,
Status: domain.CustomVerificationApproved,
}); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("pre-decided application err = %v, want ErrCustomVerificationRequestInvalid", err)
}
// The SQL byte bound reserves the worst-case UTF-8 width for the domain's
// rune limit, so valid multi-byte text must behave the same in both stores.
wideStore := NewBotVerificationStore()
wideVerifier := botVerificationTestVerifier(t, wideStore, 909, 5001)
wide := botVerificationTestRequest(wideVerifier.BotID, applicant, botVerificationUserPeer(8009), "Wide")
wide.Reason = strings.Repeat("é", domain.MaxCustomVerificationReasonLength-1)
if _, err := wideStore.CreateCustomVerificationRequest(ctx, wide); err != nil {
t.Fatalf("valid multi-byte reason: %v", err)
}
pending, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer)
if err != nil || pending.ID != filed.ID {
t.Fatalf("pending application = %+v err=%v", pending, err)
}
// A failing callback rolls the whole decision back, including what the
// callback itself wrote before it failed.
boom := errors.New("apply exploded")
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "looks good", "",
func(ctx context.Context, req domain.CustomVerificationRequest) error {
if err := grant(ctx, req); err != nil {
return err
}
return boom
}); !errors.Is(err, boom) {
t.Fatalf("failing apply err = %v, want the callback error", err)
}
rolledBack, err := s.CustomVerificationRequest(ctx, filed.ID)
if err != nil {
t.Fatalf("read after rollback: %v", err)
}
if rolledBack.Status != domain.CustomVerificationPending || rolledBack.Version != filed.Version {
t.Fatalf("application after rollback = %s v%d, want pending v%d",
rolledBack.Status, rolledBack.Version, filed.Version)
}
if !rolledBack.ApprovedAt.IsZero() || rolledBack.DecidedBy != "" {
t.Fatalf("application after rollback carries a decision: %+v", rolledBack)
}
if _, err := s.CustomVerification(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("mark after rollback err = %v, want ErrCustomVerificationNotFound", err)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after rollback err = %v, want ErrCustomVerificationNotFound", err)
}
// Approving requires a callback: there is no "approved, mark later".
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "", "", nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version+7,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationVersionConflict) {
t.Fatalf("stale decision err = %v, want ErrCustomVerificationVersionConflict", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationPending, "operator", "", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("decision back to pending err = %v, want ErrCustomVerificationRequestInvalid", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, filed.ID+9000, filed.Version,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("decision on unknown application err = %v, want ErrCustomVerificationRequestNotFound", err)
}
approved, changed, err := s.DecideCustomVerificationRequest(ctx, filed.ID, filed.Version,
domain.CustomVerificationApproved, "operator", "brand confirmed", "ticket 12", grant)
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.CustomVerificationApproved || approved.Version != filed.Version+1 {
t.Fatalf("approved application = %s v%d", approved.Status, approved.Version)
}
if approved.ApprovedAt.IsZero() || !approved.RejectedAt.IsZero() {
t.Fatalf("approved stamps = %v / %v", approved.ApprovedAt, approved.RejectedAt)
}
if approved.DecidedBy != "operator" || approved.DecisionReason != "brand confirmed" ||
approved.InternalNote != "ticket 12" {
t.Fatalf("approved decision metadata = %+v", approved)
}
mark, err := s.PeerVerification(ctx, peer)
if err != nil {
t.Fatalf("projection after approve: %v", err)
}
if mark.VerifierBotID != verifier.BotID || mark.Description != filed.RequestedDescription ||
mark.IconDocumentID != verifier.IconDocumentID {
t.Fatalf("mark after approve = %+v", mark)
}
if _, err := s.PendingCustomVerificationRequest(ctx, verifier.BotID, peer); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("pending after approve err = %v, want ErrCustomVerificationRequestNotFound", err)
}
// Re-issuing the decision that already holds moves nothing and does not apply
// the callback a second time.
repeat, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationApproved, "someone else", "again", "", func(context.Context, domain.CustomVerificationRequest) error {
t.Fatal("apply ran for a decision that already held")
return nil
})
if err != nil || changed {
t.Fatalf("repeated approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.DecidedBy != "operator" {
t.Fatalf("repeated approve mutated the row: %+v", repeat)
}
// approved -> rejected is not in the status machine; approved -> revoked is.
if _, _, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationRejected, "operator", "changed my mind", "", nil); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("approved -> rejected err = %v, want ErrCustomVerificationRequestInvalid", err)
}
revoked, changed, err := s.DecideCustomVerificationRequest(ctx, approved.ID, approved.Version,
domain.CustomVerificationRevoked, "operator", "brand asked us to", "", revoke)
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if revoked.Status != domain.CustomVerificationRevoked || revoked.Version != approved.Version+1 {
t.Fatalf("revoked application = %s v%d", revoked.Status, revoked.Version)
}
// The stamps are paired with the status, so leaving approved clears approved_at.
if !revoked.ApprovedAt.IsZero() || !revoked.RejectedAt.IsZero() {
t.Fatalf("revoked stamps = %v / %v", revoked.ApprovedAt, revoked.RejectedAt)
}
if _, err := s.PeerVerification(ctx, peer); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("projection after revoke err = %v, want ErrCustomVerificationNotFound", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, revoked.ID, revoked.Version,
domain.CustomVerificationApproved, "operator", "", "", grant); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("revoked -> approved err = %v, want ErrCustomVerificationRequestInvalid", err)
}
// A rejection needs a reason, and the domain is what says so.
other := botVerificationUserPeer(8002)
second, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO"))
if err != nil {
t.Fatalf("file second application: %v", err)
}
if _, _, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version,
domain.CustomVerificationRejected, "operator", " ", "", nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without a reason err = %v, want ErrVerificationReasonRequired", err)
}
stillPending, err := s.CustomVerificationRequest(ctx, second.ID)
if err != nil || stillPending.Status != domain.CustomVerificationPending ||
stillPending.Version != second.Version {
t.Fatalf("application after refused rejection = %+v err=%v", stillPending, err)
}
rejected, changed, err := s.DecideCustomVerificationRequest(ctx, second.ID, second.Version,
domain.CustomVerificationRejected, "operator", "not a public figure", "", nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.CustomVerificationRejected || rejected.RejectedAt.IsZero() ||
!rejected.ApprovedAt.IsZero() {
t.Fatalf("rejected application = %+v", rejected)
}
if _, err := s.CustomVerification(ctx, verifier.BotID, other); !errors.Is(err, domain.ErrCustomVerificationNotFound) {
t.Fatalf("rejection granted a mark: %v", err)
}
// A decided pair is free again: history keeps the rejection.
reapplied, err := s.CreateCustomVerificationRequest(ctx,
botVerificationTestRequest(verifier.BotID, applicant, other, "AcmeCEO"))
if err != nil {
t.Fatalf("re-apply after rejection: %v", err)
}
counts, err := s.CustomVerificationRequestCounts(ctx)
if err != nil {
t.Fatalf("queue counts: %v", err)
}
if counts[domain.CustomVerificationPending] != 1 ||
counts[domain.CustomVerificationRejected] != 1 ||
counts[domain.CustomVerificationRevoked] != 1 {
t.Fatalf("queue counts = %+v", counts)
}
if _, present := counts[domain.CustomVerificationApproved]; present {
t.Fatalf("queue counts invented an approved application: %+v", counts)
}
listed, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{})
if err != nil || len(listed) != 3 {
t.Fatalf("queue list = %+v err=%v", listed, err)
}
if listed[0].ID != reapplied.ID {
t.Fatalf("queue order = %+v, want newest first", listed)
}
pendingOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Statuses: []domain.CustomVerificationRequestStatus{domain.CustomVerificationPending},
})
if err != nil || len(pendingOnly) != 1 || pendingOnly[0].ID != reapplied.ID {
t.Fatalf("pending queue = %+v err=%v", pendingOnly, err)
}
page, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{Limit: 2})
if err != nil || len(page) != 2 {
t.Fatalf("queue page = %+v err=%v", page, err)
}
next, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Limit: 2, BeforeID: page[len(page)-1].ID,
})
if err != nil || len(next) != 1 || next[0].ID >= page[len(page)-1].ID {
t.Fatalf("queue keyset page = %+v err=%v", next, err)
}
byUsername, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Query: "@acmec",
})
if err != nil || len(byUsername) != 2 {
t.Fatalf("username query = %+v err=%v", byUsername, err)
}
byPeerID, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Query: fmt.Sprintf("%d", peer.ID),
})
if err != nil || len(byPeerID) != 1 || byPeerID[0].ID != revoked.ID {
t.Fatalf("numeric query = %+v err=%v", byPeerID, err)
}
channelsOnly, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
PeerType: domain.PeerTypeChannel, VerifierBotID: verifier.BotID,
})
if err != nil || len(channelsOnly) != 1 || channelsOnly[0].ID != revoked.ID {
t.Fatalf("channel queue = %+v err=%v", channelsOnly, err)
}
if _, err := s.ListCustomVerificationRequests(ctx, domain.CustomVerificationRequestFilter{
Statuses: []domain.CustomVerificationRequestStatus{"nonsense"},
}); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("bad status filter err = %v, want ErrCustomVerificationRequestInvalid", err)
}
history, err := s.CustomVerificationRequestsForApplicant(ctx, applicant, 0)
if err != nil || len(history) != 3 || history[0].ID != reapplied.ID {
t.Fatalf("applicant history = %+v err=%v", history, err)
}
if empty, err := s.CustomVerificationRequestsForApplicant(ctx, applicant+1, 0); err != nil ||
len(empty) != 0 {
t.Fatalf("other applicant history = %+v err=%v", empty, err)
}
if _, err := s.CustomVerificationRequestsForApplicant(ctx, 0, 0); !errors.Is(err, domain.ErrCustomVerificationRequestInvalid) {
t.Fatalf("history for applicant 0 err = %v, want ErrCustomVerificationRequestInvalid", err)
}
if _, err := s.CustomVerificationRequest(ctx, reapplied.ID+9000); !errors.Is(err, domain.ErrCustomVerificationRequestNotFound) {
t.Fatalf("unknown application err = %v, want ErrCustomVerificationRequestNotFound", err)
}
}

View file

@ -229,8 +229,15 @@ func (s *ChannelStore) GetChannelByID(_ context.Context, channelID int64) (domai
return cloneChannel(channel), nil
}
func publicPreviewableChannel(channel domain.Channel) bool {
return publicSearchableChannel(channel)
func (s *ChannelStore) publicPreviewableChannelLocked(channel domain.Channel) bool {
hasActiveUsername := strings.TrimSpace(channel.Username) != ""
if !hasActiveUsername && s.usernameRegistry != nil {
hasActiveUsername = s.usernameRegistry.peerHasActiveCollectibleUsername(domain.Peer{
Type: domain.PeerTypeChannel,
ID: channel.ID,
})
}
return publicSearchableChannel(channel) && hasActiveUsername
}
func minInt(a, b int) int {

View file

@ -95,8 +95,8 @@ func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64,
out.Dialogs = append(out.Dialogs, dialog)
channel := s.channels[dialog.Peer.ID]
out.Channels = append(out.Channels, channel)
if msg, ok := s.findMessageLocked(dialog.Peer.ID, dialog.TopMessage); ok && !msg.Deleted {
out.Messages = append(out.Messages, cloneChannelMessage(msg))
if msg, ok := s.channelMessageForMemberLocked(viewerUserID, dialog.Peer.ID, dialog.TopMessage); ok {
out.Messages = append(out.Messages, msg)
}
}
// 与 PG 同因:getDialogs top message 按 viewer 补未读提及标志。
@ -151,8 +151,8 @@ func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64,
out.Channels = append(out.Channels, cloneChannel(parent))
}
}
if msg, ok := s.findMessageLocked(channelID, dialog.TopMessage); ok && !msg.Deleted {
out.Messages = append(out.Messages, cloneChannelMessage(msg))
if msg, ok := s.channelMessageForMemberLocked(viewerUserID, channelID, dialog.TopMessage); ok {
out.Messages = append(out.Messages, msg)
}
}
out.Count = len(out.Dialogs)
@ -470,36 +470,47 @@ func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int, memberSt
return domain.Dialog{
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
// 非成员预览(publicPreviewMember/被踢)须标记 ChannelLeft,客户端据此把频道渲染为只读 left 预览。
ChannelLeft: memberStatus == domain.ChannelMemberLeft,
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: channelPts,
ChannelLeft: memberStatus == domain.ChannelMemberLeft,
FolderID: dialog.FolderID,
TopMessage: dialog.TopMessageID,
TopMessageDate: dialog.TopMessageDate,
HistoryClearAnchorID: dialog.HistoryClearAnchorID,
HistoryClearAnchorDate: dialog.HistoryClearAnchorDate,
ReadInboxMaxID: dialog.ReadInboxMaxID,
ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount,
UnreadMentions: dialog.UnreadMentions,
UnreadReactions: dialog.UnreadReactions,
Pinned: dialog.Pinned,
PinnedOrder: dialog.PinnedOrder,
UnreadMark: dialog.UnreadMark,
ViewForumAsMessages: dialog.ViewForumAsMessages,
HasScheduled: dialog.HasScheduled,
Pts: channelPts,
}
}
func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog {
topMessageID := channel.TopMessageID
if topMessageID <= member.AvailableMinID {
topMessageID = 0
topMessageID = member.HistoryClearAnchorID
if topMessageID != member.AvailableMinID {
topMessageID = 0
}
}
topMessageDate := channel.Date
if topMessageID > 0 && topMessageID == member.HistoryClearAnchorID {
topMessageDate = member.HistoryClearAnchorDate
}
return domain.ChannelDialog{
UserID: userID,
ChannelID: channel.ID,
TopMessageID: topMessageID,
TopMessageDate: channel.Date,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
UserID: userID,
ChannelID: channel.ID,
TopMessageID: topMessageID,
TopMessageDate: topMessageDate,
HistoryClearAnchorID: member.HistoryClearAnchorID,
HistoryClearAnchorDate: member.HistoryClearAnchorDate,
ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID),
ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID),
}
}

View file

@ -83,6 +83,13 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
return domain.PublicChannelSearchResult{}, nil
}
s.mu.RLock()
registry := s.usernameRegistry
s.mu.RUnlock()
var usernameMatches map[int64]int
if registry != nil {
usernameMatches = registry.activeUsernameMatches(query, domain.PeerTypeChannel)
}
s.mu.RLock()
defer s.mu.RUnlock()
type item struct {
@ -92,6 +99,11 @@ func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int6
items := make([]item, 0, limit)
for channelID, channel := range s.channels {
rank, ok := publicChannelSearchRank(channel, query)
if usernameRank, matched := usernameMatches[channelID]; matched &&
!channel.Deleted && (channel.Broadcast || channel.Megagroup) &&
(!ok || usernameRank < rank) {
rank, ok = usernameRank, true
}
if !ok {
continue
}
@ -325,9 +337,19 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
if !ok || member.Status != domain.ChannelMemberActive {
continue
}
item := domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}
checkpoint := s.channelUpdateCheckpointLocked(channelID, channel)
if checkpoint.LatestEventDate > sinceDate {
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
item.ChannelUpdatesDirty = true
}
if clearDate := s.historyClearDates[channelID][userID]; clearDate >= sinceDate &&
member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
item.AvailableMinID = member.AvailableMinID
item.HistoryClearDate = clearDate
}
if item.ChannelUpdatesDirty || item.AvailableMinID > 0 {
out = append(out, item)
}
}
for channelID, channel := range s.channels {
@ -344,7 +366,11 @@ func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID
}
}
if !found {
out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts})
out = append(out, domain.DirtyChannel{
ChannelID: channelID,
Pts: channel.Pts,
ChannelUpdatesDirty: true,
})
}
}
}
@ -426,12 +452,25 @@ func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.C
return channel, syntheticMonoforumUserMember(channel, userID), true, nil
}
}
if !publicPreviewableChannel(channel) {
if !s.publicPreviewableChannelLocked(channel) {
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
}
return channel, publicPreviewMember(channel, userID, existing, found), true, nil
}
// channelMessageVisibleToViewerLocked applies the message-level half of synthetic monoforum
// access. The channel shell is visible without a channel_members row, but a subscriber may only
// address messages in saved_peer=self; managers may address every subscriber sub-dialog.
func channelMessageVisibleToViewerLocked(channel domain.Channel, member domain.ChannelMember, viewerUserID int64, msg domain.ChannelMessage) bool {
if !channel.Monoforum {
return true
}
if member.CanManageDirectMessages() {
return true
}
return msg.SavedPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: viewerUserID})
}
func (s *ChannelStore) dialogForUserLocked(userID int64, channel domain.Channel) domain.ChannelDialog {
return s.dialogForMemberLocked(userID, channel, s.members[channel.ID][userID])
}
@ -441,12 +480,16 @@ func (s *ChannelStore) dialogForMemberLocked(userID int64, channel domain.Channe
dialog.UserID = userID
dialog.ChannelID = channel.ID
dialog.TopMessageID = s.visibleTopMessageIDForMemberLocked(channel, member)
dialog.HistoryClearAnchorID = member.HistoryClearAnchorID
dialog.HistoryClearAnchorDate = member.HistoryClearAnchorDate
// TopMessageDate 必须从可见 top 消息派生(不能继承空缓存的 0),否则会话排序/分页与预览
// dialog 的日期全错。与 postgres GetChannelDialogs 用 getChannelMessage 设 date 对齐。
dialog.TopMessageDate = 0
if dialog.TopMessageID > 0 {
if top, ok := s.findMessageLocked(channel.ID, dialog.TopMessageID); ok {
if top, ok := s.channelMessageForMemberLocked(userID, channel.ID, dialog.TopMessageID); ok {
dialog.TopMessageDate = top.Date
} else if dialog.TopMessageID == member.HistoryClearAnchorID {
dialog.TopMessageDate = member.HistoryClearAnchorDate
}
}
if member.ReadInboxMaxID > dialog.ReadInboxMaxID {
@ -556,8 +599,7 @@ func recommendableChannel(channel domain.Channel) bool {
func publicSearchableChannel(channel domain.Channel) bool {
return !channel.Deleted &&
(channel.Broadcast || channel.Megagroup) &&
strings.TrimSpace(channel.Username) != ""
(channel.Broadcast || channel.Megagroup)
}
func channelRoleOrder(role domain.ChannelMemberRole) int {

View file

@ -892,6 +892,42 @@ func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID
return out, nil
}
func (s *ChannelStore) FilterChannelMessageAudienceIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if channelID == 0 || len(userIDs) == 0 {
return nil, nil
}
channel, ok := s.channels[channelID]
if !ok || channel.Deleted {
return nil, nil
}
public := s.publicPreviewableChannelLocked(channel)
members := s.members[channelID]
out := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID == 0 {
continue
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
member, found := members[userID]
if member.BannedRights.ViewMessages ||
member.Status == domain.ChannelMemberKicked ||
member.Status == domain.ChannelMemberBanned {
continue
}
if member.Status == domain.ChannelMemberActive || public && (!found || member.Status == domain.ChannelMemberLeft) {
out = append(out, userID)
}
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out, nil
}
func (s *ChannelStore) ListActiveChannelMembers(_ context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@ -1071,6 +1107,8 @@ func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.Chan
}
member.AvailableMinID = 0
member.AvailableMinPts = 0
member.HistoryClearAnchorID = 0
member.HistoryClearAnchorDate = 0
member.ReadInboxMaxID = mono.TopMessageID
member.ReadOutboxMaxID = mono.TopMessageID
member.UnreadMark = false

View file

@ -72,6 +72,12 @@ func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.Delete
if err != nil {
return domain.DeleteChannelHistoryResult{}, err
}
if req.Date <= 0 {
req.Date = channel.Date
if req.Date <= 0 {
req.Date = 1
}
}
maxID := req.MaxID
if maxID <= 0 || maxID > channel.TopMessageID {
maxID = channel.TopMessageID
@ -79,6 +85,22 @@ func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.Delete
member := s.members[req.ChannelID][req.UserID]
if !req.ForEveryone {
appliedMinID := maxInt(member.AvailableMinID, maxID)
changed := appliedMinID > member.AvailableMinID
if changed {
anchorDate := req.Date
if msg, ok := s.findMessageLocked(req.ChannelID, appliedMinID); ok && msg.Date > 0 {
anchorDate = msg.Date
}
if anchorDate <= 0 {
anchorDate = channel.Date
}
member.HistoryClearAnchorID = appliedMinID
member.HistoryClearAnchorDate = anchorDate
if s.historyClearDates[req.ChannelID] == nil {
s.historyClearDates[req.ChannelID] = make(map[int64]int)
}
s.historyClearDates[req.ChannelID][req.UserID] = req.Date
}
member.AvailableMinID = appliedMinID
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, appliedMinID)
member.UnreadMark = false
@ -92,7 +114,11 @@ func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.Delete
s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog)
}
s.dialogs[req.UserID][req.ChannelID] = s.dialogForUserLocked(req.UserID, channel)
return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil
return domain.DeleteChannelHistoryResult{
Channel: channel,
AvailableMinID: appliedMinID,
AvailableMinChanged: changed,
}, nil
}
if !canDeleteAnyChannelMessage(member) {
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired

View file

@ -25,8 +25,16 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
query := strings.ToLower(strings.TrimSpace(filter.Query))
matched := make([]domain.ChannelMessage, 0, len(items))
monoforumUserView := channel.Monoforum && !member.CanManageDirectMessages()
anchorID := 0
if filter.IncludeHistoryClearAnchor &&
member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
anchorID = member.HistoryClearAnchorID
}
anchorSeen := false
for _, msg := range items {
if msg.Deleted {
isAnchor := anchorID > 0 && msg.ID == anchorID
if msg.Deleted && !isAnchor {
continue
}
if channel.Monoforum {
@ -37,9 +45,18 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
continue
}
}
if msg.ID <= member.AvailableMinID {
if msg.ID < member.AvailableMinID || (msg.ID == member.AvailableMinID && !isAnchor) {
continue
}
if isAnchor {
msg = domain.ProjectChannelHistoryClearMessage(
msg,
filter.ChannelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
anchorSeen = true
}
if filter.PinnedOnly && !msg.Pinned {
continue
}
@ -66,6 +83,38 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
}
matched = append(matched, msg)
}
if anchorID > 0 && !anchorSeen {
msg := domain.ProjectChannelHistoryClearMessage(
domain.ChannelMessage{},
filter.ChannelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
if (filter.MinDate <= 0 || msg.Date > filter.MinDate) &&
(filter.MaxDate <= 0 || msg.Date < filter.MaxDate) &&
(filter.MaxID <= 0 || msg.ID <= filter.MaxID) &&
(filter.MinID <= 0 || msg.ID > filter.MinID) &&
!filter.PinnedOnly &&
!filter.MusicOnly &&
query == "" &&
filter.SenderUserID == 0 {
matched = append(matched, msg)
}
}
extraChannels := []domain.Channel(nil)
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted {
extraChannels = append(extraChannels, cloneChannel(parent))
}
}
if filter.CountOnly {
return domain.ChannelHistory{
Channel: channel,
Self: member,
Channels: extraChannels,
Count: len(matched),
}, nil
}
// add_offset 决定加载方向(对齐 postgres ListChannelHistory):
// >= 0 backward:锚点更旧方向(不含锚点),先跳过 add_offset 条
// < 0 且 +limit>0 around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条
@ -160,14 +209,12 @@ func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64,
if hasMoreOlder {
count = len(out) + 1
}
if filter.NeedTotalCount {
count = len(matched)
}
s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out)
s.populateChannelMessageReactionsLocked(viewerUserID, channel, out)
extraChannels := []domain.Channel(nil)
if channel.Monoforum && channel.LinkedMonoforumID != 0 {
if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted {
extraChannels = append(extraChannels, cloneChannel(parent))
}
}
projectMemoryChannelHistoryClearMessages(channel.ID, member, out)
return domain.ChannelHistory{
Channel: channel,
Self: member,
@ -213,7 +260,7 @@ func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int6
}
member, ok := s.members[channelID][viewerUserID]
joined := ok && member.Status == domain.ChannelMemberActive && !member.BannedRights.ViewMessages
publicPreview := req.AllowPublicPreview && publicPreviewableChannel(channel) &&
publicPreview := req.AllowPublicPreview && s.publicPreviewableChannelLocked(channel) &&
(!ok || member.Status != domain.ChannelMemberKicked && !member.BannedRights.ViewMessages)
if !joined && !publicPreview {
continue
@ -307,17 +354,62 @@ func (s *ChannelStore) GetChannelMessages(_ context.Context, viewerUserID, chann
if _, ok := wanted[msg.ID]; !ok {
continue
}
if msg.ID == member.HistoryClearAnchorID &&
member.HistoryClearAnchorID == member.AvailableMinID {
messages = append(messages, domain.ProjectChannelHistoryClearMessage(
msg,
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
))
delete(wanted, msg.ID)
continue
}
if msg.Deleted || msg.ID <= member.AvailableMinID {
continue
}
if !channelMessageVisibleToViewerLocked(channel, member, viewerUserID, msg) {
continue
}
messages = append(messages, cloneChannelMessage(msg))
delete(wanted, msg.ID)
}
if member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
if _, ok := wanted[member.HistoryClearAnchorID]; ok {
messages = append(messages, domain.ProjectChannelHistoryClearMessage(
domain.ChannelMessage{},
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
))
}
}
sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID })
s.populateChannelMessageRepliesLocked(viewerUserID, channelID, messages)
s.populateChannelMessageReactionsLocked(viewerUserID, channel, messages)
projectMemoryChannelHistoryClearMessages(channelID, member, messages)
return domain.ChannelHistory{Channel: channel, Self: member, Messages: messages, Count: len(messages)}, nil
}
func projectMemoryChannelHistoryClearMessages(channelID int64, member domain.ChannelMember, messages []domain.ChannelMessage) {
if member.HistoryClearAnchorID <= 0 ||
member.HistoryClearAnchorID != member.AvailableMinID {
return
}
for i := range messages {
if messages[i].ID != member.HistoryClearAnchorID {
continue
}
messages[i] = domain.ProjectChannelHistoryClearMessage(
messages[i],
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
)
}
}
func (s *ChannelStore) ListStoryMessageForwards(_ context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) {
if req.ViewerUserID == 0 || req.Owner.ID == 0 || req.StoryID <= 0 || req.StoryID > domain.MaxStoryID {
return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid
@ -620,9 +712,40 @@ func (s *ChannelStore) visibleTopMessageIDForMemberLocked(channel domain.Channel
return msg.ID
}
}
if member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID {
return member.HistoryClearAnchorID
}
return 0
}
func (s *ChannelStore) channelMessageForMemberLocked(userID, channelID int64, messageID int) (domain.ChannelMessage, bool) {
member, ok := s.members[channelID][userID]
if !ok {
msg, found := s.findMessageLocked(channelID, messageID)
if !found || msg.Deleted {
return domain.ChannelMessage{}, false
}
return cloneChannelMessage(msg), true
}
if member.HistoryClearAnchorID > 0 &&
member.HistoryClearAnchorID == member.AvailableMinID &&
messageID == member.HistoryClearAnchorID {
source, _ := s.findMessageLocked(channelID, messageID)
return domain.ProjectChannelHistoryClearMessage(
source,
channelID,
member.HistoryClearAnchorID,
member.HistoryClearAnchorDate,
), true
}
msg, ok := s.findMessageLocked(channelID, messageID)
if !ok || msg.Deleted || msg.ID <= member.AvailableMinID {
return domain.ChannelMessage{}, false
}
return cloneChannelMessage(msg), true
}
func (s *ChannelStore) topicHasVisibleMessagesLocked(channelID int64, topicID int) bool {
for _, msg := range s.messages[channelID] {
if msg.Deleted {

View file

@ -17,7 +17,7 @@ func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.Chan
}
s.mu.Lock()
defer s.mu.Unlock()
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageViewsResult{}, err
}
@ -33,7 +33,8 @@ func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.Chan
if _, ok := wanted[msg.ID]; !ok {
continue
}
if msg.Deleted || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) {
continue
}
visible[msg.ID] = struct{}{}

View file

@ -119,6 +119,7 @@ func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMo
Entities: append([]domain.MessageEntity(nil), req.Entities...),
Media: req.Media,
ReplyTo: req.ReplyTo,
Forward: req.Forward,
Pts: pts,
}
// Store owns the persisted snapshot; callers must not be able to mutate it through

View file

@ -27,8 +27,9 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
}
sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42}
forward := &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: 1}, Date: 1_700_000_999}
m1, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_001})
m1, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Forward: forward, Date: 1_700_001_001})
if err != nil {
t.Fatalf("subscriber send 1: %v", err)
}
@ -75,7 +76,7 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
}
// 幂等:相同 randomID 返回原消息、不重复。
dup, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_004})
dup, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Forward: forward, Date: 1_700_001_004})
if err != nil {
t.Fatalf("dup send: %v", err)
}
@ -99,6 +100,9 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
if hist.Messages[0].ReplyTo == nil || hist.Messages[0].ReplyTo.MessageID != m1.Message.ID {
t.Fatalf("history[0] reply = %+v, want message %d", hist.Messages[0].ReplyTo, m1.Message.ID)
}
if oldest := hist.Messages[len(hist.Messages)-1]; oldest.Forward == nil || oldest.Forward.From.ID != 1 || oldest.Forward.Date != 1_700_000_999 {
t.Fatalf("persisted monoforum forward = %+v, want source user 1/date 1700000999", oldest.Forward)
}
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 114, Message: "cross reply", ReplyTo: &domain.MessageReply{MessageID: 999999, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}}, Date: 1_700_001_004}); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
t.Fatalf("invalid monoforum reply err = %v, want ErrReplyMessageIDInvalid", err)
}
@ -110,7 +114,8 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
// 另一个订阅者的私信不串会话。
other := domain.Peer{Type: domain.PeerTypeUser, ID: 99}
if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 201, Message: "other", Date: 1_700_001_005}); err != nil {
otherMessage, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 201, Message: "other", Date: 1_700_001_005})
if err != nil {
t.Fatalf("other subscriber send: %v", err)
}
subHist, _ := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10})
@ -129,6 +134,104 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) {
t.Fatalf("subscriber channel history leaked message %+v", message)
}
}
exactMessages, err := store.GetChannelMessages(ctx, 42, monoID, []int{m1.Message.ID, otherMessage.Message.ID})
if err != nil {
t.Fatalf("subscriber exact monoforum messages: %v", err)
}
if len(exactMessages.Messages) != 1 || exactMessages.Messages[0].ID != m1.Message.ID {
t.Fatalf("subscriber exact monoforum messages = %+v, want only own message %d", exactMessages.Messages, m1.Message.ID)
}
ptsBeforeViews := store.channels[monoID].Pts
subViews, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: 42, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
Increment: true, Date: 1_700_001_006,
})
if err != nil {
t.Fatalf("subscriber get monoforum message views: %v", err)
}
if len(subViews.Views) != 1 || subViews.Views[m1.Message.ID] != 1 {
t.Fatalf("subscriber monoforum views = %+v, want own message %d at 1", subViews.Views, m1.Message.ID)
}
if _, ok := subViews.Views[otherMessage.Message.ID]; ok {
t.Fatalf("subscriber monoforum views leaked other saved_peer message %d", otherMessage.Message.ID)
}
repeatedViews, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: 42, ChannelID: monoID, IDs: []int{m1.Message.ID},
Increment: true, Date: 1_700_001_007,
})
if err != nil || repeatedViews.Views[m1.Message.ID] != 1 {
t.Fatalf("repeated subscriber monoforum views = %+v, %v; want idempotent 1", repeatedViews.Views, err)
}
if got := store.msgViews[monoID][otherMessage.Message.ID]; got != 0 {
t.Fatalf("hidden saved_peer views = %d, want 0 before admin view", got)
}
adminViews, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
UserID: 1, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
Increment: true, Date: 1_700_001_008,
})
if err != nil {
t.Fatalf("admin get monoforum message views: %v", err)
}
if len(adminViews.Views) != 2 || adminViews.Views[m1.Message.ID] != 2 || adminViews.Views[otherMessage.Message.ID] != 1 {
t.Fatalf("admin monoforum views = %+v, want both saved peers at 2/1", adminViews.Views)
}
if got := store.channels[monoID].Pts; got != ptsBeforeViews {
t.Fatalf("message views advanced monoforum pts = %d, want unchanged %d", got, ptsBeforeViews)
}
if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: 42, ChannelID: monoID, MessageID: m1.Message.ID,
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}},
Date: 1_700_001_006,
}); err != nil {
t.Fatalf("subscriber react to own monoforum message: %v", err)
}
if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
UserID: 42, ChannelID: monoID, MessageID: otherMessage.Message.ID,
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f525"}},
Date: 1_700_001_006,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber react to another saved_peer err = %v, want ErrMessageIDInvalid", err)
}
subReactions, err := store.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: 42, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
})
if err != nil {
t.Fatalf("subscriber get monoforum reactions: %v", err)
}
if len(subReactions.Messages) != 1 || subReactions.Messages[0].ID != m1.Message.ID {
t.Fatalf("subscriber monoforum reactions = %+v, want only own message %d", subReactions.Messages, m1.Message.ID)
}
adminReactions, err := store.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
UserID: 1, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
})
if err != nil {
t.Fatalf("admin get monoforum reactions: %v", err)
}
if len(adminReactions.Messages) != 2 {
t.Fatalf("admin monoforum reactions = %+v, want both subscriber messages", adminReactions.Messages)
}
reactionList, err := store.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
UserID: 42, ChannelID: monoID, MessageID: m1.Message.ID, Limit: 10,
})
if err != nil || reactionList.Count != 1 || len(reactionList.Reactions) != 1 {
t.Fatalf("subscriber monoforum reaction list = %+v, %v; want one", reactionList, err)
}
if _, err := store.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
UserID: 42, ChannelID: monoID, MessageID: otherMessage.Message.ID, Limit: 10,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber list another saved_peer reactions err = %v, want ErrMessageIDInvalid", err)
}
reactionLookup, found, err := store.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
ViewerUserID: 42, ChannelID: monoID, MessageID: m1.Message.ID, ReactorUserID: 42,
})
if err != nil || !found || len(reactionLookup.Reactions) != 1 {
t.Fatalf("subscriber monoforum reaction lookup = %+v, %v, %v; want one", reactionLookup, found, err)
}
if _, _, err := store.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
ViewerUserID: 42, ChannelID: monoID, MessageID: otherMessage.Message.ID, ReactorUserID: 99,
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
t.Fatalf("subscriber lookup another saved_peer reaction err = %v, want ErrMessageIDInvalid", err)
}
diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 42, ChannelID: monoID, Pts: 0, Limit: 100})
if err != nil {
t.Fatalf("subscriber channel difference: %v", err)

View file

@ -41,6 +41,23 @@ func TestSetPaidMessagesPriceCreatesAndReusesMonoforum(t *testing.T) {
if mono.TopMessageID == 0 || mono.Pts == 0 {
t.Fatalf("monoforum top/pts = %d/%d, want service top message", mono.TopMessageID, mono.Pts)
}
for _, userID := range []int64{1, 42} {
read, err := store.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: userID, ChannelID: monoID, MaxID: mono.TopMessageID, Date: 1_700_000_901,
})
if err != nil {
t.Fatalf("synthetic monoforum read for %d: %v", userID, err)
}
if !read.ReadOnly || read.Changed || read.MaxID != mono.TopMessageID {
t.Fatalf("synthetic monoforum read for %d = %+v, want read-only no-op at %d", userID, read, mono.TopMessageID)
}
if _, exists := store.members[monoID][userID]; exists {
t.Fatalf("synthetic monoforum read persisted member for %d", userID)
}
if _, exists := store.dialogs[userID][monoID]; exists {
t.Fatalf("synthetic monoforum read persisted dialog for %d", userID)
}
}
dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 100})
if err != nil {
t.Fatalf("list dialogs after enable: %v", err)

View file

@ -46,7 +46,7 @@ func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain.
}
s.mu.Lock()
defer s.mu.Unlock()
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
@ -64,7 +64,8 @@ func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain.
return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
msg := s.messages[req.ChannelID][idx]
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) {
return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
// 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。
@ -385,7 +386,7 @@ func (s *ChannelStore) GetChannelMessageReactions(_ context.Context, req domain.
}
s.mu.RLock()
defer s.mu.RUnlock()
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsResult{}, err
}
@ -401,7 +402,8 @@ func (s *ChannelStore) GetChannelMessageReactions(_ context.Context, req domain.
if _, ok := wanted[msg.ID]; !ok {
continue
}
if msg.Deleted || msg.ID <= member.AvailableMinID {
if msg.Deleted || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) {
continue
}
item := cloneChannelMessage(msg)
@ -432,7 +434,7 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain
}
s.mu.RLock()
defer s.mu.RUnlock()
channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID)
channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionsList{}, err
}
@ -440,7 +442,8 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain
return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden
}
msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
if !ok || msg.Deleted || msg.ID <= member.AvailableMinID {
if !ok || msg.Deleted || msg.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) {
return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid
}
rows := s.channelMessageReactionRowsLocked(req.ChannelID, req.MessageID, req.UserID, req.Reaction)
@ -479,6 +482,40 @@ func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain
}, nil
}
func (s *ChannelStore) FindChannelMessageReaction(_ context.Context, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error) {
if req.ViewerUserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 ||
req.MessageID > domain.MaxMessageBoxID || req.ReactorUserID == 0 {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrChannelInvalid
}
s.mu.RLock()
defer s.mu.RUnlock()
channel, member, _, err := s.channelForViewerLocked(req.ViewerUserID, req.ChannelID)
if err != nil {
return domain.ChannelMessageReactionLookup{}, false, err
}
message, ok := s.findMessageLocked(req.ChannelID, req.MessageID)
if !ok || message.Deleted || message.ID <= member.AvailableMinID ||
!channelMessageVisibleToViewerLocked(channel, member, req.ViewerUserID, message) {
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
}
rows := cloneChannelPeerReactions(s.reactions[req.ChannelID][req.MessageID][req.ReactorUserID])
if len(rows) == 0 {
return domain.ChannelMessageReactionLookup{
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
}, false, nil
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].ChosenOrder != rows[j].ChosenOrder {
return rows[i].ChosenOrder < rows[j].ChosenOrder
}
return messageReactionKey(rows[i].Reaction) < messageReactionKey(rows[j].Reaction)
})
return domain.ChannelMessageReactionLookup{
Channel: cloneChannel(channel), Message: cloneChannelMessage(message),
Reactions: rows,
}, true, nil
}
func (s *ChannelStore) RecordMessageReactionUse(_ context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error {
if userID == 0 || len(reactions) == 0 {
return nil
@ -599,54 +636,6 @@ func (s *ChannelStore) ClearRecentMessageReactions(_ context.Context, userID int
return nil
}
func (s *ChannelStore) ListSavedReactionTags(_ context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) {
if userID == 0 {
return nil, domain.ErrChannelInvalid
}
if limit <= 0 {
return []domain.SavedReactionTag{}, nil
}
if limit > domain.MaxSavedReactionTags {
limit = domain.MaxSavedReactionTags
}
s.mu.RLock()
defer s.mu.RUnlock()
rows := make([]domain.SavedReactionTag, 0, len(s.savedTags[userID]))
for _, row := range s.savedTags[userID] {
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
if rows[i].Reaction.Type != rows[j].Reaction.Type {
return rows[i].Reaction.Type < rows[j].Reaction.Type
}
return rows[i].Reaction.Value() < rows[j].Reaction.Value()
})
if len(rows) > limit {
rows = rows[:limit]
}
return rows, nil
}
func (s *ChannelStore) UpsertSavedReactionTag(_ context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(tag.Reaction.Emoticon) == "" {
return domain.ErrChannelInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if s.savedTags[tag.UserID] == nil {
s.savedTags[tag.UserID] = make(map[string]domain.SavedReactionTag)
}
tag.Reaction.Emoticon = strings.TrimSpace(tag.Reaction.Emoticon)
if tag.Count < 0 {
tag.Count = 0
}
s.savedTags[tag.UserID][messageReactionKey(tag.Reaction)] = tag
return nil
}
func (s *ChannelStore) ListChannelUnreadReactions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) {
s.mu.RLock()
defer s.mu.RUnlock()

View file

@ -252,7 +252,7 @@ func (s *ChannelStore) ReadChannelMentions(_ context.Context, req domain.ReadCha
func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID)
channel, _, readOnly, err := s.channelForViewerLocked(req.UserID, req.ChannelID)
if err != nil {
return domain.ReadChannelHistoryResult{}, err
}
@ -260,6 +260,15 @@ func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChan
if maxID <= 0 || maxID > channel.TopMessageID {
maxID = channel.TopMessageID
}
if readOnly {
return domain.ReadChannelHistoryResult{
ChannelID: req.ChannelID,
MaxID: maxID,
ReadOnly: true,
Pts: channel.Pts,
Forum: channel.Forum,
}, nil
}
member := s.members[req.ChannelID][req.UserID]
previous := member.ReadInboxMaxID
changed := maxID > member.ReadInboxMaxID

View file

@ -139,7 +139,7 @@ func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64,
return true, nil
}
func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) {
func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) {
if req.UserID == 0 || req.ChannelID == 0 {
return domain.Channel{}, domain.ErrChannelInvalid
}
@ -168,6 +168,11 @@ func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChanne
}
}
}
if s.usernameRegistry != nil {
if _, err := s.usernameRegistry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, username); err != nil {
return domain.Channel{}, err
}
}
prevUsername := channel.Username
channel.Username = username
s.channels[req.ChannelID] = channel
@ -311,16 +316,27 @@ func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUse
return domain.Channel{}, false, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
registry := s.usernameRegistry
for _, channel := range s.channels {
if !publicSearchableChannel(channel) {
continue
}
if strings.ToLower(channel.Username) == username {
s.mu.RUnlock()
return cloneChannel(channel), true, nil
}
}
s.mu.RUnlock()
if registry != nil {
if peer, ok := registry.activeUsernamePeer(username, domain.PeerTypeChannel); ok {
s.mu.RLock()
channel, found := s.channels[peer.ID]
s.mu.RUnlock()
if found && !channel.Deleted && (channel.Broadcast || channel.Megagroup) {
return cloneChannel(channel), true, nil
}
}
}
return domain.Channel{}, false, nil
}

View file

@ -73,15 +73,19 @@ type ChannelStore struct {
messages map[int64][]domain.ChannelMessage
reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction
// paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
savedTags map[int64]map[string]domain.SavedReactionTag
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
paidReactions map[int64]map[int]map[int64]memoryPaidReaction
top map[int64]map[string]domain.TopMessageReaction
recent map[int64]map[string]domain.RecentMessageReaction
mentions map[int64]map[int64]map[int]memoryMention
msgViews map[int64]map[int]int
msgViewers map[int64]map[int]map[int64]struct{}
events map[int64][]domain.ChannelUpdateEvent
retention map[int64]domain.ChannelUpdateRetentionCheckpoint
// historyClearDates is the no-PTS recovery timestamp for a future
// owner-local clear, keyed by channel then user. The member remains the
// absolute boundary authority; this map only makes account difference
// discovery bounded without scanning messages.
historyClearDates map[int64]map[int64]int
adminLogs map[int64][]domain.ChannelAdminLogEvent
invites map[string]domain.ChannelInvite
importers map[int64]map[int64]domain.ChannelInviteImporter
@ -102,7 +106,8 @@ type ChannelStore struct {
// topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。
topicReads map[int64]map[int64]map[int]memoryTopicRead
// polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。
polls *PollStore
polls *PollStore
usernameRegistry *CollectibleUsernameStore
}
// AttachPollStore 注入共享 poll 权威。
@ -110,6 +115,14 @@ func (s *ChannelStore) AttachPollStore(polls *PollStore) {
s.polls = polls
}
// AttachUsernameRegistry gives the memory backend the same global username
// index the PostgreSQL stores share through peer_usernames.
func (s *ChannelStore) AttachUsernameRegistry(registry *CollectibleUsernameStore) {
s.mu.Lock()
s.usernameRegistry = registry
s.mu.Unlock()
}
// NewChannelStore creates an in-memory ChannelStore.
func NewChannelStore() *ChannelStore {
return &ChannelStore{
@ -124,12 +137,12 @@ func NewChannelStore() *ChannelStore {
paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction),
top: make(map[int64]map[string]domain.TopMessageReaction),
recent: make(map[int64]map[string]domain.RecentMessageReaction),
savedTags: make(map[int64]map[string]domain.SavedReactionTag),
mentions: make(map[int64]map[int64]map[int]memoryMention),
msgViews: make(map[int64]map[int]int),
msgViewers: make(map[int64]map[int]map[int64]struct{}),
events: make(map[int64][]domain.ChannelUpdateEvent),
retention: make(map[int64]domain.ChannelUpdateRetentionCheckpoint),
historyClearDates: make(map[int64]map[int64]int),
adminLogs: make(map[int64][]domain.ChannelAdminLogEvent),
invites: make(map[string]domain.ChannelInvite),
importers: make(map[int64]map[int64]domain.ChannelInviteImporter),

View file

@ -85,8 +85,12 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges
if req.ScheduleDate > 0 {
scheduleDate = req.ScheduleDate
}
if !req.Reject && scheduleDate > 0 && (scheduleDate < req.Date+5*60 || scheduleDate > req.Date+31*24*60*60) {
return domain.ToggleSuggestedPostApprovalResult{}, domain.ErrSuggestedPostInvalid
if !req.Reject {
effectiveDate, scheduleErr := domain.EffectiveSuggestedPostPublishDate(scheduleDate, req.Date)
if scheduleErr != nil {
return domain.ToggleSuggestedPostApprovalResult{}, scheduleErr
}
scheduleDate = effectiveDate
}
recipients := s.monoforumRecipientsLocked(parent.ID, original.SavedPeer.ID)
base := domain.ToggleSuggestedPostApprovalResult{
@ -136,13 +140,6 @@ func (s *ChannelStore) toggleSuggestedPostApprovalLocked(req domain.ToggleSugges
original.SuggestedPost.Accepted = true
original.SuggestedPost.Rejected = false
effectivePublishDate := scheduleDate
if effectivePublishDate == 0 {
// TDesktop deliberately omits schedule_date for "Publish Now", but
// renders the approval service action as an absolute date. Persist one
// effective publication timestamp across the edited suggestion, action
// and approval record instead of leaking an accepted zero date.
effectivePublishDate = req.Date
}
original.SuggestedPost.ScheduleDate = effectivePublishDate
original.Pts = s.nextChannelPtsLocked(mono.ID)
s.messages[mono.ID][idx] = cloneChannelMessage(original)

View file

@ -179,6 +179,89 @@ func TestSuggestedPostLowBalanceRetryScheduleAndRoleMatrix(t *testing.T) {
}
}
func TestSuggestedPostApprovalAcceptsDelayedScheduleAndKeepsPTSIdempotent(t *testing.T) {
ctx := context.Background()
store, parent, mono, subscriber := newSuggestedPostMemoryFixture(t)
const now = 1_700_010_000
near, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
RandomID: 71, Message: "near schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now - 10,
})
if err != nil {
t.Fatal(err)
}
nearDate := now + 2*60
accepted, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: 1, MonoforumID: mono.ID, MessageID: near.Message.ID,
ScheduleDate: nearDate, Date: now,
})
if err != nil {
t.Fatalf("accept schedule below former five-minute gate: %v", err)
}
if accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil ||
accepted.OriginalMessage.SuggestedPost.ScheduleDate != nearDate ||
accepted.ServiceMessage.Action == nil ||
accepted.ServiceMessage.Action.SuggestedPostScheduleDate != nearDate {
t.Fatalf("near schedule approval = %+v, want scheduled at %d", accepted, nearDate)
}
monoPts, parentPts := store.channels[mono.ID].Pts, store.channels[parent.ID].Pts
monoEvents, parentEvents := len(store.events[mono.ID]), len(store.events[parent.ID])
replay, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: 1, MonoforumID: mono.ID, MessageID: near.Message.ID,
ScheduleDate: nearDate, Date: now + 10*60,
})
if err != nil || !replay.Duplicate {
t.Fatalf("late duplicate approval = %+v err=%v", replay, err)
}
if store.channels[mono.ID].Pts != monoPts || store.channels[parent.ID].Pts != parentPts ||
len(store.events[mono.ID]) != monoEvents || len(store.events[parent.ID]) != parentEvents {
t.Fatal("late duplicate approval advanced PTS or appended an event")
}
due, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
RandomID: 72, Message: "already due", SuggestedPost: &domain.SuggestedPost{}, Date: now + 20,
})
if err != nil {
t.Fatal(err)
}
approvedAt := now + 30
dueResult, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: 1, MonoforumID: mono.ID, MessageID: due.Message.ID,
ScheduleDate: now - 1, Date: approvedAt,
})
if err != nil {
t.Fatalf("approve already-due schedule: %v", err)
}
if dueResult.State != domain.SuggestedPostStateCompleted || dueResult.Published == nil ||
dueResult.OriginalMessage.SuggestedPost.ScheduleDate != approvedAt ||
dueResult.ServiceMessage.Action == nil ||
dueResult.ServiceMessage.Action.SuggestedPostScheduleDate != approvedAt {
t.Fatalf("due schedule approval = %+v, want immediate publish at %d", dueResult, approvedAt)
}
far, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
MonoforumID: mono.ID, SenderUserID: subscriber.ID, SavedPeer: subscriber,
RandomID: 73, Message: "too far", SuggestedPost: &domain.SuggestedPost{}, Date: now + 40,
})
if err != nil {
t.Fatal(err)
}
monoPts, parentPts = store.channels[mono.ID].Pts, store.channels[parent.ID].Pts
monoEvents, parentEvents = len(store.events[mono.ID]), len(store.events[parent.ID])
if _, err := store.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
UserID: 1, MonoforumID: mono.ID, MessageID: far.Message.ID,
ScheduleDate: approvedAt + domain.MaxSuggestedPostScheduleDelay + 1, Date: approvedAt,
}); !errors.Is(err, domain.ErrSuggestedPostInvalid) {
t.Fatalf("far schedule err=%v, want suggested post invalid", err)
}
if store.channels[mono.ID].Pts != monoPts || store.channels[parent.ID].Pts != parentPts ||
len(store.events[mono.ID]) != monoEvents || len(store.events[parent.ID]) != parentEvents {
t.Fatal("far schedule rejection advanced PTS or appended an event")
}
}
func TestChannelAuthoredSuggestedPostAcceptedBySubscriber(t *testing.T) {
ctx := context.Background()
store, _, mono, subscriber := newSuggestedPostMemoryFixture(t)

View file

@ -873,6 +873,7 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
CreatorUserID: 1,
Title: "monotonic local clear",
Megagroup: true,
MemberUserIDs: []int64{2},
Date: 1_700_000_250,
})
if err != nil {
@ -911,6 +912,20 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
if high.AvailableMinID != second.Message.ID {
t.Fatalf("high available_min_id = %d, want %d", high.AvailableMinID, second.Message.ID)
}
if !high.AvailableMinChanged {
t.Fatal("high clear did not report an advanced owner-local boundary")
}
dirtyAfterClear, err := store.ListDirtyActiveChannelsForUser(ctx, 1, 1_700_000_253, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after owner-local clear: %v", err)
}
if len(dirtyAfterClear) != 1 ||
dirtyAfterClear[0].ChannelID != created.Channel.ID ||
dirtyAfterClear[0].AvailableMinID != second.Message.ID ||
dirtyAfterClear[0].HistoryClearDate != 1_700_000_253 ||
dirtyAfterClear[0].ChannelUpdatesDirty {
t.Fatalf("dirty owner-local clear = %+v, want only absolute boundary %d at date 1700000253", dirtyAfterClear, second.Message.ID)
}
stale, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 1,
@ -924,13 +939,38 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
if stale.AvailableMinID != second.Message.ID {
t.Fatalf("stale available_min_id = %d, want monotonic %d", stale.AvailableMinID, second.Message.ID)
}
if stale.AvailableMinChanged {
t.Fatal("stale clear unexpectedly replaced the owner-local anchor")
}
dirtyAfterStale, err := store.ListDirtyActiveChannelsForUser(ctx, 1, 1_700_000_254, 0, 10)
if err != nil {
t.Fatalf("list dirty channels after stale owner-local clear: %v", err)
}
if len(dirtyAfterStale) != 0 {
t.Fatalf("stale clear refreshed recovery timestamp: %+v", dirtyAfterStale)
}
history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10})
if err != nil {
t.Fatalf("list history: %v", err)
}
if len(history.Messages) != 0 {
t.Fatalf("history after stale clear = %+v, want no visible messages", history.Messages)
t.Fatalf("unprojected history after stale clear = %+v, want no shared messages", history.Messages)
}
history, err = store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{
ChannelID: created.Channel.ID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list projected history: %v", err)
}
if len(history.Messages) != 1 ||
history.Messages[0].ID != second.Message.ID ||
!domain.IsChannelHistoryClearMessage(history.Messages[0]) ||
history.Messages[0].Body != "" ||
history.Messages[0].Media != nil {
t.Fatalf("projected history after stale clear = %+v, want sanitized history-clear anchor %d", history.Messages, second.Message.ID)
}
dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID})
if err != nil {
@ -939,8 +979,140 @@ func TestChannelDeleteHistoryLocalClearReturnsMonotonicAvailableMinID(t *testing
if len(dialogs.Dialogs) != 1 {
t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs)
}
if dialogs.Dialogs[0].TopMessage != 0 || dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID || dialogs.Dialogs[0].UnreadCount != 0 {
t.Fatalf("dialog after stale clear = %+v, want top=0 read=%d unread=0", dialogs.Dialogs[0], second.Message.ID)
if dialogs.Dialogs[0].TopMessage != second.Message.ID ||
dialogs.Dialogs[0].ReadInboxMaxID != second.Message.ID ||
dialogs.Dialogs[0].UnreadCount != 0 ||
len(dialogs.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(dialogs.Messages[0]) {
t.Fatalf("dialog after stale clear = %+v messages=%+v, want anchored top=%d read=%d unread=0", dialogs.Dialogs[0], dialogs.Messages, second.Message.ID, second.Message.ID)
}
otherDialogs, err := store.GetChannelDialogs(ctx, 2, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get other member dialog: %v", err)
}
if len(otherDialogs.Dialogs) != 1 ||
otherDialogs.Dialogs[0].TopMessage != second.Message.ID ||
len(otherDialogs.Messages) != 1 ||
otherDialogs.Messages[0].Body != "second visible" ||
otherDialogs.Messages[0].Action != nil {
t.Fatalf("other member projection changed by owner clear: dialogs=%+v messages=%+v", otherDialogs.Dialogs, otherDialogs.Messages)
}
}
func TestChannelDeleteHistoryLocalClearKeepsMegagroupAndBroadcastDialogs(t *testing.T) {
for _, tc := range []struct {
name string
megagroup bool
broadcast bool
}{
{name: "megagroup", megagroup: true},
{name: "broadcast", broadcast: true},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 11,
Title: tc.name + " local clear",
Megagroup: tc.megagroup,
Broadcast: tc.broadcast,
Date: 1_700_000_270,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 11,
ChannelID: created.Channel.ID,
RandomID: 31_001,
Message: "clear this",
Date: 1_700_000_271,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
channelPts := sent.Channel.Pts
cleared, err := store.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
UserID: 11,
ChannelID: created.Channel.ID,
MaxID: sent.Message.ID,
Date: 1_700_000_272,
})
if err != nil {
t.Fatalf("clear local history: %v", err)
}
if cleared.Channel.Pts != channelPts || cleared.Event.Pts != 0 {
t.Fatalf("local clear changed channel pts: before=%d result=%+v", channelPts, cleared)
}
dialogs, err := store.GetChannelDialogs(ctx, 11, []int64{created.Channel.ID})
if err != nil {
t.Fatalf("get dialogs after clear: %v", err)
}
if len(dialogs.Dialogs) != 1 ||
dialogs.Dialogs[0].TopMessage != sent.Message.ID ||
len(dialogs.Messages) != 1 ||
!domain.IsChannelHistoryClearMessage(dialogs.Messages[0]) {
t.Fatalf("dialog disappeared after %s clear: dialogs=%+v messages=%+v", tc.name, dialogs.Dialogs, dialogs.Messages)
}
})
}
}
func TestChannelPrehistoryBoundaryDoesNotCreateHistoryClearAnchor(t *testing.T) {
ctx := context.Background()
store := NewChannelStore()
created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: 21,
Title: "hidden prehistory",
Megagroup: true,
Date: 1_700_000_280,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
old, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: 21,
ChannelID: created.Channel.ID,
RandomID: 32_001,
Message: "prehistory",
Date: 1_700_000_281,
})
if err != nil {
t.Fatalf("send prehistory message: %v", err)
}
if _, err := store.SetPreHistoryHidden(ctx, 21, created.Channel.ID, true); err != nil {
t.Fatalf("hide prehistory: %v", err)
}
invited, err := store.InviteToChannel(ctx, created.Channel.ID, 21, []int64{22}, 1_700_000_282)
if err != nil {
t.Fatalf("invite member: %v", err)
}
if len(invited.Members) != 1 ||
invited.Members[0].AvailableMinID != old.Message.ID ||
invited.Members[0].HistoryClearAnchorID != 0 ||
invited.Members[0].HistoryClearAnchorDate != 0 {
t.Fatalf("invited member = %+v, want prehistory boundary without local-clear anchor", invited.Members)
}
history, err := store.ListChannelHistory(ctx, 22, domain.ChannelHistoryFilter{
ChannelID: created.Channel.ID,
Limit: 10,
IncludeHistoryClearAnchor: true,
})
if err != nil {
t.Fatalf("list invited member history: %v", err)
}
for _, message := range history.Messages {
if message.ID == old.Message.ID || domain.IsChannelHistoryClearMessage(message) {
t.Fatalf("prehistory boundary leaked a local-clear marker: %+v", history.Messages)
}
}
byID, err := store.GetChannelMessages(ctx, 22, created.Channel.ID, []int{old.Message.ID})
if err != nil {
t.Fatalf("get prehistory message by id: %v", err)
}
if len(byID.Messages) != 0 {
t.Fatalf("prehistory message by id = %+v, want hidden without fabricated marker", byID.Messages)
}
}

View file

@ -31,16 +31,6 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
if preview {
dialog = previewChannelDialog(req.UserID, channel, member)
}
if preview && member.Status != domain.ChannelMemberActive {
return domain.ChannelDifference{
Channel: channel,
Self: member,
Pts: channel.Pts,
Final: true,
Timeout: 30,
Dialog: dialog,
}, nil
}
checkpoint := s.channelUpdateCheckpointLocked(req.ChannelID, channel)
if req.Pts < checkpoint.RetainedThroughPts || channel.Pts-req.Pts > limit {
messages := make([]domain.ChannelMessage, 0, domain.MaxChannelDifferenceTooLongMessages)
@ -81,10 +71,15 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
}
}
}
scanned := 0
for _, event := range s.events[req.ChannelID] {
if event.Pts <= req.Pts {
continue
}
if scanned >= limit {
break
}
scanned++
lastPts = event.Pts
visible, ok := domain.FilterChannelUpdateEventForAvailableMinID(cloneChannelEvent(event), member.AvailableMinID)
if !ok {
@ -106,7 +101,7 @@ func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.Chann
Channel: channel,
Self: member,
Pts: maxInt(lastPts, req.Pts),
Final: true,
Final: lastPts >= channel.Pts,
Timeout: 30,
Dialog: dialog,
}, nil

View file

@ -0,0 +1,99 @@
package memory
import (
"context"
"sort"
"sync"
"time"
"telesrv/internal/domain"
)
type ClientTelemetryStore struct {
mu sync.Mutex
nextID int64
byID map[int64]domain.ClientTelemetryEvent
byFingerprint map[[32]byte]int64
}
func NewClientTelemetryStore() *ClientTelemetryStore {
return &ClientTelemetryStore{
nextID: 1, byID: make(map[int64]domain.ClientTelemetryEvent),
byFingerprint: make(map[[32]byte]int64),
}
}
func (s *ClientTelemetryStore) CreateClientTelemetry(_ context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error) {
if err := event.Validate(); err != nil || event.ID != 0 {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if id, ok := s.byFingerprint[event.Fingerprint]; ok {
return cloneClientTelemetry(s.byID[id]), false, nil
}
var hourly, daily int
for _, existing := range s.byID {
if existing.UserID != event.UserID ||
existing.CreatedAt.After(event.CreatedAt) {
continue
}
if !existing.CreatedAt.Before(event.CreatedAt.Add(-24 * time.Hour)) {
daily++
}
if !existing.CreatedAt.Before(event.CreatedAt.Add(-time.Hour)) {
hourly++
}
}
if hourly >= domain.MaxClientTelemetryEventsPerHour ||
daily >= domain.MaxClientTelemetryEventsPerDay {
return domain.ClientTelemetryEvent{}, false, domain.ErrClientTelemetryRateLimited
}
event.ID = s.nextID
s.nextID++
event = cloneClientTelemetry(event)
s.byID[event.ID] = event
s.byFingerprint[event.Fingerprint] = event.ID
return cloneClientTelemetry(event), true, nil
}
func (s *ClientTelemetryStore) DeleteExpiredClientTelemetry(_ context.Context, olderThan time.Time, limit int) (int, error) {
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
return 0, domain.ErrClientTelemetryInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
ids := make([]int64, 0)
for id, event := range s.byID {
if event.CreatedAt.Before(olderThan) {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
if len(ids) > limit {
ids = ids[:limit]
}
for _, id := range ids {
event := s.byID[id]
delete(s.byFingerprint, event.Fingerprint)
delete(s.byID, id)
}
return len(ids), nil
}
func (s *ClientTelemetryStore) Events() []domain.ClientTelemetryEvent {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.ClientTelemetryEvent, 0, len(s.byID))
for _, event := range s.byID {
out = append(out, cloneClientTelemetry(event))
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func cloneClientTelemetry(event domain.ClientTelemetryEvent) domain.ClientTelemetryEvent {
event.SubjectIDs = append([]int64(nil), event.SubjectIDs...)
event.Payload = append([]byte(nil), event.Payload...)
return event
}

View file

@ -0,0 +1,62 @@
package memory
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
)
func TestClientTelemetryStoreIdempotencyRateLimitAndRetention(t *testing.T) {
ctx := context.Background()
store := NewClientTelemetryStore()
now := time.Unix(1_750_000_000, 0).UTC()
newEvent := func(subject int64, at time.Time) domain.ClientTelemetryEvent {
event, err := domain.NewClientTelemetryEvent(
71, domain.ClientTelemetryMessageDelivery,
domain.Peer{Type: domain.PeerTypeUser, ID: 72},
[]int64{subject}, map[string]any{"push": true}, at,
)
if err != nil {
t.Fatal(err)
}
return event
}
first := newEvent(1, now)
stored, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || !created || stored.ID <= 0 {
t.Fatalf("first=%+v created=%v err=%v", stored, created, err)
}
retry, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || created || retry.ID != stored.ID {
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
}
for i := 1; i < domain.MaxClientTelemetryEventsPerHour; i++ {
if _, created, err := store.CreateClientTelemetry(
ctx, newEvent(int64(i+1), now),
); err != nil || !created {
t.Fatalf("create %d created=%v err=%v", i, created, err)
}
}
if got, created, err := store.CreateClientTelemetry(ctx, first); err != nil ||
created || got.ID != stored.ID {
t.Fatalf("retry at limit got=%+v created=%v err=%v", got, created, err)
}
if _, _, err := store.CreateClientTelemetry(
ctx, newEvent(domain.MaxClientTelemetryEventsPerHour+1, now),
); !errors.Is(err, domain.ErrClientTelemetryRateLimited) {
t.Fatalf("overflow err=%v", err)
}
deleted, err := store.DeleteExpiredClientTelemetry(
ctx, now.Add(time.Second), domain.MaxClientTelemetryEventsPerHour+1,
)
if err != nil || deleted != domain.MaxClientTelemetryEventsPerHour {
t.Fatalf("deleted=%d err=%v", deleted, err)
}
recreated, created, err := store.CreateClientTelemetry(ctx, first)
if err != nil || !created || recreated.ID == stored.ID {
t.Fatalf("recreated=%+v created=%v err=%v", recreated, created, err)
}
}

View file

@ -0,0 +1,742 @@
package memory
import (
"context"
"sort"
"strings"
"sync"
"time"
"telesrv/internal/domain"
)
// Default page sizes applied when an admin filter leaves the limit unset. The
// PostgreSQL queries page with LIMIT, so an unset limit has to resolve to a
// finite page in both backends.
const (
defaultCollectibleUsernameListLimit = 50
defaultCollectibleUsernameTransferLimit = 50
)
// CollectibleUsernameStore is the in-memory implementation of both
// store.UsernameRegistryStore and store.CollectibleUsernameStore. RPC unit tests
// run against it, so it reproduces every invariant migration 0150 encodes as an
// index or CHECK constraint and returns the same domain errors PostgreSQL maps
// its violations onto:
//
// - peer_usernames_peer_editable_idx: exactly one editable row per peer.
// - peer_usernames_collectible_not_editable_check: a row backed by an asset is
// never editable, so client-driven username edits cannot move an asset.
// - peer_usernames.username_lower UNIQUE: global, case-insensitive name
// uniqueness across users and channels, keyed by lower(username).
// - collectible_usernames.username_lower UNIQUE: a name is minted at most
// once. A burn therefore releases the registry row -- the name can be
// occupied again as a peer username -- while the asset row keeps the name
// for provenance and blocks a second mint.
// - the status/owner CHECK pair: owner is populated exactly for status 'owned'.
// - collectible_username_transfers_command_idx: command keys are globally
// unique, which is what makes mint/transfer/revoke replay-safe.
type CollectibleUsernameStore struct {
mu sync.Mutex
nextAssetID int64
nextTransferID int64
// assets is collectible_usernames keyed by identity.
assets map[int64]domain.CollectibleUsername
// assetsByName resolves a name onto the asset it currently stands for. After
// migration 0152 uniqueness covers live rows only, so one name can accumulate
// several burned rows plus at most one live row; this index points at the live
// row when there is one and at the newest burned row otherwise, mirroring the
// SQL lookup order.
assetsByName map[string]int64
// registry is peer_usernames keyed by username_lower, which is exactly how
// the table enforces global uniqueness.
registry map[string]collectibleRegistryRow
// transfers is the append-only provenance log per asset.
transfers map[int64][]domain.CollectibleUsernameTransfer
// commands maps a provenance command key onto the asset it touched.
commands map[string]int64
}
// collectibleRegistryRow is one peer_usernames row: the owning peer plus the
// projected username shape.
type collectibleRegistryRow struct {
peer domain.Peer
row domain.Username
}
// NewCollectibleUsernameStore creates an empty registry. Asset ids start at 1 so
// a zero CollectibleID keeps meaning "editable slot", matching the nullable
// collectible_id column.
func NewCollectibleUsernameStore() *CollectibleUsernameStore {
return &CollectibleUsernameStore{
nextAssetID: 1,
nextTransferID: 1,
assets: make(map[int64]domain.CollectibleUsername),
assetsByName: make(map[string]int64),
registry: make(map[string]collectibleRegistryRow),
transfers: make(map[int64][]domain.CollectibleUsernameTransfer),
commands: make(map[string]int64),
}
}
// SetEditableUsername writes the peer's editable slot, mirroring the
// replace-then-insert the PostgreSQL user and channel stores run inside
// account.updateUsername / channels.updateUsername. The memory backend keeps
// usernames on the user and channel rows, so tests need this hook to give a peer
// the editable registry row the projection expects. An empty username clears the
// slot.
func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer domain.Peer, username string) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
username = domain.NormalizeUsername(username)
s.mu.Lock()
defer s.mu.Unlock()
if username == "" {
return s.clearEditableLocked(peer), nil
}
// peer_usernames has no length CHECK; the 5..32 editable rule lives in the
// service layer. Only the character rules are a registry concern.
if !domain.ValidCollectibleUsername(username) {
return false, domain.ErrUsernameInvalid
}
key := strings.ToLower(username)
if existing, ok := s.registry[key]; ok {
if existing.peer == peer && existing.row.Editable {
if existing.row.Username == username {
return false, nil
}
existing.row.Username = username
s.registry[key] = existing
return true, nil
}
return false, domain.ErrUsernameOccupied
}
// A live asset owns its name even while it sits in the vault: only a burn
// puts the name back into the free pool.
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
return false, domain.ErrUsernameOccupied
}
s.clearEditableLocked(peer)
s.registry[key] = collectibleRegistryRow{
peer: peer,
row: domain.Username{
Username: username,
Active: true,
Editable: true,
},
}
return true, nil
}
// PeerUsernames returns the peer's registry rows in projection order.
func (s *CollectibleUsernameStore) PeerUsernames(_ context.Context, peer domain.Peer) ([]domain.Username, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.peerUsernamesLocked(peer), nil
}
// PeerUsernamesBatch resolves several peers at once; peers holding no username
// are absent from the result.
func (s *CollectibleUsernameStore) PeerUsernamesBatch(_ context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
out := make(map[domain.Peer][]domain.Username, len(peers))
if len(peers) == 0 {
return out, nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, peer := range peers {
if !validCollectibleUsernamePeer(peer) {
continue
}
if _, done := out[peer]; done {
continue
}
rows := s.peerUsernamesLocked(peer)
if len(rows) == 0 {
continue
}
out[peer] = rows
}
return out, nil
}
// activeUsernamePeer resolves an active registry name for the memory user and
// channel stores. Keeping lookup on the same registry that owns toggle/reorder
// state prevents the test backend from silently falling back to scalar-only
// behavior.
func (s *CollectibleUsernameStore) activeUsernamePeer(username string, peerType domain.PeerType) (domain.Peer, bool) {
key := strings.ToLower(domain.NormalizeUsername(username))
if key == "" {
return domain.Peer{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.registry[key]
if !ok || !entry.row.Active || entry.peer.Type != peerType {
return domain.Peer{}, false
}
return entry.peer, true
}
// activeUsernameMatches returns the best username rank for each peer: exact
// matches precede prefix matches. Inactive rows stay occupied in the registry
// but are deliberately absent from client search.
func (s *CollectibleUsernameStore) activeUsernameMatches(query string, peerType domain.PeerType) map[int64]int {
query = strings.ToLower(domain.NormalizeUsername(query))
if query == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[int64]int)
for username, entry := range s.registry {
if !entry.row.Active || entry.peer.Type != peerType || !strings.HasPrefix(username, query) {
continue
}
rank := 1
if username == query {
rank = 0
}
if current, ok := out[entry.peer.ID]; !ok || rank < current {
out[entry.peer.ID] = rank
}
}
return out
}
func (s *CollectibleUsernameStore) peerHasActiveCollectibleUsername(peer domain.Peer) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, entry := range s.registry {
if entry.peer == peer && entry.row.Active && !entry.row.Editable {
return true
}
}
return false
}
// SetUsernameActive toggles one collectible row. The domain validator owns the
// rules: the editable slot is off limits and a peer that holds usernames must
// keep at least one active.
func (s *CollectibleUsernameStore) SetUsernameActive(_ context.Context, peer domain.Peer, username string, active bool) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
username = domain.NormalizeUsername(username)
s.mu.Lock()
defer s.mu.Unlock()
current := s.peerUsernamesLocked(peer)
if err := domain.ValidateUsernameToggle(current, username, active); err != nil {
return false, err
}
key := strings.ToLower(username)
entry := s.registry[key]
if entry.row.Active == active {
return false, nil
}
entry.row.Active = active
s.registry[key] = entry
return true, nil
}
// ReorderUsernames rewrites the peer's username sort order, editable slot
// included. Validation and the resulting order both come from the domain helper,
// so the two backends cannot drift.
func (s *CollectibleUsernameStore) ReorderUsernames(_ context.Context, peer domain.Peer, order []string) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
current := s.peerUsernamesLocked(peer)
reordered, err := domain.ApplyUsernameReorder(current, order)
if err != nil {
return false, err
}
// Renumbering always happens; "changed" is about what a client can see.
changed := !domain.SameUsernameOrder(current, reordered)
for _, row := range reordered {
key := strings.ToLower(row.Username)
if key == "" {
continue
}
entry := s.registry[key]
if entry.row.SortOrder == row.SortOrder {
continue
}
entry.row.SortOrder = row.SortOrder
s.registry[key] = entry
}
return changed, nil
}
// DeactivateAllUsernames clears the active flag on every collectible row and
// leaves the editable slot alone.
func (s *CollectibleUsernameStore) DeactivateAllUsernames(_ context.Context, peer domain.Peer) (bool, error) {
if !validCollectibleUsernamePeer(peer) {
return false, domain.ErrUsernameInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
changed := false
for key, entry := range s.registry {
if entry.peer != peer || !entry.row.Collectible() || !entry.row.Active {
continue
}
entry.row.Active = false
s.registry[key] = entry
changed = true
}
return changed, nil
}
// MintCollectibleUsername creates the asset, optionally assigning it in the same
// call. A replayed command key returns the recorded asset with created=false.
func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, req domain.MintCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
key := strings.ToLower(req.Username)
// Only a live asset occupies a name. A name whose history is entirely burned
// is free to be issued again, and the new asset takes over the index entry
// while the burned rows stay as provenance.
if id, ok := s.assetsByName[key]; ok && s.assets[id].Status != domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
if _, ok := s.registry[key]; ok {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
now := time.Now().UTC()
purchaseDate := req.PurchaseDate
if purchaseDate.IsZero() {
purchaseDate = now
}
asset := domain.CollectibleUsername{
ID: s.nextAssetID,
Username: req.Username,
Status: domain.CollectibleUsernameStatusVault,
PurchaseDate: purchaseDate,
Currency: req.Currency,
Amount: req.Amount,
CryptoCurrency: req.CryptoCurrency,
CryptoAmount: req.CryptoAmount,
URL: req.URL,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}
if req.Owner.Type != "" {
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.Owner
// The first holder is the original owner and survives every later move.
asset.OriginalOwner = req.Owner
}
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Owned() && s.countCollectiblesLocked(req.Owner) >= domain.MaxPeerCollectibleUsernames {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
}
s.nextAssetID++
s.assets[asset.ID] = asset
s.assetsByName[key] = asset.ID
if asset.Owned() {
s.attachLocked(asset, req.Owner)
}
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: domain.CollectibleUsernameKindMint,
To: req.Owner,
Currency: req.Currency,
Amount: req.Amount,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// TransferCollectibleUsername moves the asset out of the vault or between
// holders. Handing the asset to the peer that already holds it is a no-op.
func (s *CollectibleUsernameStore) TransferCollectibleUsername(_ context.Context, req domain.TransferCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
asset, err := s.assetByNameLocked(req.Username)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if asset.Owned() && asset.Owner == req.To {
return asset, false, nil
}
key := strings.ToLower(asset.Username)
// Defensive: the registry row for this name must be the asset's own. A live
// asset occupies its name globally, so the only way another row can hold it
// is an inconsistently seeded store.
if existing, ok := s.registry[key]; ok && existing.row.CollectibleID != asset.ID {
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
}
if s.countCollectiblesLocked(req.To) >= domain.MaxPeerCollectibleUsernames {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameLimit
}
from := asset.Owner
now := time.Now().UTC()
asset.Status = domain.CollectibleUsernameStatusOwned
asset.Owner = req.To
if asset.OriginalOwner.Type == "" {
asset.OriginalOwner = req.To
}
asset.TransferCount++
asset.Version++
asset.UpdatedAt = now
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.assets[asset.ID] = asset
s.detachLocked(asset.ID)
s.attachLocked(asset, req.To)
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: domain.CollectibleUsernameKindTransfer,
From: from,
To: req.To,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// RevokeCollectibleUsername returns the asset to the vault, or burns it.
//
// A revoke keeps the name owned by the asset -- nobody else can take it -- while
// a burn drops the registry row and releases the name back to the free pool. The
// burned asset row itself survives with its name so provenance stays readable
// and the name never mints twice.
func (s *CollectibleUsernameStore) RevokeCollectibleUsername(_ context.Context, req domain.RevokeCollectibleUsernameRequest) (domain.CollectibleUsername, bool, error) {
req.Username = domain.NormalizeUsername(req.Username)
if err := req.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
if asset, ok := s.replayLocked(req.CommandKey); ok {
return asset, false, nil
}
asset, err := s.assetByNameLocked(req.Username)
if err != nil {
return domain.CollectibleUsername{}, false, err
}
if asset.Status == domain.CollectibleUsernameStatusBurned {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameBurned
}
if !req.Burn && !asset.Owned() {
return domain.CollectibleUsername{}, false, domain.ErrCollectibleUsernameNotOwned
}
from := asset.Owner
now := time.Now().UTC()
kind := domain.CollectibleUsernameKindRevoke
asset.Status = domain.CollectibleUsernameStatusVault
if req.Burn {
kind = domain.CollectibleUsernameKindBurn
asset.Status = domain.CollectibleUsernameStatusBurned
}
asset.Owner = domain.Peer{}
asset.Version++
asset.UpdatedAt = now
if err := asset.Validate(); err != nil {
return domain.CollectibleUsername{}, false, err
}
s.assets[asset.ID] = asset
s.detachLocked(asset.ID)
s.recordTransferLocked(domain.CollectibleUsernameTransfer{
CollectibleID: asset.ID,
Kind: kind,
From: from,
Actor: req.Actor,
Reason: req.Reason,
CommandKey: req.CommandKey,
CreatedAt: now,
})
return asset, true, nil
}
// CollectibleUsername looks the asset up by name, case-insensitively.
// DeleteCollectibleUsername removes the live asset for a name completely --
// registry row, asset and provenance -- and frees the name for any use. Revoke
// with Burn retires an asset but keeps its history; this is the escape hatch for
// an asset issued by mistake.
//
// A command key cannot make this idempotent: the record it would resolve to is
// gone. A repeated call therefore reports deleted=false once no live asset is
// left, which is also what a delete of a burned-only name reports.
func (s *CollectibleUsernameStore) DeleteCollectibleUsername(_ context.Context, req domain.DeleteCollectibleUsernameRequest) (bool, error) {
if s == nil {
return false, nil
}
req.Username = domain.NormalizeUsername(req.Username)
req.Actor = strings.TrimSpace(req.Actor)
req.Reason = strings.TrimSpace(req.Reason)
req.CommandKey = strings.TrimSpace(req.CommandKey)
if err := req.Validate(); err != nil {
return false, err
}
s.mu.Lock()
defer s.mu.Unlock()
key := strings.ToLower(req.Username)
id, ok := s.assetsByName[key]
if !ok {
return false, nil
}
asset, ok := s.assets[id]
if !ok || asset.Status == domain.CollectibleUsernameStatusBurned {
return false, nil
}
s.detachLocked(id)
delete(s.assets, id)
delete(s.transfers, id)
for commandKey, target := range s.commands {
if target == id {
delete(s.commands, commandKey)
}
}
s.rebindAssetNameLocked(key)
return true, nil
}
// rebindAssetNameLocked re-points the name index after a row disappears: the
// newest remaining row wins, and the entry is dropped when none is left.
func (s *CollectibleUsernameStore) rebindAssetNameLocked(key string) {
best := int64(0)
for id, asset := range s.assets {
if strings.ToLower(asset.Username) != key {
continue
}
if best == 0 || id > best {
best = id
}
}
if best == 0 {
delete(s.assetsByName, key)
return
}
s.assetsByName[key] = best
}
func (s *CollectibleUsernameStore) CollectibleUsername(_ context.Context, username string) (domain.CollectibleUsername, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.assetByNameLocked(domain.NormalizeUsername(username))
}
// CollectibleUsernameByID looks the asset up by identity.
func (s *CollectibleUsernameStore) CollectibleUsernameByID(_ context.Context, id int64) (domain.CollectibleUsername, error) {
s.mu.Lock()
defer s.mu.Unlock()
asset, ok := s.assets[id]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
// ListCollectibleUsernames is the admin listing query: newest first, paged by a
// BeforeID keyset, matching collectible_usernames_status_idx ordering.
func (s *CollectibleUsernameStore) ListCollectibleUsernames(_ context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error) {
if filter.Status != "" && !filter.Status.Valid() {
return nil, domain.ErrCollectibleUsernameStateInvalid
}
limit := filter.Limit
if limit <= 0 {
limit = defaultCollectibleUsernameListLimit
}
query := strings.ToLower(domain.NormalizeUsername(filter.Query))
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domain.CollectibleUsername, 0, len(s.assets))
for _, asset := range s.assets {
if filter.Status != "" && asset.Status != filter.Status {
continue
}
if filter.Owner.Type != "" && asset.Owner != filter.Owner {
continue
}
if query != "" && !strings.Contains(strings.ToLower(asset.Username), query) {
continue
}
if filter.BeforeID > 0 && asset.ID >= filter.BeforeID {
continue
}
out = append(out, asset)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
// CollectibleUsernameTransfers returns the provenance log newest first.
func (s *CollectibleUsernameStore) CollectibleUsernameTransfers(_ context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error) {
if limit <= 0 {
limit = defaultCollectibleUsernameTransferLimit
}
s.mu.Lock()
defer s.mu.Unlock()
stored := s.transfers[collectibleID]
out := make([]domain.CollectibleUsernameTransfer, 0, len(stored))
for i := len(stored) - 1; i >= 0 && len(out) < limit; i-- {
out = append(out, stored[i])
}
return out, nil
}
// peerUsernamesLocked collects the peer's rows in projection order. The rows are
// values, so the returned slice cannot be used to mutate stored state.
func (s *CollectibleUsernameStore) peerUsernamesLocked(peer domain.Peer) []domain.Username {
rows := make([]domain.Username, 0, 4)
for _, entry := range s.registry {
if entry.peer != peer {
continue
}
rows = append(rows, entry.row)
}
return domain.SortUsernames(rows)
}
// countCollectiblesLocked counts the peer's collectible registry rows, which is
// what MaxPeerCollectibleUsernames bounds.
func (s *CollectibleUsernameStore) countCollectiblesLocked(peer domain.Peer) int {
count := 0
for _, entry := range s.registry {
if entry.peer == peer && entry.row.Collectible() {
count++
}
}
return count
}
// nextSortOrderLocked appends the new collectible after the peer's existing ones,
// clamped by the registry sort_order CHECK.
func (s *CollectibleUsernameStore) nextSortOrderLocked(peer domain.Peer) int {
next := 0
for _, entry := range s.registry {
if entry.peer != peer || !entry.row.Collectible() {
continue
}
if entry.row.SortOrder >= next {
next = entry.row.SortOrder + 1
}
}
if next > domain.MaxUsernameSortOrder {
next = domain.MaxUsernameSortOrder
}
return next
}
// attachLocked projects an owned asset into the registry. Callers check
// occupancy and the per-peer bound first, exactly like the PostgreSQL path does
// before it hits the unique index.
func (s *CollectibleUsernameStore) attachLocked(asset domain.CollectibleUsername, peer domain.Peer) {
s.registry[strings.ToLower(asset.Username)] = collectibleRegistryRow{
peer: peer,
row: domain.Username{
Username: asset.Username,
Active: true,
Editable: false,
SortOrder: s.nextSortOrderLocked(peer),
CollectibleID: asset.ID,
},
}
}
// detachLocked removes the registry row backed by the asset, leaving the peer's
// editable slot and its other collectibles untouched.
func (s *CollectibleUsernameStore) detachLocked(collectibleID int64) {
for key, entry := range s.registry {
if entry.row.CollectibleID == collectibleID {
delete(s.registry, key)
return
}
}
}
// clearEditableLocked drops the peer's editable row, keeping the one-editable-row
// index true by construction.
func (s *CollectibleUsernameStore) clearEditableLocked(peer domain.Peer) bool {
for key, entry := range s.registry {
if entry.peer == peer && entry.row.Editable {
delete(s.registry, key)
return true
}
}
return false
}
func (s *CollectibleUsernameStore) assetByNameLocked(username string) (domain.CollectibleUsername, error) {
if username == "" {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
id, ok := s.assetsByName[strings.ToLower(username)]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
asset, ok := s.assets[id]
if !ok {
return domain.CollectibleUsername{}, domain.ErrCollectibleUsernameNotFound
}
return asset, nil
}
// replayLocked resolves a command key onto the asset the recorded command
// touched. The provenance command index is global, so a replayed key is a no-op
// for every kind, just like the INSERT ... ON CONFLICT DO NOTHING path.
func (s *CollectibleUsernameStore) replayLocked(commandKey string) (domain.CollectibleUsername, bool) {
if commandKey == "" {
return domain.CollectibleUsername{}, false
}
id, ok := s.commands[commandKey]
if !ok {
return domain.CollectibleUsername{}, false
}
asset, ok := s.assets[id]
return asset, ok
}
func (s *CollectibleUsernameStore) recordTransferLocked(entry domain.CollectibleUsernameTransfer) {
entry.ID = s.nextTransferID
s.nextTransferID++
s.transfers[entry.CollectibleID] = append(s.transfers[entry.CollectibleID], entry)
if entry.CommandKey != "" {
s.commands[entry.CommandKey] = entry.CollectibleID
}
}
// validCollectibleUsernamePeer mirrors the peer_type CHECK: only real user and
// channel peers can hold a username.
func validCollectibleUsernamePeer(peer domain.Peer) bool {
switch peer.Type {
case domain.PeerTypeUser, domain.PeerTypeChannel:
return peer.ID > 0
default:
return false
}
}

View file

@ -0,0 +1,845 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
)
var (
_ store.UsernameRegistryStore = (*CollectibleUsernameStore)(nil)
_ store.CollectibleUsernameStore = (*CollectibleUsernameStore)(nil)
)
func collectibleMintRequest(username string, owner domain.Peer, commandKey string) domain.MintCollectibleUsernameRequest {
return domain.MintCollectibleUsernameRequest{
Username: username,
Owner: owner,
PurchaseDate: time.Unix(1700000000, 0).UTC(),
Currency: domain.CollectibleCurrencyStars,
Amount: 2500,
Actor: "admin",
Reason: "unit test",
CommandKey: commandKey,
}
}
func mustMintCollectible(t *testing.T, s *CollectibleUsernameStore, username string, owner domain.Peer) domain.CollectibleUsername {
t.Helper()
asset, created, err := s.MintCollectibleUsername(context.Background(),
collectibleMintRequest(username, owner, "mint-"+username))
if err != nil || !created {
t.Fatalf("mint %s: created=%v err=%v", username, created, err)
}
return asset
}
func mustSetEditable(t *testing.T, s *CollectibleUsernameStore, peer domain.Peer, username string) {
t.Helper()
changed, err := s.SetEditableUsername(context.Background(), peer, username)
if err != nil || !changed {
t.Fatalf("set editable %s: changed=%v err=%v", username, changed, err)
}
}
// usernameRow finds a row by name the way the registry keys it: case-insensitively.
func usernameRow(t *testing.T, rows []domain.Username, username string) domain.Username {
t.Helper()
want := domain.NormalizeUsername(username)
for _, row := range rows {
if strings.EqualFold(row.Username, want) {
return row
}
}
t.Fatalf("username %q missing from %+v", username, rows)
return domain.Username{}
}
func TestCollectibleUsernameMint(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 2002}
tests := []struct {
name string
seed func(t *testing.T, s *CollectibleUsernameStore)
req domain.MintCollectibleUsernameRequest
wantErr error
wantCreated bool
check func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername)
}{
{
name: "into vault",
req: collectibleMintRequest("vaultname", domain.Peer{}, "cmd-vault"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() {
t.Fatalf("asset=%+v", asset)
}
if asset.Owner != (domain.Peer{}) || asset.OriginalOwner != (domain.Peer{}) {
t.Fatalf("vault asset carries an owner: %+v", asset)
}
if asset.Version != 1 || asset.TransferCount != 0 {
t.Fatalf("asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 0 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
},
},
{
name: "with owner",
req: collectibleMintRequest("OwnedName", holder, "cmd-owned"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Status != domain.CollectibleUsernameStatusOwned || !asset.Owned() {
t.Fatalf("asset=%+v", asset)
}
if asset.Owner != holder || asset.OriginalOwner != holder {
t.Fatalf("asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
row := rows[0]
if row.Username != "OwnedName" || !row.Active || row.Editable ||
row.CollectibleID != asset.ID {
t.Fatalf("row=%+v", row)
}
},
},
{
name: "channel owner",
req: collectibleMintRequest("chanpost", channel, "cmd-chan"),
wantCreated: true,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
rows, err := s.PeerUsernames(ctx, channel)
if err != nil || len(rows) != 1 || rows[0].CollectibleID != asset.ID {
t.Fatalf("rows=%+v err=%v", rows, err)
}
},
},
{
name: "command key replay",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("replayed", holder, "cmd-replay")); err != nil || !created {
t.Fatalf("seed mint created=%v err=%v", created, err)
}
},
req: collectibleMintRequest("replayed", holder, "cmd-replay"),
wantCreated: false,
check: func(t *testing.T, s *CollectibleUsernameStore, asset domain.CollectibleUsername) {
if asset.Username != "replayed" || asset.ID != 1 {
t.Fatalf("replay returned %+v", asset)
}
log, err := s.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(log) != 1 || log[0].Kind != domain.CollectibleUsernameKindMint {
t.Fatalf("replay appended provenance: %+v err=%v", log, err)
}
},
},
{
name: "name held by another asset",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
mustMintCollectible(t, s, "TakenName", holder)
},
req: collectibleMintRequest("takenname", channel, "cmd-dup"),
wantErr: domain.ErrUsernameOccupied,
},
{
name: "name held by an editable slot",
seed: func(t *testing.T, s *CollectibleUsernameStore) {
mustSetEditable(t, s, holder, "EditableOne")
},
req: collectibleMintRequest("editableone", channel, "cmd-editable"),
wantErr: domain.ErrUsernameOccupied,
},
{
name: "syntactically invalid",
req: collectibleMintRequest("ab", holder, "cmd-short"),
wantErr: domain.ErrUsernameInvalid,
},
{
name: "crypto pair without amount",
req: func() domain.MintCollectibleUsernameRequest {
req := collectibleMintRequest("cryptoname", holder, "cmd-crypto")
req.CryptoCurrency = domain.CollectibleCryptoCurrencyTON
return req
}(),
wantErr: domain.ErrCollectibleCurrencyInvalid,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
if tc.seed != nil {
tc.seed(t, s)
}
asset, created, err := s.MintCollectibleUsername(ctx, tc.req)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if created != tc.wantCreated {
t.Fatalf("created=%v want %v", created, tc.wantCreated)
}
if tc.wantErr != nil {
return
}
if tc.check != nil {
tc.check(t, s, asset)
}
})
}
}
func TestCollectibleUsernamePeerLimit(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
for i := 0; i < domain.MaxPeerCollectibleUsernames; i++ {
mustMintCollectible(t, s, fmt.Sprintf("holder%04d", i), holder)
}
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("overflow", holder, "cmd-overflow")); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("mint over the limit err=%v", err)
}
// The rejected mint left no asset behind, so the name is still free.
if _, err := s.CollectibleUsername(ctx, "overflow"); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("rejected mint stored an asset: %v", err)
}
spare := mustMintCollectible(t, s, "sparename", other)
if _, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: spare.Username, To: holder, Actor: "admin", CommandKey: "cmd-limit-transfer",
}); !errors.Is(err, domain.ErrCollectibleUsernameLimit) {
t.Fatalf("transfer over the limit err=%v", err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != domain.MaxPeerCollectibleUsernames {
t.Fatalf("rows=%d err=%v", len(rows), err)
}
// The failed transfer did not move the asset either.
stored, err := s.CollectibleUsername(ctx, "sparename")
if err != nil || stored.Owner != other {
t.Fatalf("stored=%+v err=%v", stored, err)
}
}
func TestCollectibleUsernameTransfer(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
mustSetEditable(t, s, holder, "holderslot")
owned := mustMintCollectible(t, s, "alphaone", holder)
vaulted := mustMintCollectible(t, s, "vaultone", domain.Peer{})
// Out of the vault: the asset gains a holder and its first original owner.
moved, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "VaultOne", To: holder, Actor: "admin", CommandKey: "cmd-vault-out",
})
if err != nil || !changed {
t.Fatalf("transfer out of vault changed=%v err=%v", changed, err)
}
if moved.ID != vaulted.ID || moved.Owner != holder || moved.OriginalOwner != holder ||
moved.TransferCount != 1 || moved.Version != vaulted.Version+1 {
t.Fatalf("moved=%+v", moved)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 3 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// The editable slot stays first, untouched and editable.
if rows[0].Username != "holderslot" || !rows[0].Editable || !rows[0].Active ||
rows[0].CollectibleID != 0 {
t.Fatalf("editable row=%+v", rows[0])
}
if rows[1].Username != "alphaone" || rows[2].Username != "vaultone" {
t.Fatalf("collectible order=%+v", rows)
}
// Replaying the command key is a no-op.
replay, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "vaultone", To: other, Actor: "admin", CommandKey: "cmd-vault-out",
})
if err != nil || changed || replay.Owner != holder {
t.Fatalf("replay=%+v changed=%v err=%v", replay, changed, err)
}
// Handing an asset to its current holder changes nothing.
same, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "alphaone", To: holder, Actor: "admin", CommandKey: "cmd-noop",
})
if err != nil || changed || same.Version != owned.Version {
t.Fatalf("no-op transfer=%+v changed=%v err=%v", same, changed, err)
}
// Between peers: the previous holder loses the registry row, the editable
// slot survives, and the original owner is preserved.
handed, changed, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "alphaone", To: other, Actor: "admin", CommandKey: "cmd-handover",
})
if err != nil || !changed {
t.Fatalf("handover changed=%v err=%v", changed, err)
}
if handed.Owner != other || handed.OriginalOwner != holder || handed.TransferCount != 1 {
t.Fatalf("handed=%+v", handed)
}
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 2 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if usernameRow(t, rows, "holderslot").Editable != true {
t.Fatalf("editable slot lost: %+v", rows)
}
for _, row := range rows {
if row.Username == "alphaone" {
t.Fatalf("old owner kept the registry row: %+v", rows)
}
}
batch, err := s.PeerUsernamesBatch(ctx, []domain.Peer{holder, other, {Type: domain.PeerTypeUser, ID: 9}})
if err != nil || len(batch) != 2 {
t.Fatalf("batch=%+v err=%v", batch, err)
}
if len(batch[other]) != 1 || batch[other][0].Username != "alphaone" ||
batch[other][0].SortOrder != 0 {
t.Fatalf("new owner rows=%+v", batch[other])
}
if _, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "missingone", To: other, Actor: "admin",
}); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("transfer of unknown asset err=%v", err)
}
log, err := s.CollectibleUsernameTransfers(ctx, owned.ID, 10)
if err != nil || len(log) != 2 {
t.Fatalf("provenance=%+v err=%v", log, err)
}
if log[0].Kind != domain.CollectibleUsernameKindTransfer || log[0].From != holder || log[0].To != other {
t.Fatalf("newest provenance=%+v", log[0])
}
if log[1].Kind != domain.CollectibleUsernameKindMint || log[1].To != holder {
t.Fatalf("oldest provenance=%+v", log[1])
}
}
func TestCollectibleUsernameRevokeAndBurn(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
revoked := mustMintCollectible(t, s, "revokeme", holder)
burned := mustMintCollectible(t, s, "burnme", holder)
asset, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "RevokeMe", Actor: "admin", Reason: "abuse", CommandKey: "cmd-revoke",
})
if err != nil || !changed {
t.Fatalf("revoke changed=%v err=%v", changed, err)
}
if asset.Status != domain.CollectibleUsernameStatusVault || asset.Owned() ||
asset.Owner != (domain.Peer{}) || asset.OriginalOwner != holder ||
asset.Version != revoked.Version+1 {
t.Fatalf("revoked asset=%+v", asset)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 || rows[0].Username != "burnme" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// Back in the vault the asset still owns its name: nobody else can take it.
if _, err := s.SetEditableUsername(ctx, other, "revokeme"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("revoked name became claimable: %v", err)
}
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("revokeme", other, "cmd-remint")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("revoked name was re-minted: %v", err)
}
// Replay and a second revoke of a vault asset.
if replay, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "revokeme", Actor: "admin", CommandKey: "cmd-revoke",
}); err != nil || changed || replay.Version != asset.Version {
t.Fatalf("replay=%+v changed=%v err=%v", replay, changed, err)
}
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "revokeme", Actor: "admin", CommandKey: "cmd-revoke-again",
}); !errors.Is(err, domain.ErrCollectibleUsernameNotOwned) {
t.Fatalf("revoke of an unowned asset err=%v", err)
}
dead, changed, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnme", Burn: true, Actor: "admin", CommandKey: "cmd-burn",
})
if err != nil || !changed {
t.Fatalf("burn changed=%v err=%v", changed, err)
}
if dead.Status != domain.CollectibleUsernameStatusBurned || dead.Owner != (domain.Peer{}) ||
dead.OriginalOwner != holder || dead.Version != burned.Version+1 {
t.Fatalf("burned asset=%+v", dead)
}
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 0 {
t.Fatalf("burn left registry rows: %+v err=%v", rows, err)
}
// The burn released the name: another peer may occupy it again.
if changed, err := s.SetEditableUsername(ctx, other, "BurnMe"); err != nil || !changed {
t.Fatalf("claim freed name changed=%v err=%v", changed, err)
}
claimed, err := s.PeerUsernames(ctx, other)
if err != nil || len(claimed) != 1 || claimed[0].Username != "BurnMe" || !claimed[0].Editable {
t.Fatalf("claimed=%+v err=%v", claimed, err)
}
// The burned asset row survives for provenance, so the name never mints twice.
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("burnme", holder, "cmd-burn-remint")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("burned name was re-minted: %v", err)
}
for _, err := range []error{
mustErr(s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "burnme", To: other, Actor: "admin", CommandKey: "cmd-burn-transfer",
})),
mustErr(s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "burnme", Actor: "admin", CommandKey: "cmd-burn-revoke",
})),
} {
if !errors.Is(err, domain.ErrCollectibleUsernameBurned) {
t.Fatalf("mutation of a burned asset err=%v", err)
}
}
log, err := s.CollectibleUsernameTransfers(ctx, dead.ID, 10)
if err != nil || len(log) != 2 || log[0].Kind != domain.CollectibleUsernameKindBurn ||
log[0].From != holder {
t.Fatalf("burn provenance=%+v err=%v", log, err)
}
listed, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatusBurned,
})
if err != nil || len(listed) != 1 || listed[0].ID != dead.ID {
t.Fatalf("listed=%+v err=%v", listed, err)
}
byID, err := s.CollectibleUsernameByID(ctx, dead.ID)
if err != nil || byID.Username != "burnme" {
t.Fatalf("byID=%+v err=%v", byID, err)
}
}
func mustErr[T any](_ T, _ bool, err error) error { return err }
func TestCollectibleUsernameToggle(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
lonely := domain.Peer{Type: domain.PeerTypeUser, ID: 1003}
tests := []struct {
name string
peer domain.Peer
username string
active bool
wantErr error
wantChanged bool
wantActive bool
}{
{name: "deactivate collectible", peer: holder, username: "alphaone", active: false, wantChanged: true},
{name: "activating an already active row", peer: holder, username: "ALPHAONE", active: true, wantChanged: false, wantActive: true},
{name: "editable slot is off limits", peer: holder, username: "holderslot", active: false, wantErr: domain.ErrUsernameNotCollectible, wantActive: true},
{name: "unknown username", peer: holder, username: "nothere", active: false, wantErr: domain.ErrUsernameNotOccupied},
{name: "last active collectible may be deactivated", peer: lonely, username: "lonelyone", active: false, wantChanged: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
mustMintCollectible(t, s, "lonelyone", lonely)
changed, err := s.SetUsernameActive(ctx, tc.peer, tc.username, tc.active)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
if tc.username == "nothere" {
return
}
rows, err := s.PeerUsernames(ctx, tc.peer)
if err != nil {
t.Fatal(err)
}
row := usernameRow(t, rows, tc.username)
if row.Active != tc.wantActive {
t.Fatalf("row=%+v want active=%v", row, tc.wantActive)
}
})
}
t.Run("deactivate all keeps the editable slot", func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
changed, err := s.DeactivateAllUsernames(ctx, holder)
if err != nil || !changed {
t.Fatalf("deactivate all changed=%v err=%v", changed, err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 3 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
if !rows[0].Editable || !rows[0].Active {
t.Fatalf("editable row=%+v", rows[0])
}
if rows[1].Active || rows[2].Active {
t.Fatalf("collectibles still active: %+v", rows)
}
if changed, err := s.DeactivateAllUsernames(ctx, holder); err != nil || changed {
t.Fatalf("second deactivate changed=%v err=%v", changed, err)
}
})
}
func TestCollectibleUsernameReorder(t *testing.T) {
ctx := context.Background()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
tests := []struct {
name string
order []string
wantErr error
wantChanged bool
wantOrder []string
}{
{
// Clients send the whole active list, editable slot included.
name: "valid permutation",
order: []string{"holderslot", "gammathree", "@AlphaOne", "betatwo"},
wantChanged: true,
wantOrder: []string{"holderslot", "gammathree", "alphaone", "betatwo"},
},
{
name: "identity permutation",
order: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
wantChanged: false,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
// The editable slot is reorderable: a collectible may be made primary.
name: "collectible ahead of the editable slot",
order: []string{"gammathree", "holderslot", "alphaone", "betatwo"},
wantChanged: true,
wantOrder: []string{"gammathree", "holderslot", "alphaone", "betatwo"},
},
{
name: "partial order",
order: []string{"holderslot", "alphaone"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "active editable slot omitted",
order: []string{"alphaone", "betatwo", "gammathree"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "duplicate entry",
order: []string{"holderslot", "alphaone", "alphaone", "betatwo"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "unknown username",
order: []string{"holderslot", "alphaone", "betatwo", "nothere"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
{
name: "garbage input",
order: []string{"holderslot", "", "@", "alphaone"},
wantErr: domain.ErrUsernameOrderInvalid,
wantOrder: []string{"holderslot", "alphaone", "betatwo", "gammathree"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
mustMintCollectible(t, s, "alphaone", holder)
mustMintCollectible(t, s, "betatwo", holder)
mustMintCollectible(t, s, "gammathree", holder)
changed, err := s.ReorderUsernames(ctx, holder, tc.order)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("err=%v want %v", err, tc.wantErr)
}
if changed != tc.wantChanged {
t.Fatalf("changed=%v want %v", changed, tc.wantChanged)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil {
t.Fatal(err)
}
want := tc.wantOrder
if len(rows) != len(want) {
t.Fatalf("rows=%+v want %v", rows, want)
}
for i, name := range want {
if rows[i].Username != name {
t.Fatalf("rows=%+v want %v", rows, want)
}
}
})
}
// A peer whose only username is the editable slot: sending just that name is
// the identity order, and sending nothing at all is the no-op every client
// gets when it reconciles an empty collectible list.
t.Run("editable slot only", func(t *testing.T) {
s := NewCollectibleUsernameStore()
mustSetEditable(t, s, holder, "holderslot")
if changed, err := s.ReorderUsernames(ctx, holder, []string{"holderslot"}); err != nil || changed {
t.Fatalf("editable-only order: changed=%v err=%v", changed, err)
}
})
t.Run("no usernames at all", func(t *testing.T) {
s := NewCollectibleUsernameStore()
if changed, err := s.ReorderUsernames(ctx, holder, nil); err != nil || changed {
t.Fatalf("changed=%v err=%v", changed, err)
}
})
}
func TestCollectibleUsernameRegistryUniqueness(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
mustSetEditable(t, s, holder, "holderslot")
// One editable row per peer: setting a new one replaces the old.
mustSetEditable(t, s, holder, "secondslot")
rows, err := s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 || rows[0].Username != "secondslot" {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// The released name is free again, case-insensitively.
if changed, err := s.SetEditableUsername(ctx, other, "HOLDERSLOT"); err != nil || !changed {
t.Fatalf("changed=%v err=%v", changed, err)
}
if _, err := s.SetEditableUsername(ctx, holder, "holderslot"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("occupied name reused: %v", err)
}
if changed, err := s.SetEditableUsername(ctx, other, ""); err != nil || !changed {
t.Fatalf("clear editable changed=%v err=%v", changed, err)
}
if rows, err := s.PeerUsernames(ctx, other); err != nil || len(rows) != 0 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
// A vault asset owns its name even without a registry row, so the editable
// slot cannot take it and the asset stays handable.
vaulted := mustMintCollectible(t, s, "vaultname", domain.Peer{})
if _, err := s.SetEditableUsername(ctx, other, "VaultName"); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("vault name became claimable: %v", err)
}
if moved, _, err := s.TransferCollectibleUsername(ctx, domain.TransferCollectibleUsernameRequest{
Username: "vaultname", To: other, Actor: "admin", CommandKey: "cmd-vault-out",
}); err != nil || moved.ID != vaulted.ID || moved.Owner != other {
t.Fatalf("moved=%+v err=%v", moved, err)
}
// Returned slices are copies: mutating them cannot change stored state.
rows, err = s.PeerUsernames(ctx, holder)
if err != nil || len(rows) != 1 {
t.Fatalf("rows=%+v err=%v", rows, err)
}
rows[0].Username = "mutated"
rows[0].Active = false
again, err := s.PeerUsernames(ctx, holder)
if err != nil || again[0].Username != "secondslot" || !again[0].Active {
t.Fatalf("stored state mutated through the returned slice: %+v err=%v", again, err)
}
}
func TestCollectibleUsernameListingAndPaging(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 1001}
other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002}
first := mustMintCollectible(t, s, "alphaone", holder)
second := mustMintCollectible(t, s, "alphatwo", other)
third := mustMintCollectible(t, s, "betathree", domain.Peer{})
all, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{})
if err != nil || len(all) != 3 || all[0].ID != third.ID || all[2].ID != first.ID {
t.Fatalf("all=%+v err=%v", all, err)
}
page, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Limit: 2})
if err != nil || len(page) != 2 || page[0].ID != third.ID {
t.Fatalf("page=%+v err=%v", page, err)
}
next, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
BeforeID: page[len(page)-1].ID, Limit: 2,
})
if err != nil || len(next) != 1 || next[0].ID != first.ID {
t.Fatalf("next=%+v err=%v", next, err)
}
owned, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Owner: other})
if err != nil || len(owned) != 1 || owned[0].ID != second.ID {
t.Fatalf("owned=%+v err=%v", owned, err)
}
matched, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{Query: "ALPHA"})
if err != nil || len(matched) != 2 {
t.Fatalf("matched=%+v err=%v", matched, err)
}
vault, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatusVault,
})
if err != nil || len(vault) != 1 || vault[0].ID != third.ID {
t.Fatalf("vault=%+v err=%v", vault, err)
}
if _, err := s.ListCollectibleUsernames(ctx, domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatus("gone"),
}); !errors.Is(err, domain.ErrCollectibleUsernameStateInvalid) {
t.Fatalf("invalid status filter err=%v", err)
}
if _, err := s.CollectibleUsername(ctx, "@AlphaOne"); err != nil {
t.Fatalf("lookup by display form: %v", err)
}
if _, err := s.CollectibleUsername(ctx, ""); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("empty lookup err=%v", err)
}
if _, err := s.CollectibleUsernameByID(ctx, 4242); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("unknown id err=%v", err)
}
}
// TestCollectibleUsernameReissueAfterBurn covers migration 0152: burning retires
// the asset but releases the name, so the same name can be issued again while the
// burned rows stay as provenance.
func TestCollectibleUsernameReissueAfterBurn(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 4001}
first := mustMintCollectible(t, s, "Nfts", holder)
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "nfts", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-1",
}); err != nil {
t.Fatalf("burn: %v", err)
}
second, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("NFTS", holder, "mint-again-1"))
if err != nil || !created {
t.Fatalf("reissue after burn: created=%v err=%v", created, err)
}
if second.ID == first.ID {
t.Fatalf("reissue reused asset id %d", second.ID)
}
if second.Status != domain.CollectibleUsernameStatusOwned {
t.Fatalf("reissued status = %q, want owned", second.Status)
}
// The name now resolves to the live asset, not to either burned row.
live, err := s.CollectibleUsername(ctx, "nfts")
if err != nil {
t.Fatalf("lookup after reissue: %v", err)
}
if live.ID != second.ID {
t.Fatalf("lookup id = %d, want the live asset %d", live.ID, second.ID)
}
// The burned row is still readable by identity: it is the provenance record.
burned, err := s.CollectibleUsernameByID(ctx, first.ID)
if err != nil || burned.Status != domain.CollectibleUsernameStatusBurned {
t.Fatalf("burned row = %+v err=%v", burned, err)
}
// A second burn releases the name again, so the cycle repeats.
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "nfts", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-2",
}); err != nil {
t.Fatalf("second burn: %v", err)
}
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("nfts", domain.Peer{}, "mint-again-2")); err != nil || !created {
t.Fatalf("second reissue: created=%v err=%v", created, err)
}
// A live asset still blocks a mint, burned history or not.
if _, _, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("nfts", domain.Peer{}, "mint-again-3")); !errors.Is(err, domain.ErrUsernameOccupied) {
t.Fatalf("mint over live asset err = %v, want ErrUsernameOccupied", err)
}
}
func TestCollectibleUsernameDelete(t *testing.T) {
ctx := context.Background()
s := NewCollectibleUsernameStore()
holder := domain.Peer{Type: domain.PeerTypeUser, ID: 4101}
mustSetEditable(t, s, holder, "holder_main")
asset := mustMintCollectible(t, s, "Gone", holder)
deleted, err := s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "@gone", Actor: "admin", Reason: "issued by mistake", CommandKey: "del-1",
})
if err != nil || !deleted {
t.Fatalf("delete: deleted=%v err=%v", deleted, err)
}
if _, err := s.CollectibleUsernameByID(ctx, asset.ID); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("asset after delete err = %v, want not found", err)
}
if _, err := s.CollectibleUsername(ctx, "gone"); !errors.Is(err, domain.ErrCollectibleUsernameNotFound) {
t.Fatalf("lookup after delete err = %v, want not found", err)
}
log, err := s.CollectibleUsernameTransfers(ctx, asset.ID, 10)
if err != nil || len(log) != 0 {
t.Fatalf("provenance after delete = %d rows err=%v, want none", len(log), err)
}
rows, err := s.PeerUsernames(ctx, holder)
if err != nil {
t.Fatalf("peer usernames: %v", err)
}
if len(rows) != 1 || !rows[0].Editable {
t.Fatalf("owner rows after delete = %+v, want only the editable slot", rows)
}
// The name is completely free afterwards, for a collectible or an editable slot.
if _, created, err := s.MintCollectibleUsername(ctx,
collectibleMintRequest("gone", domain.Peer{}, "mint-after-delete")); err != nil || !created {
t.Fatalf("mint after delete: created=%v err=%v", created, err)
}
// A repeat is a no-op rather than an error: the record a command key would
// resolve to is gone, so idempotency degrades to "nothing live left".
if _, _, err := s.RevokeCollectibleUsername(ctx, domain.RevokeCollectibleUsernameRequest{
Username: "gone", Burn: true, Actor: "admin", Reason: "retire", CommandKey: "burn-after-delete",
}); err != nil {
t.Fatalf("burn reissued asset: %v", err)
}
deleted, err = s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "gone", Actor: "admin", Reason: "again", CommandKey: "del-2",
})
if err != nil || deleted {
t.Fatalf("delete of burned-only name = %v err=%v, want (false, nil)", deleted, err)
}
deleted, err = s.DeleteCollectibleUsername(ctx, domain.DeleteCollectibleUsernameRequest{
Username: "never_issued", Actor: "admin", Reason: "again", CommandKey: "del-3",
})
if err != nil || deleted {
t.Fatalf("delete of unknown name = %v err=%v, want (false, nil)", deleted, err)
}
}

View file

@ -112,7 +112,7 @@ func (s *CommunityStore) viewLocked(userID, id int64) (domain.CommunityView, err
cm, ok := s.channels.members[l.Peer.ID][userID]
joined = ok && cm.Status == domain.ChannelMemberActive
if channel, ok := s.channels.channels[l.Peer.ID]; ok {
inherentlyViewable = publicPreviewableChannel(channel)
inherentlyViewable = s.channels.publicPreviewableChannelLocked(channel)
}
s.channels.mu.RUnlock()
} else if l.Peer.Type == domain.PeerTypeUser && s.dialogs != nil {

View file

@ -138,9 +138,6 @@ func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.Cont
contact.User.EmojiStatusUntil = existing.User.EmojiStatusUntil
contact.CloseFriend = existing.CloseFriend
contact.User.CloseFriend = existing.CloseFriend || existing.User.CloseFriend
if contact.Phone == "" {
contact.User.Phone = existing.User.Phone
}
if contact.FirstName == "" {
contact.User.FirstName = existing.User.FirstName
}

View file

@ -33,7 +33,7 @@ func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessag
if req.Revoke && len(revokeUIDs) > 0 {
deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...)
}
return s.finishMemoryDeleteLocked(res, deleted, req.Date, false), nil
return s.finishMemoryDeleteLocked(res, deleted, req.Date, nil), nil
}
type deletedMemoryMessage struct {
@ -45,70 +45,177 @@ type deletedMemoryMessage struct {
randomID int64
}
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult {
if len(deleted) == 0 {
type memoryHistoryClearAnchor struct {
message domain.Message
materialized bool
}
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, anchors map[int64]memoryHistoryClearAnchor) domain.DeleteMessagesResult {
if len(deleted) == 0 && len(anchors) == 0 {
return res
}
idsByOwner := make(map[int64][]int)
peersByOwner := make(map[int64]map[domain.Peer]struct{})
for _, row := range deleted {
if byMessage := s.savedMessageTags[row.userID]; byMessage != nil {
delete(byMessage, row.id)
if len(byMessage) == 0 {
delete(s.savedMessageTags, row.userID)
}
}
idsByOwner[row.userID] = append(idsByOwner[row.userID], row.id)
if peersByOwner[row.userID] == nil {
peersByOwner[row.userID] = make(map[domain.Peer]struct{})
}
peersByOwner[row.userID][row.peer] = struct{}{}
}
if s.dialogs != nil {
s.dialogs.mu.Lock()
for userID, peers := range peersByOwner {
for peer := range peers {
s.rebuildMemoryDialogLocked(userID, peer, preserveEmptyDialogs)
}
for userID, anchor := range anchors {
if peersByOwner[userID] == nil {
peersByOwner[userID] = make(map[domain.Peer]struct{})
}
s.dialogs.mu.Unlock()
peersByOwner[userID][anchor.message.Peer] = struct{}{}
}
ownerIDs := make([]int64, 0, len(idsByOwner))
ownerSet := make(map[int64]struct{}, len(idsByOwner)+len(anchors))
for userID := range idsByOwner {
ownerSet[userID] = struct{}{}
}
for userID, anchor := range anchors {
if !anchor.materialized {
ownerSet[userID] = struct{}{}
}
}
ownerIDs := make([]int64, 0, len(ownerSet))
for userID := range ownerSet {
ownerIDs = append(ownerIDs, userID)
}
sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] })
for _, userID := range ownerIDs {
ids := normalizeMemoryMessageIDs(idsByOwner[userID])
if len(ids) == 0 {
anchor, hasAnchor := anchors[userID]
materializeAnchor := hasAnchor && !anchor.materialized
totalPtsCount := len(ids)
if materializeAnchor {
totalPtsCount += 2
}
if totalPtsCount == 0 {
continue
}
pts := s.nextPtsNLocked(userID, len(ids))
event := domain.UpdateEvent{
pts := s.nextPtsNLocked(userID, totalPtsCount)
cursor := pts - totalPtsCount
item := domain.DeletedMessagesForUser{
UserID: userID,
Type: domain.UpdateEventDeleteMessages,
MessageIDs: ids,
Pts: pts,
PtsCount: len(ids),
Date: date,
MessageIDs: ids,
PtsCount: totalPtsCount,
Events: make([]domain.UpdateEvent, 0, 3),
}
for _, row := range deleted {
if row.userID != userID || row.messageSenderID != userID || row.randomID == 0 || row.privateMessageID == 0 {
continue
if len(ids) > 0 {
cursor += len(ids)
event := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventDeleteMessages,
Pts: cursor,
PtsCount: len(ids),
Date: date,
MessageIDs: ids,
}
key := privateSendDedupKey{senderUserID: userID, randomID: row.randomID}
record, ok := s.privateSendDedup[key]
if !ok {
continue
for _, row := range deleted {
if row.userID != userID || row.messageSenderID != userID || row.randomID == 0 || row.privateMessageID == 0 {
continue
}
key := privateSendDedupKey{senderUserID: userID, randomID: row.randomID}
record, ok := s.privateSendDedup[key]
if !ok {
continue
}
cloned := cloneUpdateEvent(event)
record.senderDeleteEvent = &cloned
s.privateSendDedup[key] = record
}
cloned := cloneUpdateEvent(event)
record.senderDeleteEvent = &cloned
s.privateSendDedup[key] = record
item.Event = event
item.Events = append(item.Events, event)
}
res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{
UserID: userID,
MessageIDs: ids,
Event: event,
})
if materializeAnchor {
readPts := cursor + 1
editPts := readPts + 1
msg := domain.NewHistoryClearMessage(
userID,
anchor.message.Peer,
anchor.message.ID,
anchor.message.UID,
anchor.message.Date,
editPts,
)
for i := range s.m[userID] {
if s.m[userID][i].ID == anchor.message.ID && s.m[userID][i].Peer == anchor.message.Peer {
s.m[userID][i] = msg
break
}
}
if byMessage := s.savedMessageTags[userID]; byMessage != nil {
delete(byMessage, anchor.message.ID)
if len(byMessage) == 0 {
delete(s.savedMessageTags, userID)
}
}
readEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventReadHistoryInbox,
Pts: readPts,
PtsCount: 1,
Date: date,
Peer: anchor.message.Peer,
MaxID: anchor.message.ID,
StillUnreadCount: 0,
}
editEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventEditMessage,
Pts: editPts,
PtsCount: 1,
Date: date,
Message: cloneMessage(msg),
}
item.Events = append(item.Events, readEvent, editEvent)
cursor = editPts
}
if s.dialogs != nil {
s.dialogs.mu.Lock()
for peer := range peersByOwner[userID] {
s.rebuildMemoryDialogLocked(userID, peer)
}
if materializeAnchor {
s.advanceMemoryHistoryClearDialogLocked(userID, anchor.message.Peer, anchor.message.ID)
}
s.dialogs.mu.Unlock()
}
if cursor != pts {
panic(fmt.Sprintf("memory delete history pts cursor %d does not reach reserved pts %d", cursor, pts))
}
res.Deleted = append(res.Deleted, item)
}
return res
}
func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer, preserveEmpty bool) {
func (s *MessageStore) advanceMemoryHistoryClearDialogLocked(userID int64, peer domain.Peer, maxID int) {
list := s.dialogs.m[userID]
for i := range list.Dialogs {
if list.Dialogs[i].Peer != peer {
continue
}
if list.Dialogs[i].ReadInboxMaxID < maxID {
list.Dialogs[i].ReadInboxMaxID = maxID
}
list.Dialogs[i].UnreadCount = 0
list.Dialogs[i].UnreadMark = false
list.Dialogs[i].UnreadMentions = 0
list.Dialogs[i].UnreadReactions = 0
break
}
s.dialogs.m[userID] = list
}
func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer) {
list := s.dialogs.m[userID]
topID := 0
topDate := 0
@ -129,22 +236,6 @@ func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer,
continue
}
if topID == 0 {
if preserveEmpty {
oldTop := dialog.TopMessage
dialog.TopMessage = 0
dialog.TopMessageDate = 0
if dialog.ReadInboxMaxID < oldTop {
dialog.ReadInboxMaxID = oldTop
}
if dialog.ReadOutboxMaxID < oldTop {
dialog.ReadOutboxMaxID = oldTop
}
dialog.UnreadCount = 0
dialog.UnreadMark = false
dialog.UnreadMentions = 0
dialog.UnreadReactions = 0
dialogs = append(dialogs, dialog)
}
continue
}
for _, msg := range s.m[userID] {

View file

@ -20,6 +20,9 @@ func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.Fo
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if s.privateNoForwardsEnabled(req.OwnerUserID, req.FromPeer.ID) {
return res, domain.ErrChatForwardsRestricted
}
s.mu.RLock()
sources := make([]domain.Message, 0, len(req.MessageIDs))
for _, id := range req.MessageIDs {

View file

@ -114,10 +114,18 @@ func cloneRequestedPeerMedia(media *domain.MessageMedia) *domain.MessageMedia {
video.Attributes = append([]domain.DocumentAttribute(nil), media.LivePhotoVideo.Attributes...)
clone.LivePhotoVideo = &video
}
if media.ServiceAction == nil || media.ServiceAction.RequestedPeer == nil {
if media.ServiceAction == nil {
return &clone
}
action := *media.ServiceAction
if media.ServiceAction.NoForwards != nil {
noForwards := *media.ServiceAction.NoForwards
action.NoForwards = &noForwards
}
if media.ServiceAction.RequestedPeer == nil {
clone.ServiceAction = &action
return &clone
}
requested := *media.ServiceAction.RequestedPeer
requested.Peers = append([]domain.Peer(nil), requested.Peers...)
requested.Details = append([]domain.MessageRequestedPeerDetails(nil), requested.Details...)

View file

@ -4,8 +4,9 @@ import (
"context"
"sort"
"strings"
"telesrv/internal/domain"
"time"
"telesrv/internal/domain"
)
func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (domain.MessageList, error) {
@ -231,30 +232,66 @@ func (s *MessageStore) DeleteHistory(_ context.Context, req domain.DeleteHistory
}
return true
}
var anchors map[int64]memoryHistoryClearAnchor
fullJustClear := req.JustClear && req.MaxID <= 0 && req.MinDate <= 0 && req.MaxDate <= 0
if fullJustClear {
anchors = make(map[int64]memoryHistoryClearAnchor, 2)
if anchor, found := s.memoryHistoryClearAnchorLocked(req.OwnerUserID, req.Peer); found {
anchors[req.OwnerUserID] = anchor
}
if req.Revoke && req.Peer.ID != req.OwnerUserID {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}
if anchor, found := s.memoryHistoryClearAnchorLocked(req.Peer.ID, peer); found {
anchors[req.Peer.ID] = anchor
}
}
}
deleted, revokeUIDs, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool {
if anchor, ok := anchors[req.OwnerUserID]; ok && msg.ID == anchor.message.ID {
return false
}
return msg.Peer == req.Peer && (req.MaxID <= 0 || msg.ID <= req.MaxID) && inDateRange(msg)
})
if req.Revoke {
if len(revokeUIDs) > 0 {
if req.MaxID > 0 && len(revokeUIDs) > 0 {
deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...)
}
// 与 PG 同语义:全量/按日期的双向清史直扫对端残余,我方早已
// 单向删除的消息不能在对端残留。
if req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID {
peerDeleted, _, peerMore := s.deleteMemoryMessagesLocked(req.Peer.ID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool {
if anchor, ok := anchors[req.Peer.ID]; ok && msg.ID == anchor.message.ID {
return false
}
return msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) && inDateRange(msg)
})
deleted = append(deleted, peerDeleted...)
more = more || peerMore
}
}
res = s.finishMemoryDeleteLocked(res, deleted, req.Date, req.JustClear)
res = s.finishMemoryDeleteLocked(res, deleted, req.Date, anchors)
if more {
res.Offset = 1
}
return res, nil
}
func (s *MessageStore) memoryHistoryClearAnchorLocked(userID int64, peer domain.Peer) (memoryHistoryClearAnchor, bool) {
var top domain.Message
for _, msg := range s.m[userID] {
if msg.Peer == peer && msg.ID > top.ID {
top = msg
}
}
if top.ID == 0 {
return memoryHistoryClearAnchor{}, false
}
return memoryHistoryClearAnchor{
message: cloneMessage(top),
materialized: domain.IsHistoryClearServiceMessage(top),
}, true
}
func filterMessageList(messages []domain.Message, filter domain.MessageFilter) domain.MessageList {
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
sort.SliceStable(messages, func(i, j int) bool {
@ -282,6 +319,12 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) {
continue
}
if filter.MinDate > 0 && msg.Date <= filter.MinDate {
continue
}
if filter.MaxDate > 0 && msg.Date >= filter.MaxDate {
continue
}
if filter.MaxID > 0 && msg.ID >= filter.MaxID {
continue
}
@ -297,6 +340,9 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
if filter.SavedPeer.ID != 0 && msg.SavedPeer != filter.SavedPeer {
continue
}
if len(filter.SavedReactions) > 0 && !messageHasAnySavedTag(msg, filter.SavedReactions) {
continue
}
base = append(base, msg)
}
@ -316,6 +362,22 @@ func filterMessageList(messages []domain.Message, filter domain.MessageFilter) d
}
}
func messageHasAnySavedTag(msg domain.Message, wanted []domain.MessageReaction) bool {
if msg.Reactions == nil || !msg.Reactions.AsTags {
return false
}
have := make(map[string]struct{}, len(msg.Reactions.Results))
for _, result := range msg.Reactions.Results {
have[result.Reaction.Key()] = struct{}{}
}
for _, reaction := range wanted {
if _, ok := have[reaction.Key()]; ok {
return true
}
}
return false
}
func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message {
if limit <= 0 || len(base) == 0 {
return nil

View file

@ -0,0 +1,200 @@
package memory
import (
"context"
"time"
"telesrv/internal/domain"
)
type privateNoForwardsPair struct {
low int64
high int64
}
type memoryNoForwardsRequest struct {
privateMessageID int64
requesterUserID int64
responderUserID int64
expiresAt int
handled bool
}
func noForwardsPair(a, b int64) (privateNoForwardsPair, bool) {
if a <= 0 || b <= 0 || a == b {
return privateNoForwardsPair{}, false
}
if a > b {
a, b = b, a
}
return privateNoForwardsPair{low: a, high: b}, true
}
func (s *MessageStore) GetPrivateNoForwards(_ context.Context, viewerUserID, peerUserID int64) (domain.PrivateNoForwardsState, error) {
pair, ok := noForwardsPair(viewerUserID, peerUserID)
if !ok {
return domain.PrivateNoForwardsState{}, domain.ErrMessageIDInvalid
}
s.noForwardsMu.Lock()
defer s.noForwardsMu.Unlock()
state := s.privateNoForwards[pair]
state.UserLowID, state.UserHighID = pair.low, pair.high
return state, nil
}
func (s *MessageStore) TogglePrivateNoForwards(ctx context.Context, req domain.TogglePrivateNoForwardsRequest) (domain.TogglePrivateNoForwardsResult, error) {
pair, ok := noForwardsPair(req.ActorUserID, req.PeerUserID)
if !ok || req.RequestMsgID < 0 || req.RequestMsgID > domain.MaxMessageBoxID {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrMessageIDInvalid
}
if req.Date == 0 {
req.Date = int(time.Now().Unix())
}
if req.RandomID == 0 {
req.RandomID = time.Now().UnixNano()
if req.RandomID == 0 {
req.RandomID = 1
}
}
s.noForwardsMu.Lock()
defer s.noForwardsMu.Unlock()
state := s.privateNoForwards[pair]
state.UserLowID, state.UserHighID = pair.low, pair.high
previousEnabled := state.Enabled()
var (
kind domain.MessageServiceActionKind
action domain.MessageNoForwardsAction
requestRecord *memoryNoForwardsRequest
requestUID int64
)
if req.RequestMsgID != 0 {
s.mu.RLock()
var source domain.Message
for _, msg := range s.m[req.ActorUserID] {
if msg.ID == req.RequestMsgID && msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.PeerUserID}) {
source = msg
break
}
}
if source.ID != 0 {
record := s.privateNoForwardsRequests[source.UID]
requestRecord = &record
requestUID = source.UID
}
s.mu.RUnlock()
if source.ID == 0 || requestRecord == nil || requestRecord.privateMessageID != source.UID ||
requestRecord.requesterUserID != req.PeerUserID || requestRecord.responderUserID != req.ActorUserID ||
requestRecord.handled || requestRecord.expiresAt <= req.Date {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrNoForwardsRequestExpired
}
kind = domain.MessageServiceActionNoForwardsToggle
action = domain.MessageNoForwardsAction{PrevValue: previousEnabled, NewValue: req.Enabled}
if req.Enabled {
state.EnabledByUserID = req.ActorUserID
} else {
state.EnabledByUserID = 0
}
} else if req.Enabled {
if state.EnabledByUserID != 0 {
return domain.TogglePrivateNoForwardsResult{State: state}, nil
}
kind = domain.MessageServiceActionNoForwardsToggle
action = domain.MessageNoForwardsAction{PrevValue: false, NewValue: true}
state.EnabledByUserID = req.ActorUserID
} else {
switch state.EnabledByUserID {
case 0:
return domain.TogglePrivateNoForwardsResult{State: state}, nil
case req.ActorUserID:
kind = domain.MessageServiceActionNoForwardsToggle
action = domain.MessageNoForwardsAction{PrevValue: true, NewValue: false}
state.EnabledByUserID = 0
default:
kind = domain.MessageServiceActionNoForwardsRequest
action = domain.MessageNoForwardsAction{
PrevValue: true,
NewValue: false,
ExpiresAt: req.Date + domain.PrivateNoForwardsRequestExpirePeriod,
}
}
}
reply := (*domain.MessageReply)(nil)
if req.RequestMsgID != 0 {
reply = &domain.MessageReply{
MessageID: req.RequestMsgID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.PeerUserID},
}
}
send, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: req.ActorUserID,
RecipientUserID: req.PeerUserID,
RandomID: req.RandomID,
Silent: true,
Date: req.Date,
OriginAuthKeyID: req.OriginAuthKeyID,
OriginSessionID: req.OriginSessionID,
ReplyTo: reply,
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: kind,
NoForwards: &action,
},
},
})
if err != nil {
if req.RequestMsgID != 0 && err == domain.ErrReplyMessageIDInvalid {
return domain.TogglePrivateNoForwardsResult{}, domain.ErrNoForwardsRequestExpired
}
return domain.TogglePrivateNoForwardsResult{}, err
}
s.privateNoForwards[pair] = state
if kind == domain.MessageServiceActionNoForwardsRequest {
s.privateNoForwardsRequests[send.SenderMessage.UID] = memoryNoForwardsRequest{
privateMessageID: send.SenderMessage.UID,
requesterUserID: req.ActorUserID,
responderUserID: req.PeerUserID,
expiresAt: action.ExpiresAt,
}
}
if requestUID != 0 {
record := s.privateNoForwardsRequests[requestUID]
record.handled = true
s.privateNoForwardsRequests[requestUID] = record
s.markNoForwardsRequestExpired(requestUID)
}
return domain.TogglePrivateNoForwardsResult{State: state, Changed: true, Send: send}, nil
}
func (s *MessageStore) markNoForwardsRequestExpired(privateMessageID int64) {
s.mu.Lock()
defer s.mu.Unlock()
for ownerID, messages := range s.m {
for i := range messages {
action := messages[i].Media
if messages[i].UID != privateMessageID || action == nil || action.ServiceAction == nil ||
action.ServiceAction.Kind != domain.MessageServiceActionNoForwardsRequest ||
action.ServiceAction.NoForwards == nil {
continue
}
messages[i].Media = cloneRequestedPeerMedia(messages[i].Media)
messages[i].Media.ServiceAction.NoForwards.Expired = true
}
s.m[ownerID] = messages
}
}
func (s *MessageStore) privateNoForwardsEnabled(a, b int64) bool {
pair, ok := noForwardsPair(a, b)
if !ok {
return false
}
s.noForwardsMu.Lock()
defer s.noForwardsMu.Unlock()
return s.privateNoForwards[pair].Enabled()
}

View file

@ -0,0 +1,162 @@
package memory
import (
"context"
"errors"
"testing"
"telesrv/internal/domain"
)
func TestPrivateNoForwardsStateMachineAndForwardGate(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
const alice, bob int64 = 1001, 1002
enable, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, Enabled: true, RandomID: 11, Date: 100,
})
if err != nil {
t.Fatalf("enable: %v", err)
}
if !enable.Changed || enable.State.EnabledByUserID != alice ||
enable.Send.SenderMessage.Pts != 1 || enable.Send.RecipientMessage.Pts != 1 ||
enable.Send.SenderMessage.NoForwards {
t.Fatalf("enable result = %+v", enable)
}
assertMemoryNoForwardsAction(t, enable.Send.SenderMessage, domain.MessageServiceActionNoForwardsToggle, false, true, false)
repeat, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, Enabled: true, RandomID: 12, Date: 101,
})
if err != nil || repeat.Changed || repeat.State.EnabledByUserID != alice {
t.Fatalf("repeat enable = %+v err=%v, want no-op", repeat, err)
}
request, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: bob, PeerUserID: alice, Enabled: false, RandomID: 13, Date: 102,
})
if err != nil {
t.Fatalf("request disable: %v", err)
}
if request.State.EnabledByUserID != alice || request.Send.SenderMessage.Pts != 2 ||
request.Send.RecipientMessage.Pts != 2 {
t.Fatalf("request result = %+v", request)
}
assertMemoryNoForwardsAction(t, request.Send.SenderMessage, domain.MessageServiceActionNoForwardsRequest, true, false, false)
answer, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice,
PeerUserID: bob,
Enabled: false,
RequestMsgID: request.Send.RecipientMessage.ID,
RandomID: 14,
Date: 103,
})
if err != nil {
t.Fatalf("accept request: %v", err)
}
if answer.State.Enabled() || answer.Send.SenderMessage.Pts != 3 || answer.Send.RecipientMessage.Pts != 3 {
t.Fatalf("answer result = %+v", answer)
}
if answer.Send.SenderMessage.ReplyTo == nil ||
answer.Send.SenderMessage.ReplyTo.MessageID != request.Send.RecipientMessage.ID ||
answer.Send.RecipientMessage.ReplyTo == nil ||
answer.Send.RecipientMessage.ReplyTo.MessageID != request.Send.SenderMessage.ID {
t.Fatalf("answer reply mapping sender=%+v recipient=%+v", answer.Send.SenderMessage.ReplyTo, answer.Send.RecipientMessage.ReplyTo)
}
assertMemoryNoForwardsAction(t, answer.Send.SenderMessage, domain.MessageServiceActionNoForwardsToggle, true, false, false)
aliceHistory, err := messages.ListByUser(ctx, alice, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob}, Limit: 20,
})
if err != nil {
t.Fatalf("alice history: %v", err)
}
var expired bool
for _, msg := range aliceHistory.Messages {
if msg.ID == request.Send.RecipientMessage.ID && msg.Media != nil && msg.Media.ServiceAction != nil &&
msg.Media.ServiceAction.NoForwards != nil {
expired = msg.Media.ServiceAction.NoForwards.Expired
}
}
if !expired {
t.Fatal("handled request was not projected expired")
}
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, RequestMsgID: request.Send.RecipientMessage.ID,
RandomID: 15, Date: 104,
}); !errors.Is(err, domain.ErrNoForwardsRequestExpired) {
t.Fatalf("repeat answer err=%v, want ErrNoForwardsRequestExpired", err)
}
source, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: alice, RecipientUserID: bob, RandomID: 20, Message: "source", Date: 105,
})
if err != nil {
t.Fatalf("send source: %v", err)
}
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, Enabled: true, RandomID: 21, Date: 106,
}); err != nil {
t.Fatalf("re-enable: %v", err)
}
if _, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{
OwnerUserID: alice,
FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: bob},
ToUserID: alice,
MessageIDs: []int{source.SenderMessage.ID},
RandomIDs: []int64{22},
Date: 107,
}); !errors.Is(err, domain.ErrChatForwardsRestricted) {
t.Fatalf("forward protected chat err=%v, want ErrChatForwardsRestricted", err)
}
}
func TestPrivateNoForwardsRequestExpiresWithoutPTS(t *testing.T) {
ctx := context.Background()
messages := NewMessageStore()
const alice, bob int64 = 2001, 2002
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice, PeerUserID: bob, Enabled: true, RandomID: 31, Date: 200,
}); err != nil {
t.Fatal(err)
}
request, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: bob, PeerUserID: alice, RandomID: 32, Date: 201,
})
if err != nil {
t.Fatal(err)
}
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{
ActorUserID: alice,
PeerUserID: bob,
RequestMsgID: request.Send.RecipientMessage.ID,
RandomID: 33,
Date: 201 + domain.PrivateNoForwardsRequestExpirePeriod,
}); !errors.Is(err, domain.ErrNoForwardsRequestExpired) {
t.Fatalf("expired answer err=%v", err)
}
state, _ := messages.GetPrivateNoForwards(ctx, alice, bob)
if state.EnabledByUserID != alice {
t.Fatalf("expired answer changed state = %+v", state)
}
history, _ := messages.ListByUser(ctx, alice, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob}, Limit: 20,
})
if len(history.Messages) != 2 || history.Messages[0].Pts != 2 {
t.Fatalf("expired answer allocated message/pts: %+v", history.Messages)
}
}
func assertMemoryNoForwardsAction(t *testing.T, msg domain.Message, kind domain.MessageServiceActionKind, prev, next, expired bool) {
t.Helper()
if msg.Media == nil || msg.Media.ServiceAction == nil || msg.Media.ServiceAction.Kind != kind ||
msg.Media.ServiceAction.NoForwards == nil {
t.Fatalf("message action = %+v, want %s", msg.Media, kind)
}
action := msg.Media.ServiceAction.NoForwards
if action.PrevValue != prev || action.NewValue != next || action.Expired != expired {
t.Fatalf("action = %+v, want prev=%v new=%v expired=%v", action, prev, next, expired)
}
}

View file

@ -22,6 +22,9 @@ func (s *MessageStore) SetMessageReactions(_ context.Context, req domain.SetPriv
}
s.mu.Lock()
defer s.mu.Unlock()
if req.Peer.ID == req.UserID {
return s.setSavedMessageTagsLocked(req)
}
var target domain.Message
for _, msg := range s.m[req.UserID] {
if msg.ID == req.MessageID && msg.Peer == req.Peer {
@ -119,6 +122,10 @@ func (s *MessageStore) privateReactionResultLocked(uid int64) domain.PrivateMess
}
func (s *MessageStore) privateMessageReactionsForMessageLocked(msg domain.Message) domain.ChannelMessageReactions {
if msg.OwnerUserID != 0 &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: msg.OwnerUserID}) {
return s.savedMessageTagsForMessageLocked(msg)
}
reactions := s.privateMessageReactionsLocked(msg.UID, msg.OwnerUserID)
if len(reactions.Recent) == 0 || msg.From.ID == 0 {
return reactions
@ -232,6 +239,11 @@ func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageRe
return
}
var buf [16]byte
if reactions.AsTags {
_, _ = h.Write([]byte{1})
} else {
_, _ = h.Write([]byte{0})
}
for _, item := range reactions.Results {
_, _ = h.Write([]byte(item.Reaction.Type))
_, _ = h.Write([]byte{0})

View file

@ -0,0 +1,159 @@
package memory
import (
"context"
"sort"
"telesrv/internal/domain"
)
func (s *MessageStore) setSavedMessageTagsLocked(req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) {
var target domain.Message
for _, msg := range s.m[req.UserID] {
if msg.ID == req.MessageID &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
target = msg
break
}
}
if target.ID == 0 {
return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid
}
for _, reaction := range req.Reactions {
if !reaction.Valid() {
return domain.PrivateMessageReactionsResult{}, domain.ErrReactionInvalid
}
}
if len(req.Reactions) == 0 {
if byMessage := s.savedMessageTags[req.UserID]; byMessage != nil {
delete(byMessage, target.ID)
if len(byMessage) == 0 {
delete(s.savedMessageTags, req.UserID)
}
}
} else {
if s.savedMessageTags[req.UserID] == nil {
s.savedMessageTags[req.UserID] = make(map[int][]domain.MessageReaction)
}
s.savedMessageTags[req.UserID][target.ID] = append([]domain.MessageReaction(nil), req.Reactions...)
}
item := cloneMessage(target)
reactions := s.savedMessageTagsForMessageLocked(item)
item.Reactions = cloneChannelMessageReactionsPtr(&reactions)
return domain.PrivateMessageReactionsResult{
Messages: []domain.Message{item},
Reactions: reactions,
}, nil
}
func (s *MessageStore) savedMessageTagsForMessageLocked(msg domain.Message) domain.ChannelMessageReactions {
out := domain.ChannelMessageReactions{
AsTags: true,
Results: []domain.ChannelMessageReactionCount{},
Recent: []domain.ChannelMessagePeerReaction{},
}
for i, reaction := range s.savedMessageTags[msg.OwnerUserID][msg.ID] {
out.Results = append(out.Results, domain.ChannelMessageReactionCount{
Reaction: reaction,
Count: 1,
ChosenOrder: i + 1,
})
}
return out
}
func (s *MessageStore) ListSavedReactionTags(_ context.Context, req domain.SavedReactionTagsRequest) ([]domain.SavedReactionTag, error) {
if req.UserID == 0 {
return nil, domain.ErrReactionInvalid
}
if req.Limit <= 0 || req.Limit > domain.MaxSavedReactionTags {
req.Limit = domain.MaxSavedReactionTags
}
s.mu.RLock()
defer s.mu.RUnlock()
visible := make(map[int]domain.Message, len(s.m[req.UserID]))
for _, msg := range s.m[req.UserID] {
if msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}) {
visible[msg.ID] = msg
}
}
byKey := make(map[string]domain.SavedReactionTag)
for messageID, reactions := range s.savedMessageTags[req.UserID] {
msg, ok := visible[messageID]
if !ok || (req.SavedPeer.ID != 0 && msg.SavedPeer != req.SavedPeer) {
continue
}
for _, reaction := range reactions {
key := reaction.Key()
tag := byKey[key]
tag.UserID = req.UserID
tag.Reaction = reaction
tag.Count++
if req.SavedPeer.ID == 0 {
tag.Title = s.savedTagTitles[req.UserID][key]
}
byKey[key] = tag
}
}
out := make([]domain.SavedReactionTag, 0, len(byKey))
for _, tag := range byKey {
out = append(out, tag)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Reaction.Key() > out[j].Reaction.Key()
})
if len(out) > req.Limit {
out = out[:req.Limit]
}
return out, nil
}
func (s *MessageStore) UpsertSavedReactionTag(_ context.Context, tag domain.SavedReactionTag) error {
if tag.UserID == 0 || !tag.Reaction.Valid() {
return domain.ErrReactionInvalid
}
key := tag.Reaction.Key()
s.mu.Lock()
defer s.mu.Unlock()
found := false
for messageID, reactions := range s.savedMessageTags[tag.UserID] {
alive := false
for _, msg := range s.m[tag.UserID] {
if msg.ID == messageID &&
msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: tag.UserID}) {
alive = true
break
}
}
if !alive {
continue
}
for _, reaction := range reactions {
if reaction.Key() == key {
found = true
break
}
}
if found {
break
}
}
if !found {
return domain.ErrReactionInvalid
}
if tag.Title == "" {
if titles := s.savedTagTitles[tag.UserID]; titles != nil {
delete(titles, key)
}
return nil
}
if s.savedTagTitles[tag.UserID] == nil {
s.savedTagTitles[tag.UserID] = make(map[string]string)
}
s.savedTagTitles[tag.UserID][key] = tag.Title
return nil
}

View file

@ -0,0 +1,150 @@
package memory
import (
"context"
"testing"
"telesrv/internal/domain"
)
func TestSavedMessageTagsAssignmentCountsSearchAndDelete(t *testing.T) {
ctx := context.Background()
const userID int64 = 1001
self := domain.Peer{Type: domain.PeerTypeUser, ID: userID}
peerA := domain.Peer{Type: domain.PeerTypeUser, ID: 2001}
peerB := domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}
thumb := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"}
custom := domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: 90001}
store := NewMessageStore()
create := func(body string, savedPeer domain.Peer) domain.Message {
msg, err := store.Create(ctx, domain.Message{
OwnerUserID: userID,
Peer: self,
From: self,
SavedPeer: savedPeer,
Date: 1_700_000_000,
Body: body,
})
if err != nil {
t.Fatalf("create saved message: %v", err)
}
return msg
}
first := create("first", peerA)
second := create("second", peerA)
third := create("third", peerB)
set := func(msg domain.Message, reactions ...domain.MessageReaction) {
t.Helper()
result, err := store.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{
UserID: userID,
Peer: self,
MessageID: msg.ID,
Reactions: reactions,
ReactionsPerUserMax: 3,
})
if err != nil {
t.Fatalf("set saved tags for %d: %v", msg.ID, err)
}
if len(result.Messages) != 1 || result.Messages[0].Reactions == nil ||
!result.Messages[0].Reactions.AsTags {
t.Fatalf("saved tag result = %+v, want one reactions_as_tags message", result)
}
}
set(first, thumb)
set(second, thumb, custom)
set(third, custom)
if got := store.nextPts[userID]; got != 0 {
t.Fatalf("tag mutations pts = %d, want 0", got)
}
if err := store.UpsertSavedReactionTag(ctx, domain.SavedReactionTag{
UserID: userID, Reaction: thumb, Title: "Fav",
}); err != nil {
t.Fatalf("rename saved tag: %v", err)
}
global, err := store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{UserID: userID, Limit: 100})
if err != nil {
t.Fatalf("list global saved tags: %v", err)
}
assertMemorySavedTag(t, global, thumb, 2, "Fav")
assertMemorySavedTag(t, global, custom, 2, "")
perPeer, err := store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{
UserID: userID, SavedPeer: peerA, Limit: 100,
})
if err != nil {
t.Fatalf("list per-peer saved tags: %v", err)
}
assertMemorySavedTag(t, perPeer, thumb, 2, "")
assertMemorySavedTag(t, perPeer, custom, 1, "")
found, err := store.ListByUser(ctx, userID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedPeer: peerA,
SavedReactions: []domain.MessageReaction{custom},
Limit: 10,
})
if err != nil {
t.Fatalf("search saved tag: %v", err)
}
if len(found.Messages) != 1 || found.Messages[0].ID != second.ID ||
found.Messages[0].Reactions == nil || !found.Messages[0].Reactions.AsTags {
t.Fatalf("saved tag search = %+v, want second message", found.Messages)
}
foundAny, err := store.ListByUser(ctx, userID, domain.MessageFilter{
HasPeer: true,
Peer: self,
SavedReactions: []domain.MessageReaction{thumb, custom},
Limit: 10,
})
if err != nil {
t.Fatalf("search any saved tag: %v", err)
}
if len(foundAny.Messages) != 3 {
t.Fatalf("saved tag OR search = %+v, want all three messages", foundAny.Messages)
}
if _, err := store.DeleteMessages(ctx, domain.DeleteMessagesRequest{
OwnerUserID: userID,
IDs: []int{second.ID},
Date: 1_700_000_100,
}); err != nil {
t.Fatalf("delete tagged message: %v", err)
}
global, err = store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{UserID: userID, Limit: 100})
if err != nil {
t.Fatalf("list tags after delete: %v", err)
}
assertMemorySavedTag(t, global, thumb, 1, "Fav")
assertMemorySavedTag(t, global, custom, 1, "")
set(first)
global, err = store.ListSavedReactionTags(ctx, domain.SavedReactionTagsRequest{UserID: userID, Limit: 100})
if err != nil {
t.Fatalf("list tags after clear: %v", err)
}
if len(global) != 1 || global[0].Reaction.Key() != custom.Key() {
t.Fatalf("tags after clear = %+v, want only custom", global)
}
if err := store.UpsertSavedReactionTag(ctx, domain.SavedReactionTag{
UserID: userID, Reaction: thumb, Title: "ghost",
}); err != domain.ErrReactionInvalid {
t.Fatalf("rename unassigned tag err = %v, want ErrReactionInvalid", err)
}
}
func assertMemorySavedTag(t *testing.T, tags []domain.SavedReactionTag, reaction domain.MessageReaction, count int, title string) {
t.Helper()
for _, tag := range tags {
if tag.Reaction.Key() == reaction.Key() {
if tag.Count != count || tag.Title != title {
t.Fatalf("tag %s = %+v, want count=%d title=%q", reaction.Key(), tag, count, title)
}
return
}
}
t.Fatalf("tag %s not found in %+v", reaction.Key(), tags)
}

View file

@ -8,12 +8,15 @@ import (
// MessageStore 是 store.MessageStore 的内存实现。
type MessageStore struct {
mu sync.RWMutex
noForwardsMu sync.Mutex
m map[int64][]domain.Message
nextUID int64
nextBox map[int64]int
nextPts map[int64]int
readOutboxDates map[readOutboxDateKey]int
privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction
savedMessageTags map[int64]map[int][]domain.MessageReaction
savedTagTitles map[int64]map[string]string
privateSendDedup map[privateSendDedupKey]privateSendDedupRecord
loginCodeDeliveries map[[32]byte]loginCodeDeliveryRecord
albumGroups map[albumGroupKey]albumGroupRecord
@ -22,6 +25,11 @@ type MessageStore struct {
polls *PollStore
// savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order,越小越前)。
savedPins map[int64][]domain.Peer
// privateNoForwards is keyed by the sorted user pair. Requests are keyed by
// the shared logical private-message id so both local box ids resolve to one
// one-shot response fact.
privateNoForwards map[privateNoForwardsPair]domain.PrivateNoForwardsState
privateNoForwardsRequests map[int64]memoryNoForwardsRequest
}
// AttachPollStore 注入共享 poll 权威(与 ChannelStore 共用同一实例)。
@ -38,16 +46,20 @@ type readOutboxDateKey struct {
// NewMessageStore 创建内存 MessageStore。
func NewMessageStore(dialogs ...*DialogStore) *MessageStore {
s := &MessageStore{
m: make(map[int64][]domain.Message),
nextUID: 1,
nextBox: make(map[int64]int),
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
privateSendDedup: make(map[privateSendDedupKey]privateSendDedupRecord),
loginCodeDeliveries: make(map[[32]byte]loginCodeDeliveryRecord),
albumGroups: make(map[albumGroupKey]albumGroupRecord),
savedPins: make(map[int64][]domain.Peer),
m: make(map[int64][]domain.Message),
nextUID: 1,
nextBox: make(map[int64]int),
nextPts: make(map[int64]int),
readOutboxDates: make(map[readOutboxDateKey]int),
privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction),
savedMessageTags: make(map[int64]map[int][]domain.MessageReaction),
savedTagTitles: make(map[int64]map[string]string),
privateSendDedup: make(map[privateSendDedupKey]privateSendDedupRecord),
loginCodeDeliveries: make(map[[32]byte]loginCodeDeliveryRecord),
albumGroups: make(map[albumGroupKey]albumGroupRecord),
savedPins: make(map[int64][]domain.Peer),
privateNoForwards: make(map[privateNoForwardsPair]domain.PrivateNoForwardsState),
privateNoForwardsRequests: make(map[int64]memoryNoForwardsRequest),
}
if len(dialogs) > 0 {
s.dialogs = dialogs[0]

View file

@ -1218,29 +1218,191 @@ func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing
preservedOwner := int64(1000000003)
preservedPeerID := int64(1000000004)
preservedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: preservedPeerID}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: preservedOwner,
RecipientUserID: preservedPeerID,
RandomID: 300,
Message: "clear but keep dialog",
Date: 1700000500,
}); err != nil {
t.Fatalf("seed preserved send: %v", err)
var preservedTop domain.Message
for i := 0; i < 2; i++ {
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: preservedOwner,
RecipientUserID: preservedPeerID,
RandomID: int64(300 + i),
Message: "clear but keep dialog",
Date: 1700000500 + i,
})
if err != nil {
t.Fatalf("seed preserved send %d: %v", i, err)
}
preservedTop = sent.SenderMessage
}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
clearResult, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: preservedOwner,
Peer: preservedPeer,
JustClear: true,
Date: 1700000600,
}); err != nil {
})
if err != nil {
t.Fatalf("DeleteHistory just_clear: %v", err)
}
clearSelf := clearResult.Self()
if clearSelf.Pts != 5 || clearSelf.PtsCount != 3 || len(clearSelf.MessageIDs) != 1 || len(clearSelf.Events) != 3 {
t.Fatalf("just_clear result = %+v, want delete+read+edit ending pts=5 count=3", clearSelf)
}
if clearSelf.Events[0].Type != domain.UpdateEventDeleteMessages ||
clearSelf.Events[1].Type != domain.UpdateEventReadHistoryInbox ||
clearSelf.Events[2].Type != domain.UpdateEventEditMessage {
t.Fatalf("just_clear events = %+v, want delete/read/edit order", clearSelf.Events)
}
preservedDialogs, err := dialogs.ListByUser(ctx, preservedOwner, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("preserved dialogs: %v", err)
}
if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer || preservedDialogs.Dialogs[0].TopMessage != 0 || len(preservedDialogs.Messages) != 0 {
t.Fatalf("preserved dialogs = %+v messages=%+v, want empty dialog kept after just_clear", preservedDialogs.Dialogs, preservedDialogs.Messages)
if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer ||
preservedDialogs.Dialogs[0].TopMessage != preservedTop.ID || len(preservedDialogs.Messages) != 1 {
t.Fatalf("preserved dialogs = %+v messages=%+v, want history-clear top %d", preservedDialogs.Dialogs, preservedDialogs.Messages, preservedTop.ID)
}
clearMessage := preservedDialogs.Messages[0]
if !domain.IsHistoryClearServiceMessage(clearMessage) || clearMessage.ID != preservedTop.ID ||
!clearMessage.Out || clearMessage.From.ID != preservedOwner || clearMessage.Body != "" ||
clearMessage.ReplyTo != nil || clearMessage.Forward != nil || clearMessage.MediaUnread ||
clearMessage.ReactionUnread || clearMessage.Pinned {
t.Fatalf("history clear anchor = %+v, want clean owner-local service message", clearMessage)
}
repeated, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: preservedOwner,
Peer: preservedPeer,
JustClear: true,
Date: 1700000601,
})
if err != nil {
t.Fatalf("repeat DeleteHistory just_clear: %v", err)
}
if repeated.Changed() || len(repeated.Deleted) != 0 || messages.nextPts[preservedOwner] != 5 {
t.Fatalf("repeat just_clear = %+v pts=%d, want idempotent no-op", repeated, messages.nextPts[preservedOwner])
}
}
func TestMessageStoreDeleteHistoryJustClearRevokeKeepsPerOwnerAnchors(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
const alice, bob = int64(1101), int64(1102)
var sent domain.SendPrivateTextResult
for i := 0; i < 2; i++ {
var err error
sent, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: alice, RecipientUserID: bob, RandomID: int64(800 + i),
Message: "revoke clear", Date: 1700000700 + i,
})
if err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
res, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: alice,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob},
JustClear: true,
Revoke: true,
Date: 1700000800,
})
if err != nil {
t.Fatalf("revoke just_clear: %v", err)
}
if len(res.Deleted) != 2 {
t.Fatalf("deleted owners = %+v, want alice and bob", res.Deleted)
}
for _, tc := range []struct {
userID int64
peerID int64
topID int
}{
{alice, bob, sent.SenderMessage.ID},
{bob, alice, sent.RecipientMessage.ID},
} {
history, err := messages.ListByUser(ctx, tc.userID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: tc.peerID}, Limit: 10,
})
if err != nil {
t.Fatalf("history user %d: %v", tc.userID, err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != tc.topID ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) ||
history.Messages[0].From.ID != tc.userID || !history.Messages[0].Out {
t.Fatalf("history user %d = %+v, want owner-local anchor %d", tc.userID, history.Messages, tc.topID)
}
}
}
func TestMessageStoreDeleteHistoryDateRangeDoesNotCreateHistoryClearAnchor(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
const owner, peerID = int64(1201), int64(1202)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerID}
for i, date := range []int{100, 200} {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner, RecipientUserID: peerID, RandomID: int64(900 + i),
Message: "dated", Date: date,
}); err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner, Peer: peer, JustClear: true, MinDate: 150, MaxDate: 250, Date: 300,
}); err != nil {
t.Fatalf("date delete: %v", err)
}
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history.Messages) != 1 || history.Messages[0].Date != 100 || domain.IsHistoryClearServiceMessage(history.Messages[0]) {
t.Fatalf("date history = %+v, want surviving ordinary message only", history.Messages)
}
}
func TestMessageStoreDeleteHistoryJustClearKeepsAnchorAcrossBatches(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
const owner, peerID = int64(1301), int64(1302)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerID}
total := domain.MaxDeleteHistoryBatch + 2
var topID int
for i := 0; i < total; i++ {
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner, RecipientUserID: peerID, RandomID: int64(10000 + i),
Message: "batch clear", Date: 1700010000 + i,
})
if err != nil {
t.Fatalf("send %d: %v", i, err)
}
topID = sent.SenderMessage.ID
}
first, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner, Peer: peer, JustClear: true, Date: 1700020000,
})
if err != nil {
t.Fatalf("first clear: %v", err)
}
if first.Offset == 0 || len(first.Self().MessageIDs) != domain.MaxDeleteHistoryBatch ||
first.Self().PtsCount != domain.MaxDeleteHistoryBatch+2 {
t.Fatalf("first clear = %+v, want full batch plus one read/edit", first.Self())
}
second, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner, Peer: peer, JustClear: true, Date: 1700020001,
})
if err != nil {
t.Fatalf("second clear: %v", err)
}
if second.Offset != 0 || len(second.Self().MessageIDs) != 1 || second.Self().PtsCount != 1 ||
len(second.Self().Events) != 1 || second.Self().Events[0].Type != domain.UpdateEventDeleteMessages {
t.Fatalf("second clear = %+v, want remaining delete only", second.Self())
}
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != topID ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) {
t.Fatalf("history = %+v, want stable top anchor %d", history.Messages, topID)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,221 @@
package memory
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestModerationCaseLifecycleAndNewReportsDuringAction(t *testing.T) {
ctx := context.Background()
now := time.Now().UTC()
target := domain.Peer{Type: domain.PeerTypeUser, ID: 900}
store := NewModerationReportStore()
create := func(reporter int64, option string, at time.Time) domain.ModerationReport {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: reporter, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonFake,
Option: option,
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: at,
})
if err != nil {
t.Fatal(err)
}
stored, created, err := store.CreateModerationReport(ctx, report)
if err != nil || !created {
t.Fatalf("create report created=%v err=%v", created, err)
}
return stored
}
create(101, "fake", now)
create(102, "fake:impersonation", now.Add(time.Second))
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
item := cases[0]
if item.ReportCount != 2 || item.DistinctReporterCount != 2 ||
item.Version != 2 || item.Severity != domain.ModerationSeverityMedium {
t.Fatalf("case aggregate=%+v", item)
}
claimed, err := store.ClaimModerationCase(ctx, item.ID, item.Version, "reviewer", now.Add(2*time.Second))
if err != nil || claimed.Status != domain.ModerationCaseInReview {
t.Fatalf("claim=%+v err=%v", claimed, err)
}
decision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: item.ID, ExpectedVersion: claimed.Version, Actor: "reviewer",
Reason: "confirmed impersonation", CommandID: "decision-1",
Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkFake, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(3 * time.Second),
})
if err != nil {
t.Fatal(err)
}
detail, created, err := store.DecideModerationCase(ctx, decision)
if err != nil || !created ||
detail.Case.Status != domain.ModerationCaseActionPending ||
len(detail.Actions) != 1 {
t.Fatalf("decision detail=%+v created=%v err=%v", detail, created, err)
}
if _, created, err := store.DecideModerationCase(ctx, decision); err != nil || created {
t.Fatalf("decision retry created=%v err=%v", created, err)
}
// Once a decision is durable, later reports open a new case instead of
// mutating the evidence set under the pending action.
create(103, "fake:new-evidence", now.Add(4*time.Second))
cases, err = store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 2 {
t.Fatalf("cases after new evidence=%+v err=%v", cases, err)
}
actions, err := store.ClaimModerationActions(ctx, now.Add(5*time.Second), 10, time.Minute)
if err != nil || len(actions) != 1 {
t.Fatalf("claimed actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(6*time.Second),
); err != nil {
t.Fatal(err)
}
resolved, found, err := store.GetModerationCase(ctx, item.ID)
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
}
appeal, err := domain.NewModerationAppeal(
item.ID, target.ID, domain.ModerationCaseResolved,
"This is a mistake.", now.Add(7*time.Second),
)
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationAppeal(ctx, appeal); err != nil || !created {
t.Fatalf("appeal created=%v err=%v", created, err)
}
appealed, _, _ := store.GetModerationCase(ctx, item.ID)
if appealed.Case.Status != domain.ModerationCaseAppealReview ||
len(appealed.Appeals) != 1 {
t.Fatalf("appealed detail=%+v", appealed)
}
}
func TestModerationActionFailedCanBeRedrivenByNewDecision(t *testing.T) {
ctx := context.Background()
now := time.Unix(1_750_000_000, 0).UTC()
target := domain.Peer{Type: domain.PeerTypeUser, ID: 902}
store := NewModerationReportStore()
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 901, Source: domain.ModerationSourceAccountPeer,
Target: target, Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemPeer, Peer: target, ItemID: target.ID,
AuthorUserID: target.ID, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"schema_version":1}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
t.Fatalf("create report created=%v err=%v", created, err)
}
cases, err := store.ListModerationCases(ctx, domain.ModerationCaseFilter{Limit: 10})
if err != nil || len(cases) != 1 {
t.Fatalf("cases=%+v err=%v", cases, err)
}
claimed, err := store.ClaimModerationCase(
ctx, cases[0].ID, cases[0].Version, "reviewer", now.Add(time.Second),
)
if err != nil {
t.Fatal(err)
}
firstDecision, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: claimed.Version,
Actor: "reviewer", Reason: "first command kept failing",
CommandID: "redrive-first", Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(2 * time.Second),
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.DecideModerationCase(ctx, firstDecision); err != nil || !created {
t.Fatalf("first decision created=%v err=%v", created, err)
}
for attempt := 1; attempt <= domain.MaxModerationActionAttempts; attempt++ {
at := now.Add(time.Duration(attempt+2) * time.Second)
actions, err := store.ClaimModerationActions(ctx, at, 10, time.Second)
if err != nil || len(actions) != 1 {
t.Fatalf("attempt %d actions=%+v err=%v", attempt, actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, false, "downstream unavailable",
at.Add(time.Millisecond), at,
); err != nil {
t.Fatalf("attempt %d: %v", attempt, err)
}
}
failed, found, err := store.GetModerationCase(ctx, claimed.ID)
if err != nil || !found || failed.Case.Status != domain.ModerationCaseActionFailed ||
len(failed.Actions) != 1 ||
failed.Actions[0].Status != domain.ModerationActionFailed {
t.Fatalf("failed=%+v found=%v err=%v", failed, found, err)
}
redrive, err := domain.NewModerationDecisionRequest(domain.ModerationDecisionRequest{
CaseID: claimed.ID, ExpectedVersion: failed.Case.Version,
Actor: "reviewer", Reason: "redrive after dependency recovery",
CommandID: "redrive-second", Kind: domain.ModerationDecisionViolation,
Actions: []domain.ModerationActionDraft{{
Kind: domain.ModerationActionMarkScam, Payload: []byte(`{}`),
}},
CreatedAt: now.Add(time.Minute),
})
if err != nil {
t.Fatal(err)
}
pending, created, err := store.DecideModerationCase(ctx, redrive)
if err != nil || !created ||
pending.Case.Status != domain.ModerationCaseActionPending ||
len(pending.Actions) != 2 {
t.Fatalf("pending=%+v created=%v err=%v", pending, created, err)
}
actions, err := store.ClaimModerationActions(ctx, now.Add(2*time.Minute), 10, time.Second)
if err != nil || len(actions) != 1 || actions[0].DecisionID == failed.Actions[0].DecisionID {
t.Fatalf("redrive actions=%+v err=%v", actions, err)
}
if err := store.CompleteModerationAction(
ctx, actions[0].ID, actions[0].Attempts, true, "",
time.Time{}, now.Add(2*time.Minute+time.Second),
); err != nil {
t.Fatal(err)
}
resolved, found, err := store.GetModerationCase(ctx, claimed.ID)
if err != nil || !found || resolved.Case.Status != domain.ModerationCaseResolved {
t.Fatalf("resolved=%+v found=%v err=%v", resolved, found, err)
}
var failedCount, succeededCount int
for _, action := range resolved.Actions {
switch action.Status {
case domain.ModerationActionFailed:
failedCount++
case domain.ModerationActionSucceeded:
succeededCount++
}
}
if failedCount != 1 || succeededCount != 1 {
t.Fatalf("action history failed=%d succeeded=%d", failedCount, succeededCount)
}
}

View file

@ -0,0 +1,92 @@
package memory
import (
"context"
"testing"
"time"
"telesrv/internal/domain"
)
func TestModerationReportStoreIdempotencyAndCopyIsolation(t *testing.T) {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 11, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
Reason: domain.ModerationReasonSpam, Option: "v1/spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 22},
ItemID: 5, AuthorUserID: 22, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"spam"}`),
}},
CreatedAt: time.Now().UTC(),
})
if err != nil {
t.Fatal(err)
}
store := NewModerationReportStore()
first, created, err := store.CreateModerationReport(context.Background(), report)
if err != nil || !created || first.ID <= 0 {
t.Fatalf("first = %+v created=%v err=%v", first, created, err)
}
first.Items[0].Evidence[0] = '['
retry, created, err := store.CreateModerationReport(context.Background(), report)
if err != nil || created || retry.ID != first.ID {
t.Fatalf("retry = %+v created=%v err=%v", retry, created, err)
}
if retry.Items[0].Evidence[0] != '{' {
t.Fatalf("caller mutation changed stored evidence: %s", retry.Items[0].Evidence)
}
}
func TestModerationReportStoreRateLimitDoesNotChargeIdempotentRetry(t *testing.T) {
ctx := context.Background()
store := NewModerationReportStore()
now := time.Unix(1_750_000_000, 0).UTC()
var first domain.ModerationReport
for i := 0; i < domain.MaxModerationReportsPerHour; i++ {
report, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
ItemID: int64(i + 1), AuthorUserID: 72,
EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"spam"}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, created, err := store.CreateModerationReport(ctx, report); err != nil || !created {
t.Fatalf("create %d: created=%v err=%v", i, created, err)
}
if i == 0 {
first = report
}
}
if got, created, err := store.CreateModerationReport(ctx, first); err != nil || created || got.ID == 0 {
t.Fatalf("retry after limit: got=%+v created=%v err=%v", got, created, err)
}
overflow, err := domain.NewModerationReport(domain.ModerationReportDraft{
ReporterUserID: 71, Source: domain.ModerationSourceMessages,
Target: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
Reason: domain.ModerationReasonSpam, Option: "spam",
Items: []domain.ModerationReportItem{{
Kind: domain.ModerationItemMessage,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 72},
ItemID: 999, AuthorUserID: 72, EvidenceSchemaVersion: 1,
Evidence: []byte(`{"message":"overflow"}`),
}},
CreatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, _, err := store.CreateModerationReport(ctx, overflow); err != domain.ErrModerationRateLimited {
t.Fatalf("overflow err=%v, want ErrModerationRateLimited", err)
}
}

View file

@ -160,6 +160,18 @@ func (s *PasswordStore) GetAccountSettings(_ context.Context, userID int64) (dom
return settings, ok, nil // AccountSettings 全是值类型,无需深拷贝
}
func (s *PasswordStore) GetAccountSettingsBatch(_ context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
out := make(map[int64]domain.AccountSettings, len(userIDs))
s.mu.RLock()
for _, userID := range userIDs {
if settings, ok := s.accountSettings[userID]; ok {
out[userID] = settings
}
}
s.mu.RUnlock()
return out, nil
}
func (s *PasswordStore) SaveAccountSettings(_ context.Context, userID int64, settings domain.AccountSettings) error {
s.mu.Lock()
s.accountSettings[userID] = settings

View file

@ -243,7 +243,7 @@ func (s *MessageStore) DeleteSavedHistory(_ context.Context, req domain.DeleteSa
return true
}
deleted, _, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, match)
delRes := s.finishMemoryDeleteLocked(domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}, deleted, req.Date, false)
delRes := s.finishMemoryDeleteLocked(domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}, deleted, req.Date, nil)
res.More = more
for _, d := range delRes.Deleted {
if d.UserID == req.OwnerUserID {

View file

@ -2,6 +2,7 @@ package memory
import (
"context"
"math/rand/v2"
"sort"
"strings"
"sync"
@ -253,6 +254,20 @@ func (s *StarGiftStore) ActiveCollectibleRevision(_ context.Context, giftID int6
return cloneCollectibleRevision(revision), ok, nil
}
func (s *StarGiftStore) ActiveCollectibleProjection(_ context.Context, giftID int64, samplePerKind int) (domain.StarGiftCollectibleRevision, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
revision, ok := s.collectibles[giftID]
if !ok {
return domain.StarGiftCollectibleRevision{}, false, nil
}
projection := cloneCollectibleRevision(revision)
projection.Models = projectCollectibleAttributes(projection.Models, domain.StarGiftCollectibleModel, samplePerKind)
projection.Patterns = projectCollectibleAttributes(projection.Patterns, domain.StarGiftCollectiblePattern, samplePerKind)
projection.Backdrops = projectCollectibleAttributes(projection.Backdrops, domain.StarGiftCollectibleBackdrop, samplePerKind)
return projection, true, nil
}
func (s *StarGiftStore) CollectibleAvailability(_ context.Context, giftIDs []int64) (map[int64]domain.StarGiftCollectibleAvailability, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -506,6 +521,10 @@ func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef)
return domain.SavedStarGift{}, false, nil
}
func (s *StarGiftStore) ResolveUserMessageRef(_ context.Context, _ int64, _ int) (domain.SavedStarGiftRef, bool, error) {
return domain.SavedStarGiftRef{}, false, nil
}
func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int, error) {
if !validStarGiftOwner(owner) {
return 0, nil
@ -845,6 +864,34 @@ func cloneCollectibleRevision(in domain.StarGiftCollectibleRevision) domain.Star
return out
}
func projectCollectibleAttributes(in []domain.StarGiftCollectibleAttribute, kind domain.StarGiftCollectibleAttributeKind, samplePerKind int) []domain.StarGiftCollectibleAttribute {
out := in
if samplePerKind > 0 {
out = make([]domain.StarGiftCollectibleAttribute, 0, len(in))
for _, attribute := range in {
if attribute.RarityKind != domain.StarGiftRarityPermille || attribute.RarityPermille <= 0 ||
(kind == domain.StarGiftCollectibleModel && attribute.Crafted) {
continue
}
out = append(out, attribute)
}
for i := 0; i < len(out) && i < samplePerKind; i++ {
j := i + rand.IntN(len(out)-i)
out[i], out[j] = out[j], out[i]
}
if len(out) > samplePerKind {
out = out[:samplePerKind]
}
}
for i := range out {
if out[i].Animation != nil {
out[i].Animation.JSON = nil
out[i].Animation.TGS = nil
}
}
return out
}
func cloneStarGiftCollections(in []domain.StarGiftCollection) []domain.StarGiftCollection {
out := make([]domain.StarGiftCollection, len(in))
for i, collection := range in {

View file

@ -93,12 +93,13 @@ func (s *StarsStore) Debit(_ context.Context, userID, amount int64, reason domai
return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil
}
func (s *StarsStore) ListTransactions(_ context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) {
func (s *StarsStore) ListTransactions(_ context.Context, userID int64, query domain.StarsTransactionQuery) (domain.StarsTransactionPage, error) {
if userID == 0 {
return domain.StarsTransactionPage{}, nil
}
if limit <= 0 || limit > domain.MaxStarsTransactionsLimit {
limit = domain.MaxStarsTransactionsLimit
query, err := domain.NormalizeStarsTransactionQuery(query)
if err != nil {
return domain.StarsTransactionPage{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
@ -107,22 +108,35 @@ func (s *StarsStore) ListTransactions(_ context.Context, userID int64, offset st
return domain.StarsTransactionPage{}, nil
}
page := domain.StarsTransactionPage{Balance: st.balance}
cursor, hasCursor := domain.DecodeStarsCursor(offset)
// 倒序遍历(id DESC)。
out := make([]domain.StarsTransaction, 0, limit)
for i := len(st.txns) - 1; i >= 0; i-- {
t := st.txns[i]
if hasCursor && t.ID >= cursor {
continue
cursor, hasCursor := domain.DecodeStarsCursor(query.Offset)
out := make([]domain.StarsTransaction, 0, query.Limit+1)
appendMatch := func(t domain.StarsTransaction) bool {
if hasCursor {
if query.Ascending && t.ID <= cursor {
return false
}
if !query.Ascending && t.ID >= cursor {
return false
}
}
if !query.Direction.IncludesAmount(t.Amount) {
return false
}
out = append(out, t)
if len(out) == limit {
// 还有更早的流水则给出下一页游标。
if i-1 >= 0 {
page.NextOffset = domain.EncodeStarsCursor(t.ID)
}
break
return len(out) > query.Limit
}
if query.Ascending {
for i := 0; i < len(st.txns) && len(out) <= query.Limit; i++ {
appendMatch(st.txns[i])
}
} else {
for i := len(st.txns) - 1; i >= 0 && len(out) <= query.Limit; i-- {
appendMatch(st.txns[i])
}
}
if len(out) > query.Limit {
out = out[:query.Limit]
page.NextOffset = domain.EncodeStarsCursor(out[len(out)-1].ID)
}
page.Transactions = out
return page, nil

View file

@ -1597,7 +1597,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User,
profile.LastName,
strings.TrimSpace(profile.FirstName + " " + profile.LastName),
profile.Username,
profile.Phone,
strconv.FormatInt(viewerID, 10),
}
if isContact {
@ -1610,7 +1609,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User,
contact.User.LastName,
strings.TrimSpace(contact.User.FirstName+" "+contact.User.LastName),
contact.User.Username,
contact.User.Phone,
)
}
for _, candidate := range candidates {

View file

@ -1627,6 +1627,33 @@ func TestStoryStoreListStoryViewsFiltersByContactsAndQuery(t *testing.T) {
t.Fatalf("username query = %+v, want viewer 2002", stranger)
}
hiddenAccountPhone, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
ViewerUserID: owner.ID,
Owner: owner,
StoryID: 1,
Limit: 10,
Query: "155502",
})
if err != nil {
t.Fatalf("list query hidden account phone: %v", err)
}
if hiddenAccountPhone.Count != 0 || len(hiddenAccountPhone.Views) != 0 {
t.Fatalf("hidden account phone query = %+v, want no match", hiddenAccountPhone)
}
knownContactPhone, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
ViewerUserID: owner.ID,
Owner: owner,
StoryID: 1,
Limit: 10,
Query: "7001",
})
if err != nil {
t.Fatalf("list query known contact phone: %v", err)
}
if knownContactPhone.Count != 1 || len(knownContactPhone.Views) != 1 || knownContactPhone.Views[0].ViewerID != 2001 {
t.Fatalf("known contact phone query = %+v, want viewer 2001", knownContactPhone)
}
intersection, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
ViewerUserID: owner.ID,
Owner: owner,

View file

@ -12,9 +12,10 @@ import (
// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。
type UserStore struct {
mu sync.RWMutex
byID map[int64]domain.User
nextID int64
mu sync.RWMutex
byID map[int64]domain.User
nextID int64
usernameRegistry *CollectibleUsernameStore
}
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers / ChatBot)
@ -29,6 +30,14 @@ func NewUserStore() *UserStore {
return s
}
// AttachUsernameRegistry gives the memory backend the same global username
// index the PostgreSQL stores share through peer_usernames.
func (s *UserStore) AttachUsernameRegistry(registry *CollectibleUsernameStore) {
s.mu.Lock()
s.usernameRegistry = registry
s.mu.Unlock()
}
func (s *UserStore) ByID(_ context.Context, id int64) (domain.User, bool, error) {
s.mu.RLock()
u, ok := s.byID[id]
@ -122,18 +131,25 @@ func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User,
return out, nil
}
func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, bool, error) {
func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) {
username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@")))
if username == "" {
return domain.User{}, false, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
registry := s.usernameRegistry
for _, u := range s.byID {
if !u.Deleted && strings.ToLower(u.Username) == username {
s.mu.RUnlock()
return u, true, nil
}
}
s.mu.RUnlock()
if registry != nil {
if peer, ok := registry.activeUsernamePeer(username, domain.PeerTypeUser); ok {
return s.ByID(ctx, peer.ID)
}
}
return domain.User{}, false, nil
}
@ -162,13 +178,21 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
return domain.UserSearchResult{}, nil
}
s.mu.RLock()
registry := s.usernameRegistry
s.mu.RUnlock()
var usernameMatches map[int64]int
if registry != nil {
usernameMatches = registry.activeUsernameMatches(query, domain.PeerTypeUser)
}
s.mu.RLock()
defer s.mu.RUnlock()
users := make([]domain.User, 0)
for _, u := range s.byID {
if u.ID == currentUserID || u.Deleted {
continue
}
if userMatchesSearch(u, query, phoneQuery) {
_, usernameMatch := usernameMatches[u.ID]
if usernameMatch || userMatchesSearch(u, query, phoneQuery) {
users = append(users, u)
}
}
@ -181,7 +205,7 @@ func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQ
return domain.UserSearchResult{Results: users}, nil
}
func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) {
func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) {
username = strings.TrimSpace(strings.TrimPrefix(username, "@"))
usernameLower := strings.ToLower(username)
s.mu.Lock()
@ -197,6 +221,11 @@ func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username str
}
}
}
if s.usernameRegistry != nil {
if _, err := s.usernameRegistry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: userID}, username); err != nil {
return domain.User{}, err
}
}
u.Username = username
s.byID[userID] = u
return u, nil

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,760 @@
package memory
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"telesrv/internal/domain"
)
// verificationTestDraft is a payload that clears domain.ValidateForSubmission:
// a category, a long enough description, a website and two independent press
// links.
func verificationTestDraft() domain.VerificationDraftInput {
return domain.VerificationDraftInput{
Category: "media",
Description: strings.Repeat("independent newsroom covering the region ", 2),
OfficialWebsite: "https://example.com",
SocialLinks: []string{"https://t.me/example"},
PressLinks: []string{
"https://press.example.com/story",
"https://press.example.org/profile",
},
AdditionalNote: "filed through the bot dialog",
}
}
func verificationTestRequest(applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.SubmitVerificationApplicationRequest {
return domain.SubmitVerificationApplicationRequest{
ApplicantUserID: applicant,
TargetType: targetType,
TargetID: targetID,
TargetTitle: "Target " + username,
TargetUsername: username,
Draft: verificationTestDraft(),
CorrelationID: fmt.Sprintf("corr-%d", targetID),
}
}
// submittedVerificationApplication drives the applicant path up to the review
// queue, which is the state every reviewer test starts from.
func submittedVerificationApplication(t *testing.T, s *VerificationStore, applicant int64, targetType domain.VerificationTargetType, targetID int64, username string) domain.VerificationApplication {
t.Helper()
ctx := context.Background()
app, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(applicant, targetType, targetID, username))
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
app, err = s.SubmitVerificationApplication(ctx, app.ID, app.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
return app
}
// TestMemoryVerificationDraftLifecycle covers the applicant path: a draft is
// opened once and resumed on the next /start, the payload is stored verbatim, and
// submission stamps the queue entry.
func TestMemoryVerificationDraftLifecycle(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
req := verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "AlphaBot")
req.Draft = domain.VerificationDraftInput{Category: "media"}
app, created, err := s.CreateVerificationDraft(ctx, req)
if err != nil || !created {
t.Fatalf("create draft: created=%v err=%v", created, err)
}
if app.Status != domain.VerificationStatusDraft || app.Version != 1 {
t.Fatalf("draft state = %s v%d, want draft v1", app.Status, app.Version)
}
if app.TargetUsername != "AlphaBot" || app.CorrelationID != "corr-5001" {
t.Fatalf("draft snapshot = %q / %q", app.TargetUsername, app.CorrelationID)
}
if !app.SubmittedAt.IsZero() || !app.ReviewedAt.IsZero() || app.ReviewerAdminID != "" {
t.Fatal("fresh draft carries review metadata")
}
// The bot dialog is one conversation: a second /start resumes the draft.
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1001, domain.VerificationTargetBot, 5001, "AlphaBot"))
if err != nil || created {
t.Fatalf("resume draft: created=%v err=%v", created, err)
}
if resumed.ID != app.ID || resumed.Version != app.Version {
t.Fatalf("resumed draft = %d v%d, want %d v%d", resumed.ID, resumed.Version, app.ID, app.Version)
}
if _, err := s.SubmitVerificationApplication(ctx, app.ID, app.Version); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("submit incomplete draft err = %v, want ErrVerificationApplicationInvalid", err)
}
saved, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft())
if err != nil {
t.Fatalf("save draft: %v", err)
}
if saved.Version != app.Version+1 || len(saved.PressLinks) != 2 || saved.OfficialWebsite != "https://example.com" {
t.Fatalf("saved draft = v%d links=%v site=%q", saved.Version, saved.PressLinks, saved.OfficialWebsite)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, app.Version, verificationTestDraft()); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("stale save err = %v, want ErrVerificationVersionConflict", err)
}
if _, err := s.SaveVerificationDraft(ctx, app.ID, saved.Version, domain.VerificationDraftInput{OfficialWebsite: "http://127.0.0.1/x"}); !errors.Is(err, domain.ErrVerificationURLInvalid) {
t.Fatalf("private-host save err = %v, want ErrVerificationURLInvalid", err)
}
submitted, err := s.SubmitVerificationApplication(ctx, saved.ID, saved.Version)
if err != nil {
t.Fatalf("submit: %v", err)
}
if submitted.Status != domain.VerificationStatusSubmitted || submitted.SubmittedAt.IsZero() {
t.Fatalf("submitted = %s at %v", submitted.Status, submitted.SubmittedAt)
}
if !submitted.ReviewedAt.IsZero() || submitted.ReviewerAdminID != "" {
t.Fatal("submitted application carries a reviewer")
}
if _, err := s.VerificationDraftForApplicant(ctx, 1001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("draft after submit err = %v, want ErrVerificationApplicationNotFound", err)
}
active, err := s.ActiveVerificationApplicationForTarget(ctx, domain.VerificationTargetBot, 5001)
if err != nil || active.ID != app.ID {
t.Fatalf("active for target = %d err=%v", active.ID, err)
}
// Returned slices are copies: mutating them must not reach the store.
submitted.PressLinks[0] = "https://evil.example.com"
reread, err := s.VerificationApplication(ctx, app.ID)
if err != nil || reread.PressLinks[0] != "https://press.example.com/story" {
t.Fatalf("stored press links mutated through the caller: %v err=%v", reread.PressLinks, err)
}
}
// TestMemoryVerificationActiveTargetUniqueness is the partial unique index: one
// live application per target, and a decided one no longer blocks a fresh
// attempt.
func TestMemoryVerificationActiveTargetUniqueness(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 6001, "beta")
if _, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetChannel, 6001, "beta")); !errors.Is(err, domain.ErrVerificationApplicationExists) {
t.Fatalf("second application err = %v, want ErrVerificationApplicationExists", err)
}
// A different target is free for the same target id in another namespace.
namespaced, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6001, "betabot"))
if err != nil {
t.Fatalf("other namespace draft: %v", err)
}
// One draft per applicant: naming another target resumes the same
// conversation instead of opening a second draft.
resumed, created, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1002, domain.VerificationTargetBot, 6002, "betabot2"))
if err != nil || created || resumed.ID != namespaced.ID {
t.Fatalf("cross-target draft = %d created=%v err=%v, want draft %d", resumed.ID, created, err, namespaced.ID)
}
cancelled, err := s.CancelVerificationApplication(ctx, first.ID, first.Version, "changed my mind")
if err != nil {
t.Fatalf("cancel: %v", err)
}
if cancelled.Status != domain.VerificationStatusCancelled || cancelled.SubmittedAt.IsZero() {
t.Fatalf("cancelled = %s at %v", cancelled.Status, cancelled.SubmittedAt)
}
if cancelled.DecisionReason != "" {
t.Fatalf("cancel wrote the applicant reason into decision_reason: %q", cancelled.DecisionReason)
}
if _, err := s.CancelVerificationApplication(ctx, cancelled.ID, cancelled.Version, "again"); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("cancel of cancelled err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1003, domain.VerificationTargetChannel, 6001, "beta")); err != nil {
t.Fatalf("draft after cancellation: %v", err)
}
}
// TestMemoryVerificationClaimAndApprove is the reviewer path, including the
// invariant that matters most: the peer flag is written by the callback and the
// application is only approved if that callback succeeded.
func TestMemoryVerificationClaimAndApprove(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 7001, "gamma")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if claimed.Status != domain.VerificationStatusInReview || claimed.ReviewerAdminID != "admin-a" {
t.Fatalf("claimed = %s by %q", claimed.Status, claimed.ReviewerAdminID)
}
if !claimed.ReviewedAt.IsZero() {
t.Fatal("claim stamped reviewed_at, which the schema pairs with a decision")
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-b",
}); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("re-claim err = %v, want ErrVerificationStatusInvalid", err)
}
// A callback failure must roll the whole decision back.
failing := errors.New("peer store unavailable")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing approve err = %v, want %v", err, failing)
}
rolled, err := s.VerificationApplication(ctx, app.ID)
if err != nil {
t.Fatalf("read after failed approve: %v", err)
}
if rolled.Status != domain.VerificationStatusInReview || rolled.Version != claimed.Version {
t.Fatalf("failed approve left %s v%d, want in_review v%d", rolled.Status, rolled.Version, claimed.Version)
}
if !rolled.ReviewedAt.IsZero() || rolled.DecisionReason != "" {
t.Fatal("failed approve wrote decision metadata")
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 0 {
t.Fatalf("outbox after failed approve = %d rows err=%v", len(pending), err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 10)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventApproved {
t.Fatal("failed approve appended an approved event")
}
}
verified := make(map[domain.Peer]bool)
applyVerified := func(_ context.Context, decided domain.VerificationApplication) error {
if decided.Status != domain.VerificationStatusApproved {
return fmt.Errorf("callback saw %s, want approved", decided.Status)
}
verified[decided.Target()] = true
return nil
}
approved, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a",
InternalNote: "checked the press coverage", CorrelationID: "cmd-1",
}, true, applyVerified)
if err != nil || !changed {
t.Fatalf("approve: changed=%v err=%v", changed, err)
}
if approved.Status != domain.VerificationStatusApproved || approved.ReviewedAt.IsZero() ||
approved.ReviewerAdminID != "admin-a" || approved.Version != claimed.Version+1 {
t.Fatalf("approved = %s v%d by %q at %v", approved.Status, approved.Version,
approved.ReviewerAdminID, approved.ReviewedAt)
}
if !verified[approved.Target()] {
t.Fatal("approved application whose target is not verified")
}
// Re-issuing the decision must not notify twice.
repeat, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-b",
}, true, func(context.Context, domain.VerificationApplication) error {
t.Fatal("idempotent approve invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat approve: changed=%v err=%v", changed, err)
}
if repeat.Version != approved.Version || repeat.ReviewerAdminID != "admin-a" {
t.Fatalf("repeat approve mutated the record: v%d by %q", repeat.Version, repeat.ReviewerAdminID)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0].Kind != "approved" || pending[0].RecipientUserID != 1001 {
t.Fatalf("outbox = %+v, want one approved row for the applicant", pending)
}
if pending[0].Application.ID != app.ID || pending[0].Application.TargetUsername != "gamma" {
t.Fatalf("outbox row carries no application context: %+v", pending[0].Application)
}
// A decided application is terminal.
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a", Reason: "changed our mind",
}, false, nil); !errors.Is(err, domain.ErrVerificationStatusInvalid) {
t.Fatalf("reject after approve err = %v, want ErrVerificationStatusInvalid", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: approved.Version, Reviewer: "admin-a",
}, true, nil); err == nil {
t.Fatal("approve without a callback succeeded")
}
}
// TestMemoryVerificationRejectRequiresReason keeps the audit trail honest: a
// rejection the applicant is told about always states why.
func TestMemoryVerificationRejectRequiresReason(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, false, nil); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("reject without reason err = %v, want ErrVerificationReasonRequired", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: " ", Reason: "not eligible",
}, false, nil); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("reject without reviewer err = %v, want ErrVerificationApplicationInvalid", err)
}
rejected, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
Reason: "press coverage is not independent", InternalNote: "second attempt this month",
}, false, nil)
if err != nil || !changed {
t.Fatalf("reject: changed=%v err=%v", changed, err)
}
if rejected.Status != domain.VerificationStatusRejected || rejected.DecisionReason == "" {
t.Fatalf("rejected = %s reason=%q", rejected.Status, rejected.DecisionReason)
}
cooldown, err := s.LastVerificationRejection(ctx, 1001, domain.VerificationTargetChannel, 8001)
if err != nil || cooldown.ID != app.ID {
t.Fatalf("cooldown lookup = %d err=%v", cooldown.ID, err)
}
if _, err := s.LastVerificationRejection(ctx, 1002, domain.VerificationTargetChannel, 8001); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("cooldown for another applicant err = %v, want ErrVerificationApplicationNotFound", err)
}
// The rejected application is history, so the target is free again, and the
// newest rejection is the one the cooldown is measured from.
second := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 8001, "delta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: second.ID, Version: second.Version, Reviewer: "admin-b", Reason: "still no",
}, false, nil); err != nil {
t.Fatalf("second reject: %v", err)
}
cooldown, err = s.LastVerificationRejection(ctx, 1001, domain.VerificationTargetChannel, 8001)
if err != nil || cooldown.ID != second.ID {
t.Fatalf("newest rejection = %d err=%v, want %d", cooldown.ID, err, second.ID)
}
}
// TestMemoryVerificationConcurrentDecision is the two-reviewers case: both read
// the same version, exactly one decision lands and the loser is told.
func TestMemoryVerificationConcurrentDecision(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9001, "epsilon")
calls := 0
applyVerified := func(context.Context, domain.VerificationApplication) error {
calls++
return nil
}
if _, changed, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, applyVerified); err != nil || !changed {
t.Fatalf("first approve: changed=%v err=%v", changed, err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b",
}, true, applyVerified); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("second approve err = %v, want ErrVerificationVersionConflict", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-b", Reason: "no",
}, false, nil); !errors.Is(err, domain.ErrVerificationVersionConflict) {
t.Fatalf("losing reject err = %v, want ErrVerificationVersionConflict", err)
}
if calls != 1 {
t.Fatalf("applyVerified called %d times, want exactly 1", calls)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("outbox = %d rows err=%v, want exactly one notification", len(pending), err)
}
final, err := s.VerificationApplication(ctx, app.ID)
if err != nil || final.ReviewerAdminID != "admin-a" {
t.Fatalf("final decision by %q err=%v, want admin-a", final.ReviewerAdminID, err)
}
}
// TestMemoryVerificationRevoke covers taking the badge back: the flag is cleared
// through the callback, the application stays approved as history and the
// revocation notifies exactly once.
func TestMemoryVerificationRevoke(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetChannel, 9101, "zeta")
approved, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error { return nil })
if err != nil {
t.Fatalf("approve: %v", err)
}
req := domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel, TargetID: 9101,
Reviewer: "admin-b", Reason: "impersonation report upheld",
}
if _, _, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetChannel, TargetID: 9101, Reviewer: "admin-b",
}, func(context.Context, domain.Peer) error { return nil }); !errors.Is(err, domain.ErrVerificationReasonRequired) {
t.Fatalf("revoke without reason err = %v, want ErrVerificationReasonRequired", err)
}
failing := errors.New("peer store unavailable")
if _, _, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
return failing
}); !errors.Is(err, failing) {
t.Fatalf("failing revoke err = %v, want %v", err, failing)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
for _, event := range events {
if event.Kind == domain.VerificationEventRevoked {
t.Fatal("failed revoke appended a revoked event")
}
}
var cleared []domain.Peer
revoked, changed, err := s.RevokeVerification(ctx, req, func(_ context.Context, target domain.Peer) error {
cleared = append(cleared, target)
return nil
})
if err != nil || !changed {
t.Fatalf("revoke: changed=%v err=%v", changed, err)
}
if len(cleared) != 1 || cleared[0] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 9101}) {
t.Fatalf("cleared = %v, want the channel peer", cleared)
}
if revoked.ID != approved.ID || revoked.Status != domain.VerificationStatusApproved {
t.Fatalf("revoked application = %d %s, want %d approved", revoked.ID, revoked.Status, approved.ID)
}
// A second revocation is a no-op: one outbox row, one history entry.
repeat, changed, err := s.RevokeVerification(ctx, req, func(context.Context, domain.Peer) error {
t.Fatal("idempotent revoke invoked the callback")
return nil
})
if err != nil || changed {
t.Fatalf("repeat revoke: changed=%v err=%v", changed, err)
}
if repeat.ID != approved.ID {
t.Fatalf("repeat revoke returned %d, want %d", repeat.ID, approved.ID)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
kinds := make([]string, 0, len(pending))
for _, item := range pending {
kinds = append(kinds, item.Kind)
}
if len(kinds) != 2 || kinds[0] != "approved" || kinds[1] != "revoked" {
t.Fatalf("outbox kinds = %v, want [approved revoked] in that order", kinds)
}
// A target nobody ever applied for is still cleared: a standing flag is worse
// than a missing audit row.
orphan, changed, err := s.RevokeVerification(ctx, domain.VerificationRevocation{
TargetType: domain.VerificationTargetBot, TargetID: 9999,
Reviewer: "admin-b", Reason: "manual flag from an older deployment",
}, func(context.Context, domain.Peer) error { return nil })
if err != nil || !changed || orphan.ID != 0 {
t.Fatalf("orphan revoke: app=%d changed=%v err=%v", orphan.ID, changed, err)
}
}
// TestMemoryVerificationHistoryOrder pins the append-only timeline: newest first,
// with the from/to statuses of every transition.
func TestMemoryVerificationHistoryOrder(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9201, "eta")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: claimed.Version, Reviewer: "admin-a", CorrelationID: "cmd-9",
}, true, func(context.Context, domain.VerificationApplication) error { return nil }); err != nil {
t.Fatalf("approve: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 {
t.Fatalf("pending = %d err=%v", len(pending), err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, pending[0].ID); err != nil {
t.Fatalf("deliver: %v", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
wantKinds := []domain.VerificationApplicationEventKind{
domain.VerificationEventNotified,
domain.VerificationEventApproved,
domain.VerificationEventClaimed,
domain.VerificationEventSubmitted,
domain.VerificationEventCreated,
}
if len(events) != len(wantKinds) {
t.Fatalf("history = %d rows, want %d", len(events), len(wantKinds))
}
for i, kind := range wantKinds {
if events[i].Kind != kind {
t.Fatalf("history[%d] = %s, want %s", i, events[i].Kind, kind)
}
if i > 0 && events[i].ID >= events[i-1].ID {
t.Fatalf("history is not newest-first at %d: %d >= %d", i, events[i].ID, events[i-1].ID)
}
}
approvedEvent := events[1]
if approvedEvent.FromStatus != domain.VerificationStatusInReview ||
approvedEvent.ToStatus != domain.VerificationStatusApproved ||
approvedEvent.Actor != "admin-a" || approvedEvent.CorrelationID != "cmd-9" {
t.Fatalf("approved event = %+v", approvedEvent)
}
if events[3].FromStatus != domain.VerificationStatusDraft ||
events[3].ToStatus != domain.VerificationStatusSubmitted {
t.Fatalf("submitted event = %+v", events[3])
}
}
// TestMemoryVerificationOutboxDelivery walks one notification from pending to
// delivered, including the failure trace a poisoned row leaves behind.
func TestMemoryVerificationOutboxDelivery(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
app := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9301, "theta")
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: app.ID, Version: app.Version, Reviewer: "admin-a", Reason: "no press",
}, false, nil); err != nil {
t.Fatalf("reject: %v", err)
}
pending, err := s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 || pending[0].Attempts != 0 {
t.Fatalf("pending = %+v err=%v", pending, err)
}
id := pending[0].ID
if err := s.MarkVerificationNotificationFailed(ctx, id, "bot blocked by user"); err != nil {
t.Fatalf("fail: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 1 || pending[0].Attempts != 1 {
t.Fatalf("after failure = %+v err=%v, want still pending with 1 attempt", pending, err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("deliver: %v", err)
}
pending, err = s.PendingVerificationNotifications(ctx, 10)
if err != nil || len(pending) != 0 {
t.Fatalf("after delivery = %d rows err=%v, want none", len(pending), err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id); err != nil {
t.Fatalf("repeat deliver: %v", err)
}
if err := s.MarkVerificationNotificationFailed(ctx, id, "late error"); err != nil {
t.Fatalf("fail after delivery: %v", err)
}
if err := s.MarkVerificationNotificationDelivered(ctx, id+1000); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("deliver unknown id err = %v, want ErrVerificationApplicationNotFound", err)
}
events, err := s.VerificationApplicationEvents(ctx, app.ID, 20)
if err != nil {
t.Fatalf("events: %v", err)
}
if events[0].Kind != domain.VerificationEventNotified || events[0].Reason != "rejected" {
t.Fatalf("notified event = %+v, want the rejected notification", events[0])
}
}
// TestMemoryVerificationQueueQueries covers the review-queue projection: status
// and target filters, reviewer scoping, the three search shapes and keyset paging.
func TestMemoryVerificationQueueQueries(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
first := submittedVerificationApplication(t, s, 1001, domain.VerificationTargetBot, 9401, "AlphaBot")
claimed, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: first.ID, Version: first.Version, Reviewer: "admin-a",
})
if err != nil {
t.Fatalf("claim: %v", err)
}
second := submittedVerificationApplication(t, s, 1002, domain.VerificationTargetChannel, 9402, "BetaChannel")
third, _, err := s.CreateVerificationDraft(ctx, verificationTestRequest(1003, domain.VerificationTargetSupergroup, 9403, "gammagroup"))
if err != nil {
t.Fatalf("third draft: %v", err)
}
ids := func(apps []domain.VerificationApplication) []int64 {
out := make([]int64, 0, len(apps))
for _, app := range apps {
out = append(out, app.ID)
}
return out
}
equal := func(got []int64, want ...int64) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
all, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{})
if err != nil {
t.Fatalf("list: %v", err)
}
if !equal(ids(all), third.ID, second.ID, first.ID) {
t.Fatalf("queue order = %v, want newest first", ids(all))
}
got, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{domain.VerificationStatusSubmitted, domain.VerificationStatusInReview},
})
if err != nil || !equal(ids(got), second.ID, first.ID) {
t.Fatalf("status filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: domain.VerificationTargetChannel,
})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("target type filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-a"})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("reviewer filter = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Reviewer: "admin-z"})
if err != nil || len(got) != 0 {
t.Fatalf("unknown reviewer = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
CreatedAt: second.CreatedAt,
})
if err != nil || !equal(ids(got), third.ID, second.ID) {
t.Fatalf("since filter = %v err=%v", ids(got), err)
}
// BeforeID without a cursor timestamp still bounds the page.
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
BeforeID: second.ID,
})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("id-only cursor = %v err=%v", ids(got), err)
}
// Search: application id, peer id, username prefix (with and without @).
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Query: fmt.Sprint(first.ID),
})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("id search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "9402"})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("peer id search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "@beta"})
if err != nil || !equal(ids(got), second.ID) {
t.Fatalf("username search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "ALPHA"})
if err != nil || !equal(ids(got), first.ID) {
t.Fatalf("case-insensitive username search = %v err=%v", ids(got), err)
}
got, err = s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Query: "nobody"})
if err != nil || len(got) != 0 {
t.Fatalf("miss search = %v err=%v", ids(got), err)
}
// Keyset paging over (created_at DESC, id DESC).
page, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{Limit: 2})
if err != nil || !equal(ids(page), third.ID, second.ID) {
t.Fatalf("first page = %v err=%v", ids(page), err)
}
last := page[len(page)-1]
next, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || !equal(ids(next), first.ID) {
t.Fatalf("second page = %v err=%v", ids(next), err)
}
last = next[len(next)-1]
tail, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Limit: 2, Until: last.CreatedAt, BeforeID: last.ID,
})
if err != nil || len(tail) != 0 {
t.Fatalf("third page = %v err=%v, want empty", ids(tail), err)
}
counts, err := s.VerificationStatusCounts(ctx)
if err != nil {
t.Fatalf("counts: %v", err)
}
if counts[domain.VerificationStatusInReview] != 1 ||
counts[domain.VerificationStatusSubmitted] != 1 ||
counts[domain.VerificationStatusDraft] != 1 ||
counts[domain.VerificationStatusApproved] != 0 {
t.Fatalf("counts = %v", counts)
}
mine, err := s.VerificationApplicationsForApplicant(ctx, 1001, 10)
if err != nil || !equal(ids(mine), first.ID) {
t.Fatalf("applicant history = %v err=%v", ids(mine), err)
}
_ = claimed
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
Statuses: []domain.VerificationStatus{"bogus"},
}); !errors.Is(err, domain.ErrVerificationApplicationInvalid) {
t.Fatalf("bogus status filter err = %v, want ErrVerificationApplicationInvalid", err)
}
if _, err := s.ListVerificationApplications(ctx, domain.VerificationApplicationFilter{
TargetType: "bogus",
}); !errors.Is(err, domain.ErrVerificationTargetInvalid) {
t.Fatalf("bogus target filter err = %v, want ErrVerificationTargetInvalid", err)
}
}
// TestMemoryVerificationMissingApplication pins the not-found surface every
// mutation shares.
func TestMemoryVerificationMissingApplication(t *testing.T) {
ctx := context.Background()
s := NewVerificationStore()
if _, err := s.VerificationApplication(ctx, 42); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("read err = %v", err)
}
if _, err := s.SubmitVerificationApplication(ctx, 42, 1); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("submit err = %v", err)
}
if _, err := s.ClaimVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: 42, Version: 1, Reviewer: "admin-a",
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("claim err = %v", err)
}
if _, _, err := s.DecideVerificationApplication(ctx, domain.VerificationDecision{
ApplicationID: 42, Version: 1, Reviewer: "admin-a",
}, true, func(context.Context, domain.VerificationApplication) error {
return nil
}); !errors.Is(err, domain.ErrVerificationApplicationNotFound) {
t.Fatalf("decide err = %v", err)
}
}