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
|
|
@ -26,6 +26,13 @@ type AccountSettingsStore interface {
|
|||
SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error
|
||||
}
|
||||
|
||||
// AccountSettingsBatchStore is the cold-load boundary for the per-user account
|
||||
// settings read model. Production stores implement it so a 100-user
|
||||
// users.getRequirementsToContact request never becomes 100 SQL queries.
|
||||
type AccountSettingsBatchStore interface {
|
||||
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
|
||||
}
|
||||
|
||||
// NotifySettingsStore persists per-scope notification settings (specific peer /
|
||||
// forum topic / the three category defaults: users, chats, broadcasts).
|
||||
type NotifySettingsStore interface {
|
||||
|
|
|
|||
49
internal/store/account_rating.go
Normal file
49
internal/store/account_rating.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// AccountRatingStore owns the composite rating read model and its contribution
|
||||
// ledger.
|
||||
//
|
||||
// The read model is derived: SaveAccountRating writes a recomputed projection,
|
||||
// AccountRatingSignals gathers the raw inputs from the contributing tables, and
|
||||
// the ledger keeps manual adjustments that must survive a recompute.
|
||||
type AccountRatingStore interface {
|
||||
// AccountRating returns the stored projection. Missing rows return
|
||||
// domain.ErrAccountRatingNotFound so callers can distinguish "never computed"
|
||||
// from "computed as zero".
|
||||
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
|
||||
// AccountRatingBatch resolves several users in one round trip. Users without
|
||||
// a row are absent from the map.
|
||||
AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error)
|
||||
// SaveAccountRating upserts the projection using optimistic concurrency on
|
||||
// the stored version; a stale version reports changed=false.
|
||||
SaveAccountRating(ctx context.Context, rating domain.AccountRating) (stored domain.AccountRating, changed bool, err error)
|
||||
// AccountRatingSignals gathers the raw contribution snapshot for one user,
|
||||
// including the manual total carried from the ledger.
|
||||
AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error)
|
||||
// AdjustAccountRating appends a manual adjustment. Replaying the same
|
||||
// CommandKey returns the recorded event and applied=false.
|
||||
AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (event domain.AccountRatingEvent, applied bool, err error)
|
||||
// ListAccountRatings is the admin leaderboard query with keyset paging.
|
||||
ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
|
||||
// AccountRatingEvents returns the ledger for one user, newest first.
|
||||
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
|
||||
// StaleAccountRatings returns user ids whose projection is older than the
|
||||
// given horizon, for the background recompute worker.
|
||||
StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error)
|
||||
// UnratedAccounts returns user ids that have no projection at all, oldest
|
||||
// account first, for the same worker.
|
||||
//
|
||||
// Without this the read model can never populate itself: StaleAccountRatings
|
||||
// walks account_rating, so it can only refresh rows that already exist, and the
|
||||
// very first row for a user would have to come from an operator recomputing
|
||||
// that user by hand. Seeding is what makes the local admin leaderboard useful.
|
||||
//
|
||||
// Deleted accounts and bots are excluded: neither has a rating to show.
|
||||
UnratedAccounts(ctx context.Context, limit int) ([]int64, error)
|
||||
}
|
||||
13
internal/store/auth_delivery_report.go
Normal file
13
internal/store/auth_delivery_report.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type AuthDeliveryReportStore interface {
|
||||
CreateAuthDeliveryReport(ctx context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error)
|
||||
DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
}
|
||||
97
internal/store/bot_verification.go
Normal file
97
internal/store/bot_verification.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// BotVerificationStore owns third-party verification: the icon catalogue, verifier
|
||||
// status, the granted marks and the application queue in front of them.
|
||||
//
|
||||
// Reads on the projection path (PeerVerification / PeerVerificationBatch) are on
|
||||
// every peer serialisation, so they must be cheap. The wire model carries one
|
||||
// BotVerification, and the store therefore permits exactly one mark per peer.
|
||||
type BotVerificationStore interface {
|
||||
// --- icon catalogue ---
|
||||
|
||||
// UpsertVerificationIcon adds or updates a catalogue entry by document id.
|
||||
UpsertVerificationIcon(ctx context.Context, icon domain.VerificationIcon) (domain.VerificationIcon, error)
|
||||
// SetVerificationIconActive retires or restores an entry. Marks already granted
|
||||
// with it keep rendering: the icon id is denormalised onto the mark.
|
||||
SetVerificationIconActive(ctx context.Context, iconID int64, active bool) (domain.VerificationIcon, error)
|
||||
// VerificationIcon reads one entry by id.
|
||||
VerificationIcon(ctx context.Context, iconID int64) (domain.VerificationIcon, error)
|
||||
// VerificationIconByDocument reads one entry by its custom emoji document id.
|
||||
VerificationIconByDocument(ctx context.Context, documentID int64) (domain.VerificationIcon, error)
|
||||
// ListVerificationIcons lists the catalogue, newest first.
|
||||
ListVerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
|
||||
|
||||
// --- verifier status ---
|
||||
|
||||
// UpsertBotVerifierSettings grants or updates verifier status. Optimistic
|
||||
// locking on the stored version keeps two operators from clobbering each other.
|
||||
UpsertBotVerifierSettings(ctx context.Context, settings domain.BotVerifierSettings) (domain.BotVerifierSettings, error)
|
||||
// SetBotVerifierEnabled flips the operator kill switch. Existing marks stay,
|
||||
// but the verifier can grant nothing new and its settings stop being projected.
|
||||
SetBotVerifierEnabled(ctx context.Context, botID int64, enabled bool) (domain.BotVerifierSettings, error)
|
||||
// DeleteBotVerifierSettings removes verifier status; its marks cascade away with
|
||||
// it, because a mark whose verifier no longer exists has nothing to render.
|
||||
DeleteBotVerifierSettings(ctx context.Context, botID int64) (bool, error)
|
||||
// BotVerifierSettings reads one verifier's status, enabled or not.
|
||||
BotVerifierSettings(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
|
||||
// BotVerifierSettingsBatch resolves several bots in one round trip for the
|
||||
// botInfo projection; bots without verifier status are absent from the map.
|
||||
BotVerifierSettingsBatch(ctx context.Context, botIDs []int64) (map[int64]domain.BotVerifierSettings, error)
|
||||
// ListBotVerifiers lists verifier bots for the admin panel.
|
||||
ListBotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
|
||||
|
||||
// --- granted marks ---
|
||||
|
||||
// GrantCustomVerification creates or updates the peer's mark. A different
|
||||
// verifier replaces the current mark rather than leaving hidden fallback state.
|
||||
// The icon is taken from the verifier's settings at grant time and the caller
|
||||
// has already resolved the description through
|
||||
// domain.BotVerifierSettings.DescriptionFor.
|
||||
GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error)
|
||||
// RevokeCustomVerification removes this verifier's mark from the peer and
|
||||
// reports whether anything was removed, so a repeated revoke is a no-op.
|
||||
RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
|
||||
// CustomVerification reads one verifier's mark on a peer.
|
||||
CustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerification, error)
|
||||
// PeerVerification returns the peer's single mark. Missing marks report
|
||||
// domain.ErrCustomVerificationNotFound.
|
||||
PeerVerification(ctx context.Context, peer domain.Peer) (domain.CustomVerification, error)
|
||||
// PeerVerificationBatch resolves the projection for many peers at once. This is
|
||||
// the call on the hot serialisation path, so peers without a mark are simply
|
||||
// absent instead of erroring.
|
||||
PeerVerificationBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer]domain.CustomVerification, error)
|
||||
// CountCustomVerifications reports how many peers a verifier has marked, for the
|
||||
// per-verifier bound.
|
||||
CountCustomVerifications(ctx context.Context, verifierBotID int64) (int, error)
|
||||
// ListCustomVerifications is the admin listing query with keyset paging.
|
||||
ListCustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
|
||||
|
||||
// --- application queue ---
|
||||
|
||||
// CreateCustomVerificationRequest files an application. A pending application on
|
||||
// the same (verifier, peer) reports domain.ErrCustomVerificationRequestExists.
|
||||
CreateCustomVerificationRequest(ctx context.Context, req domain.CustomVerificationRequest) (domain.CustomVerificationRequest, error)
|
||||
// DecideCustomVerificationRequest moves an application through its status
|
||||
// machine. approve=true grants the mark in the same transaction through the
|
||||
// supplied callback, so an approved application can never exist without its
|
||||
// mark; revoke removes it the same way.
|
||||
DecideCustomVerificationRequest(ctx context.Context, requestID int64, version int64, status domain.CustomVerificationRequestStatus, decidedBy, reason, note string, apply func(ctx context.Context, req domain.CustomVerificationRequest) error) (domain.CustomVerificationRequest, bool, error)
|
||||
// CustomVerificationRequest reads one application.
|
||||
CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
|
||||
// PendingCustomVerificationRequest returns the live application for a
|
||||
// (verifier, peer) pair, if any.
|
||||
PendingCustomVerificationRequest(ctx context.Context, verifierBotID int64, peer domain.Peer) (domain.CustomVerificationRequest, error)
|
||||
// ListCustomVerificationRequests is the review-queue query with keyset paging.
|
||||
ListCustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
|
||||
// CustomVerificationRequestsForApplicant returns an applicant's own history for
|
||||
// the verifier bot's /status command.
|
||||
CustomVerificationRequestsForApplicant(ctx context.Context, applicantUserID int64, limit int) ([]domain.CustomVerificationRequest, error)
|
||||
// CustomVerificationRequestCounts is the queue summary by status.
|
||||
CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
|
||||
}
|
||||
|
|
@ -82,8 +82,6 @@ type ChannelStore interface {
|
|||
ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
|
||||
ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
|
||||
ClearRecentMessageReactions(ctx context.Context, userID int64) error
|
||||
ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error)
|
||||
UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error
|
||||
GetPremiumBoostStatus(ctx context.Context, viewerUserID, channelID int64, now int) (domain.PremiumBoostStatus, error)
|
||||
ListPremiumBoosts(ctx context.Context, viewerUserID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error)
|
||||
GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error)
|
||||
|
|
@ -188,6 +186,10 @@ type ChannelStore interface {
|
|||
ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error)
|
||||
ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error)
|
||||
FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||
// FilterChannelMessageAudienceIDs authoritatively intersects a bounded online
|
||||
// candidate set with users allowed to receive channel message-box updates:
|
||||
// active members plus non-banned public-channel preview subscribers.
|
||||
FilterChannelMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error)
|
||||
MaxChannelPts(ctx context.Context, channelID int64) (int, error)
|
||||
// MaxChannelPtsBatch returns existing channel watermarks with one bounded store round trip.
|
||||
// Missing/deleted ids are omitted so a stale process-local membership key cannot poison the
|
||||
|
|
|
|||
13
internal/store/client_telemetry.go
Normal file
13
internal/store/client_telemetry.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type ClientTelemetryStore interface {
|
||||
CreateClientTelemetry(ctx context.Context, event domain.ClientTelemetryEvent) (domain.ClientTelemetryEvent, bool, error)
|
||||
DeleteExpiredClientTelemetry(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
}
|
||||
|
|
@ -76,6 +76,11 @@ type PhoneCode struct {
|
|||
VerifiedEmail bool
|
||||
RequireSignUp bool
|
||||
LoginEmailHash string
|
||||
// RecoveryBinding ties a password-recovery code to the exact 2FA state
|
||||
// which existed when the code was issued. A password or recovery-email
|
||||
// change makes an older code unusable without relying on cross-store
|
||||
// best-effort invalidation.
|
||||
RecoveryBinding string
|
||||
// AccountDeletionHash is the hex-encoded SHA-256 digest of the validated
|
||||
// confirmphone link token. It binds account.confirmPhone to one pending
|
||||
// deletion without persisting the raw link credential in the code record.
|
||||
|
|
|
|||
61
internal/store/collectible_username.go
Normal file
61
internal/store/collectible_username.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// UsernameRegistryStore reads a peer's full username list. The list is the
|
||||
// projection source for the TL usernames vector, so every caller sees the same
|
||||
// stored order the client will render. Reorder may promote a collectible ahead
|
||||
// of the editable slot; the first active row is also the Layer 228 main scalar.
|
||||
type UsernameRegistryStore interface {
|
||||
// PeerUsernames returns the peer's registry rows in projection order.
|
||||
PeerUsernames(ctx context.Context, peer domain.Peer) ([]domain.Username, error)
|
||||
// PeerUsernamesBatch resolves several peers in one round trip; peers absent
|
||||
// from the result simply hold no usernames.
|
||||
PeerUsernamesBatch(ctx context.Context, peers []domain.Peer) (map[domain.Peer][]domain.Username, error)
|
||||
// SetUsernameActive toggles one collectible row. It never touches the
|
||||
// editable slot and returns domain.ErrUsernameNotCollectible if asked to.
|
||||
SetUsernameActive(ctx context.Context, peer domain.Peer, username string, active bool) (bool, error)
|
||||
// ReorderUsernames rewrites collectible sort order. order must be a
|
||||
// permutation of the peer's collectible usernames.
|
||||
ReorderUsernames(ctx context.Context, peer domain.Peer, order []string) (bool, error)
|
||||
// DeactivateAllUsernames clears the active flag on every collectible row and
|
||||
// reports whether anything changed. The editable slot is left alone.
|
||||
DeactivateAllUsernames(ctx context.Context, peer domain.Peer) (bool, error)
|
||||
}
|
||||
|
||||
// CollectibleUsernameStore owns the collectible asset lifecycle: minting into the
|
||||
// operator vault, assigning to a holder, returning to the vault and burning.
|
||||
//
|
||||
// Every mutation is expected to be atomic with the corresponding username
|
||||
// registry row, so an asset can never be owned without being resolvable and can
|
||||
// never be resolvable without being owned.
|
||||
type CollectibleUsernameStore interface {
|
||||
// MintCollectibleUsername creates the asset. A request carrying an owner
|
||||
// assigns it in the same transaction. Replaying the same CommandKey returns
|
||||
// the original asset and reports created=false.
|
||||
MintCollectibleUsername(ctx context.Context, req domain.MintCollectibleUsernameRequest) (asset domain.CollectibleUsername, created bool, err error)
|
||||
// TransferCollectibleUsername moves the asset to req.To, out of the vault or
|
||||
// from the current holder. Replay by CommandKey is a no-op returning the
|
||||
// current state with changed=false.
|
||||
TransferCollectibleUsername(ctx context.Context, req domain.TransferCollectibleUsernameRequest) (asset domain.CollectibleUsername, changed bool, err error)
|
||||
// RevokeCollectibleUsername returns the asset to the vault, or burns it when
|
||||
// req.Burn is set. Burning releases the name back to the free pool.
|
||||
RevokeCollectibleUsername(ctx context.Context, req domain.RevokeCollectibleUsernameRequest) (asset domain.CollectibleUsername, changed bool, err error)
|
||||
// DeleteCollectibleUsername removes the live asset for a name completely --
|
||||
// registry row, asset and provenance -- and frees the name for any use. A
|
||||
// replay finds nothing live left and reports deleted=false without an error,
|
||||
// because the record a command key would key on is gone.
|
||||
DeleteCollectibleUsername(ctx context.Context, req domain.DeleteCollectibleUsernameRequest) (deleted bool, err error)
|
||||
// CollectibleUsername looks the asset up by name.
|
||||
CollectibleUsername(ctx context.Context, username string) (domain.CollectibleUsername, error)
|
||||
// CollectibleUsernameByID looks the asset up by identity.
|
||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||
// ListCollectibleUsernames is the admin listing query with keyset paging.
|
||||
ListCollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||
// CollectibleUsernameTransfers returns the provenance log, newest first.
|
||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ type MessageStore interface {
|
|||
GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error)
|
||||
SetMessageReactions(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
|
||||
GetMessageReactions(ctx context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error)
|
||||
ListSavedReactionTags(ctx context.Context, req domain.SavedReactionTagsRequest) ([]domain.SavedReactionTag, error)
|
||||
UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error
|
||||
VoteMessagePoll(ctx context.Context, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error)
|
||||
CloseMessagePoll(ctx context.Context, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error)
|
||||
EditMessage(ctx context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error)
|
||||
|
|
|
|||
64
internal/store/moderation.go
Normal file
64
internal/store/moderation.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ModerationReportStore atomically persists an immutable report, all evidence
|
||||
// items and media holds. A retry with the same fingerprint returns the original
|
||||
// report and created=false; implementations must never create partial items.
|
||||
type ModerationReportStore interface {
|
||||
CreateModerationReport(ctx context.Context, report domain.ModerationReport) (stored domain.ModerationReport, created bool, err error)
|
||||
GetModerationReport(ctx context.Context, reportID int64) (domain.ModerationReport, bool, error)
|
||||
}
|
||||
|
||||
type ModerationEvidenceRegistryStore interface {
|
||||
CreateSponsoredMessageImpression(ctx context.Context, impression domain.SponsoredMessageImpression) (domain.SponsoredMessageImpression, bool, error)
|
||||
GetSponsoredMessageImpression(ctx context.Context, userID int64, randomIDHash [32]byte, now time.Time) (domain.SponsoredMessageImpression, bool, error)
|
||||
CreateSponsoredModerationReport(ctx context.Context, impressionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error)
|
||||
CreateChannelAntiSpamDecision(ctx context.Context, decision domain.ChannelAntiSpamDecision) (domain.ChannelAntiSpamDecision, bool, error)
|
||||
GetChannelAntiSpamDecision(ctx context.Context, channelID int64, messageID int) (domain.ChannelAntiSpamDecision, bool, error)
|
||||
CreateAntiSpamFalsePositiveReport(ctx context.Context, decisionID int64, report domain.ModerationReport) (domain.ModerationReport, bool, error)
|
||||
DeleteExpiredSponsoredMessageImpressions(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
}
|
||||
|
||||
// LegacyEphemeralReport is an immutable row produced before ephemeral reports
|
||||
// joined the unified moderation pipeline.
|
||||
type LegacyEphemeralReport struct {
|
||||
ID int64
|
||||
Report domain.EphemeralAbuseReport
|
||||
}
|
||||
|
||||
// LegacyEphemeralReportReader exposes only still-unmapped rows. It exists for
|
||||
// the startup migration and must not be injected into RPC handlers.
|
||||
type LegacyEphemeralReportReader interface {
|
||||
ListUnmigratedEphemeralReports(ctx context.Context, limit int) ([]LegacyEphemeralReport, error)
|
||||
}
|
||||
|
||||
// LegacyEphemeralReportImporter atomically inserts the unified report and its
|
||||
// provenance mapping. Historical imports bypass submission rate limits but
|
||||
// retain normal report validation and fingerprint idempotency.
|
||||
type LegacyEphemeralReportImporter interface {
|
||||
ImportLegacyEphemeralReport(ctx context.Context, legacyReportID int64, report domain.ModerationReport) (stored domain.ModerationReport, created bool, err error)
|
||||
}
|
||||
|
||||
type ModerationCaseStore interface {
|
||||
ListModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
|
||||
GetModerationCase(ctx context.Context, caseID int64) (domain.ModerationCaseDetail, bool, error)
|
||||
ClaimModerationCase(ctx context.Context, caseID, expectedVersion int64, actor string, now time.Time) (domain.ModerationCase, error)
|
||||
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
|
||||
CreateModerationAppeal(ctx context.Context, appeal domain.ModerationAppeal) (domain.ModerationAppeal, bool, error)
|
||||
GetModerationAppeal(ctx context.Context, appealID int64) (domain.ModerationAppeal, bool, error)
|
||||
IssueModerationAppealLink(ctx context.Context, link domain.ModerationAppealLink) (domain.ModerationAppealLink, error)
|
||||
GetModerationAppealLink(ctx context.Context, tokenHash [32]byte, now time.Time) (domain.ModerationAppealLink, bool, error)
|
||||
SubmitModerationAppealByLink(ctx context.Context, tokenHash [32]byte, text string, now time.Time) (domain.ModerationAppeal, bool, error)
|
||||
DeleteExpiredModerationAppealLinks(ctx context.Context, olderThan time.Time, limit int) (int, error)
|
||||
ClaimModerationActions(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.ModerationAction, error)
|
||||
IsModerationActionCurrent(ctx context.Context, action domain.ModerationAction) (bool, error)
|
||||
SupersedeModerationAction(ctx context.Context, actionID int64, expectedAttempts int, now time.Time) error
|
||||
CompleteModerationAction(ctx context.Context, actionID int64, expectedAttempts int, succeeded bool, errorText string, retryAt, now time.Time) error
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
|
@ -36,18 +35,17 @@ SELECT
|
|||
email_unconfirmed_pattern, login_email_pattern, secure_random,
|
||||
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
|
||||
srp_id, srp_verifier, srp_b_secret, srp_b,
|
||||
recovery_email, recovery_code, recovery_code_expires_at, login_email
|
||||
recovery_email, login_email
|
||||
FROM account_passwords
|
||||
WHERE user_id = $1`, userID)
|
||||
var settings domain.PasswordSettings
|
||||
var salt1, salt2, p []byte
|
||||
var recoveryExpires sql.NullTime
|
||||
if err := row.Scan(
|
||||
&settings.HasRecovery, &settings.HasSecureValues, &settings.HasPassword, &settings.Hint,
|
||||
&settings.EmailUnconfirmedPattern, &settings.LoginEmailPattern, &settings.SecureRandom,
|
||||
&salt1, &salt2, &settings.NewAlgo.G, &p,
|
||||
&settings.SRPID, &settings.SRPVerifier, &settings.SRPBSecret, &settings.SRPB,
|
||||
&settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires, &settings.LoginEmail,
|
||||
&settings.RecoveryEmail, &settings.LoginEmail,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.PasswordSettings{}, false, nil
|
||||
|
|
@ -65,9 +63,6 @@ WHERE user_id = $1`, userID)
|
|||
settings.NewAlgo.Salt1 = append([]byte(nil), salt1...)
|
||||
settings.NewAlgo.Salt2 = append([]byte(nil), salt2...)
|
||||
settings.NewAlgo.P = append([]byte(nil), p...)
|
||||
if recoveryExpires.Valid {
|
||||
settings.RecoveryCodeExpiresAt = recoveryExpires.Time.Unix()
|
||||
}
|
||||
settings.SecureRandom = append([]byte(nil), settings.SecureRandom...)
|
||||
settings.SRPVerifier = append([]byte(nil), settings.SRPVerifier...)
|
||||
settings.SRPBSecret = append([]byte(nil), settings.SRPBSecret...)
|
||||
|
|
@ -102,19 +97,15 @@ func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.
|
|||
if settings.CurrentAlgo != nil {
|
||||
algo = *settings.CurrentAlgo
|
||||
}
|
||||
var recoveryExpires any
|
||||
if settings.RecoveryCodeExpiresAt > 0 {
|
||||
recoveryExpires = time.Unix(settings.RecoveryCodeExpiresAt, 0)
|
||||
}
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO account_passwords (
|
||||
user_id, has_recovery, has_secure_values, has_password, hint,
|
||||
email_unconfirmed_pattern, login_email_pattern, secure_random,
|
||||
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
|
||||
srp_id, srp_verifier, srp_b_secret, srp_b,
|
||||
recovery_email, recovery_code, recovery_code_expires_at, login_email
|
||||
recovery_email, login_email
|
||||
)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
has_recovery = EXCLUDED.has_recovery,
|
||||
has_secure_values = EXCLUDED.has_secure_values,
|
||||
|
|
@ -132,8 +123,6 @@ ON CONFLICT (user_id) DO UPDATE SET
|
|||
srp_b_secret = EXCLUDED.srp_b_secret,
|
||||
srp_b = EXCLUDED.srp_b,
|
||||
recovery_email = EXCLUDED.recovery_email,
|
||||
recovery_code = EXCLUDED.recovery_code,
|
||||
recovery_code_expires_at = EXCLUDED.recovery_code_expires_at,
|
||||
login_email = EXCLUDED.login_email,
|
||||
updated_at = now()`,
|
||||
userID,
|
||||
|
|
@ -141,7 +130,7 @@ ON CONFLICT (user_id) DO UPDATE SET
|
|||
settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, nonNilBytea(settings.SecureRandom),
|
||||
nonNilBytea(algo.Salt1), nonNilBytea(algo.Salt2), algo.G, nonNilBytea(algo.P),
|
||||
settings.SRPID, nonNilBytea(settings.SRPVerifier), nonNilBytea(settings.SRPBSecret), nonNilBytea(settings.SRPB),
|
||||
settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, settings.LoginEmail,
|
||||
settings.RecoveryEmail, settings.LoginEmail,
|
||||
)
|
||||
if err != nil {
|
||||
if isAccountPasswordLoginEmailUnique(err) {
|
||||
|
|
@ -261,6 +250,41 @@ WHERE user_id = $1`, userID)
|
|||
return settings, true, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
|
||||
out := make(map[int64]domain.AccountSettings, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
|
||||
hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
|
||||
noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent
|
||||
FROM account_settings
|
||||
WHERE user_id = ANY($1::bigint[])`, userIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get account settings batch: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
settings := domain.DefaultAccountSettings()
|
||||
gp := &settings.GlobalPrivacy
|
||||
if err := rows.Scan(
|
||||
&userID,
|
||||
&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
|
||||
&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
|
||||
&gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan account settings batch: %w", err)
|
||||
}
|
||||
out[userID] = settings
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account settings batch: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error {
|
||||
gp := settings.GlobalPrivacy
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, user
|
|||
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
|
||||
return domain.AccountDeletionResult{}, err
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, "", ""); err != nil {
|
||||
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
|
|
@ -505,7 +505,10 @@ FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, use
|
|||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
if err := deleteRevocationTargetsTx(ctx, tx, []int64{id}); err != nil {
|
||||
// Cancelling a pending account deletion deliberately retires the requester
|
||||
// protocol identity; unlike remote device revocation, this path does not need
|
||||
// to preserve the key for a client-visible RPC 401 transition.
|
||||
if err := deleteProtocolAuthIdentitiesTx(ctx, tx, []int64{id}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []domain.Authorization{a}, nil
|
||||
|
|
|
|||
557
internal/store/postgres/account_rating.go
Normal file
557
internal/store/postgres/account_rating.go
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// AccountRatingStore is the PostgreSQL implementation of the composite account
|
||||
// rating read model and its contribution ledger.
|
||||
//
|
||||
// account_rating is derived state: it is always rebuildable from the contributing
|
||||
// tables plus the 'manual' rows of account_rating_events, which is exactly what
|
||||
// AccountRatingSignals gathers. Writes use optimistic concurrency on the stored
|
||||
// version so a background recompute and an admin adjustment cannot silently
|
||||
// overwrite each other.
|
||||
type AccountRatingStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
// NewAccountRatingStore builds the store on a pgx pool or transaction.
|
||||
func NewAccountRatingStore(db sqlcgen.DBTX) *AccountRatingStore {
|
||||
return &AccountRatingStore{db: db}
|
||||
}
|
||||
|
||||
var _ store.AccountRatingStore = (*AccountRatingStore)(nil)
|
||||
|
||||
const (
|
||||
defaultAccountRatingListLimit = 50
|
||||
maxAccountRatingListLimit = 200
|
||||
)
|
||||
|
||||
const accountRatingColumns = `user_id, level, stars, current_level_stars, next_level_stars,
|
||||
stars_component, activity_component, penalty_component, manual_component,
|
||||
pending_stars, pending_date, computed_at, updated_at, version`
|
||||
|
||||
// accountRatingColumnsQualified is the same projection for queries that join, so
|
||||
// the shared column names stay unambiguous.
|
||||
const accountRatingColumnsQualified = `r.user_id, r.level, r.stars, r.current_level_stars, r.next_level_stars,
|
||||
r.stars_component, r.activity_component, r.penalty_component, r.manual_component,
|
||||
r.pending_stars, r.pending_date, r.computed_at, r.updated_at, r.version`
|
||||
|
||||
// AccountRating returns the stored projection, distinguishing "never computed"
|
||||
// from "computed as zero".
|
||||
func (s *AccountRatingStore) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRating{}, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
rating, err := scanAccountRating(s.db.QueryRow(ctx, `
|
||||
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = $1`, userID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRating{}, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, fmt.Errorf("get account rating: %w", err)
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
// AccountRatingBatch resolves several users in one round trip.
|
||||
func (s *AccountRatingStore) AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
out := make(map[int64]domain.AccountRating, len(userIDs))
|
||||
filtered := make([]int64, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if userID > 0 {
|
||||
filtered = append(filtered, userID)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = ANY($1::bigint[])`, filtered)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account ratings batch: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
rating, err := scanAccountRating(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account rating batch: %w", err)
|
||||
}
|
||||
out[rating.UserID] = rating
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account ratings batch: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveAccountRating upserts the projection under optimistic concurrency: the
|
||||
// caller submits the version it intends to write (prev.Version + 1, which is what
|
||||
// domain.ResolveAccountRatingPending produces), and the update only lands when the
|
||||
// stored row is still one version behind. A stale write reports changed=false and
|
||||
// returns the row that won, so the caller can recompute instead of retrying blind.
|
||||
func (s *AccountRatingStore) SaveAccountRating(ctx context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRating{}, false, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if rating.UserID <= 0 || rating.Level < 0 || rating.Level > domain.MaxAccountRatingLevel ||
|
||||
rating.CurrentLevelStars < 0 || rating.StarsComponent < 0 ||
|
||||
rating.ActivityComponent < 0 || rating.PenaltyComponent < 0 {
|
||||
return domain.AccountRating{}, false, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
if rating.Version <= 0 {
|
||||
rating.Version = 1
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if rating.ComputedAt.IsZero() {
|
||||
rating.ComputedAt = now
|
||||
}
|
||||
if rating.UpdatedAt.IsZero() {
|
||||
rating.UpdatedAt = now
|
||||
}
|
||||
// The schema pairs pending_stars with pending_date; a half-filled pending
|
||||
// record is normalised away rather than rejected by the CHECK at runtime.
|
||||
if rating.PendingStars == 0 || rating.PendingDate.IsZero() {
|
||||
rating.PendingStars = 0
|
||||
rating.PendingDate = time.Time{}
|
||||
}
|
||||
var nextLevelStars any
|
||||
if rating.HasNextLevel && rating.NextLevelStars > rating.CurrentLevelStars {
|
||||
nextLevelStars = rating.NextLevelStars
|
||||
}
|
||||
var pendingDate any
|
||||
if rating.PendingStars != 0 {
|
||||
pendingDate = rating.PendingDate.UTC()
|
||||
}
|
||||
stored, err := scanAccountRating(s.db.QueryRow(ctx, `
|
||||
INSERT INTO account_rating (
|
||||
user_id, level, stars, current_level_stars, next_level_stars,
|
||||
stars_component, activity_component, penalty_component, manual_component,
|
||||
pending_stars, pending_date, computed_at, updated_at, version
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
level = EXCLUDED.level,
|
||||
stars = EXCLUDED.stars,
|
||||
current_level_stars = EXCLUDED.current_level_stars,
|
||||
next_level_stars = EXCLUDED.next_level_stars,
|
||||
stars_component = EXCLUDED.stars_component,
|
||||
activity_component = EXCLUDED.activity_component,
|
||||
penalty_component = EXCLUDED.penalty_component,
|
||||
manual_component = EXCLUDED.manual_component,
|
||||
pending_stars = EXCLUDED.pending_stars,
|
||||
pending_date = EXCLUDED.pending_date,
|
||||
computed_at = EXCLUDED.computed_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
version = EXCLUDED.version
|
||||
WHERE account_rating.version = EXCLUDED.version - 1
|
||||
RETURNING `+accountRatingColumns,
|
||||
rating.UserID, rating.Level, rating.Stars, rating.CurrentLevelStars, nextLevelStars,
|
||||
rating.StarsComponent, rating.ActivityComponent, rating.PenaltyComponent, rating.ManualComponent,
|
||||
rating.PendingStars, pendingDate, rating.ComputedAt.UTC(), rating.UpdatedAt.UTC(), rating.Version,
|
||||
))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// The guard rejected the write; report the row that is actually stored.
|
||||
current, getErr := s.AccountRating(ctx, rating.UserID)
|
||||
if getErr != nil {
|
||||
return domain.AccountRating{}, false, getErr
|
||||
}
|
||||
return current, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRating{}, false, fmt.Errorf("save account rating: %w", err)
|
||||
}
|
||||
return stored, true, nil
|
||||
}
|
||||
|
||||
// AccountRatingSignals gathers the raw contribution snapshot for one user.
|
||||
//
|
||||
// Sources, and why each one:
|
||||
//
|
||||
// stars received / spent stars_transactions, split by the sign of amount: that
|
||||
// ledger is the single authoritative record of Stars
|
||||
// movement for a user, and stars_transactions_user_id_idx
|
||||
// (user_id, id DESC) bounds the scan to the user's rows.
|
||||
// gifts received peer_star_gifts for the user peer, restricted to
|
||||
// lifecycle_status = 'active' -- the weight rewards gifts
|
||||
// actually held, not ones converted, burned or exported
|
||||
// away. peer_star_gifts_owner_profile_order_idx is the
|
||||
// partial index on exactly that predicate and leads with
|
||||
// (owner_peer_type, owner_peer_id).
|
||||
// moderation cases moderation_cases against this user peer, restricted to
|
||||
// the statuses that follow a *violation* decision:
|
||||
// 'action_pending' (violation decided, actions running),
|
||||
// 'action_failed' (violation decided, action delivery
|
||||
// broke) and 'resolved' (violation decided and actions
|
||||
// applied). 'dismissed' covers both no_violation and a
|
||||
// granted appeal, and 'open'/'in_review'/'appeal_review'
|
||||
// are undecided, so none of them penalise the account.
|
||||
// scam / fake users.scam / users.fake, the peer flags 0136 added.
|
||||
// account age users.created_at, floored to whole days.
|
||||
// messages sent message_boxes, counted through
|
||||
// message_boxes_private_sender_live_idx
|
||||
// (message_sender_id, private_message_id) WHERE NOT
|
||||
// deleted. This is the cheapest trustworthy source: the
|
||||
// index leads with the sender and is coverable, so the
|
||||
// count needs no heap access. Every private message
|
||||
// materialises one box per participant, hence
|
||||
// count(DISTINCT private_message_id) rather than
|
||||
// count(*). Channel posts are deliberately excluded --
|
||||
// channel_messages has no sender-leading index, so
|
||||
// attributing them would cost a full table scan.
|
||||
// manual sum of account_rating_events.amount where
|
||||
// kind = 'manual', which is the part of the score that
|
||||
// must survive a full recompute.
|
||||
func (s *AccountRatingStore) AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRatingSignals{}, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
|
||||
}
|
||||
signals := domain.AccountRatingSignals{UserID: userID}
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE((SELECT sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount > 0), 0),
|
||||
COALESCE((SELECT -sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount < 0), 0),
|
||||
COALESCE((
|
||||
SELECT count(DISTINCT private_message_id) FROM message_boxes
|
||||
WHERE message_sender_id = u.id AND NOT deleted
|
||||
), 0),
|
||||
GREATEST(0, FLOOR(EXTRACT(EPOCH FROM ($2::timestamptz - u.created_at)) / 86400))::bigint,
|
||||
COALESCE((
|
||||
SELECT count(*) FROM peer_star_gifts
|
||||
WHERE owner_peer_type = 'user' AND owner_peer_id = u.id AND lifecycle_status = 'active'
|
||||
), 0),
|
||||
COALESCE((
|
||||
SELECT count(*) FROM moderation_cases
|
||||
WHERE target_peer_type = 'user' AND target_peer_id = u.id
|
||||
AND status IN ('action_pending', 'action_failed', 'resolved')
|
||||
), 0),
|
||||
u.scam,
|
||||
u.fake,
|
||||
COALESCE((
|
||||
SELECT sum(amount) FROM account_rating_events
|
||||
WHERE user_id = u.id AND kind = 'manual'
|
||||
), 0)
|
||||
FROM users u
|
||||
WHERE u.id = $1`, userID, time.Now().UTC()).Scan(
|
||||
&signals.StarsReceived, &signals.StarsSpent, &signals.MessagesSent,
|
||||
&signals.AccountAgeDays, &signals.GiftsReceived, &signals.ModerationCases,
|
||||
&signals.Scam, &signals.Fake, &signals.Manual,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRatingSignals{}, domain.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRatingSignals{}, fmt.Errorf("gather account rating signals: %w", err)
|
||||
}
|
||||
return signals, nil
|
||||
}
|
||||
|
||||
// AdjustAccountRating appends a manual adjustment to the ledger. It does not
|
||||
// recompute the projection: the caller pairs it with SaveAccountRating so the new
|
||||
// manual total is folded in through the same formula as every other signal.
|
||||
// Replaying the same CommandKey returns the recorded event and applied=false.
|
||||
func (s *AccountRatingStore) AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AccountRatingEvent{}, false, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.Actor = strings.TrimSpace(req.Actor)
|
||||
req.CommandKey = strings.TrimSpace(req.CommandKey)
|
||||
if err := req.Validate(); err != nil {
|
||||
return domain.AccountRatingEvent{}, false, err
|
||||
}
|
||||
var event domain.AccountRatingEvent
|
||||
applied := false
|
||||
err := withTx(ctx, s.db, "adjust account rating", func(tx pgx.Tx) error {
|
||||
if existing, found, err := accountRatingEventByCommandKey(ctx, tx, req.CommandKey); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
event = existing
|
||||
return nil
|
||||
}
|
||||
event = domain.AccountRatingEvent{
|
||||
UserID: req.UserID,
|
||||
Kind: domain.AccountRatingEventManual,
|
||||
Amount: req.Amount,
|
||||
Reason: req.Reason,
|
||||
Actor: req.Actor,
|
||||
CommandKey: req.CommandKey,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
// DO NOTHING on the partial command_key index closes the window between the
|
||||
// replay lookup and the insert: a concurrent retry of the same command
|
||||
// records nothing and falls back to reading the row that won.
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO account_rating_events (user_id, kind, amount, reason, actor, command_key, created_at)
|
||||
VALUES ($1,'manual',$2,$3,$4,NULLIF($5,''),$6)
|
||||
ON CONFLICT (command_key) WHERE command_key IS NOT NULL DO NOTHING
|
||||
RETURNING id`, event.UserID, event.Amount, event.Reason, event.Actor, event.CommandKey, event.CreatedAt).
|
||||
Scan(&event.ID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
existing, found, lookupErr := accountRatingEventByCommandKey(ctx, tx, req.CommandKey)
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("insert account rating adjustment: conflicting command %q vanished", req.CommandKey)
|
||||
}
|
||||
event = existing
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert account rating adjustment: %w", err)
|
||||
}
|
||||
applied = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.AccountRatingEvent{}, false, err
|
||||
}
|
||||
return event, applied, nil
|
||||
}
|
||||
|
||||
// ListAccountRatings is the admin leaderboard query. The order matches
|
||||
// account_rating_leaderboard_idx (level DESC, stars DESC, user_id) and BeforeID is
|
||||
// a keyset cursor: the cursor row's own (level, stars) are read back so paging
|
||||
// stays consistent across the compound order instead of only over user ids.
|
||||
func (s *AccountRatingStore) ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if filter.MinLevel < 0 || filter.MinLevel > domain.MaxAccountRatingLevel {
|
||||
return nil, domain.ErrAccountRatingAdjustmentInvalid
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH cursor_row AS (
|
||||
SELECT level AS c_level, stars AS c_stars, user_id AS c_user_id
|
||||
FROM account_rating WHERE $3 <> 0 AND user_id = $3
|
||||
)
|
||||
SELECT `+accountRatingColumnsQualified+`
|
||||
FROM account_rating r
|
||||
LEFT JOIN cursor_row c ON true
|
||||
WHERE r.level >= $1
|
||||
AND ($2 = 0 OR r.user_id = $2)
|
||||
AND (
|
||||
c.c_user_id IS NULL
|
||||
OR r.level < c.c_level
|
||||
OR (r.level = c.c_level AND r.stars < c.c_stars)
|
||||
OR (r.level = c.c_level AND r.stars = c.c_stars AND r.user_id > c.c_user_id)
|
||||
)
|
||||
ORDER BY r.level DESC, r.stars DESC, r.user_id
|
||||
LIMIT $4`, filter.MinLevel, filter.UserID, filter.BeforeID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account ratings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountRating, 0, limit)
|
||||
for rows.Next() {
|
||||
rating, err := scanAccountRating(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account rating: %w", err)
|
||||
}
|
||||
out = append(out, rating)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account ratings: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountRatingEvents returns the ledger for one user, newest first, over
|
||||
// account_rating_events_user_idx (user_id, id DESC).
|
||||
func (s *AccountRatingStore) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, domain.ErrAccountRatingNotFound
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
|
||||
FROM account_rating_events
|
||||
WHERE user_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account rating events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.AccountRatingEvent, 0, limit)
|
||||
for rows.Next() {
|
||||
event, err := scanAccountRatingEvent(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan account rating event: %w", err)
|
||||
}
|
||||
out = append(out, event)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate account rating events: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StaleAccountRatings returns the users whose projection is older than the
|
||||
// horizon, ordered so the walk follows account_rating_stale_idx
|
||||
// (computed_at, user_id) exactly.
|
||||
func (s *AccountRatingStore) StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if olderThanUnix <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT user_id FROM account_rating
|
||||
WHERE computed_at < to_timestamp($1)
|
||||
ORDER BY computed_at, user_id
|
||||
LIMIT $2`, olderThanUnix, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list stale account ratings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0, limit)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, fmt.Errorf("scan stale account rating: %w", err)
|
||||
}
|
||||
out = append(out, userID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate stale account ratings: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnratedAccounts returns accounts that have no projection yet, oldest account
|
||||
// first so the walk is stable and every account is eventually reached.
|
||||
//
|
||||
// Three kinds of account are skipped, per domain.RatableAccount: bots, which do
|
||||
// not transact on their own behalf; the built-in service accounts, which are
|
||||
// infrastructure -- and note that the platform account is not flagged is_bot, so
|
||||
// excluding bots alone would still have seeded it; and deleted accounts, which are
|
||||
// tombstones whose every profile field has already been cleared.
|
||||
func (s *AccountRatingStore) UnratedAccounts(ctx context.Context, limit int) ([]int64, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("account rating store is not configured")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultAccountRatingListLimit
|
||||
}
|
||||
if limit > maxAccountRatingListLimit {
|
||||
limit = maxAccountRatingListLimit
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT u.id FROM users u
|
||||
WHERE NOT u.is_bot
|
||||
AND u.deleted_at IS NULL
|
||||
AND u.id <> ALL($2::bigint[])
|
||||
AND NOT EXISTS (SELECT 1 FROM account_rating r WHERE r.user_id = u.id)
|
||||
ORDER BY u.created_at, u.id
|
||||
LIMIT $1`, limit, domain.SystemUserIDs())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list unrated accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]int64, 0, limit)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, fmt.Errorf("scan unrated account: %w", err)
|
||||
}
|
||||
out = append(out, userID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate unrated accounts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func accountRatingEventByCommandKey(ctx context.Context, db sqlcgen.DBTX, commandKey string) (domain.AccountRatingEvent, bool, error) {
|
||||
if commandKey == "" {
|
||||
return domain.AccountRatingEvent{}, false, nil
|
||||
}
|
||||
event, err := scanAccountRatingEvent(db.QueryRow(ctx, `
|
||||
SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at
|
||||
FROM account_rating_events WHERE command_key = $1`, commandKey))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AccountRatingEvent{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AccountRatingEvent{}, false, fmt.Errorf("lookup account rating command: %w", err)
|
||||
}
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
func scanAccountRating(row pgx.Row) (domain.AccountRating, error) {
|
||||
var rating domain.AccountRating
|
||||
var nextLevelStars pgtype.Int8
|
||||
var pendingDate pgtype.Timestamptz
|
||||
if err := row.Scan(&rating.UserID, &rating.Level, &rating.Stars, &rating.CurrentLevelStars,
|
||||
&nextLevelStars, &rating.StarsComponent, &rating.ActivityComponent,
|
||||
&rating.PenaltyComponent, &rating.ManualComponent, &rating.PendingStars,
|
||||
&pendingDate, &rating.ComputedAt, &rating.UpdatedAt, &rating.Version); err != nil {
|
||||
return domain.AccountRating{}, err
|
||||
}
|
||||
if nextLevelStars.Valid {
|
||||
rating.NextLevelStars = nextLevelStars.Int64
|
||||
rating.HasNextLevel = true
|
||||
}
|
||||
if pendingDate.Valid {
|
||||
rating.PendingDate = pendingDate.Time.UTC()
|
||||
}
|
||||
rating.ComputedAt = rating.ComputedAt.UTC()
|
||||
rating.UpdatedAt = rating.UpdatedAt.UTC()
|
||||
return rating, nil
|
||||
}
|
||||
|
||||
func scanAccountRatingEvent(row pgx.Row) (domain.AccountRatingEvent, error) {
|
||||
var event domain.AccountRatingEvent
|
||||
var kind string
|
||||
if err := row.Scan(&event.ID, &event.UserID, &kind, &event.Amount, &event.Reason,
|
||||
&event.Actor, &event.CommandKey, &event.CreatedAt); err != nil {
|
||||
return domain.AccountRatingEvent{}, err
|
||||
}
|
||||
event.Kind = domain.AccountRatingEventKind(kind)
|
||||
event.CreatedAt = event.CreatedAt.UTC()
|
||||
return event, nil
|
||||
}
|
||||
462
internal/store/postgres/account_rating_integration_test.go
Normal file
462
internal/store/postgres/account_rating_integration_test.go
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ratingTestUser inserts a user row with an explicit creation date so the account
|
||||
// age signal is deterministic.
|
||||
func ratingTestUser(t *testing.T, pool *pgxpool.Pool, seed int64, createdAt time.Time) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (access_hash, phone, first_name, created_at, updated_at)
|
||||
VALUES ($1, $2, 'rating test', $3, $3)
|
||||
RETURNING id`, seed, fmt.Sprintf("%d", seed), createdAt).Scan(&id); err != nil {
|
||||
t.Fatalf("insert rating test user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// ratingTestCatalogRevision publishes a throwaway star gift so peer_star_gifts
|
||||
// rows can satisfy their catalog revision foreign key.
|
||||
func ratingTestCatalogRevision(t *testing.T, pool *pgxpool.Pool) (revisionID, giftID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
docID := time.Now().UnixNano() & 0x7fffffffffffffff
|
||||
entry, err := NewStarGiftStore(pool).CreateCatalogRevision(ctx, domain.StarGiftCatalogWrite{
|
||||
Stars: 50, ConvertStars: 50, Enabled: true,
|
||||
Document: domain.Document{
|
||||
ID: docID, AccessHash: docID + 1, MimeType: "application/x-tgsticker", Size: 4, DCID: 2,
|
||||
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}},
|
||||
},
|
||||
Blob: domain.FileBlob{
|
||||
LocationKey: "doc:" + fmt.Sprint(docID), Backend: domain.MediaBackendLocalFS,
|
||||
ObjectKey: "rating-star-gift", Size: 4, SHA256: make([]byte, 32),
|
||||
MimeType: "application/x-tgsticker",
|
||||
},
|
||||
Animation: domain.StarGiftAnimation{
|
||||
JSON: []byte(`{"v":"5.7","w":512,"h":512,"fr":30,"ip":0,"op":30,"layers":[{}]}`),
|
||||
SHA256: make([]byte, 32), SourceFormat: domain.StarGiftAnimationTGS, Width: 512, Height: 512,
|
||||
},
|
||||
Actor: "test", CommandID: "rating-star-gift-" + suffix,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create catalog revision: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT id FROM star_gift_catalog_revisions WHERE gift_id = $1 ORDER BY revision DESC LIMIT 1`,
|
||||
entry.Gift.ID).Scan(&revisionID); err != nil {
|
||||
t.Fatalf("read catalog revision id: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := context.Background()
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog WHERE gift_id = $1`, entry.Gift.ID)
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM star_gift_catalog_revisions WHERE gift_id = $1`, entry.Gift.ID)
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM file_blobs WHERE location_key = $1`, "doc:"+fmt.Sprint(docID))
|
||||
_, _ = pool.Exec(cleanupCtx, `DELETE FROM documents WHERE id = $1`, docID)
|
||||
})
|
||||
return revisionID, entry.Gift.ID
|
||||
}
|
||||
|
||||
// TestAccountRatingSaveVersionConflict covers the optimistic write: a first save
|
||||
// creates the row, a stale version is refused without an error, and the next
|
||||
// version wins.
|
||||
func TestAccountRatingSaveVersionConflict(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
userID := ratingTestUser(t, pool, 3_100_000_000+seed, time.Now().UTC().AddDate(0, 0, -30))
|
||||
|
||||
if _, err := store.AccountRating(ctx, userID); !errors.Is(err, domain.ErrAccountRatingNotFound) {
|
||||
t.Fatalf("missing rating err = %v, want ErrAccountRatingNotFound", err)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
signals := domain.AccountRatingSignals{UserID: userID, StarsReceived: 900, MessagesSent: 10, AccountAgeDays: 30}
|
||||
computed := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
||||
stored, changed, err := store.SaveAccountRating(ctx, computed)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("first save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if stored.Version != 1 || stored.Stars != computed.Stars || stored.Level != computed.Level ||
|
||||
stored.HasNextLevel != computed.HasNextLevel || stored.NextLevelStars != computed.NextLevelStars {
|
||||
t.Fatalf("stored = %+v want %+v", stored, computed)
|
||||
}
|
||||
read, err := store.AccountRating(ctx, userID)
|
||||
if err != nil || read != stored {
|
||||
t.Fatalf("read = %+v stored = %+v err=%v", read, stored, err)
|
||||
}
|
||||
|
||||
// A second writer that still believes version 0 is stale: no error, no write.
|
||||
stale := computed
|
||||
stale.Stars = 999_999
|
||||
conflicted, changed, err := store.SaveAccountRating(ctx, stale)
|
||||
if err != nil || changed {
|
||||
t.Fatalf("stale save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if conflicted.Stars != stored.Stars || conflicted.Version != 1 {
|
||||
t.Fatalf("conflicted = %+v, stored row must win", conflicted)
|
||||
}
|
||||
|
||||
// The recompute that carries the right version applies, including a pending
|
||||
// delta that must round-trip through the paired pending_stars/pending_date.
|
||||
next := domain.ResolveAccountRatingPending(stored,
|
||||
domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, StarsReceived: 40_000}, domain.DefaultAccountRatingWeights(), now),
|
||||
time.Hour, now)
|
||||
if next.PendingStars == 0 || next.PendingDate.IsZero() {
|
||||
t.Fatalf("expected a parked pending delta, got %+v", next)
|
||||
}
|
||||
applied, changed, err := store.SaveAccountRating(ctx, next)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("versioned save changed=%v err=%v", changed, err)
|
||||
}
|
||||
if applied.Version != 2 || applied.PendingStars != next.PendingStars ||
|
||||
!applied.PendingDate.Equal(next.PendingDate.UTC()) {
|
||||
t.Fatalf("applied = %+v want pending %d at %v", applied, next.PendingStars, next.PendingDate)
|
||||
}
|
||||
pending, ok := applied.PendingLevel()
|
||||
if !ok || pending.Stars <= applied.Stars {
|
||||
t.Fatalf("pending projection = %+v ok=%v", pending, ok)
|
||||
}
|
||||
|
||||
batch, err := store.AccountRatingBatch(ctx, []int64{userID, userID + 1})
|
||||
if err != nil || len(batch) != 1 || batch[userID].Version != 2 {
|
||||
t.Fatalf("batch = %+v err=%v", batch, err)
|
||||
}
|
||||
list, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{UserID: userID, Limit: 10})
|
||||
if err != nil || len(list) != 1 || list[0].UserID != userID {
|
||||
t.Fatalf("list = %+v err=%v", list, err)
|
||||
}
|
||||
stale2, err := store.StaleAccountRatings(ctx, now.Add(time.Minute).Unix(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
if !containsInt64(stale2, userID) {
|
||||
t.Fatalf("stale ratings %v must contain %d", stale2, userID)
|
||||
}
|
||||
fresh, err := store.StaleAccountRatings(ctx, now.Add(-time.Hour).Unix(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("stale fresh: %v", err)
|
||||
}
|
||||
if containsInt64(fresh, userID) {
|
||||
t.Fatalf("rating computed at %v must not be stale before it", applied.ComputedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingAdjustmentIdempotency covers the manual ledger: a replayed
|
||||
// command key records nothing new and the manual total feeds the recompute.
|
||||
func TestAccountRatingAdjustmentIdempotency(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
userID := ratingTestUser(t, pool, 3_200_000_000+seed, time.Now().UTC().AddDate(0, 0, -10))
|
||||
key := fmt.Sprintf("adjust-%d", seed)
|
||||
|
||||
req := domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: 750, Reason: "community award", Actor: "ops", CommandKey: key,
|
||||
}
|
||||
event, applied, err := store.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.CommandKey != key {
|
||||
t.Fatalf("event = %+v", event)
|
||||
}
|
||||
replay, applied, err := store.AdjustAccountRating(ctx, req)
|
||||
if err != nil || applied || replay.ID != event.ID {
|
||||
t.Fatalf("replay applied=%v event=%+v err=%v", applied, replay, err)
|
||||
}
|
||||
// A second, distinct adjustment accumulates rather than replacing.
|
||||
if _, applied, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: -250, Reason: "partial revoke", Actor: "ops",
|
||||
CommandKey: key + "-b",
|
||||
}); err != nil || !applied {
|
||||
t.Fatalf("second adjust applied=%v err=%v", applied, err)
|
||||
}
|
||||
events, err := store.AccountRatingEvents(ctx, userID, 10)
|
||||
if err != nil || len(events) != 2 || events[0].Amount != -250 || events[1].Amount != 750 {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
if _, _, err := store.AdjustAccountRating(ctx, domain.AdjustAccountRatingRequest{
|
||||
UserID: userID, Amount: 0, CommandKey: key + "-c",
|
||||
}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("zero adjustment err = %v, want ErrAccountRatingAdjustmentInvalid", err)
|
||||
}
|
||||
signals, err := store.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("signals: %v", err)
|
||||
}
|
||||
if signals.Manual != 500 {
|
||||
t.Fatalf("manual signal = %d, want 500", signals.Manual)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingSignalsSources pins where each contribution comes from: the
|
||||
// Stars ledger sign split, saved gifts, upheld moderation cases, the peer flags,
|
||||
// account age and the private-message count.
|
||||
func TestAccountRatingSignalsSources(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
createdAt := time.Now().UTC().AddDate(0, 0, -45)
|
||||
userID := ratingTestUser(t, pool, 3_300_000_000+seed, createdAt)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO stars_transactions (user_id, amount, reason, date)
|
||||
VALUES ($1, 1500, 'gift', 0), ($1, 500, 'reaction', 0), ($1, -400, 'purchase', 0)`, userID); err != nil {
|
||||
t.Fatalf("seed stars: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM stars_transactions WHERE user_id = $1`, userID)
|
||||
})
|
||||
revisionID, giftID := ratingTestCatalogRevision(t, pool)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO peer_star_gifts (owner_peer_type, owner_peer_id, msg_id, gift_id, gift_date, catalog_revision_id, lifecycle_status, converted)
|
||||
VALUES ('user', $1, 1, $2, 0, $3, 'active', false),
|
||||
('user', $1, 2, $2, 0, $3, 'active', false),
|
||||
('user', $1, 3, $2, 0, $3, 'converted', true)`, userID, giftID, revisionID); err != nil {
|
||||
t.Fatalf("seed gifts: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM peer_star_gifts WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID)
|
||||
})
|
||||
// Only statuses that follow a violation decision count; 'dismissed' (which
|
||||
// covers no_violation and a granted appeal) and undecided states do not.
|
||||
now := time.Now().UTC()
|
||||
for _, status := range []string{"resolved", "action_failed", "dismissed", "open"} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO moderation_cases (
|
||||
target_peer_type, target_peer_id, status, severity, report_count,
|
||||
distinct_reporter_count, first_report_at, last_report_at, created_at, updated_at
|
||||
) VALUES ('user', $1, $2, 1, 1, 1, $3, $3, $3, $3)`, userID, status, now); err != nil {
|
||||
t.Fatalf("seed moderation case %s: %v", status, err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(),
|
||||
`DELETE FROM moderation_cases WHERE target_peer_type = 'user' AND target_peer_id = $1`, userID)
|
||||
})
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET scam = true WHERE id = $1`, userID); err != nil {
|
||||
t.Fatalf("set scam: %v", err)
|
||||
}
|
||||
// Two private messages, each materialised as the sender's and the recipient's
|
||||
// box: the distinct count must report two, not four.
|
||||
peerID := ratingTestUser(t, pool, 3_350_000_000+seed, createdAt)
|
||||
for i := 1; i <= 2; i++ {
|
||||
var messageID int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO private_messages (sender_user_id, recipient_user_id, message_date, body)
|
||||
VALUES ($1, $2, 0, 'hi')
|
||||
RETURNING id`, userID, peerID).Scan(&messageID); err != nil {
|
||||
t.Fatalf("seed private message: %v", err)
|
||||
}
|
||||
for _, box := range []struct {
|
||||
owner int64
|
||||
peer int64
|
||||
outgoing bool
|
||||
}{{userID, peerID, true}, {peerID, userID, false}} {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO message_boxes (
|
||||
owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id,
|
||||
from_user_id, message_date, outgoing, body
|
||||
) VALUES ($1, $2, $3, $4, 'user', $5, $4, 0, $6, 'hi')`,
|
||||
box.owner, i, messageID, userID, box.peer, box.outgoing); err != nil {
|
||||
t.Fatalf("seed message box: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM message_boxes WHERE message_sender_id = $1`, userID)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM private_messages WHERE sender_user_id = $1`, userID)
|
||||
})
|
||||
|
||||
signals, err := store.AccountRatingSignals(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("signals: %v", err)
|
||||
}
|
||||
if signals.UserID != userID || signals.StarsReceived != 2000 || signals.StarsSpent != 400 ||
|
||||
signals.GiftsReceived != 2 || signals.ModerationCases != 2 || !signals.Scam || signals.Fake ||
|
||||
signals.MessagesSent != 2 || signals.AccountAgeDays != 45 || signals.Manual != 0 {
|
||||
t.Fatalf("signals = %+v", signals)
|
||||
}
|
||||
rating := domain.ComputeAccountRating(signals, domain.DefaultAccountRatingWeights(), now)
|
||||
if rating.PenaltyComponent == 0 || rating.StarsComponent != 2000+100 {
|
||||
t.Fatalf("computed rating = %+v", rating)
|
||||
}
|
||||
if _, err := store.AccountRatingSignals(ctx, userID+7_000_000); !errors.Is(err, domain.ErrUserNotFound) {
|
||||
t.Fatalf("signals for unknown user err = %v, want ErrUserNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountRatingLeaderboardPaging covers the keyset walk over
|
||||
// (level DESC, stars DESC, user_id).
|
||||
func TestAccountRatingLeaderboardPaging(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
now := time.Now().UTC()
|
||||
scores := []int64{100, 400, 900, 1600}
|
||||
ids := make([]int64, 0, len(scores))
|
||||
for i, score := range scores {
|
||||
userID := ratingTestUser(t, pool, 3_400_000_000+seed+int64(i), now.AddDate(0, 0, -1))
|
||||
ids = append(ids, userID)
|
||||
rating := domain.ComputeAccountRating(domain.AccountRatingSignals{UserID: userID, Manual: score},
|
||||
domain.DefaultAccountRatingWeights(), now)
|
||||
if _, changed, err := store.SaveAccountRating(ctx, rating); err != nil || !changed {
|
||||
t.Fatalf("save rating %d: changed=%v err=%v", userID, changed, err)
|
||||
}
|
||||
}
|
||||
page, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: 2, Limit: 2})
|
||||
if err != nil || len(page) != 2 {
|
||||
t.Fatalf("first page = %+v err=%v", page, err)
|
||||
}
|
||||
if page[0].Level < page[1].Level {
|
||||
t.Fatalf("leaderboard must be level-descending: %+v", page)
|
||||
}
|
||||
next, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{
|
||||
MinLevel: 2, BeforeID: page[len(page)-1].UserID, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second page: %v", err)
|
||||
}
|
||||
for _, item := range next {
|
||||
if item.UserID == page[0].UserID || item.UserID == page[1].UserID {
|
||||
t.Fatalf("keyset page repeated user %d", item.UserID)
|
||||
}
|
||||
if item.Level > page[len(page)-1].Level {
|
||||
t.Fatalf("keyset page went backwards: %+v after %+v", item, page)
|
||||
}
|
||||
}
|
||||
if _, err := store.ListAccountRatings(ctx, domain.AccountRatingFilter{MinLevel: -1}); !errors.Is(err, domain.ErrAccountRatingAdjustmentInvalid) {
|
||||
t.Fatalf("negative level filter must be rejected")
|
||||
}
|
||||
_ = ids
|
||||
}
|
||||
|
||||
// TestAccountRatingUnratedAccountsSeedsTheReadModel proves the SQL behind the
|
||||
// bootstrap pass: only accounts with no projection are returned, bots and deleted
|
||||
// tombstones are excluded, the walk is oldest-account-first, and a user drops out of
|
||||
// the candidate set the moment a projection exists.
|
||||
//
|
||||
// Without this query the read model can never populate itself -- StaleAccountRatings
|
||||
// walks account_rating and so cannot return a user who is not in it.
|
||||
func TestAccountRatingUnratedAccountsSeedsTheReadModel(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
store := NewAccountRatingStore(pool)
|
||||
seed := time.Now().UnixNano() % 1_000_000
|
||||
base := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
|
||||
|
||||
oldest := ratingTestUser(t, pool, 4_100_000_000+seed, base)
|
||||
middle := ratingTestUser(t, pool, 4_200_000_000+seed, base.Add(time.Hour))
|
||||
newest := ratingTestUser(t, pool, 4_300_000_000+seed, base.Add(2*time.Hour))
|
||||
bot := ratingTestUser(t, pool, 4_400_000_000+seed, base.Add(3*time.Hour))
|
||||
deleted := ratingTestUser(t, pool, 4_500_000_000+seed, base.Add(4*time.Hour))
|
||||
|
||||
if _, err := pool.Exec(ctx, `UPDATE users SET is_bot = true WHERE id = $1`, bot); err != nil {
|
||||
t.Fatalf("mark bot: %v", err)
|
||||
}
|
||||
// A deleted account is a tombstone: every profile field is already cleared, so
|
||||
// there is no rating to show and no reason to compute one.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
UPDATE users SET deleted_at = now(), deletion_source = 'manual', deletion_reason = 'test',
|
||||
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
|
||||
verified = false, support = false, premium_expires_at = NULL,
|
||||
emoji_status_document_id = 0, emoji_status_until = 0,
|
||||
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
|
||||
color_set = false, color = 0, color_background_emoji_id = 0,
|
||||
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
|
||||
birthday_day = 0, birthday_month = 0, birthday_year = 0,
|
||||
personal_channel_id = 0, last_seen_at = 0, account_delete_at = NULL
|
||||
WHERE id = $1`, deleted); err != nil {
|
||||
t.Fatalf("mark deleted: %v", err)
|
||||
}
|
||||
|
||||
// The table is shared with every other account in the test database, so assert
|
||||
// on relative order and membership rather than on an exact page.
|
||||
positions := func(t *testing.T) (map[int64]int, []int64) {
|
||||
t.Helper()
|
||||
ids, err := store.UnratedAccounts(ctx, maxAccountRatingListLimit)
|
||||
if err != nil {
|
||||
t.Fatalf("UnratedAccounts: %v", err)
|
||||
}
|
||||
index := make(map[int64]int, len(ids))
|
||||
for i, id := range ids {
|
||||
index[id] = i
|
||||
}
|
||||
return index, ids
|
||||
}
|
||||
|
||||
index, ids := positions(t)
|
||||
for _, id := range []int64{oldest, middle, newest} {
|
||||
if _, ok := index[id]; !ok {
|
||||
t.Fatalf("account %d with no projection is absent from %d candidates", id, len(ids))
|
||||
}
|
||||
}
|
||||
if _, ok := index[bot]; ok {
|
||||
t.Fatalf("bot %d was offered as a rating candidate", bot)
|
||||
}
|
||||
if _, ok := index[deleted]; ok {
|
||||
t.Fatalf("deleted account %d was offered as a rating candidate", deleted)
|
||||
}
|
||||
// The service accounts are infrastructure. The platform account in particular is
|
||||
// NOT flagged is_bot, so excluding bots alone would still have seeded it -- which
|
||||
// is exactly how it got a rating.
|
||||
for _, serviceID := range domain.SystemUserIDs() {
|
||||
if _, ok := index[serviceID]; ok {
|
||||
t.Fatalf("service account %d was offered as a rating candidate", serviceID)
|
||||
}
|
||||
}
|
||||
if !(index[oldest] < index[middle] && index[middle] < index[newest]) {
|
||||
t.Fatalf("candidate order = oldest %d, middle %d, newest %d; want oldest first",
|
||||
index[oldest], index[middle], index[newest])
|
||||
}
|
||||
|
||||
// Seeding one account removes it from the candidate set, so the pass converges
|
||||
// instead of offering the same user every cycle.
|
||||
if _, changed, err := store.SaveAccountRating(ctx, domain.AccountRating{
|
||||
UserID: middle, Level: 1, Stars: 150,
|
||||
CurrentLevelStars: domain.AccountRatingLevelThreshold(1),
|
||||
ComputedAt: time.Now().UTC(), Version: 1,
|
||||
}); err != nil || !changed {
|
||||
t.Fatalf("seed projection = %v changed=%v", err, changed)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM account_rating WHERE user_id = $1`, middle)
|
||||
})
|
||||
index, _ = positions(t)
|
||||
if _, ok := index[middle]; ok {
|
||||
t.Fatalf("account %d is still a candidate after being seeded", middle)
|
||||
}
|
||||
if _, ok := index[oldest]; !ok {
|
||||
t.Fatalf("seeding %d removed unrelated candidate %d", middle, oldest)
|
||||
}
|
||||
|
||||
// The limit is honoured, so one cycle can never walk the whole users table.
|
||||
capped, err := store.UnratedAccounts(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("UnratedAccounts with a limit: %v", err)
|
||||
}
|
||||
if len(capped) != 2 {
|
||||
t.Fatalf("limited candidates = %d, want 2", len(capped))
|
||||
}
|
||||
}
|
||||
152
internal/store/postgres/auth_delivery_report.go
Normal file
152
internal/store/postgres/auth_delivery_report.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
type AuthDeliveryReportStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
func NewAuthDeliveryReportStore(db sqlcgen.DBTX) *AuthDeliveryReportStore {
|
||||
return &AuthDeliveryReportStore{db: db}
|
||||
}
|
||||
|
||||
func (s *AuthDeliveryReportStore) CreateAuthDeliveryReport(ctx context.Context, report domain.AuthDeliveryReport) (domain.AuthDeliveryReport, bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store is not configured")
|
||||
}
|
||||
if err := report.Validate(); err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, err
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("auth delivery report store requires transaction-capable postgres handle")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("begin auth delivery report: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
SELECT pg_advisory_xact_lock(
|
||||
hashtextextended('auth-delivery:' || encode($1::bytea, 'hex'), 0)
|
||||
)`, report.AuthKeyID[:]); err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("lock auth delivery reporter: %w", err)
|
||||
}
|
||||
existing, found, err := getAuthDeliveryReportByFingerprint(ctx, tx, report.AuthKeyID, report.Fingerprint)
|
||||
if err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, err
|
||||
}
|
||||
if found {
|
||||
return existing, false, nil
|
||||
}
|
||||
var hourly, phoneDaily int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
count(*) FILTER (
|
||||
WHERE auth_key_id = $1 AND created_at >= $3::timestamptz - interval '1 hour'
|
||||
),
|
||||
count(*) FILTER (
|
||||
WHERE phone_hash = $2 AND created_at >= $3::timestamptz - interval '24 hours'
|
||||
)
|
||||
FROM auth_delivery_reports
|
||||
WHERE created_at <= $3::timestamptz
|
||||
AND (auth_key_id = $1 OR phone_hash = $2)`,
|
||||
report.AuthKeyID[:], report.PhoneHash[:], report.CreatedAt,
|
||||
).Scan(&hourly, &phoneDaily); err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("count auth delivery reports: %w", err)
|
||||
}
|
||||
if hourly >= domain.MaxAuthDeliveryReportsPerHour ||
|
||||
phoneDaily >= domain.MaxAuthDeliveryReportsPerPhoneDay {
|
||||
return domain.AuthDeliveryReport{}, false, domain.ErrAuthDeliveryRateLimited
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO auth_delivery_reports (
|
||||
auth_key_id, session_id, client_type, phone_hash, code_hash,
|
||||
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
RETURNING id`,
|
||||
report.AuthKeyID[:], report.SessionID, report.ClientType,
|
||||
report.PhoneHash[:], report.CodeHash[:], report.IssuedUserID,
|
||||
report.DeliveryID, string(report.Channel), report.MNC,
|
||||
report.Fingerprint[:], report.CreatedAt,
|
||||
).Scan(&report.ID)
|
||||
if err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("insert auth delivery report: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("commit auth delivery report: %w", err)
|
||||
}
|
||||
return report, true, nil
|
||||
}
|
||||
|
||||
func getAuthDeliveryReportByFingerprint(ctx context.Context, db sqlcgen.DBTX, authKeyID [8]byte, fingerprint [32]byte) (domain.AuthDeliveryReport, bool, error) {
|
||||
var report domain.AuthDeliveryReport
|
||||
var storedAuthKey, phoneHash, codeHash, storedFingerprint []byte
|
||||
var channel string
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT id, auth_key_id, session_id, client_type, phone_hash, code_hash,
|
||||
issued_user_id, delivery_id, channel, mnc, fingerprint, created_at
|
||||
FROM auth_delivery_reports
|
||||
WHERE auth_key_id = $1 AND fingerprint = $2`,
|
||||
authKeyID[:], fingerprint[:],
|
||||
).Scan(
|
||||
&report.ID, &storedAuthKey, &report.SessionID, &report.ClientType,
|
||||
&phoneHash, &codeHash, &report.IssuedUserID, &report.DeliveryID,
|
||||
&channel, &report.MNC, &storedFingerprint, &report.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.AuthDeliveryReport{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: %w", err)
|
||||
}
|
||||
if len(storedAuthKey) != len(report.AuthKeyID) ||
|
||||
len(phoneHash) != len(report.PhoneHash) ||
|
||||
len(codeHash) != len(report.CodeHash) ||
|
||||
len(storedFingerprint) != len(report.Fingerprint) {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("get auth delivery report: invalid hash shape")
|
||||
}
|
||||
copy(report.AuthKeyID[:], storedAuthKey)
|
||||
copy(report.PhoneHash[:], phoneHash)
|
||||
copy(report.CodeHash[:], codeHash)
|
||||
copy(report.Fingerprint[:], storedFingerprint)
|
||||
report.Channel = domain.AuthCodeDeliveryKind(channel)
|
||||
if err := report.Validate(); err != nil {
|
||||
return domain.AuthDeliveryReport{}, false, fmt.Errorf("validate auth delivery report: %w", err)
|
||||
}
|
||||
return report, true, nil
|
||||
}
|
||||
|
||||
func (s *AuthDeliveryReportStore) DeleteExpiredAuthDeliveryReports(ctx context.Context, olderThan time.Time, limit int) (int, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return 0, fmt.Errorf("auth delivery report store is not configured")
|
||||
}
|
||||
if olderThan.IsZero() || limit <= 0 || limit > 10000 {
|
||||
return 0, domain.ErrAuthDeliveryReportInvalid
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
WITH doomed AS (
|
||||
SELECT id
|
||||
FROM auth_delivery_reports
|
||||
WHERE created_at < $1
|
||||
ORDER BY created_at, id
|
||||
LIMIT $2
|
||||
)
|
||||
DELETE FROM auth_delivery_reports r
|
||||
USING doomed d
|
||||
WHERE r.id = d.id`, olderThan, limit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete expired auth delivery reports: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestAuthDeliveryReportPostgresIsIdempotentAndRetainedSeparately(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
createdAt := time.Unix(123_456, 0).UTC()
|
||||
report, err := domain.NewAuthDeliveryReport(
|
||||
[8]byte{1, 2, 3, 4, 5, 6, 7, byte(time.Now().UnixNano())},
|
||||
time.Now().UnixNano(), "tdesktop", "15550004444",
|
||||
"phone-code-hash", 77, "delivery-77",
|
||||
domain.AuthCodeDeliverySMS, "46000", createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewAuthDeliveryReportStore(pool)
|
||||
stored, created, err := store.CreateAuthDeliveryReport(ctx, report)
|
||||
if err != nil || !created || stored.ID <= 0 {
|
||||
t.Fatalf("stored=%+v created=%v err=%v", stored, created, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM auth_delivery_reports WHERE id = $1", stored.ID)
|
||||
})
|
||||
retry, created, err := store.CreateAuthDeliveryReport(ctx, report)
|
||||
if err != nil || created || retry.ID != stored.ID ||
|
||||
retry.PhoneHash != stored.PhoneHash || retry.CodeHash != stored.CodeHash {
|
||||
t.Fatalf("retry=%+v created=%v err=%v", retry, created, err)
|
||||
}
|
||||
deleted, err := store.DeleteExpiredAuthDeliveryReports(
|
||||
ctx, createdAt.Add(time.Second), 10,
|
||||
)
|
||||
if err != nil || deleted < 1 {
|
||||
t.Fatalf("deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
var moderationRows int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM moderation_reports
|
||||
WHERE reporter_user_id = $1`, report.IssuedUserID).Scan(&moderationRows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moderationRows != 0 {
|
||||
t.Fatalf("auth diagnostic leaked into moderation reports: %d", moderationRows)
|
||||
}
|
||||
}
|
||||
|
|
@ -195,8 +195,9 @@ WHERE auth_key_id = $1
|
|||
//
|
||||
// 同时清理把本 key 当作 perm key 的 temp auth key 行:temp_auth_key_bindings.temp_auth_key_id
|
||||
// 侧有外键 ON DELETE CASCADE,删除 temp key 会自动清绑定;perm_auth_key_id 侧由
|
||||
// RESTRICT FK 防止悬空,因此被踢/销毁 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用
|
||||
// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED,而不是连接层 404。
|
||||
// RESTRICT FK 防止悬空,因此显式销毁 perm key 时必须先把关联 temp key 一并删掉。
|
||||
// 远程撤销 authorization 不得调用本方法:被踢客户端必须保留协议 key,重连进入 RPC
|
||||
// 层后取得 AUTH_KEY_UNREGISTERED,而不是只收到连接层 -404。
|
||||
func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error {
|
||||
return withAuthIdentityTx(ctx, s.db, "delete auth key", func(tx pgx.Tx) error {
|
||||
return deleteAuthKeyTx(ctx, tx, authKeyIDToInt64(id))
|
||||
|
|
|
|||
|
|
@ -252,9 +252,10 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
|
|||
return a, true, nil
|
||||
}
|
||||
|
||||
// RevokeByHash 删除协议 auth_key 作为远程踢设备的持久化事实入口。
|
||||
// authorizations 通过 FK cascade 删除;update_states 没有 auth_keys FK,必须显式清理;
|
||||
// 关联 temp auth key 也显式删除,避免 raw temp key 重连。
|
||||
// RevokeByHash 是远程踢设备的持久化事实入口:删除业务 authorization 与
|
||||
// device update state,但保留 permanent/temp 协议 key 和 binding。这样被踢客户端
|
||||
// 重连后仍可完成 MTProto 解密,并由 RPC gate 返回 AUTH_KEY_UNREGISTERED;若先删除
|
||||
// 协议 key,客户端只能收到 transport -404,无法可靠清理本地登录态。
|
||||
func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
|
||||
var (
|
||||
a domain.Authorization
|
||||
|
|
@ -315,7 +316,7 @@ FOR UPDATE`, candidate, userID, hash))
|
|||
if !found {
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
if err := deleteRevocationTargetsTx(ctx, tx, []int64{candidate}); err != nil {
|
||||
if err := deleteRevokedAuthorizationStateTx(ctx, tx, []int64{candidate}); err != nil {
|
||||
return domain.Authorization{}, false, err
|
||||
}
|
||||
return a, true, nil
|
||||
|
|
@ -349,7 +350,8 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// RevokeByUserExcept 批量删除协议 auth_key,保留 keepAuthKeyID 对应的当前设备。
|
||||
// RevokeByUserExcept 批量删除业务 authorization,保留 keepAuthKeyID 对应的当前设备;
|
||||
// 被撤销设备的协议 key/binding 保留,以便重连后取得 RPC 401 并完成客户端退出。
|
||||
func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
var out []domain.Authorization
|
||||
err := withAuthIdentityTx(ctx, s.db, "revoke authorizations by user", func(tx pgx.Tx) error {
|
||||
|
|
@ -449,13 +451,38 @@ FOR UPDATE`, userID, keepAuthKeyID, candidates)
|
|||
for i := range out {
|
||||
targets[i] = authKeyIDToInt64(out[i].AuthKeyID)
|
||||
}
|
||||
if err := deleteRevocationTargetsTx(ctx, tx, targets); err != nil {
|
||||
if err := deleteRevokedAuthorizationStateTx(ctx, tx, targets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func deleteRevocationTargetsTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
|
||||
func deleteRevokedAuthorizationStateTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
|
||||
if len(authKeyIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM update_states
|
||||
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
|
||||
return fmt.Errorf("delete revoked update states: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM authorizations
|
||||
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete revoked authorizations: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != int64(len(authKeyIDs)) {
|
||||
return fmt.Errorf("delete revoked authorizations: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteProtocolAuthIdentitiesTx permanently removes permanent identities and
|
||||
// their derived temp keys. Remote account.resetAuthorization/
|
||||
// auth.resetAuthorizations must not use this helper: those clients need the
|
||||
// protocol key long enough to reconnect and receive an RPC-level 401.
|
||||
func deleteProtocolAuthIdentitiesTx(ctx context.Context, tx pgx.Tx, authKeyIDs []int64) error {
|
||||
if len(authKeyIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -466,21 +493,21 @@ WHERE auth_key_id IN (
|
|||
FROM temp_auth_key_bindings
|
||||
WHERE perm_auth_key_id = ANY($1::bigint[])
|
||||
)`, authKeyIDs); err != nil {
|
||||
return fmt.Errorf("delete revoked temporary auth keys: %w", err)
|
||||
return fmt.Errorf("delete temporary auth keys: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM update_states
|
||||
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs); err != nil {
|
||||
return fmt.Errorf("delete revoked update states: %w", err)
|
||||
return fmt.Errorf("delete protocol identity update states: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM auth_keys
|
||||
WHERE auth_key_id = ANY($1::bigint[])`, authKeyIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete revoked permanent auth keys: %w", err)
|
||||
return fmt.Errorf("delete permanent auth keys: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() != int64(len(authKeyIDs)) {
|
||||
return fmt.Errorf("delete revoked permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
|
||||
return fmt.Errorf("delete permanent auth keys: deleted %d of %d locked targets", tag.RowsAffected(), len(authKeyIDs))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -13,7 +12,7 @@ import (
|
|||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *testing.T) {
|
||||
func TestAuthorizationStoreRevokeByHashKeepsProtocolIdentityPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
userID := createRevokeTestUser(t, ctx, pool, "hash")
|
||||
|
|
@ -43,14 +42,15 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
|
|||
if err := NewUpdateStateStore(pool).Save(ctx, perm, userID, domain.UpdateState{Pts: 11, Date: int(time.Now().Unix())}); err != nil {
|
||||
t.Fatalf("save update state: %v", err)
|
||||
}
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
|
||||
binding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: temp,
|
||||
PermAuthKeyID: authKeyIDToInt64(perm),
|
||||
Nonce: 1,
|
||||
TempSessionID: 2,
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte{1, 2, 3, 4},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, binding); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -61,14 +61,14 @@ func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *test
|
|||
if deleted.AuthKeyID != perm || !deleted.PasswordPending {
|
||||
t.Fatalf("deleted authorization = %+v, want perm key and password_pending", deleted)
|
||||
}
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
|
||||
assertRevokeTestTableCount(t, ctx, pool, "update_states", "auth_key_id", authKeyIDToInt64(perm), 0)
|
||||
assertRevokeTestTableCount(t, ctx, pool, "temp_auth_key_bindings", "temp_auth_key_id", authKeyIDToInt64(temp), 0)
|
||||
assertTempIdentityBinding(t, ctx, NewTempAuthKeyBindingStore(pool), binding)
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *testing.T) {
|
||||
func TestAuthorizationStoreRevokeByUserExceptKeepsRevokedProtocolIdentitiesPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
userID := createRevokeTestUser(t, ctx, pool, "bulk")
|
||||
|
|
@ -87,14 +87,15 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
|
|||
}
|
||||
tempExpiry := int(time.Now().Add(time.Hour).Unix())
|
||||
saveRevokeTestAuthKey(t, ctx, keys, tempForTwo, tempExpiry)
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{
|
||||
binding := domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempForTwo,
|
||||
PermAuthKeyID: authKeyIDToInt64(revokedTwo),
|
||||
Nonce: 3,
|
||||
TempSessionID: 4,
|
||||
ExpiresAt: tempExpiry,
|
||||
EncryptedMessage: []byte{5, 6, 7, 8},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := NewTempAuthKeyBindingStore(pool).Save(ctx, binding); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -107,9 +108,12 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
|
|||
}
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, keep)
|
||||
assertRevokeTestPresentAuthorization(t, ctx, auths, keep)
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedOne)
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, revokedTwo)
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, tempForTwo)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, revokedOne)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, revokedTwo)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, tempForTwo)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, revokedOne)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, revokedTwo)
|
||||
assertTempIdentityBinding(t, ctx, NewTempAuthKeyBindingStore(pool), binding)
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
|
||||
|
|
@ -155,7 +159,7 @@ func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePostgres(t *testing.T) {
|
||||
func TestAuthorizationStoreRevokeByHashConcurrentTempBindKeepsProtocolIdentityPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
userID := createRevokeTestUser(t, ctx, pool, "bind-revoke-race")
|
||||
|
|
@ -203,28 +207,17 @@ func TestAuthorizationStoreRevokeByHashConcurrentTempBindLeavesNoDanglingStatePo
|
|||
close(start)
|
||||
|
||||
bindErr := <-bindResult
|
||||
if bindErr != nil && !errors.Is(bindErr, store.ErrAuthKeyBindingInvalid) {
|
||||
if bindErr != nil {
|
||||
t.Fatalf("attempt %d bind/revoke race bind error = %v", attempt, bindErr)
|
||||
}
|
||||
revoked := <-revokeResults
|
||||
if revoked.err != nil || !revoked.found {
|
||||
t.Fatalf("attempt %d bind/revoke race found=%v err=%v", attempt, revoked.found, revoked.err)
|
||||
}
|
||||
if _, found, err := bindings.GetByTemp(ctx, temp); err != nil || found {
|
||||
t.Fatalf("attempt %d dangling binding found=%v err=%v", attempt, found, err)
|
||||
}
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, perm)
|
||||
assertTempIdentityBinding(t, ctx, bindings, candidate)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, perm)
|
||||
assertRevokeTestPresentAuthKey(t, ctx, keys, temp)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, perm)
|
||||
if bindErr == nil {
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
|
||||
} else {
|
||||
assertTempIdentityAuthKeyExpiry(t, ctx, keys, temp, tempExpiry)
|
||||
assertRevokeTestNoAuthorization(t, ctx, auths, temp)
|
||||
if err := keys.Delete(ctx, temp); err != nil {
|
||||
t.Fatalf("attempt %d clean unbound loser temp: %v", attempt, err)
|
||||
}
|
||||
assertRevokeTestMissingAuthKey(t, ctx, keys, temp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -520,12 +513,10 @@ func TestAuthorizationStoreRevokeByUserExceptPartiallySkipsTransferredCandidateP
|
|||
t.Fatalf("old A state for transferred key found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
|
||||
assertRevokeTestMissingAuthKey(t, testCtx, keys, revoked)
|
||||
assertRevokeTestMissingAuthKey(t, testCtx, keys, revokedTemp)
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, revoked)
|
||||
assertRevokeTestPresentAuthKey(t, testCtx, keys, revokedTemp)
|
||||
assertRevokeTestNoAuthorization(t, testCtx, auths, revoked)
|
||||
if _, found, err := bindings.GetByTemp(testCtx, revokedTemp); err != nil || found {
|
||||
t.Fatalf("revoked temp binding found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
assertTempIdentityBinding(t, testCtx, bindings, revokedBinding)
|
||||
if _, found, err := states.Get(testCtx, revoked, userA); err != nil || found {
|
||||
t.Fatalf("revoked A state found=%v err=%v, want absent", found, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profi
|
|||
return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: insert user: %w", err)
|
||||
}
|
||||
if usernameLower := strings.ToLower(row.Username); usernameLower != "" {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, row.Username, usernameLower); err != nil {
|
||||
return domain.User{}, domain.BotProfile{}, err
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ func (s *BotStore) DeleteBotAccount(ctx context.Context, botUserID int64) (domai
|
|||
if err := purgeDeletedAccountPrivateState(ctx, tx, botUserID, now); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, ""); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, botUserID, "", ""); err != nil {
|
||||
return domain.User{}, fmt.Errorf("delete bot account: release username: %w", err)
|
||||
}
|
||||
// Drop the bots row so the token can no longer authenticate a login.
|
||||
|
|
|
|||
1416
internal/store/postgres/bot_verification.go
Normal file
1416
internal/store/postgres/bot_verification.go
Normal file
File diff suppressed because it is too large
Load diff
1068
internal/store/postgres/bot_verification_integration_test.go
Normal file
1068
internal/store/postgres/bot_verification_integration_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -317,6 +317,7 @@ func (s *ChannelStore) GetChannels(ctx context.Context, viewerUserID int64, chan
|
|||
SELECT `+channelColumns+`,
|
||||
m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at,
|
||||
m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts,
|
||||
m.history_clear_anchor_id, m.history_clear_anchor_date,
|
||||
m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date
|
||||
FROM channels c
|
||||
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
|
||||
|
|
@ -395,6 +396,10 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, s.db, peerUsernameTypeChannel, remaining)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, channel := range channels {
|
||||
if member, ok := linkedGuests[channel.ID]; ok {
|
||||
views[channel.ID] = domain.ChannelView{
|
||||
|
|
@ -427,7 +432,8 @@ WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids)
|
|||
continue
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(channel) {
|
||||
_, hasActiveUsername := publicUsernameIDs[channel.ID]
|
||||
if !publicPreviewableChannel(channel, hasActiveUsername) {
|
||||
continue
|
||||
}
|
||||
existing, found := previewMembers[channel.ID]
|
||||
|
|
@ -514,17 +520,18 @@ func finishChannelScan(ch *domain.Channel, rights, reactionPolicy string, wallpa
|
|||
}
|
||||
}
|
||||
|
||||
func publicPreviewableChannel(channel domain.Channel) bool {
|
||||
func publicPreviewableChannel(channel domain.Channel, hasActiveUsername bool) bool {
|
||||
return !channel.Deleted &&
|
||||
(channel.Broadcast || channel.Megagroup) &&
|
||||
strings.TrimSpace(channel.Username) != ""
|
||||
(strings.TrimSpace(channel.Username) != "" || hasActiveUsername)
|
||||
}
|
||||
|
||||
func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) {
|
||||
var participants, admins, kicked, banned int
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
|
||||
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
FROM channel_members
|
||||
WHERE channel_id = $1`, channel.ID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,26 @@ type channelDialogListItem struct {
|
|||
defaultSendAs *domain.Peer
|
||||
}
|
||||
|
||||
func channelDialogVisibleTopIDSQL() string {
|
||||
return `CASE
|
||||
WHEN c.top_message_id > m.available_min_id THEN c.top_message_id
|
||||
WHEN m.history_clear_anchor_id > 0
|
||||
AND m.history_clear_anchor_id = m.available_min_id
|
||||
THEN m.history_clear_anchor_id
|
||||
ELSE 0
|
||||
END`
|
||||
}
|
||||
|
||||
func channelDialogVisibleTopDateSQL(globalTopDate, emptyFallback string) string {
|
||||
return `CASE
|
||||
WHEN c.top_message_id > m.available_min_id THEN ` + globalTopDate + `
|
||||
WHEN m.history_clear_anchor_id > 0
|
||||
AND m.history_clear_anchor_id = m.available_min_id
|
||||
THEN m.history_clear_anchor_date
|
||||
ELSE ` + emptyFallback + `
|
||||
END`
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) {
|
||||
if viewerUserID == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
|
|
@ -33,8 +53,8 @@ func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int6
|
|||
if len(channelIDs) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
|
||||
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END"
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
|
||||
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
args := []any{viewerUserID, channelIDs}
|
||||
|
|
@ -139,7 +159,9 @@ SELECT `+channelColumns+`,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
d.default_send_as_peer_type,
|
||||
d.default_send_as_peer_id
|
||||
d.default_send_as_peer_id,
|
||||
m.history_clear_anchor_id,
|
||||
m.history_clear_anchor_date
|
||||
FROM channel_members m
|
||||
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
|
||||
|
|
@ -232,6 +254,7 @@ LIMIT `+limitArg, args...)
|
|||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +278,9 @@ SELECT `+channelColumns+`,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
d.default_send_as_peer_type,
|
||||
d.default_send_as_peer_id
|
||||
d.default_send_as_peer_id,
|
||||
0,
|
||||
0
|
||||
FROM user_channel_member_index i
|
||||
JOIN channel_members pm ON pm.user_id = i.user_id AND pm.channel_id = i.channel_id
|
||||
JOIN channels parent ON parent.id = i.channel_id AND parent.id = ANY($2::bigint[]) AND parent.broadcast AND parent.linked_monoforum_id <> 0 AND NOT parent.deleted
|
||||
|
|
@ -357,6 +382,20 @@ WHERE `+where.String(), args...)
|
|||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scan channel dialog top messages: %w", err)
|
||||
}
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeChannel ||
|
||||
dialog.TopMessage <= 0 ||
|
||||
dialog.TopMessage != dialog.HistoryClearAnchorID {
|
||||
continue
|
||||
}
|
||||
key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}
|
||||
out[key] = domain.ProjectChannelHistoryClearMessage(
|
||||
out[key],
|
||||
dialog.Peer.ID,
|
||||
dialog.HistoryClearAnchorID,
|
||||
dialog.HistoryClearAnchorDate,
|
||||
)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -413,6 +452,14 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
|
|||
}
|
||||
}
|
||||
msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID)
|
||||
if dialog.TopMessageID > 0 && dialog.TopMessageID == dialog.HistoryClearAnchorID {
|
||||
msg = domain.ProjectChannelHistoryClearMessage(
|
||||
msg,
|
||||
channelID,
|
||||
dialog.HistoryClearAnchorID,
|
||||
dialog.HistoryClearAnchorDate,
|
||||
)
|
||||
}
|
||||
if msg.ID != 0 {
|
||||
dialog.TopMessageDate = msg.Date
|
||||
out.Messages = append(out.Messages, msg)
|
||||
|
|
@ -425,9 +472,34 @@ func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64
|
|||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil {
|
||||
return domain.ChannelDialogList{}, err
|
||||
}
|
||||
projectChannelDialogHistoryClearMessages(out.Dialogs, out.Messages)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func projectChannelDialogHistoryClearMessages(dialogs []domain.Dialog, messages []domain.ChannelMessage) {
|
||||
anchors := make(map[channelMessageLookupKey]domain.Dialog)
|
||||
for _, dialog := range dialogs {
|
||||
if dialog.Peer.Type != domain.PeerTypeChannel ||
|
||||
dialog.TopMessage <= 0 ||
|
||||
dialog.TopMessage != dialog.HistoryClearAnchorID {
|
||||
continue
|
||||
}
|
||||
anchors[channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage}] = dialog
|
||||
}
|
||||
for i := range messages {
|
||||
dialog, ok := anchors[channelMessageLookupKey{channelID: messages[i].ChannelID, id: messages[i].ID}]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
messages[i] = domain.ProjectChannelHistoryClearMessage(
|
||||
messages[i],
|
||||
dialog.Peer.ID,
|
||||
dialog.HistoryClearAnchorID,
|
||||
dialog.HistoryClearAnchorDate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) {
|
||||
if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 {
|
||||
return domain.CommonChannelsResult{}, domain.ErrChannelInvalid
|
||||
|
|
@ -553,6 +625,7 @@ func (s *ChannelStore) leftChannelsByIDs(ctx context.Context, userID int64, ids
|
|||
rows, err := s.db.Query(ctx, `
|
||||
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
|
||||
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
|
||||
history_clear_anchor_id, history_clear_anchor_date,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
FROM channel_members
|
||||
WHERE user_id = $1
|
||||
|
|
@ -603,8 +676,8 @@ func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, l
|
|||
if len(channelIDs) == 0 {
|
||||
return domain.ChannelDialogList{}, nil
|
||||
}
|
||||
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
|
||||
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE GREATEST(c.date, m.joined_at) END"
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "GREATEST(c.date, m.joined_at)")
|
||||
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
|
|
@ -619,7 +692,9 @@ SELECT `+channelColumns+`,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
d.default_send_as_peer_type,
|
||||
d.default_send_as_peer_id
|
||||
d.default_send_as_peer_id,
|
||||
m.history_clear_anchor_id,
|
||||
m.history_clear_anchor_date
|
||||
FROM channel_members m
|
||||
JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
|
||||
|
|
@ -856,6 +931,7 @@ func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64,
|
|||
// 归档必须 ensure-INSERT:从未读过/置顶过的频道还没有 dialog 行,
|
||||
// 只 UPDATE 会让归档静默丢失;新行同时带上 member 真实水位,
|
||||
// 避免 0 水位缓存行遮蔽未读/已读状态。
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
WITH requested AS (
|
||||
SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS channel_id, ($4::int[])[i] AS folder_id
|
||||
|
|
@ -870,7 +946,7 @@ deduped AS (
|
|||
)
|
||||
INSERT INTO channel_dialogs (user_id, channel_id, folder_id, top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count, updated_at)
|
||||
SELECT $1, m.channel_id, deduped.folder_id,
|
||||
CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END,
|
||||
`+visibleTopID+`,
|
||||
m.read_inbox_max_id, m.read_outbox_max_id,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
|
|
@ -904,7 +980,7 @@ func (s *ChannelStore) CountChannelArchiveUnread(ctx context.Context, userID int
|
|||
// JOIN active member:退群残留的 channel_dialogs 行不计入归档徽章。
|
||||
// unread 读时动态派生(H4a):不再消费 channel_dialogs.unread_count 缓存列;归档集合
|
||||
// 有界(需显式归档建行),每行动态 COUNT 已被 cap 钳制。
|
||||
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
|
||||
if err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
|
|
@ -937,7 +1013,7 @@ func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.
|
|||
dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID}
|
||||
var defaultSendAsType sql.NullString
|
||||
var defaultSendAsID sql.NullInt64
|
||||
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
// 单频道 TopMessageDate 用 LEFT JOIN top_msg 直接取,替代此前对每个频道再单查一次
|
||||
|
|
@ -946,7 +1022,10 @@ func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.
|
|||
// message_date(不过滤 deleted,与原 getChannelMessage 一致),否则回退
|
||||
// COALESCE(d.top_message_date, c.date)。注意:与批量版 getChannelDialogs 的隐藏态
|
||||
// (ELSE 0)刻意保持各自既有差异,本改动只去往返、不改单频道输出。
|
||||
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id AND top_msg.id IS NOT NULL THEN top_msg.message_date ELSE COALESCE(d.top_message_date, c.date) END"
|
||||
visibleTopDate := channelDialogVisibleTopDateSQL(
|
||||
"CASE WHEN top_msg.id IS NOT NULL THEN top_msg.message_date ELSE COALESCE(d.top_message_date, c.date) END",
|
||||
"COALESCE(d.top_message_date, c.date)",
|
||||
)
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT `+visibleTopID+`,
|
||||
`+visibleTopDate+`,
|
||||
|
|
@ -962,7 +1041,9 @@ SELECT `+visibleTopID+`,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
d.default_send_as_peer_type,
|
||||
d.default_send_as_peer_id
|
||||
d.default_send_as_peer_id,
|
||||
m.history_clear_anchor_id,
|
||||
m.history_clear_anchor_date
|
||||
FROM channels c
|
||||
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id
|
||||
|
|
@ -983,6 +1064,8 @@ WHERE c.id = $2`, userID, channel.ID).Scan(
|
|||
&dialog.HasScheduled,
|
||||
&defaultSendAsType,
|
||||
&defaultSendAsID,
|
||||
&dialog.HistoryClearAnchorID,
|
||||
&dialog.HistoryClearAnchorDate,
|
||||
)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ChannelDialog{}, fmt.Errorf("get channel dialog: %w", err)
|
||||
|
|
@ -1037,8 +1120,8 @@ func (s *ChannelStore) getChannelDialogsUncached(ctx context.Context, db sqlcgen
|
|||
if userID == 0 || len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END"
|
||||
visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END"
|
||||
visibleTopID := channelDialogVisibleTopIDSQL()
|
||||
visibleTopDate := channelDialogVisibleTopDateSQL("COALESCE(top_msg.message_date, d.top_message_date, c.date)", "0")
|
||||
visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)"
|
||||
visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)
|
||||
rows, err := db.Query(ctx, `
|
||||
|
|
@ -1057,7 +1140,9 @@ SELECT c.id,
|
|||
COALESCE(d.view_forum_as_messages, false),
|
||||
COALESCE(d.has_scheduled, false),
|
||||
d.default_send_as_peer_type,
|
||||
d.default_send_as_peer_id
|
||||
d.default_send_as_peer_id,
|
||||
m.history_clear_anchor_id,
|
||||
m.history_clear_anchor_date
|
||||
FROM channels c
|
||||
JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1
|
||||
LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted
|
||||
|
|
@ -1089,6 +1174,8 @@ WHERE c.id = ANY($2::bigint[])`, userID, channelIDs)
|
|||
&dialog.HasScheduled,
|
||||
&defaultSendAsType,
|
||||
&defaultSendAsID,
|
||||
&dialog.HistoryClearAnchorID,
|
||||
&dialog.HistoryClearAnchorDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1197,6 +1284,7 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
|
|||
var rights, reactionPolicy string
|
||||
var wallpaper *string
|
||||
var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int
|
||||
var historyClearAnchorID, historyClearAnchorDate int
|
||||
var pinned, unreadMark, viewForumAsMessages, hasScheduled bool
|
||||
var defaultSendAsType sql.NullString
|
||||
var defaultSendAsID sql.NullInt64
|
||||
|
|
@ -1204,27 +1292,30 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
|
|||
&topID, &topDate,
|
||||
&folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, &hasScheduled,
|
||||
&defaultSendAsType, &defaultSendAsID,
|
||||
&historyClearAnchorID, &historyClearAnchorDate,
|
||||
)
|
||||
if err := row.Scan(dest...); err != nil {
|
||||
return domain.Channel{}, domain.Dialog{}, nil, err
|
||||
}
|
||||
finishChannelScan(&ch, rights, reactionPolicy, wallpaper)
|
||||
dialog := domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
|
||||
FolderID: folderID,
|
||||
TopMessage: topID,
|
||||
TopMessageDate: topDate,
|
||||
ReadInboxMaxID: readInbox,
|
||||
ReadOutboxMaxID: readOutbox,
|
||||
UnreadCount: unreadCount,
|
||||
UnreadMentions: unreadMentions,
|
||||
UnreadReactions: unreadReactions,
|
||||
Pinned: pinned,
|
||||
PinnedOrder: pinnedOrder,
|
||||
UnreadMark: unreadMark,
|
||||
ViewForumAsMessages: viewForumAsMessages,
|
||||
HasScheduled: hasScheduled,
|
||||
Pts: ch.Pts,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID},
|
||||
FolderID: folderID,
|
||||
TopMessage: topID,
|
||||
TopMessageDate: topDate,
|
||||
HistoryClearAnchorID: historyClearAnchorID,
|
||||
HistoryClearAnchorDate: historyClearAnchorDate,
|
||||
ReadInboxMaxID: readInbox,
|
||||
ReadOutboxMaxID: readOutbox,
|
||||
UnreadCount: unreadCount,
|
||||
UnreadMentions: unreadMentions,
|
||||
UnreadReactions: unreadReactions,
|
||||
Pinned: pinned,
|
||||
PinnedOrder: pinnedOrder,
|
||||
UnreadMark: unreadMark,
|
||||
ViewForumAsMessages: viewForumAsMessages,
|
||||
HasScheduled: hasScheduled,
|
||||
Pts: ch.Pts,
|
||||
}
|
||||
var defaultSendAs *domain.Peer
|
||||
if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 {
|
||||
|
|
@ -1236,41 +1327,45 @@ func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.
|
|||
|
||||
func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.Dialog {
|
||||
return domain.Dialog{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
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,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID},
|
||||
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 channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelDialog {
|
||||
return domain.ChannelDialog{
|
||||
UserID: userID,
|
||||
ChannelID: dialog.Peer.ID,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessageID: dialog.TopMessage,
|
||||
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,
|
||||
UserID: userID,
|
||||
ChannelID: dialog.Peer.ID,
|
||||
FolderID: dialog.FolderID,
|
||||
TopMessageID: dialog.TopMessage,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1392,14 +1487,23 @@ func channelFolderPeerIDs(primary []domain.DialogFolderPeer, rest ...[]domain.Di
|
|||
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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ func TestChannelStoreDifferenceStartsAtMemberAvailableMinPts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChannelStorePublicPreviewDifferenceSkipsNonMemberMessages(t *testing.T) {
|
||||
func TestChannelStorePublicPreviewDifferenceReplaysVisibleMessages(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
|
|
@ -183,12 +183,57 @@ func TestChannelStorePublicPreviewDifferenceSkipsNonMemberMessages(t *testing.T)
|
|||
if err != nil {
|
||||
t.Fatalf("list public preview difference: %v", err)
|
||||
}
|
||||
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 0 || len(diff.NewMessages) != 0 || len(diff.OtherUpdates) != 0 {
|
||||
t.Fatalf("preview diff = %+v, want empty public preview difference at current pts", diff)
|
||||
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 1 || len(diff.NewMessages) != 1 || len(diff.OtherUpdates) != 0 {
|
||||
t.Fatalf("preview diff = %+v, want one durable public preview message at pts %d", diff, sent.Event.Pts)
|
||||
}
|
||||
if diff.NewMessages[0].ID != sent.Message.ID || diff.NewMessages[0].Body != sent.Message.Body {
|
||||
t.Fatalf("preview diff message = %+v, want sent message %+v", diff.NewMessages[0], sent.Message)
|
||||
}
|
||||
if diff.Dialog.UnreadCount != 0 || diff.Dialog.ReadInboxMaxID < sent.Message.ID {
|
||||
t.Fatalf("preview diff dialog = %+v, want read-only public preview dialog", diff.Dialog)
|
||||
}
|
||||
edited, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, ID: sent.Message.ID,
|
||||
Message: "public preview edited", EditDate: 1700000372,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("edit public preview message: %v", err)
|
||||
}
|
||||
pinned, err := channels.UpdatePinnedMessage(ctx, domain.UpdateChannelPinnedMessageRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, MessageID: sent.Message.ID,
|
||||
Pinned: true, Date: 1700000373,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("pin public preview message: %v", err)
|
||||
}
|
||||
deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, IDs: []int{sent.Message.ID}, Date: 1700000374,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete public preview message: %v", err)
|
||||
}
|
||||
mutations, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: viewer.ID, ChannelID: channelID, Pts: sent.Event.Pts, Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list public preview mutations: %v", err)
|
||||
}
|
||||
if !mutations.Final || mutations.Pts != deleted.Event.Pts || len(mutations.NewMessages) != 0 || len(mutations.OtherUpdates) != 3 {
|
||||
t.Fatalf("public preview mutations = %+v, want edit/pin/delete through pts %d", mutations, deleted.Event.Pts)
|
||||
}
|
||||
wantTypes := []domain.ChannelUpdateEventType{
|
||||
domain.ChannelUpdateEditMessage,
|
||||
domain.ChannelUpdatePinnedMessages,
|
||||
domain.ChannelUpdateDeleteMessages,
|
||||
}
|
||||
for i, want := range wantTypes {
|
||||
if mutations.OtherUpdates[i].Type != want {
|
||||
t.Fatalf("public preview mutation[%d] = %+v, want %s", i, mutations.OtherUpdates[i], want)
|
||||
}
|
||||
}
|
||||
if edited.Event.Pts >= pinned.Event.Pts || pinned.Event.Pts >= deleted.Event.Pts {
|
||||
t.Fatalf("public preview mutation pts = edit %d pin %d delete %d, want strictly increasing", edited.Event.Pts, pinned.Event.Pts, deleted.Event.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelStoreDifferenceUsesDurableMessageSnapshots(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ func (s *ChannelStore) SaveChannelDefaultSendAs(ctx context.Context, req domain.
|
|||
}
|
||||
topMessageID := channel.TopMessageID
|
||||
if topMessageID <= member.AvailableMinID {
|
||||
topMessageID = 0
|
||||
topMessageID = member.HistoryClearAnchorID
|
||||
if topMessageID != member.AvailableMinID {
|
||||
topMessageID = 0
|
||||
}
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO channel_dialogs (
|
||||
|
|
@ -150,11 +153,28 @@ func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID in
|
|||
queryPrefix := escapeLike(queryLower) + "%"
|
||||
queryLike := "%" + escapeLike(queryLower) + "%"
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH username_matches AS (
|
||||
SELECT
|
||||
peer_id,
|
||||
MIN(CASE
|
||||
WHEN username_lower = $2 THEN 0
|
||||
ELSE 1
|
||||
END) AS rank
|
||||
FROM peer_usernames
|
||||
WHERE peer_type = 'channel'
|
||||
AND active
|
||||
AND collectible_id IS NOT NULL
|
||||
AND (
|
||||
username_lower = $2
|
||||
OR username_lower LIKE $3 ESCAPE '\'
|
||||
)
|
||||
GROUP BY peer_id
|
||||
)
|
||||
SELECT `+channelColumns+`
|
||||
FROM channels c
|
||||
LEFT JOIN username_matches um ON um.peer_id = c.id
|
||||
WHERE NOT c.deleted
|
||||
AND (c.broadcast OR c.megagroup)
|
||||
AND COALESCE(c.username, '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_members m
|
||||
|
|
@ -163,15 +183,16 @@ WHERE NOT c.deleted
|
|||
AND m.status = 'active'
|
||||
)
|
||||
AND (
|
||||
lower(c.username) = $2
|
||||
um.peer_id IS NOT NULL
|
||||
OR lower(c.username) = $2
|
||||
OR lower(c.username) LIKE $3 ESCAPE '\'
|
||||
OR lower(c.title) LIKE $3 ESCAPE '\'
|
||||
OR lower(c.username) LIKE $4 ESCAPE '\'
|
||||
OR lower(c.title) LIKE $4 ESCAPE '\'
|
||||
)
|
||||
ORDER BY CASE
|
||||
WHEN lower(c.username) = $2 THEN 0
|
||||
WHEN lower(c.username) LIKE $3 ESCAPE '\' THEN 1
|
||||
WHEN um.rank = 0 OR lower(c.username) = $2 THEN 0
|
||||
WHEN um.rank = 1 OR lower(c.username) LIKE $3 ESCAPE '\' THEN 1
|
||||
WHEN lower(c.username) LIKE $4 ESCAPE '\' THEN 2
|
||||
WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3
|
||||
ELSE 4
|
||||
|
|
@ -375,15 +396,59 @@ LIMIT $4`, userID, sinceDate, afterChannelID, limit)
|
|||
return nil, fmt.Errorf("list dirty active channels for user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.DirtyChannel, 0, limit)
|
||||
byChannelID := make(map[int64]domain.DirtyChannel, limit*2)
|
||||
for rows.Next() {
|
||||
var item domain.DirtyChannel
|
||||
if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.ChannelUpdatesDirty = true
|
||||
byChannelID[item.ChannelID] = item
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clearRows, err := s.db.Query(ctx, `
|
||||
SELECT i.channel_id, c.pts, i.available_min_id, i.history_clear_updated_at
|
||||
FROM user_channel_member_index i
|
||||
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
|
||||
WHERE i.user_id = $1
|
||||
AND i.status = 'active'
|
||||
AND NOT i.deleted
|
||||
AND i.channel_id > $3
|
||||
AND i.history_clear_anchor_id > 0
|
||||
AND i.history_clear_anchor_id = i.available_min_id
|
||||
AND i.history_clear_updated_at >= $2
|
||||
ORDER BY i.channel_id ASC
|
||||
LIMIT $4`, userID, sinceDate, afterChannelID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list owner-local channel history clears for user: %w", err)
|
||||
}
|
||||
defer clearRows.Close()
|
||||
for clearRows.Next() {
|
||||
var item domain.DirtyChannel
|
||||
if err := clearRows.Scan(&item.ChannelID, &item.Pts, &item.AvailableMinID, &item.HistoryClearDate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, ok := byChannelID[item.ChannelID]; ok {
|
||||
item.ChannelUpdatesDirty = existing.ChannelUpdatesDirty
|
||||
}
|
||||
byChannelID[item.ChannelID] = item
|
||||
}
|
||||
if err := clearRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]domain.DirtyChannel, 0, len(byChannelID))
|
||||
for _, item := range byChannelID {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
|
|
@ -421,7 +486,12 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
|
|||
return ch, syntheticMonoforumUserMember(ch, viewerUserID), true, nil
|
||||
}
|
||||
}
|
||||
if !publicPreviewableChannel(ch) {
|
||||
publicUsernameIDs, err := activeCollectibleUsernamePeerIDs(ctx, db, peerUsernameTypeChannel, []int64{ch.ID})
|
||||
if err != nil {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, err
|
||||
}
|
||||
_, hasActiveUsername := publicUsernameIDs[ch.ID]
|
||||
if !publicPreviewableChannel(ch, hasActiveUsername) {
|
||||
return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate
|
||||
}
|
||||
member, err = s.getPublicPreviewMember(ctx, db, viewerUserID, ch)
|
||||
|
|
@ -431,6 +501,19 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
|
|||
return ch, member, true, nil
|
||||
}
|
||||
|
||||
// channelMessageVisibleToViewer applies the message-level half of synthetic monoforum access.
|
||||
// Subscribers do not have channel_members rows and may only address saved_peer=self; a synthetic
|
||||
// manager view may address every subscriber sub-dialog.
|
||||
func channelMessageVisibleToViewer(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 getChannelByID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.Channel, error) {
|
||||
ch, err := scanChannel(db.QueryRow(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = $1 AND NOT c.deleted`, channelID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
|
|
|
|||
|
|
@ -207,6 +207,45 @@ func TestReadModelChangeListenerInvalidatesPrivateMediaCountCache(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestReadModelChangeListenerInvalidatesAccountSettingsCache(t *testing.T) {
|
||||
settings := &fakeAccountSettingsReadModelCache{}
|
||||
listener := NewReadModelChangeListener("", ReadModelCacheSet{
|
||||
AccountSettings: settings,
|
||||
}, nil)
|
||||
|
||||
listener.handlePayload(`{"model":"account_settings","owner_user_id":100,"peer_type":"user","peer_id":100,"version":2}`)
|
||||
if len(settings.invalidated) != 1 || settings.invalidated[0] != 100 {
|
||||
t.Fatalf("account_settings invalidations = %v, want [100]", settings.invalidated)
|
||||
}
|
||||
if len(settings.warmed) != 1 || settings.warmed[0] != 100 {
|
||||
t.Fatalf("account_settings warmups = %v, want [100]", settings.warmed)
|
||||
}
|
||||
|
||||
listener.flush("test")
|
||||
if settings.flushes != 1 {
|
||||
t.Fatalf("account_settings flushes = %d, want 1", settings.flushes)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAccountSettingsReadModelCache struct {
|
||||
invalidated []int64
|
||||
warmed []int64
|
||||
flushes int
|
||||
}
|
||||
|
||||
func (f *fakeAccountSettingsReadModelCache) InvalidateAccountSettingsReadModel(userID int64) {
|
||||
f.invalidated = append(f.invalidated, userID)
|
||||
}
|
||||
|
||||
func (f *fakeAccountSettingsReadModelCache) FlushAccountSettingsReadModel() {
|
||||
f.flushes++
|
||||
}
|
||||
|
||||
func (f *fakeAccountSettingsReadModelCache) WarmAccountSettingsReadModel(_ context.Context, userID int64) error {
|
||||
f.warmed = append(f.warmed, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestReadModelChangeListenerBotFullFlushesChannelFullBots 回归: bot 改资料(bot_full 事件,
|
||||
// 迁移 0013)须 flush channelFullBotInfoCache(否则群信息页 bot 简介/命令跨实例陈旧至 TTL);
|
||||
// 普通用户的 user_base 事件不得 flush(否则该缓存形同虚设)。
|
||||
|
|
@ -419,6 +458,7 @@ func (f *fakeDialogReadModelCache) flushCount() int {
|
|||
type fakePrivacyReadModelCache struct {
|
||||
mu sync.Mutex
|
||||
ids []int64
|
||||
warmed []int64
|
||||
flushes int
|
||||
}
|
||||
|
||||
|
|
@ -428,6 +468,13 @@ func (f *fakePrivacyReadModelCache) InvalidateOwners(ids ...int64) {
|
|||
f.ids = append(f.ids, ids...)
|
||||
}
|
||||
|
||||
func (f *fakePrivacyReadModelCache) WarmOwners(_ context.Context, ids ...int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.warmed = append(f.warmed, ids...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakePrivacyReadModelCache) FlushReadModelCache() {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -440,6 +487,12 @@ func (f *fakePrivacyReadModelCache) idsSnapshot() []int64 {
|
|||
return append([]int64(nil), f.ids...)
|
||||
}
|
||||
|
||||
func (f *fakePrivacyReadModelCache) warmedSnapshot() []int64 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]int64(nil), f.warmed...)
|
||||
}
|
||||
|
||||
func (f *fakePrivacyReadModelCache) flushCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
|
@ -509,6 +562,9 @@ func TestReadModelChangeListenerInvalidatesAccountCaches(t *testing.T) {
|
|||
if len(privacy.ids) != 1 || privacy.ids[0] != 21 {
|
||||
t.Fatalf("privacy invalidations = %v, want [21]", privacy.ids)
|
||||
}
|
||||
if warmed := privacy.warmedSnapshot(); len(warmed) != 1 || warmed[0] != 21 {
|
||||
t.Fatalf("privacy warms = %v, want [21]", warmed)
|
||||
}
|
||||
|
||||
listener.handlePayload(`{"model":"dialog_light","owner_user_id":22,"peer_type":"user","peer_id":32,"version":4}`)
|
||||
if len(dialogs.owners) != 1 || dialogs.owners[0] != 22 || dialogs.keys[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 32}) {
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ func getChannelMemberByID(ctx context.Context, db sqlcgen.DBTX, channelID, userI
|
|||
row := db.QueryRow(ctx, `
|
||||
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
|
||||
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
|
||||
history_clear_anchor_id, history_clear_anchor_date,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
FROM channel_members
|
||||
WHERE channel_id = $1 AND user_id = $2`, channelID, userID)
|
||||
|
|
@ -214,8 +215,9 @@ func upsertChannelMemberTx(ctx context.Context, tx pgx.Tx, channel domain.Channe
|
|||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO channel_members (
|
||||
channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights,
|
||||
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
|
||||
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
||||
inviter_user_id = EXCLUDED.inviter_user_id,
|
||||
role = EXCLUDED.role,
|
||||
|
|
@ -231,7 +233,8 @@ ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
|||
updated_at = now()`,
|
||||
member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status),
|
||||
member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID,
|
||||
member.AvailableMinPts, member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil {
|
||||
member.AvailableMinPts, member.HistoryClearAnchorID, member.HistoryClearAnchorDate,
|
||||
member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil {
|
||||
return fmt.Errorf("upsert channel member: %w", err)
|
||||
}
|
||||
return upsertUserChannelMemberIndexTx(ctx, tx, channel, member)
|
||||
|
|
@ -316,7 +319,8 @@ func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember
|
|||
dest := append(channelScanDest(&ch, &defaultRights, &reactionPolicy, &wallpaper),
|
||||
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
|
||||
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
|
||||
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
|
||||
&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
|
||||
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
|
||||
)
|
||||
if err := row.Scan(dest...); err != nil {
|
||||
return domain.Channel{}, domain.ChannelMember{}, err
|
||||
|
|
@ -351,7 +355,8 @@ func scanChannelMember(row rowScanner) (domain.ChannelMember, error) {
|
|||
if err := row.Scan(
|
||||
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
|
||||
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
|
||||
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
|
||||
&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
|
||||
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
|
||||
); err != nil {
|
||||
return domain.ChannelMember{}, err
|
||||
}
|
||||
|
|
@ -370,7 +375,8 @@ func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, erro
|
|||
if err := row.Scan(
|
||||
&member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status,
|
||||
&member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank,
|
||||
&member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
|
||||
&member.AvailableMinID, &member.AvailableMinPts, &member.HistoryClearAnchorID, &member.HistoryClearAnchorDate,
|
||||
&member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate,
|
||||
&count,
|
||||
); err != nil {
|
||||
return domain.ChannelMember{}, 0, err
|
||||
|
|
@ -471,6 +477,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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -123,7 +124,12 @@ SELECT peer_id
|
|||
FROM peer_usernames
|
||||
WHERE username_lower = $1 AND peer_type = 'channel'
|
||||
`, publicUsername)
|
||||
requirePlanContains(t, usernameLookupPlan, "peer_usernames_pkey")
|
||||
// 0150 adds peer_usernames_peer_order_idx, which covers this lookup as an
|
||||
// index-only scan; either index is an acceptable plan, a partition scan is not.
|
||||
if !strings.Contains(usernameLookupPlan, "peer_usernames_pkey") &&
|
||||
!strings.Contains(usernameLookupPlan, "peer_usernames_peer_order_idx") {
|
||||
t.Fatalf("username lookup plan = %s, want a peer_usernames index scan", usernameLookupPlan)
|
||||
}
|
||||
requirePlanNotMatches(t, usernameLookupPlan, `channels_p\d+`)
|
||||
|
||||
usernameChannelDetailPlan := explainText(t, ctx, tx, `
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ func (s *ChannelStore) futureCreatorAfterLeave(ctx context.Context, db sqlcgen.D
|
|||
member, err := scanChannelMember(db.QueryRow(ctx, `
|
||||
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
|
||||
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
|
||||
history_clear_anchor_id, history_clear_anchor_date,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
FROM channel_members
|
||||
WHERE channel_id = $1
|
||||
|
|
|
|||
|
|
@ -128,7 +128,8 @@ func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channe
|
|||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
|
||||
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
`+from+`
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY CASE role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, user_id
|
||||
|
|
@ -221,7 +222,8 @@ func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserI
|
|||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text,
|
||||
rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
rank, available_min_id, available_min_pts, history_clear_anchor_id, history_clear_anchor_date,
|
||||
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
|
||||
FROM channel_members
|
||||
WHERE channel_id = $1 AND status = 'active'
|
||||
ORDER BY user_id
|
||||
|
|
@ -264,6 +266,7 @@ func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUs
|
|||
rows, err := s.db.Query(ctx, `
|
||||
SELECT m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at,
|
||||
m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts,
|
||||
m.history_clear_anchor_id, m.history_clear_anchor_date,
|
||||
m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date,
|
||||
COUNT(*) OVER()::int
|
||||
FROM bots b
|
||||
|
|
@ -360,3 +363,52 @@ ORDER BY user_id`, channelID, candidates[start:end])
|
|||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FilterChannelMessageAudienceIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) {
|
||||
if channelID == 0 || len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
candidates := uniqueChannelUserIDs(userIDs, 0)
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]int64, 0, len(candidates))
|
||||
for start := 0; start < len(candidates); start += channelMemberFilterBatch {
|
||||
end := start + channelMemberFilterBatch
|
||||
if end > len(candidates) {
|
||||
end = len(candidates)
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT candidate.user_id
|
||||
FROM channels c
|
||||
CROSS JOIN unnest($2::bigint[]) AS candidate(user_id)
|
||||
LEFT JOIN channel_members m
|
||||
ON m.channel_id = c.id AND m.user_id = candidate.user_id
|
||||
WHERE c.id = $1
|
||||
AND NOT c.deleted
|
||||
AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false)
|
||||
AND COALESCE(m.status, '') NOT IN ('kicked', 'banned')
|
||||
AND (
|
||||
m.status = 'active'
|
||||
OR (COALESCE(c.username, '') <> '' AND COALESCE(m.status, 'left') = 'left')
|
||||
)
|
||||
ORDER BY candidate.user_id`, channelID, candidates[start:end])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("filter channel message audience: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, userID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package postgres
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"sort"
|
||||
|
|
@ -60,6 +61,68 @@ func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.Del
|
|||
return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, DiscussionDeletes: cascades}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ModerationDeleteChannelMessages(ctx context.Context, channelID int64, ids []int, date int) (domain.DeleteChannelMessagesResult, error) {
|
||||
if channelID <= 0 || len(ids) == 0 || len(ids) > domain.MaxDeleteMessageIDs {
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
if date == 0 {
|
||||
date = nowUnix()
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("moderation delete channel messages: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin moderation delete channel messages: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
channel, err := getChannelByID(ctx, tx, channelID)
|
||||
if err != nil || channel.Deleted {
|
||||
if err != nil {
|
||||
return domain.DeleteChannelMessagesResult{}, err
|
||||
}
|
||||
return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
refs, err := s.discussionRefsForMessages(ctx, tx, channel.ID, ids)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelMessagesResult{}, err
|
||||
}
|
||||
systemMember := domain.ChannelMember{
|
||||
ChannelID: channel.ID, UserID: domain.OfficialSystemUserID,
|
||||
Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive,
|
||||
}
|
||||
deleted, event, channel, err := s.deleteChannelMessagesTx(
|
||||
ctx, tx, channel, systemMember, ids, domain.OfficialSystemUserID, date,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelMessagesResult{}, err
|
||||
}
|
||||
cascades, err := s.cascadeDeleteDiscussionRootsTx(
|
||||
ctx, tx, refs, deleted, domain.OfficialSystemUserID, date,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelMessagesResult{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit moderation delete channel messages: %w", err)
|
||||
}
|
||||
committed = true
|
||||
recipients, _ := s.ListActiveChannelMemberIDs(ctx, 0, channel.ID, 0)
|
||||
for i := range cascades {
|
||||
cascades[i].Recipients, _ = s.ListActiveChannelMemberIDs(ctx, 0, cascades[i].Channel.ID, 0)
|
||||
}
|
||||
return domain.DeleteChannelMessagesResult{
|
||||
Channel: channel, Event: event, DeletedIDs: deleted,
|
||||
Recipients: recipients, DiscussionDeletes: cascades,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// discussionRefsForMessages 取待删消息携带的讨论组转发根引用。
|
||||
func (s *ChannelStore) discussionRefsForMessages(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) (map[int]domain.ChannelDiscussionRef, error) {
|
||||
id32, _, err := validUniqueChannelMessageIDs(ids)
|
||||
|
|
@ -161,19 +224,68 @@ func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.Dele
|
|||
}
|
||||
if !req.ForEveryone {
|
||||
appliedMinID := maxInt(member.AvailableMinID, maxID)
|
||||
changed := appliedMinID > member.AvailableMinID
|
||||
anchorID := member.HistoryClearAnchorID
|
||||
anchorDate := member.HistoryClearAnchorDate
|
||||
if changed {
|
||||
anchorID = appliedMinID
|
||||
anchorDate = req.Date
|
||||
var messageDate int
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT message_date
|
||||
FROM channel_messages
|
||||
WHERE channel_id = $1 AND id = $2`, req.ChannelID, appliedMinID).Scan(&messageDate)
|
||||
switch {
|
||||
case err == nil && messageDate > 0:
|
||||
anchorDate = messageDate
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
// max_id may name an already-pruned/hole id. The owner-local
|
||||
// marker is still a valid monotonic boundary; retain request
|
||||
// time so its dialog projection remains loadable.
|
||||
case err != nil:
|
||||
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("select channel clear anchor date: %w", err)
|
||||
}
|
||||
if anchorDate <= 0 {
|
||||
anchorDate = channel.Date
|
||||
}
|
||||
}
|
||||
topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date)
|
||||
if err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
if topID == 0 && anchorID > 0 && anchorID == appliedMinID {
|
||||
topID = anchorID
|
||||
topDate = anchorDate
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE channel_members
|
||||
SET available_min_id = GREATEST(available_min_id, $3),
|
||||
history_clear_anchor_id = CASE WHEN available_min_id < $3 THEN $3 ELSE history_clear_anchor_id END,
|
||||
history_clear_anchor_date = CASE WHEN available_min_id < $3 THEN $4 ELSE history_clear_anchor_date END,
|
||||
read_inbox_max_id = GREATEST(read_inbox_max_id, $3),
|
||||
unread_mark = false,
|
||||
updated_at = now()
|
||||
WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID); err != nil {
|
||||
WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID, anchorDate); err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err)
|
||||
}
|
||||
if changed {
|
||||
member.AvailableMinID = appliedMinID
|
||||
member.HistoryClearAnchorID = anchorID
|
||||
member.HistoryClearAnchorDate = anchorDate
|
||||
if err := upsertUserChannelMemberIndexTx(ctx, tx, channel, member); err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE user_channel_member_index
|
||||
SET available_min_id = $3,
|
||||
history_clear_anchor_id = $3,
|
||||
history_clear_updated_at = $4,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1 AND channel_id = $2`,
|
||||
req.UserID, req.ChannelID, appliedMinID, req.Date); err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear recovery index: %w", err)
|
||||
}
|
||||
}
|
||||
if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil {
|
||||
return domain.DeleteChannelHistoryResult{}, err
|
||||
}
|
||||
|
|
@ -197,7 +309,17 @@ ON CONFLICT (user_id, channel_id) DO UPDATE SET
|
|||
return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil
|
||||
if s.memberCacheActive(s.db) {
|
||||
s.memberCache.delete(req.ChannelID, req.UserID)
|
||||
}
|
||||
if s.dialogCacheActive(s.db) {
|
||||
s.dialogCache.delete(req.UserID, req.ChannelID)
|
||||
}
|
||||
return domain.DeleteChannelHistoryResult{
|
||||
Channel: channel,
|
||||
AvailableMinID: appliedMinID,
|
||||
AvailableMinChanged: changed,
|
||||
}, nil
|
||||
}
|
||||
if !canDeleteAnyChannelMessage(member) {
|
||||
return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired
|
||||
|
|
|
|||
|
|
@ -79,6 +79,31 @@ AND EXISTS (
|
|||
baseArgs = append(baseArgs, filter.MinID)
|
||||
base += fmt.Sprintf(" AND id > $%d", len(baseArgs))
|
||||
}
|
||||
historyClearAnchor, hasHistoryClearAnchor, err := s.channelHistoryClearAnchor(ctx, channel, member, filter)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels}
|
||||
needExactTotal := filter.NeedTotalCount || filter.CountOnly
|
||||
exactTotal := 0
|
||||
if needExactTotal {
|
||||
// Exact totals are opt-in. messages.search first/count-only pages and
|
||||
// messages.getSearchCounters need protocol-exact Count, while ordinary
|
||||
// getHistory pages must stay on the single bounded page query.
|
||||
if err := s.db.QueryRow(ctx,
|
||||
"SELECT count(*)::int FROM channel_messages WHERE "+base,
|
||||
baseArgs...,
|
||||
).Scan(&exactTotal); err != nil {
|
||||
return domain.ChannelHistory{}, fmt.Errorf("count channel history: %w", err)
|
||||
}
|
||||
if hasHistoryClearAnchor {
|
||||
exactTotal++
|
||||
}
|
||||
out.Count = exactTotal
|
||||
}
|
||||
if filter.CountOnly {
|
||||
return out, nil
|
||||
}
|
||||
scanList := func(sql string, queryArgs []any) ([]domain.ChannelMessage, error) {
|
||||
rows, err := s.db.Query(ctx, sql, queryArgs...)
|
||||
if err != nil {
|
||||
|
|
@ -102,7 +127,6 @@ AND EXISTS (
|
|||
// store 层二次钳制 add_offset 到 [-100,100](与私聊 ListMessagesByUser 对齐):
|
||||
// 即便某个 caller 漏在 RPC 层钳制,也不会把客户端巨大值变成大 SQL OFFSET 跳扫。
|
||||
addOffset := domain.ClampMessageHistoryAddOffset(filter.AddOffset)
|
||||
out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels}
|
||||
hasMoreOlder := false
|
||||
// 锚点条件:offset_date 优先按日期、否则按消息 id(对齐私聊);
|
||||
// 二者皆空时向更新方向退化为空、向更旧方向退化为全部(取最新)。
|
||||
|
|
@ -117,6 +141,18 @@ AND EXISTS (
|
|||
}
|
||||
return "false"
|
||||
}
|
||||
anchorMatchesForward := func() bool {
|
||||
if !hasHistoryClearAnchor {
|
||||
return false
|
||||
}
|
||||
if filter.OffsetDate > 0 {
|
||||
return historyClearAnchor.Date >= filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID > 0 {
|
||||
return historyClearAnchor.ID > filter.OffsetID
|
||||
}
|
||||
return false
|
||||
}
|
||||
aroundOlderCond := func(args *[]any) string {
|
||||
if filter.OffsetDate > 0 {
|
||||
*args = append(*args, filter.OffsetDate)
|
||||
|
|
@ -128,6 +164,30 @@ AND EXISTS (
|
|||
}
|
||||
return "true"
|
||||
}
|
||||
anchorMatchesAroundOlder := func() bool {
|
||||
if !hasHistoryClearAnchor {
|
||||
return false
|
||||
}
|
||||
if filter.OffsetDate > 0 {
|
||||
return historyClearAnchor.Date < filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID > 0 {
|
||||
return historyClearAnchor.ID <= filter.OffsetID
|
||||
}
|
||||
return true
|
||||
}
|
||||
anchorMatchesBackward := func() bool {
|
||||
if !hasHistoryClearAnchor {
|
||||
return false
|
||||
}
|
||||
if filter.OffsetDate > 0 {
|
||||
return historyClearAnchor.Date < filter.OffsetDate
|
||||
}
|
||||
if filter.OffsetID > 0 {
|
||||
return historyClearAnchor.ID < filter.OffsetID
|
||||
}
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case addOffset < 0 && addOffset+limit > 0:
|
||||
// around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条
|
||||
|
|
@ -140,6 +200,12 @@ AND EXISTS (
|
|||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if anchorMatchesForward() {
|
||||
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
|
||||
if len(newer) > fwdLimit {
|
||||
newer = newer[:fwdLimit]
|
||||
}
|
||||
}
|
||||
bwdArgs := append([]any{}, baseArgs...)
|
||||
bwdWhere := aroundOlderCond(&bwdArgs)
|
||||
bwdArgs = append(bwdArgs, bwdLimit+1)
|
||||
|
|
@ -147,6 +213,9 @@ AND EXISTS (
|
|||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if anchorMatchesAroundOlder() {
|
||||
older = append(older, historyClearAnchor)
|
||||
}
|
||||
if len(older) > bwdLimit {
|
||||
older = older[:bwdLimit]
|
||||
hasMoreOlder = true
|
||||
|
|
@ -164,6 +233,9 @@ AND EXISTS (
|
|||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if anchorMatchesForward() {
|
||||
newer = append([]domain.ChannelMessage{historyClearAnchor}, newer...)
|
||||
}
|
||||
if len(newer) > limit {
|
||||
newer = newer[:limit]
|
||||
}
|
||||
|
|
@ -181,27 +253,37 @@ AND EXISTS (
|
|||
args = append(args, filter.OffsetID)
|
||||
where += fmt.Sprintf(" AND id < $%d", len(args))
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
// Fetch the bounded add_offset window and slice it in memory. This keeps
|
||||
// the shared-history branch on its ordered (channel_id,id) seek index;
|
||||
// the owner-local anchor is one separate PK lookup and never adds an OR
|
||||
// that would force BitmapOr + Sort for large channels.
|
||||
args = append(args, addOffset+limit+1)
|
||||
limIdx := len(args)
|
||||
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where + " ORDER BY id DESC"
|
||||
if addOffset > 0 {
|
||||
args = append(args, addOffset)
|
||||
sql += fmt.Sprintf(" OFFSET $%d", len(args))
|
||||
}
|
||||
sql += fmt.Sprintf(" LIMIT $%d", limIdx)
|
||||
sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where +
|
||||
fmt.Sprintf(" ORDER BY id DESC LIMIT $%d", limIdx)
|
||||
older, err := scanList(sql, args)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if anchorMatchesBackward() {
|
||||
older = append(older, historyClearAnchor)
|
||||
}
|
||||
if addOffset >= len(older) {
|
||||
older = nil
|
||||
} else if addOffset > 0 {
|
||||
older = older[addOffset:]
|
||||
}
|
||||
if len(older) > limit {
|
||||
older = older[:limit]
|
||||
hasMoreOlder = true
|
||||
}
|
||||
out.Messages = older
|
||||
}
|
||||
out.Count = len(out.Messages)
|
||||
if hasMoreOlder {
|
||||
out.Count = len(out.Messages) + 1
|
||||
if !needExactTotal {
|
||||
out.Count = len(out.Messages)
|
||||
if hasMoreOlder {
|
||||
out.Count = len(out.Messages) + 1
|
||||
}
|
||||
}
|
||||
if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
|
|
@ -209,9 +291,63 @@ AND EXISTS (
|
|||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if hasHistoryClearAnchor {
|
||||
for i := range out.Messages {
|
||||
if out.Messages[i].ID == historyClearAnchor.ID {
|
||||
out.Messages[i] = domain.ProjectChannelHistoryClearMessage(
|
||||
out.Messages[i],
|
||||
channel.ID,
|
||||
member.HistoryClearAnchorID,
|
||||
member.HistoryClearAnchorDate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) channelHistoryClearAnchor(
|
||||
ctx context.Context,
|
||||
channel domain.Channel,
|
||||
member domain.ChannelMember,
|
||||
filter domain.ChannelHistoryFilter,
|
||||
) (domain.ChannelMessage, bool, error) {
|
||||
if !filter.IncludeHistoryClearAnchor ||
|
||||
member.HistoryClearAnchorID <= 0 ||
|
||||
member.HistoryClearAnchorID != member.AvailableMinID ||
|
||||
filter.PinnedOnly ||
|
||||
filter.MusicOnly ||
|
||||
filter.Query != "" {
|
||||
return domain.ChannelMessage{}, false, nil
|
||||
}
|
||||
source, err := s.getChannelMessage(ctx, s.db, channel.ID, member.HistoryClearAnchorID)
|
||||
if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
return domain.ChannelMessage{}, false, fmt.Errorf("load channel history-clear anchor: %w", err)
|
||||
}
|
||||
anchor := domain.ProjectChannelHistoryClearMessage(
|
||||
source,
|
||||
channel.ID,
|
||||
member.HistoryClearAnchorID,
|
||||
member.HistoryClearAnchorDate,
|
||||
)
|
||||
if filter.SenderUserID != 0 && anchor.SenderUserID != filter.SenderUserID {
|
||||
return domain.ChannelMessage{}, false, nil
|
||||
}
|
||||
if filter.MinDate > 0 && anchor.Date <= filter.MinDate {
|
||||
return domain.ChannelMessage{}, false, nil
|
||||
}
|
||||
if filter.MaxDate > 0 && anchor.Date >= filter.MaxDate {
|
||||
return domain.ChannelMessage{}, false, nil
|
||||
}
|
||||
if filter.MaxID > 0 && anchor.ID > filter.MaxID {
|
||||
return domain.ChannelMessage{}, false, nil
|
||||
}
|
||||
if filter.MinID > 0 && anchor.ID <= filter.MinID {
|
||||
return domain.ChannelMessage{}, false, nil
|
||||
}
|
||||
return anchor, true, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) {
|
||||
query := strings.TrimSpace(req.Query)
|
||||
if viewerUserID == 0 || (query == "" && !req.MusicOnly) {
|
||||
|
|
@ -371,8 +507,19 @@ func (s *ChannelStore) getChannelMessagesForMember(ctx context.Context, viewerUs
|
|||
// 执行不变。注意:这种"OR 哨兵"只对【非排序锚点】的残余过滤安全;ListChannelHistory
|
||||
// 的方向/anchor 条件若同样哨兵化会让规划器无法用索引顺序做 LIMIT、退化为全表扫+排序
|
||||
// (实测 0.06ms→23ms),故那里【刻意保留】动态 SQL。
|
||||
args := []any{channel.ID, id32, member.AvailableMinID}
|
||||
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted AND ($3 <= 0 OR id > $3)"
|
||||
anchorID := 0
|
||||
if member.HistoryClearAnchorID > 0 && member.HistoryClearAnchorID == member.AvailableMinID {
|
||||
anchorID = member.HistoryClearAnchorID
|
||||
}
|
||||
args := []any{channel.ID, id32, member.AvailableMinID, anchorID}
|
||||
where := `channel_id = $1
|
||||
AND id = ANY($2::int[])
|
||||
AND (NOT deleted OR ($4 > 0 AND id = $4))
|
||||
AND (($3 <= 0 OR id > $3) OR ($4 > 0 AND id = $4))`
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
args = append(args, string(domain.PeerTypeUser), viewerUserID)
|
||||
where += fmt.Sprintf("\nAND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args))
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+channelMessageColumns+`
|
||||
FROM channel_messages
|
||||
|
|
@ -400,6 +547,36 @@ ORDER BY id DESC`, args...)
|
|||
if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil {
|
||||
return domain.ChannelHistory{}, err
|
||||
}
|
||||
if anchorID > 0 {
|
||||
anchorFound := false
|
||||
for i := range out.Messages {
|
||||
if out.Messages[i].ID != anchorID {
|
||||
continue
|
||||
}
|
||||
out.Messages[i] = domain.ProjectChannelHistoryClearMessage(
|
||||
out.Messages[i],
|
||||
channel.ID,
|
||||
member.HistoryClearAnchorID,
|
||||
member.HistoryClearAnchorDate,
|
||||
)
|
||||
anchorFound = true
|
||||
}
|
||||
if !anchorFound {
|
||||
for _, id := range ids {
|
||||
if id != anchorID {
|
||||
continue
|
||||
}
|
||||
out.Messages = append(out.Messages, domain.ProjectChannelHistoryClearMessage(
|
||||
domain.ChannelMessage{},
|
||||
channel.ID,
|
||||
member.HistoryClearAnchorID,
|
||||
member.HistoryClearAnchorDate,
|
||||
))
|
||||
out.Count = len(out.Messages)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -737,7 +914,7 @@ WHERE (
|
|||
}
|
||||
|
||||
func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
|
||||
channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
|
||||
channel, _, readOnly, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ReadChannelHistoryResult{}, err
|
||||
}
|
||||
|
|
@ -745,6 +922,15 @@ func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.Re
|
|||
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
|
||||
}
|
||||
previous, unreadMark, err := s.channelReadHistoryState(ctx, req.ChannelID, req.UserID)
|
||||
if err != nil {
|
||||
return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err)
|
||||
|
|
|
|||
|
|
@ -189,6 +189,17 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
|
|||
t.Fatalf("read participants = %+v, want friend read date", readers.Participants)
|
||||
}
|
||||
|
||||
var channelPtsBeforeClear, channelEventsBeforeClear int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT c.pts, (
|
||||
SELECT count(*)::int
|
||||
FROM channel_update_events e
|
||||
WHERE e.channel_id = c.id
|
||||
)
|
||||
FROM channels c
|
||||
WHERE c.id = $1`, channelID).Scan(&channelPtsBeforeClear, &channelEventsBeforeClear); err != nil {
|
||||
t.Fatalf("query channel state before local clear: %v", err)
|
||||
}
|
||||
cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
|
||||
UserID: friend.ID,
|
||||
ChannelID: channelID,
|
||||
|
|
@ -201,6 +212,70 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
|
|||
if cleared.AvailableMinID != sent.Message.ID {
|
||||
t.Fatalf("local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID)
|
||||
}
|
||||
if !cleared.AvailableMinChanged {
|
||||
t.Fatal("local clear did not report an advanced owner-local boundary")
|
||||
}
|
||||
var channelPtsAfterClear, channelEventsAfterClear int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT c.pts, (
|
||||
SELECT count(*)::int
|
||||
FROM channel_update_events e
|
||||
WHERE e.channel_id = c.id
|
||||
)
|
||||
FROM channels c
|
||||
WHERE c.id = $1`, channelID).Scan(&channelPtsAfterClear, &channelEventsAfterClear); err != nil {
|
||||
t.Fatalf("query channel state after local clear: %v", err)
|
||||
}
|
||||
if channelPtsAfterClear != channelPtsBeforeClear || channelEventsAfterClear != channelEventsBeforeClear {
|
||||
t.Fatalf("local clear changed channel sequence: pts %d->%d events %d->%d",
|
||||
channelPtsBeforeClear, channelPtsAfterClear, channelEventsBeforeClear, channelEventsAfterClear)
|
||||
}
|
||||
var recoveryMinID, recoveryAnchorID, recoveryDate int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT available_min_id, history_clear_anchor_id, history_clear_updated_at
|
||||
FROM user_channel_member_index
|
||||
WHERE user_id = $1 AND channel_id = $2`, friend.ID, channelID).Scan(
|
||||
&recoveryMinID, &recoveryAnchorID, &recoveryDate,
|
||||
); err != nil {
|
||||
t.Fatalf("query owner-local clear recovery index: %v", err)
|
||||
}
|
||||
if recoveryMinID != sent.Message.ID || recoveryAnchorID != sent.Message.ID || recoveryDate != 1700000302 {
|
||||
t.Fatalf("recovery index = min %d anchor %d date %d, want %d/%d/1700000302",
|
||||
recoveryMinID, recoveryAnchorID, recoveryDate, sent.Message.ID, sent.Message.ID)
|
||||
}
|
||||
dirtyAfterClear, err := channels.ListDirtyActiveChannelsForUser(ctx, friend.ID, 1700000302, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list dirty channels after local clear: %v", err)
|
||||
}
|
||||
if len(dirtyAfterClear) != 1 ||
|
||||
dirtyAfterClear[0].ChannelID != channelID ||
|
||||
dirtyAfterClear[0].AvailableMinID != sent.Message.ID ||
|
||||
dirtyAfterClear[0].HistoryClearDate != 1700000302 {
|
||||
t.Fatalf("dirty channel recovery = %+v, want channel %d boundary %d", dirtyAfterClear, channelID, sent.Message.ID)
|
||||
}
|
||||
planTx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin history-clear recovery plan transaction: %v", err)
|
||||
}
|
||||
defer func() { _ = planTx.Rollback(ctx) }()
|
||||
if _, err := planTx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
|
||||
t.Fatalf("disable seqscan for history-clear recovery plan: %v", err)
|
||||
}
|
||||
clearPlan := explainText(t, ctx, planTx, `
|
||||
SELECT i.channel_id, c.pts, i.available_min_id, i.history_clear_updated_at
|
||||
FROM user_channel_member_index i
|
||||
JOIN channels c ON c.id = i.channel_id AND NOT c.deleted
|
||||
WHERE i.user_id = $1
|
||||
AND i.status = 'active'
|
||||
AND NOT i.deleted
|
||||
AND i.channel_id > $3
|
||||
AND i.history_clear_anchor_id > 0
|
||||
AND i.history_clear_anchor_id = i.available_min_id
|
||||
AND i.history_clear_updated_at >= $2
|
||||
ORDER BY i.channel_id ASC
|
||||
LIMIT $4`, friend.ID, 1700000302, int64(0), 10)
|
||||
requirePlanContains(t, clearPlan, "user_channel_member_index_history_clear_idx")
|
||||
_ = planTx.Rollback(ctx)
|
||||
staleClear, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{
|
||||
UserID: friend.ID,
|
||||
ChannelID: channelID,
|
||||
|
|
@ -213,12 +288,60 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
|
|||
if staleClear.AvailableMinID != sent.Message.ID {
|
||||
t.Fatalf("stale local clear available_min_id = %d, want monotonic %d", staleClear.AvailableMinID, sent.Message.ID)
|
||||
}
|
||||
if staleClear.AvailableMinChanged {
|
||||
t.Fatal("stale local clear unexpectedly replaced the owner-local anchor")
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT history_clear_updated_at
|
||||
FROM user_channel_member_index
|
||||
WHERE user_id = $1 AND channel_id = $2`, friend.ID, channelID).Scan(&recoveryDate); err != nil {
|
||||
t.Fatalf("query recovery timestamp after stale clear: %v", err)
|
||||
}
|
||||
if recoveryDate != 1700000302 {
|
||||
t.Fatalf("stale clear recovery date = %d, want unchanged 1700000302", recoveryDate)
|
||||
}
|
||||
afterClear, err := channels.GetChannel(ctx, friend.ID, channelID)
|
||||
if err != nil {
|
||||
t.Fatalf("get channel after clear: %v", err)
|
||||
}
|
||||
if afterClear.Dialog.TopMessageID != 0 {
|
||||
t.Fatalf("dialog after clear = %+v, want no visible top", afterClear.Dialog)
|
||||
if afterClear.Dialog.TopMessageID != sent.Message.ID ||
|
||||
afterClear.Dialog.HistoryClearAnchorID != sent.Message.ID ||
|
||||
afterClear.Dialog.UnreadCount != 0 {
|
||||
t.Fatalf("dialog after clear = %+v, want owner-local anchored top %d", afterClear.Dialog, sent.Message.ID)
|
||||
}
|
||||
dialogsAfterClear, err := channels.GetChannelDialogs(ctx, friend.ID, []int64{channelID})
|
||||
if err != nil {
|
||||
t.Fatalf("get channel dialogs after clear: %v", err)
|
||||
}
|
||||
if len(dialogsAfterClear.Dialogs) != 1 ||
|
||||
len(dialogsAfterClear.Messages) != 1 ||
|
||||
!domain.IsChannelHistoryClearMessage(dialogsAfterClear.Messages[0]) ||
|
||||
dialogsAfterClear.Messages[0].ID != sent.Message.ID ||
|
||||
dialogsAfterClear.Messages[0].Body != "" {
|
||||
t.Fatalf("dialog projection after clear = dialogs=%+v messages=%+v", dialogsAfterClear.Dialogs, dialogsAfterClear.Messages)
|
||||
}
|
||||
projectedHistory, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID,
|
||||
Limit: 10,
|
||||
IncludeHistoryClearAnchor: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("project history-clear anchor: %v", err)
|
||||
}
|
||||
if len(projectedHistory.Messages) != 1 ||
|
||||
!domain.IsChannelHistoryClearMessage(projectedHistory.Messages[0]) ||
|
||||
projectedHistory.Messages[0].ID != sent.Message.ID {
|
||||
t.Fatalf("projected history after clear = %+v, want anchor %d", projectedHistory.Messages, sent.Message.ID)
|
||||
}
|
||||
ownerDialogsAfterClear, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID})
|
||||
if err != nil {
|
||||
t.Fatalf("get unaffected owner dialog after friend clear: %v", err)
|
||||
}
|
||||
if len(ownerDialogsAfterClear.Messages) != 1 ||
|
||||
ownerDialogsAfterClear.Messages[0].ID != sent.Message.ID ||
|
||||
ownerDialogsAfterClear.Messages[0].Body != "first visible channel text" ||
|
||||
ownerDialogsAfterClear.Messages[0].Action != nil {
|
||||
t.Fatalf("friend clear changed shared owner projection: %+v", ownerDialogsAfterClear.Messages)
|
||||
}
|
||||
|
||||
next, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
|
|
@ -238,6 +361,59 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberR
|
|||
if afterNext.Dialog.TopMessageID != next.Message.ID || afterNext.Dialog.UnreadCount != 1 {
|
||||
t.Fatalf("dialog after next = %+v, want top %d unread 1", afterNext.Dialog, next.Message.ID)
|
||||
}
|
||||
firstPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID,
|
||||
Limit: 1,
|
||||
IncludeHistoryClearAnchor: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list first page after next message: %v", err)
|
||||
}
|
||||
if len(firstPage.Messages) != 1 ||
|
||||
firstPage.Messages[0].ID != next.Message.ID ||
|
||||
firstPage.Count != 2 {
|
||||
t.Fatalf("first page after next = %+v count=%d, want next message and bounded has-more count", firstPage.Messages, firstPage.Count)
|
||||
}
|
||||
offsetPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID,
|
||||
AddOffset: 1,
|
||||
Limit: 1,
|
||||
IncludeHistoryClearAnchor: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list add-offset page after next message: %v", err)
|
||||
}
|
||||
if len(offsetPage.Messages) != 1 ||
|
||||
offsetPage.Messages[0].ID != sent.Message.ID ||
|
||||
!domain.IsChannelHistoryClearMessage(offsetPage.Messages[0]) {
|
||||
t.Fatalf("add-offset page after next = %+v, want retained clear anchor %d", offsetPage.Messages, sent.Message.ID)
|
||||
}
|
||||
exactCount, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID,
|
||||
CountOnly: true,
|
||||
NeedTotalCount: true,
|
||||
IncludeHistoryClearAnchor: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("count history after next message: %v", err)
|
||||
}
|
||||
if exactCount.Count != 2 {
|
||||
t.Fatalf("exact history count after next = %d, want shared message plus clear anchor", exactCount.Count)
|
||||
}
|
||||
olderPage, err := channels.ListChannelHistory(ctx, friend.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID,
|
||||
OffsetID: next.Message.ID,
|
||||
Limit: 10,
|
||||
IncludeHistoryClearAnchor: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list older page after next message: %v", err)
|
||||
}
|
||||
if len(olderPage.Messages) != 1 ||
|
||||
olderPage.Messages[0].ID != sent.Message.ID ||
|
||||
!domain.IsChannelHistoryClearMessage(olderPage.Messages[0]) {
|
||||
t.Fatalf("older page after next = %+v, want retained clear anchor %d", olderPage.Messages, sent.Message.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollbackPostgres(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,18 @@ import (
|
|||
)
|
||||
|
||||
func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) {
|
||||
return s.sendChannelMessageWithHooks(ctx, req, channelSendTxHooks{})
|
||||
}
|
||||
|
||||
type channelSendTxHooks struct {
|
||||
before func(context.Context, pgx.Tx, *domain.SendChannelMessageRequest) error
|
||||
after func(context.Context, pgx.Tx, domain.SendChannelMessageResult) error
|
||||
}
|
||||
|
||||
// sendChannelMessageWithHooks lets a tightly coupled domain command join the
|
||||
// channel message/event/PTS transaction. It is deliberately package-private:
|
||||
// ordinary callers must use SendChannelMessage and may not inject SQL work.
|
||||
func (s *ChannelStore) sendChannelMessageWithHooks(ctx context.Context, req domain.SendChannelMessageRequest, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) {
|
||||
if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero()) {
|
||||
return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
|
|
@ -27,7 +39,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh
|
|||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ {
|
||||
res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint)
|
||||
res, err := s.sendChannelMessageOnce(ctx, req, requestFingerprint, hooks)
|
||||
if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil {
|
||||
return res, err
|
||||
}
|
||||
|
|
@ -36,7 +48,7 @@ func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendCh
|
|||
return domain.SendChannelMessageResult{}, lastErr
|
||||
}
|
||||
|
||||
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte) (domain.SendChannelMessageResult, error) {
|
||||
func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest, requestFingerprint []byte, hooks channelSendTxHooks) (domain.SendChannelMessageResult, error) {
|
||||
if req.RandomID != 0 && !req.IdempotencyPreflighted {
|
||||
if dup, found, err := s.LookupChannelSendReplay(ctx, domain.ChannelSendReplayRequest{
|
||||
ChannelID: req.ChannelID,
|
||||
|
|
@ -125,6 +137,11 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
|
|||
p := *req.SendAs
|
||||
sendAs = &p
|
||||
}
|
||||
if hooks.before != nil {
|
||||
if err := hooks.before(ctx, tx, &req); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
}
|
||||
msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel message id: %w", err)
|
||||
|
|
@ -135,7 +152,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
|
|||
}
|
||||
var discussion *domain.SendChannelDiscussionResult
|
||||
var discussionRef *domain.ChannelDiscussionRef
|
||||
if channel.Broadcast && channel.LinkedChatID != 0 {
|
||||
if channel.Broadcast && channel.LinkedChatID != 0 && req.Action == nil {
|
||||
linked, err := getChannelByID(ctx, tx, channel.LinkedChatID)
|
||||
if err == nil && !linked.Deleted && linked.Megagroup {
|
||||
discussionMsgID, err := s.msgIDs.NextChannelMessageID(ctx, linked.ID)
|
||||
|
|
@ -330,6 +347,16 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User
|
|||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
}
|
||||
txResult := domain.SendChannelMessageResult{
|
||||
Channel: channel, Message: msg, Event: event, Discussion: discussion,
|
||||
MentionUserIDs: append([]int64(nil), req.MentionUserIDs...),
|
||||
SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...),
|
||||
}
|
||||
if hooks.after != nil {
|
||||
if err := hooks.after(ctx, tx, txResult); err != nil {
|
||||
return domain.SendChannelMessageResult{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.SendChannelMessageResult{}, fmt.Errorf("commit send channel: %w", err)
|
||||
}
|
||||
|
|
@ -342,7 +369,8 @@ WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.User
|
|||
discussion.Recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, discussion.Channel.ID, 0)
|
||||
}
|
||||
}
|
||||
return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients, Discussion: discussion, MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...)}, nil
|
||||
txResult.Recipients = recipients
|
||||
return txResult, nil
|
||||
}
|
||||
|
||||
func channelDeliverySkipSet(ids []int64) map[int64]struct{} {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.Ch
|
|||
if req.UserID == 0 || req.ChannelID == 0 {
|
||||
return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
|
||||
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageViewsResult{}, err
|
||||
}
|
||||
|
|
@ -43,6 +43,16 @@ func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.Ch
|
|||
if date <= 0 {
|
||||
date = nowUnix()
|
||||
}
|
||||
args := []any{req.ChannelID, id32, req.UserID, date, member.AvailableMinID}
|
||||
visibility := ""
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
args = append(args, string(domain.PeerTypeUser), req.UserID)
|
||||
visibility = fmt.Sprintf(
|
||||
" AND m.saved_peer_type = $%d AND m.saved_peer_id = $%d",
|
||||
len(args)-1,
|
||||
len(args),
|
||||
)
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
WITH inserted AS (
|
||||
INSERT INTO channel_message_viewers (channel_id, message_id, viewer_user_id, viewed_at)
|
||||
|
|
@ -52,6 +62,7 @@ WITH inserted AS (
|
|||
AND m.id = ANY($2::int[])
|
||||
AND NOT m.deleted
|
||||
AND m.id > $5
|
||||
`+visibility+`
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING message_id
|
||||
), updated AS (
|
||||
|
|
@ -65,7 +76,7 @@ WITH inserted AS (
|
|||
)
|
||||
SELECT i.message_id
|
||||
FROM inserted i
|
||||
LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, date, member.AvailableMinID)
|
||||
LEFT JOIN updated u ON u.id = i.message_id`, args...)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageViewsResult{}, fmt.Errorf("increment channel message views: %w", err)
|
||||
}
|
||||
|
|
@ -82,7 +93,7 @@ LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, da
|
|||
}
|
||||
rows.Close()
|
||||
}
|
||||
summaries, err := s.listChannelMessageViewSummaries(ctx, req.ChannelID, id32, member.AvailableMinID)
|
||||
summaries, err := s.listChannelMessageViewSummaries(ctx, req.UserID, channel, member, id32)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageViewsResult{}, err
|
||||
}
|
||||
|
|
@ -110,13 +121,17 @@ LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, da
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, channelID int64, ids []int32, availableMinID int) ([]channelMessageViewSummary, error) {
|
||||
args := []any{channelID, ids}
|
||||
func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, viewerUserID int64, channel domain.Channel, member domain.ChannelMember, ids []int32) ([]channelMessageViewSummary, error) {
|
||||
args := []any{channel.ID, ids}
|
||||
where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted"
|
||||
if availableMinID > 0 {
|
||||
args = append(args, availableMinID)
|
||||
if member.AvailableMinID > 0 {
|
||||
args = append(args, member.AvailableMinID)
|
||||
where += fmt.Sprintf(" AND id > $%d", len(args))
|
||||
}
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
args = append(args, string(domain.PeerTypeUser), viewerUserID)
|
||||
where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args))
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, views_count, post, discussion_channel_id, discussion_message_id, sender_user_id, from_peer_type, from_peer_id
|
||||
FROM channel_messages
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ SELECT EXISTS (
|
|||
Entities: append([]domain.MessageEntity(nil), req.Entities...),
|
||||
Media: req.Media,
|
||||
ReplyTo: req.ReplyTo,
|
||||
Forward: req.Forward,
|
||||
Pts: pts,
|
||||
}
|
||||
event := domain.ChannelUpdateEvent{
|
||||
|
|
|
|||
|
|
@ -71,6 +71,28 @@ func TestChannelStoreEnablingDirectMessagesCreatesMonoforum(t *testing.T) {
|
|||
if mfTop == 0 || mfPts == 0 {
|
||||
t.Fatalf("monoforum top/pts = %d/%d, want paid-messages service top", mfTop, mfPts)
|
||||
}
|
||||
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||||
UserID: owner.ID, ChannelID: monoID, MaxID: mfTop, Date: 1700000901,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read synthetic monoforum history: %v", err)
|
||||
}
|
||||
if !read.ReadOnly || read.Changed || read.MaxID != mfTop {
|
||||
t.Fatalf("synthetic monoforum read = %+v, want read-only no-op at %d", read, mfTop)
|
||||
}
|
||||
var memberExists, dialogExists bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM channel_members WHERE channel_id=$1 AND user_id=$2
|
||||
),
|
||||
EXISTS (
|
||||
SELECT 1 FROM channel_dialogs WHERE channel_id=$1 AND user_id=$2
|
||||
)`, monoID, owner.ID).Scan(&memberExists, &dialogExists); err != nil {
|
||||
t.Fatalf("check synthetic monoforum read footprint: %v", err)
|
||||
}
|
||||
if memberExists || dialogExists {
|
||||
t.Fatalf("synthetic monoforum read persisted member/dialog = %v/%v", memberExists, dialogExists)
|
||||
}
|
||||
// 同批下发母广播频道(TDesktop 据此 resolve linked_monoforum_id 并派生 MonoforumAdmin
|
||||
// 渲染 Direct-Messages 容器):GetChannelDialogs([mono]) 的 chats[] 必须同时带 mono 与母频道。
|
||||
coDelivery, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{monoID})
|
||||
|
|
|
|||
|
|
@ -79,9 +79,10 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
}
|
||||
|
||||
suggestedPost := &domain.SuggestedPost{Price: &domain.SuggestedPostPrice{Kind: domain.SuggestedPostPriceStars, Amount: 10}, ScheduleDate: 1700100000}
|
||||
forward := &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, Date: 1700000999}
|
||||
m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001,
|
||||
SuggestedPost: suggestedPost,
|
||||
SuggestedPost: suggestedPost, Forward: forward,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber send 1: %v", err)
|
||||
|
|
@ -128,7 +129,7 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
}
|
||||
|
||||
// 幂等。
|
||||
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Date: 1700001004})
|
||||
dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", SuggestedPost: suggestedPost, Forward: forward, Date: 1700001004})
|
||||
if err != nil {
|
||||
t.Fatalf("dup send: %v", err)
|
||||
}
|
||||
|
|
@ -166,6 +167,9 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
if oldest.SuggestedPost == nil || oldest.SuggestedPost.Price == nil || oldest.SuggestedPost.Price.Kind != domain.SuggestedPostPriceStars || oldest.SuggestedPost.Price.Amount != 10 || oldest.SuggestedPost.ScheduleDate != 1700100000 {
|
||||
t.Fatalf("persisted suggested post = %+v, want 10 Stars + schedule", oldest.SuggestedPost)
|
||||
}
|
||||
if oldest.Forward == nil || oldest.Forward.From.ID != owner.ID || oldest.Forward.Date != 1700000999 {
|
||||
t.Fatalf("persisted monoforum forward = %+v, want source user %d/date 1700000999", oldest.Forward, owner.ID)
|
||||
}
|
||||
if newest := hist.Messages[0]; newest.ReplyTo == nil || newest.ReplyTo.MessageID != m1.Message.ID {
|
||||
t.Fatalf("persisted admin reply = %+v, want message %d", newest.ReplyTo, m1.Message.ID)
|
||||
}
|
||||
|
|
@ -175,7 +179,8 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
|
||||
// 另一个订阅者不串会话。
|
||||
otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID}
|
||||
if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 201, Message: "other", Date: 1700001005}); err != nil {
|
||||
otherMessage, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 201, Message: "other", Date: 1700001005})
|
||||
if err != nil {
|
||||
t.Fatalf("other subscriber send: %v", err)
|
||||
}
|
||||
subHist, _ := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: subPeer, Limit: 10})
|
||||
|
|
@ -191,6 +196,126 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) {
|
|||
t.Fatalf("subscriber channel history leaked message %+v", message)
|
||||
}
|
||||
}
|
||||
exactMessages, err := channels.GetChannelMessages(ctx, sub.ID, 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)
|
||||
}
|
||||
monoBeforeViews, err := channels.GetChannelByID(ctx, monoID)
|
||||
if err != nil {
|
||||
t.Fatalf("get monoforum before views: %v", err)
|
||||
}
|
||||
subViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
|
||||
UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
|
||||
Increment: true, Date: 1700001006,
|
||||
})
|
||||
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)
|
||||
}
|
||||
var hiddenViews int
|
||||
var hiddenViewer bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT m.views_count,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_message_viewers v
|
||||
WHERE v.channel_id = m.channel_id
|
||||
AND v.message_id = m.id
|
||||
AND v.viewer_user_id = $3
|
||||
)
|
||||
FROM channel_messages m
|
||||
WHERE m.channel_id = $1 AND m.id = $2`, monoID, otherMessage.Message.ID, sub.ID).Scan(&hiddenViews, &hiddenViewer); err != nil {
|
||||
t.Fatalf("load hidden monoforum view state: %v", err)
|
||||
}
|
||||
if hiddenViews != 0 || hiddenViewer {
|
||||
t.Fatalf("hidden monoforum view state = count %d viewer %v, want 0/false", hiddenViews, hiddenViewer)
|
||||
}
|
||||
repeatedViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
|
||||
UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID},
|
||||
Increment: true, Date: 1700001007,
|
||||
})
|
||||
if err != nil || repeatedViews.Views[m1.Message.ID] != 1 {
|
||||
t.Fatalf("repeated subscriber monoforum views = %+v, %v; want idempotent 1", repeatedViews.Views, err)
|
||||
}
|
||||
adminViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{
|
||||
UserID: owner.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID},
|
||||
Increment: true, Date: 1700001008,
|
||||
})
|
||||
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)
|
||||
}
|
||||
monoAfterViews, err := channels.GetChannelByID(ctx, monoID)
|
||||
if err != nil {
|
||||
t.Fatalf("get monoforum after views: %v", err)
|
||||
}
|
||||
if monoAfterViews.Pts != monoBeforeViews.Pts {
|
||||
t.Fatalf("message views advanced monoforum pts = %d, want unchanged %d", monoAfterViews.Pts, monoBeforeViews.Pts)
|
||||
}
|
||||
if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
|
||||
UserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID,
|
||||
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}},
|
||||
Date: 1700001006,
|
||||
}); err != nil {
|
||||
t.Fatalf("subscriber react to own monoforum message: %v", err)
|
||||
}
|
||||
if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{
|
||||
UserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID,
|
||||
Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f525"}},
|
||||
Date: 1700001006,
|
||||
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
t.Fatalf("subscriber react to another saved_peer err = %v, want ErrMessageIDInvalid", err)
|
||||
}
|
||||
subReactions, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
|
||||
UserID: sub.ID, 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 := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{
|
||||
UserID: owner.ID, 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 := channels.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
|
||||
UserID: sub.ID, 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 := channels.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{
|
||||
UserID: sub.ID, 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 := channels.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
|
||||
ViewerUserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID, ReactorUserID: sub.ID,
|
||||
})
|
||||
if err != nil || !found || len(reactionLookup.Reactions) != 1 {
|
||||
t.Fatalf("subscriber monoforum reaction lookup = %+v, %v, %v; want one", reactionLookup, found, err)
|
||||
}
|
||||
if _, _, err := channels.FindChannelMessageReaction(ctx, domain.ChannelMessageReactionLookupRequest{
|
||||
ViewerUserID: sub.ID, ChannelID: monoID, MessageID: otherMessage.Message.ID, ReactorUserID: other.ID,
|
||||
}); !errors.Is(err, domain.ErrMessageIDInvalid) {
|
||||
t.Fatalf("subscriber lookup another saved_peer reaction err = %v, want ErrMessageIDInvalid", err)
|
||||
}
|
||||
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: sub.ID, ChannelID: monoID, Pts: 0, Limit: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("subscriber channel difference: %v", err)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -98,6 +99,70 @@ func TestChannelMultiPin(t *testing.T) {
|
|||
t.Fatalf("filterPinned message %d lacks pinned flag", msg.ID)
|
||||
}
|
||||
}
|
||||
// 普通历史热路径保留 bounded has-more hint,不为每页额外 COUNT。
|
||||
hint, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID, PinnedOnly: true, Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("filterPinned hint page: %v", err)
|
||||
}
|
||||
if len(hint.Messages) != 1 || hint.Count != 2 {
|
||||
t.Fatalf("filterPinned hint messages/count = %d/%d, want 1/2", len(hint.Messages), hint.Count)
|
||||
}
|
||||
// messages.search 首屏显式请求精确总数;页大小不能污染 Count。
|
||||
exact, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID, PinnedOnly: true, Limit: 1, NeedTotalCount: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("filterPinned exact page: %v", err)
|
||||
}
|
||||
if len(exact.Messages) != 1 || exact.Count != 3 {
|
||||
t.Fatalf("filterPinned exact messages/count = %d/%d, want 1/3", len(exact.Messages), exact.Count)
|
||||
}
|
||||
// messages.getSearchCounters/limit=0 只计数,不加载消息及 reply/reaction companion。
|
||||
countOnly, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{
|
||||
ChannelID: channelID, PinnedOnly: true, NeedTotalCount: true, CountOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("filterPinned count-only: %v", err)
|
||||
}
|
||||
if len(countOnly.Messages) != 0 || countOnly.Count != 3 {
|
||||
t.Fatalf("filterPinned count-only messages/count = %d/%d, want 0/3", len(countOnly.Messages), countOnly.Count)
|
||||
}
|
||||
// 精确计数只扫描该频道的 pinned 部分索引,不能退化为全频道消息扫描。
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin pinned count explain: %v", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil {
|
||||
t.Fatalf("disable seqscan for pinned count explain: %v", err)
|
||||
}
|
||||
rows, err := tx.Query(ctx, `EXPLAIN (COSTS OFF)
|
||||
SELECT count(*)::int
|
||||
FROM channel_messages
|
||||
WHERE channel_id = $1 AND NOT deleted AND pinned`, channelID)
|
||||
if err != nil {
|
||||
t.Fatalf("explain pinned count: %v", err)
|
||||
}
|
||||
var plan strings.Builder
|
||||
for rows.Next() {
|
||||
var line string
|
||||
if err := rows.Scan(&line); err != nil {
|
||||
rows.Close()
|
||||
t.Fatalf("scan pinned count plan: %v", err)
|
||||
}
|
||||
plan.WriteString(line)
|
||||
plan.WriteByte('\n')
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
t.Fatalf("read pinned count plan: %v", err)
|
||||
}
|
||||
rows.Close()
|
||||
if !strings.Contains(plan.String(), "channel_messages_live_pinned_idx") {
|
||||
t.Fatalf("pinned count plan misses partial index:\n%s", plan.String())
|
||||
}
|
||||
// 普通历史页的消息行直接携带 pinned 标志(多置顶都标,不只最新)。
|
||||
page, err := channels.ListChannelHistory(ctx, member.ID, domain.ChannelHistoryFilter{ChannelID: channelID, Limit: 50})
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -75,20 +75,61 @@ func TestPublicChannelAndMegagroupPreviewPostgres(t *testing.T) {
|
|||
if !found || history.Self.Status != domain.ChannelMemberLeft {
|
||||
t.Fatalf("preview history = %+v self=%+v", history.Messages, history.Self)
|
||||
}
|
||||
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
|
||||
UserID: viewer.ID, ChannelID: public.ID, MaxID: sent.Message.ID, Date: 1700009411 + i,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("read public preview history: %v", err)
|
||||
}
|
||||
if !read.ReadOnly || read.Changed || read.MaxID != sent.Message.ID {
|
||||
t.Fatalf("public preview read = %+v, want read-only no-op at %d", read, sent.Message.ID)
|
||||
}
|
||||
audience, err := channels.FilterChannelMessageAudienceIDs(ctx, public.ID, []int64{viewer.ID, owner.ID, viewer.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("filter public message audience: %v", err)
|
||||
}
|
||||
if len(audience) != 2 || audience[0] != owner.ID || audience[1] != viewer.ID {
|
||||
t.Fatalf("public message audience = %v, want owner/member and viewer/subscriber", audience)
|
||||
}
|
||||
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: viewer.ID, ChannelID: public.ID, Pts: created.Event.Pts, Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("public preview difference: %v", err)
|
||||
}
|
||||
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].ID != sent.Message.ID {
|
||||
t.Fatalf("public preview difference = %+v, want sent message through pts %d", diff, sent.Event.Pts)
|
||||
}
|
||||
if _, err := channels.GetParticipants(ctx, viewer.ID, public.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}, 0, 20); err != nil {
|
||||
t.Fatalf("public preview participants: %v", err)
|
||||
}
|
||||
if _, err := channels.GetParticipant(ctx, viewer.ID, public.ID, viewer.ID); !errors.Is(err, domain.ErrUserNotParticipant) {
|
||||
t.Fatalf("public preview self participant err = %v, want ErrUserNotParticipant", err)
|
||||
}
|
||||
var memberExists bool
|
||||
dialogs := appdialogs.NewService(nil, channels)
|
||||
peerDialogs, err := dialogs.GetPeerDialogs(ctx, viewer.ID, []domain.Peer{{Type: domain.PeerTypeChannel, ID: public.ID}})
|
||||
if err != nil {
|
||||
t.Fatalf("public preview peer dialogs: %v", err)
|
||||
}
|
||||
if len(peerDialogs.Dialogs) != 1 || len(peerDialogs.ChannelMessages) != 0 || len(peerDialogs.Channels) != 1 {
|
||||
t.Fatalf("public preview peer dialogs = %+v, want one zero-top bootstrap", peerDialogs)
|
||||
}
|
||||
previewDialog := peerDialogs.Dialogs[0]
|
||||
if previewDialog.TopMessage != 0 || !previewDialog.ChannelLeft ||
|
||||
previewDialog.ReadInboxMaxID != 0 || previewDialog.ReadOutboxMaxID != 0 ||
|
||||
previewDialog.Pts != sent.Event.Pts {
|
||||
t.Fatalf("public preview bootstrap dialog = %+v", previewDialog)
|
||||
}
|
||||
var memberExists, dialogExists bool
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
|
||||
)`, public.ID, viewer.ID).Scan(&memberExists); err != nil {
|
||||
), EXISTS (
|
||||
SELECT 1 FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2
|
||||
)`, public.ID, viewer.ID).Scan(&memberExists, &dialogExists); err != nil {
|
||||
t.Fatalf("check preview member row: %v", err)
|
||||
}
|
||||
if memberExists {
|
||||
t.Fatal("public preview persisted a channel member row")
|
||||
if memberExists || dialogExists {
|
||||
t.Fatalf("public preview persisted member/dialog = %v/%v", memberExists, dialogExists)
|
||||
}
|
||||
if _, err := channels.JoinChannel(ctx, public.ID, viewer.ID, 1700009420+i); err != nil {
|
||||
t.Fatalf("join public peer: %v", err)
|
||||
|
|
@ -96,6 +137,28 @@ SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
|
|||
if _, err := channels.LeaveChannel(ctx, public.ID, viewer.ID, 1700009430+i); err != nil {
|
||||
t.Fatalf("leave public peer: %v", err)
|
||||
}
|
||||
filtered, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: viewer.ID, ChannelID: public.ID, Pts: sent.Event.Pts, Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("public difference across participant events: %v", err)
|
||||
}
|
||||
if tc.broadcast {
|
||||
if !filtered.Final || filtered.Pts != sent.Event.Pts || len(filtered.Events) != 0 ||
|
||||
len(filtered.NewMessages) != 0 || len(filtered.OtherUpdates) != 0 {
|
||||
t.Fatalf("broadcast difference after transient participant changes = %+v, want unchanged PTS", filtered)
|
||||
}
|
||||
} else {
|
||||
if !filtered.Final || filtered.Pts <= sent.Event.Pts || len(filtered.NewMessages) != 2 ||
|
||||
len(filtered.OtherUpdates) != 0 {
|
||||
t.Fatalf("megagroup join/leave difference = %+v, want two real service messages", filtered)
|
||||
}
|
||||
for _, message := range filtered.NewMessages {
|
||||
if message.Action == nil {
|
||||
t.Fatalf("megagroup join/leave difference message = %+v, want service action", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := channels.GetParticipant(ctx, viewer.ID, public.ID, viewer.ID); !errors.Is(err, domain.ErrUserNotParticipant) {
|
||||
t.Fatalf("left self participant err = %v, want ErrUserNotParticipant", err)
|
||||
}
|
||||
|
|
@ -112,6 +175,9 @@ SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
|
|||
t.Fatalf("create private group: %v", err)
|
||||
}
|
||||
channelIDs = append(channelIDs, private.Channel.ID)
|
||||
if audience, err := channels.FilterChannelMessageAudienceIDs(ctx, private.Channel.ID, []int64{viewer.ID}); err != nil || len(audience) != 0 {
|
||||
t.Fatalf("private message audience = %v err %v, want empty", audience, err)
|
||||
}
|
||||
if _, err := channels.ListChannelHistory(ctx, viewer.ID, domain.ChannelHistoryFilter{ChannelID: private.Channel.ID, Limit: 20}); !errors.Is(err, domain.ErrChannelPrivate) {
|
||||
t.Fatalf("private preview history err = %v, want ErrChannelPrivate", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,70 +121,3 @@ func (s *ChannelStore) ClearRecentMessageReactions(ctx context.Context, userID i
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) ListSavedReactionTags(ctx 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
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT reaction_type, reaction_value, title, reaction_count
|
||||
FROM user_saved_reaction_tags
|
||||
WHERE user_id = $1
|
||||
ORDER BY reaction_count DESC, updated_at DESC, reaction_type ASC, reaction_value ASC
|
||||
LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list saved reaction tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.SavedReactionTag, 0, limit)
|
||||
for rows.Next() {
|
||||
var reactionType, reactionValue, title string
|
||||
var count int
|
||||
if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, domain.SavedReactionTag{
|
||||
UserID: userID,
|
||||
Reaction: reaction,
|
||||
Title: title,
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error {
|
||||
if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
reactionValue := strings.TrimSpace(tag.Reaction.Emoticon)
|
||||
if reactionValue == "" {
|
||||
return domain.ErrChannelInvalid
|
||||
}
|
||||
if tag.Count < 0 {
|
||||
tag.Count = 0
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO user_saved_reaction_tags (user_id, reaction_type, reaction_value, title, reaction_count)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
reaction_count = GREATEST(user_saved_reaction_tags.reaction_count, EXCLUDED.reaction_count),
|
||||
updated_at = now()`, tag.UserID, string(tag.Reaction.Type), reactionValue, tag.Title, tag.Count); err != nil {
|
||||
return fmt.Errorf("upsert saved reaction tag: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai
|
|||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
|
||||
channel, member, _, err := s.getChannelForViewer(ctx, tx, req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageReactionsResult{}, err
|
||||
}
|
||||
|
|
@ -57,7 +57,8 @@ func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domai
|
|||
if err != nil {
|
||||
return domain.ChannelMessageReactionsResult{}, err
|
||||
}
|
||||
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID {
|
||||
if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID ||
|
||||
!channelMessageVisibleToViewer(channel, member, req.UserID, msg) {
|
||||
return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
// 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。
|
||||
|
|
@ -474,7 +475,7 @@ func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domai
|
|||
if len(req.IDs) > domain.MaxGetMessageIDs {
|
||||
return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid
|
||||
}
|
||||
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
|
||||
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageReactionsResult{}, err
|
||||
}
|
||||
|
|
@ -491,6 +492,10 @@ func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domai
|
|||
args = append(args, member.AvailableMinID)
|
||||
where += fmt.Sprintf(" AND id > $%d", len(args))
|
||||
}
|
||||
if channel.Monoforum && !member.CanManageDirectMessages() {
|
||||
args = append(args, string(domain.PeerTypeUser), req.UserID)
|
||||
where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args))
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT `+channelMessageColumns+`
|
||||
FROM channel_messages
|
||||
|
|
@ -532,7 +537,7 @@ func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req doma
|
|||
if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit {
|
||||
req.Limit = domain.MaxChannelMessageReactionListLimit
|
||||
}
|
||||
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
|
||||
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageReactionsList{}, err
|
||||
}
|
||||
|
|
@ -543,7 +548,8 @@ func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req doma
|
|||
if err != nil {
|
||||
return domain.ChannelMessageReactionsList{}, err
|
||||
}
|
||||
if msg.Deleted || msg.ID <= member.AvailableMinID {
|
||||
if msg.Deleted || msg.ID <= member.AvailableMinID ||
|
||||
!channelMessageVisibleToViewer(channel, member, req.UserID, msg) {
|
||||
return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid
|
||||
}
|
||||
baseWhere := []string{"channel_id = $1", "message_id = $2"}
|
||||
|
|
@ -612,3 +618,49 @@ LIMIT $`+fmt.Sprint(len(args)), args...)
|
|||
NextOffset: next,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ChannelStore) FindChannelMessageReaction(ctx 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
|
||||
}
|
||||
channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.ViewerUserID, req.ChannelID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageReactionLookup{}, false, err
|
||||
}
|
||||
message, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageReactionLookup{}, false, err
|
||||
}
|
||||
if message.Deleted || message.ID <= member.AvailableMinID ||
|
||||
!channelMessageVisibleToViewer(channel, member, req.ViewerUserID, message) {
|
||||
return domain.ChannelMessageReactionLookup{}, false, domain.ErrMessageIDInvalid
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT channel_id, message_id, reacted_user_id, sender_user_id,
|
||||
reaction_type, reaction_value, big, unread, chosen_order, reaction_date
|
||||
FROM channel_message_reactions
|
||||
WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3
|
||||
ORDER BY chosen_order, reaction_type, reaction_value
|
||||
LIMIT $4`,
|
||||
req.ChannelID, req.MessageID, req.ReactorUserID,
|
||||
domain.MaxChannelMessageReactionsPerUser)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageReactionLookup{}, false, fmt.Errorf("find channel message reaction: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
reactions := make([]domain.ChannelMessagePeerReaction, 0, domain.MaxChannelMessageReactionsPerUser)
|
||||
for rows.Next() {
|
||||
reaction, err := scanChannelMessagePeerReaction(rows, req.ViewerUserID)
|
||||
if err != nil {
|
||||
return domain.ChannelMessageReactionLookup{}, false, err
|
||||
}
|
||||
reactions = append(reactions, reaction)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.ChannelMessageReactionLookup{}, false, err
|
||||
}
|
||||
return domain.ChannelMessageReactionLookup{
|
||||
Channel: channel, Message: message, Reactions: reactions,
|
||||
}, len(reactions) > 0, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -574,8 +574,10 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedTop,
|
|||
if err != nil {
|
||||
t.Fatalf("get large channel after local clear: %v", err)
|
||||
}
|
||||
if afterClear.Dialog.TopMessageID != 0 || afterClear.Dialog.UnreadCount != 0 {
|
||||
t.Fatalf("large dialog after local clear = %+v, want no visible unread top", afterClear.Dialog)
|
||||
if afterClear.Dialog.TopMessageID != sent.Message.ID ||
|
||||
afterClear.Dialog.HistoryClearAnchorID != sent.Message.ID ||
|
||||
afterClear.Dialog.UnreadCount != 0 {
|
||||
t.Fatalf("large dialog after local clear = %+v, want anchored top %d with no unread", afterClear.Dialog, sent.Message.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
|
|
@ -235,7 +238,7 @@ func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChan
|
|||
if strings.EqualFold(channel.Username, username) {
|
||||
return domain.Channel{}, domain.ErrChannelNotModified
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, usernameLower); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, username, usernameLower); err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil {
|
||||
|
|
@ -292,16 +295,52 @@ func (s *ChannelStore) SetChannelScamFake(ctx context.Context, channelID int64,
|
|||
if scam && fake {
|
||||
return domain.Channel{}, domain.ErrPeerModerationFlagsInvalid
|
||||
}
|
||||
channel, err := s.channelByID(ctx, s.db, channelID)
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.Channel{}, fmt.Errorf("set channel scam/fake: db does not support transactions")
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("begin set channel scam/fake: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
var currentScam, currentFake bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT scam, fake
|
||||
FROM channels
|
||||
WHERE id = $1 AND NOT deleted
|
||||
FOR UPDATE`, channelID).Scan(¤tScam, ¤tFake); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Channel{}, domain.ErrChannelInvalid
|
||||
}
|
||||
return domain.Channel{}, fmt.Errorf("lock channel scam/fake: %w", err)
|
||||
}
|
||||
channel, err := s.channelByID(ctx, tx, channelID)
|
||||
if err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if channel.Scam == scam && channel.Fake == fake {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("commit unchanged channel scam/fake: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return channel, nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
|
||||
if currentScam != channel.Scam || currentFake != channel.Fake {
|
||||
return domain.Channel{}, fmt.Errorf("channel scam/fake snapshot changed while locked")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET scam = $2, fake = $3, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, scam, fake); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("set channel scam/fake: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Channel{}, fmt.Errorf("commit channel scam/fake: %w", err)
|
||||
}
|
||||
committed = true
|
||||
if s.rowCache != nil {
|
||||
s.rowCache.delete(channelID)
|
||||
}
|
||||
|
|
@ -387,7 +426,7 @@ func (s *ChannelStore) SetChannelUsernameAdmin(ctx context.Context, channelID in
|
|||
if strings.EqualFold(channel.Username, username) {
|
||||
return channel, nil
|
||||
}
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, usernameLower); err != nil {
|
||||
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, channelID, username, usernameLower); err != nil {
|
||||
return domain.Channel{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, channelID, username); err != nil {
|
||||
|
|
@ -471,6 +510,9 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
|
|||
if !found || owner.peerType != peerUsernameTypeChannel {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
if !owner.active {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
ch, err := getChannelByID(ctx, s.db, owner.peerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrChannelInvalid) {
|
||||
|
|
@ -478,7 +520,14 @@ func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerU
|
|||
}
|
||||
return domain.Channel{}, false, fmt.Errorf("resolve public channel username channel: %w", err)
|
||||
}
|
||||
if !publicPreviewableChannel(ch) || !strings.EqualFold(ch.Username, usernameLower) {
|
||||
if !publicPreviewableChannel(ch, true) {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
// A collectible row is authoritative for its own name: the channel's scalar
|
||||
// username column only ever mirrors the editable slot, so re-checking it here
|
||||
// would make collectible names unresolvable. The scalar comparison stays for
|
||||
// the editable slot, where it guards against a stale registry row.
|
||||
if !owner.collectible && !strings.EqualFold(ch.Username, usernameLower) {
|
||||
return domain.Channel{}, false, nil
|
||||
}
|
||||
return ch, true, nil
|
||||
|
|
|
|||
|
|
@ -118,8 +118,12 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
|
|||
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, err := monoforumManagerRecipientsTx(ctx, tx, parent.ID, original.SavedPeer.ID)
|
||||
if err != nil {
|
||||
|
|
@ -173,11 +177,6 @@ func (s *ChannelStore) ToggleSuggestedPostApproval(ctx context.Context, req doma
|
|||
}
|
||||
} else {
|
||||
effectivePublishDate := scheduleDate
|
||||
if effectivePublishDate == 0 {
|
||||
// TDesktop's "Publish Now" request has no schedule_date flag, but
|
||||
// its approval service renderer always expects an absolute date.
|
||||
effectivePublishDate = req.Date
|
||||
}
|
||||
original.SuggestedPost.Accepted, original.SuggestedPost.Rejected, original.SuggestedPost.ScheduleDate = true, false, effectivePublishDate
|
||||
original, result.OriginalEvent, err = s.persistSuggestedPostEditTx(ctx, tx, original, req.UserID, req.Date)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -133,3 +133,122 @@ func TestSuggestedPostLifecyclePostgres(t *testing.T) {
|
|||
t.Fatalf("late settlement balance/channel=%d/%d, want 90/8", debit, channelBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedPostApprovalAcceptsDelayedSchedulePostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
suffix := randomSuffix(t)
|
||||
users := NewUserStore(pool)
|
||||
owner, err := users.Create(ctx, domain.User{AccessHash: 211, Phone: "+1889" + suffix + "01", FirstName: "DelayedOwner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subscriber, err := users.Create(ctx, domain.User{AccessHash: 212, Phone: "+1889" + suffix + "02", FirstName: "DelayedSubscriber"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channels := NewChannelStore(pool)
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID, Title: "Delayed Suggested " + suffix, Broadcast: true, Date: 1_700_020_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
monoID := enabled.Channel.LinkedMonoforumID
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM suggested_post_approvals WHERE monoforum_id=$1`, monoID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM channels WHERE id=ANY($1::bigint[])`, []int64{monoID, created.Channel.ID})
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1::bigint[])`, []int64{owner.ID, subscriber.ID})
|
||||
})
|
||||
saved := domain.Peer{Type: domain.PeerTypeUser, ID: subscriber.ID}
|
||||
|
||||
const now = 1_700_020_100
|
||||
near, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved,
|
||||
RandomID: 81, Message: "postgres near schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now - 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nearDate := now + 2*60
|
||||
accepted, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||
UserID: owner.ID, MonoforumID: monoID, MessageID: near.Message.ID,
|
||||
ScheduleDate: nearDate, Date: now,
|
||||
})
|
||||
if err != nil || accepted.State != domain.SuggestedPostStateScheduled || accepted.Published != nil {
|
||||
t.Fatalf("near schedule approval=%+v err=%v", accepted, err)
|
||||
}
|
||||
var monoPts, parentPts, monoEvents, parentEvents int
|
||||
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, monoID).Scan(&monoPts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&parentPts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, monoID).Scan(&monoEvents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, created.Channel.ID).Scan(&parentEvents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replay, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||
UserID: owner.ID, MonoforumID: monoID, MessageID: near.Message.ID,
|
||||
ScheduleDate: nearDate, Date: now + 10*60,
|
||||
})
|
||||
if err != nil || !replay.Duplicate {
|
||||
t.Fatalf("late replay=%+v err=%v", replay, err)
|
||||
}
|
||||
var gotMonoPts, gotParentPts, gotMonoEvents, gotParentEvents int
|
||||
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, monoID).Scan(&gotMonoPts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT pts FROM channels WHERE id=$1`, created.Channel.ID).Scan(&gotParentPts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, monoID).Scan(&gotMonoEvents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_update_events WHERE channel_id=$1`, created.Channel.ID).Scan(&gotParentEvents); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMonoPts != monoPts || gotParentPts != parentPts || gotMonoEvents != monoEvents || gotParentEvents != parentEvents {
|
||||
t.Fatalf("late replay changed pts/events mono=%d/%d→%d/%d parent=%d/%d→%d/%d",
|
||||
monoPts, monoEvents, gotMonoPts, gotMonoEvents, parentPts, parentEvents, gotParentPts, gotParentEvents)
|
||||
}
|
||||
|
||||
due, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{
|
||||
MonoforumID: monoID, SenderUserID: subscriber.ID, SavedPeer: saved,
|
||||
RandomID: 82, Message: "postgres due schedule", SuggestedPost: &domain.SuggestedPost{}, Date: now + 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approvedAt := now + 30
|
||||
dueResult, err := channels.ToggleSuggestedPostApproval(ctx, domain.ToggleSuggestedPostApprovalRequest{
|
||||
UserID: owner.ID, MonoforumID: monoID, MessageID: due.Message.ID,
|
||||
ScheduleDate: now - 1, Date: approvedAt,
|
||||
})
|
||||
if err != nil || dueResult.State != domain.SuggestedPostStateCompleted || dueResult.Published == nil {
|
||||
t.Fatalf("due approval=%+v err=%v", dueResult, err)
|
||||
}
|
||||
if dueResult.OriginalMessage.SuggestedPost.ScheduleDate != approvedAt ||
|
||||
dueResult.ServiceMessage.Action == nil ||
|
||||
dueResult.ServiceMessage.Action.SuggestedPostScheduleDate != approvedAt {
|
||||
t.Fatalf("due effective dates original/action=%d/%+v, want %d",
|
||||
dueResult.OriginalMessage.SuggestedPost.ScheduleDate, dueResult.ServiceMessage.Action, approvedAt)
|
||||
}
|
||||
var persistedDate int
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT schedule_date
|
||||
FROM suggested_post_approvals
|
||||
WHERE monoforum_id=$1 AND suggestion_message_id=$2`, monoID, due.Message.ID).Scan(&persistedDate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persistedDate != approvedAt {
|
||||
t.Fatalf("persisted due schedule=%d, want %d", persistedDate, approvedAt)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue