Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
392
internal/store/memory/account_rating.go
Normal file
392
internal/store/memory/account_rating.go
Normal 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
|
||||
}
|
||||
425
internal/store/memory/account_rating_test.go
Normal file
425
internal/store/memory/account_rating_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
83
internal/store/memory/auth_delivery_report.go
Normal file
83
internal/store/memory/auth_delivery_report.go
Normal 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
|
||||
}
|
||||
1088
internal/store/memory/bot_verification.go
Normal file
1088
internal/store/memory/bot_verification.go
Normal file
File diff suppressed because it is too large
Load diff
851
internal/store/memory/bot_verification_test.go
Normal file
851
internal/store/memory/bot_verification_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{}{}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
internal/store/memory/client_telemetry.go
Normal file
99
internal/store/memory/client_telemetry.go
Normal 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
|
||||
}
|
||||
62
internal/store/memory/client_telemetry_test.go
Normal file
62
internal/store/memory/client_telemetry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
742
internal/store/memory/collectible_username.go
Normal file
742
internal/store/memory/collectible_username.go
Normal 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
|
||||
}
|
||||
}
|
||||
845
internal/store/memory/collectible_username_test.go
Normal file
845
internal/store/memory/collectible_username_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
200
internal/store/memory/message_no_forwards.go
Normal file
200
internal/store/memory/message_no_forwards.go
Normal 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()
|
||||
}
|
||||
162
internal/store/memory/message_no_forwards_test.go
Normal file
162
internal/store/memory/message_no_forwards_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
159
internal/store/memory/message_saved_reactions.go
Normal file
159
internal/store/memory/message_saved_reactions.go
Normal 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
|
||||
}
|
||||
150
internal/store/memory/message_saved_reactions_test.go
Normal file
150
internal/store/memory/message_saved_reactions_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1010
internal/store/memory/moderation.go
Normal file
1010
internal/store/memory/moderation.go
Normal file
File diff suppressed because it is too large
Load diff
221
internal/store/memory/moderation_case_test.go
Normal file
221
internal/store/memory/moderation_case_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
92
internal/store/memory/moderation_test.go
Normal file
92
internal/store/memory/moderation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1008
internal/store/memory/verification.go
Normal file
1008
internal/store/memory/verification.go
Normal file
File diff suppressed because it is too large
Load diff
760
internal/store/memory/verification_test.go
Normal file
760
internal/store/memory/verification_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue