owpengram-server/internal/store/postgres/retention_v2_integration_test.go
2026-09-03 05:00:42 +03:00

360 lines
16 KiB
Go

package postgres
import (
"context"
"strconv"
"testing"
"time"
"telesrv/internal/domain"
)
// TestRetentionPurgeBypassProducesServiceMessage_PrivateMessage exercises
// Part 1 of the storage retention v2 plan end-to-end against a real
// Postgres: EditMessage with RetentionPurge=true bypasses the ordinary
// author check (the storage retention sweep is not the message's author) and
// replaces the message media with a messageActionCustomAction service
// payload, and a second retention-purge edit on the now-already-service
// message is a no-op (ErrMessageNotModified) -- guarding the idempotency
// guard that keeps a shared media_references duplicate (owner+peer both
// referencing the same purged document) from double-editing the same box.
func TestRetentionPurgeBypassProducesServiceMessage_PrivateMessage(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{AccessHash: 81, Phone: "+1997" + suffix + "01", FirstName: "RetentionSender"})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{AccessHash: 82, Phone: "+1997" + suffix + "02", FirstName: "RetentionRecipient"})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
ids := []int64{sender.ID, recipient.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
})
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}))
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: time.Now().UnixNano(),
Message: "a file worth keeping",
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{ID: time.Now().UnixNano(), AccessHash: 1, MimeType: "application/octet-stream"},
},
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("send private media: %v", err)
}
noticeMedia := &domain.MessageMedia{
Kind: domain.MessageMediaKindService,
ServiceAction: &domain.MessageServiceAction{
Kind: domain.MessageServiceActionCustomText,
Text: domain.RetentionPurgeNoticeText,
},
}
req := domain.EditMessageRequest{
OwnerUserID: sender.ID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID},
ID: sent.SenderMessage.ID,
SetRichMessage: true,
Media: noticeMedia,
RetentionPurge: true,
}
res, err := messages.EditMessage(ctx, req)
if err != nil {
t.Fatalf("EditMessage(RetentionPurge): %v", err)
}
self := res.Self()
if self.Message.Media == nil || self.Message.Media.Kind != domain.MessageMediaKindService {
t.Fatalf("edited message media = %+v, want service kind", self.Message.Media)
}
if self.Message.Media.ServiceAction == nil || self.Message.Media.ServiceAction.Kind != domain.MessageServiceActionCustomText {
t.Fatalf("edited message service action = %+v, want custom_text", self.Message.Media.ServiceAction)
}
if self.Message.Media.ServiceAction.Text != domain.RetentionPurgeNoticeText {
t.Fatalf("edited message notice text = %q, want %q", self.Message.Media.ServiceAction.Text, domain.RetentionPurgeNoticeText)
}
// A second retention-purge edit is a no-op: the message is already the
// notice, so this must not keep bumping pts / re-writing it forever.
if _, err := messages.EditMessage(ctx, req); err != domain.ErrMessageNotModified {
t.Fatalf("second RetentionPurge edit err = %v, want ErrMessageNotModified", err)
}
// A non-author, non-bypass edit on someone else's message must still be
// rejected -- RetentionPurge only bypasses the author check when it is
// actually set, not for ordinary edits in general.
ordinary := req
ordinary.RetentionPurge = false
ordinary.OwnerUserID = recipient.ID
ordinary.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}
if _, err := messages.EditMessage(ctx, ordinary); err != domain.ErrMessageAuthorRequired {
t.Fatalf("non-bypass edit by non-author err = %v, want ErrMessageAuthorRequired", err)
}
}
// TestRetentionPurgeBypassProducesServiceMessage_ChannelMessage is the
// channel counterpart of the private-message test above: channel messages
// store their service action in a structurally separate Action column (see
// tgChannelMessage), so applyRetentionPurgeChannelMessage must clear body/
// media and set Action instead of replacing Media like the private path.
func TestRetentionPurgeBypassProducesServiceMessage_ChannelMessage(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{AccessHash: 83, Phone: "+1997" + suffix + "03", FirstName: "RetentionChOwner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) })
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Retention " + suffix, Megagroup: true, Date: 1700003000})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID := created.Channel.ID
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) })
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID, ChannelID: channelID, RandomID: 771122, Message: "old file",
Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &domain.Document{ID: time.Now().UnixNano(), AccessHash: 1}},
Date: 1700003001,
})
if err != nil {
t.Fatalf("send channel message: %v", err)
}
req := domain.EditChannelMessageRequest{
ChannelID: channelID,
ID: sent.Message.ID,
RetentionPurge: true,
RetentionPurgeAction: &domain.ChannelMessageAction{Type: domain.ChannelActionCustomText, Text: domain.RetentionPurgeNoticeText},
}
res, err := channels.EditChannelMessage(ctx, req)
if err != nil {
t.Fatalf("EditChannelMessage(RetentionPurge): %v", err)
}
if res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionCustomText {
t.Fatalf("edited channel message action = %+v, want custom_text", res.Message.Action)
}
if res.Message.Action.Text != domain.RetentionPurgeNoticeText {
t.Fatalf("edited channel message notice text = %q, want %q", res.Message.Action.Text, domain.RetentionPurgeNoticeText)
}
if res.Message.Body != "" || !res.Message.Media.IsZero() {
t.Fatalf("edited channel message body/media not cleared: body=%q media=%+v", res.Message.Body, res.Message.Media)
}
// Idempotency guard: a second retention-purge edit on an already-service
// message must not succeed again.
if _, err := channels.EditChannelMessage(ctx, req); err != domain.ErrMessageNotModified {
t.Fatalf("second RetentionPurge channel edit err = %v, want ErrMessageNotModified", err)
}
// Read-back via ListChannelHistory renders as a service message too.
hist, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: channelID, Limit: 10})
if err != nil {
t.Fatalf("list channel history: %v", err)
}
var found bool
for _, m := range hist.Messages {
if m.ID == sent.Message.ID {
found = true
if m.Action == nil || m.Action.Type != domain.ChannelActionCustomText {
t.Fatalf("history action = %+v, want custom_text", m.Action)
}
}
}
if !found {
t.Fatalf("purged message %d not found in channel history", sent.Message.ID)
}
}
// TestDocumentCategoryPopulatedOnSend guards Part 2's schema/write-path
// change: sending a document message stamps the already-computed
// domain.DocumentMediaCategory value directly onto the documents row, at the
// same point the shared message_box_media index is written -- zero new
// classification logic.
func TestDocumentCategoryPopulatedOnSend(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
sender, err := users.Create(ctx, domain.User{AccessHash: 84, Phone: "+1997" + suffix + "04", FirstName: "CatSender"})
if err != nil {
t.Fatalf("create sender: %v", err)
}
recipient, err := users.Create(ctx, domain.User{AccessHash: 85, Phone: "+1997" + suffix + "05", FirstName: "CatRecipient"})
if err != nil {
t.Fatalf("create recipient: %v", err)
}
ids := []int64{sender.ID, recipient.ID}
docID := time.Now().UnixNano()
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids)
_, _ = pool.Exec(ctx, "DELETE FROM documents WHERE id = $1", docID)
_, _ = pool.Exec(ctx, "DELETE FROM media_references WHERE media_kind = 'document' AND media_id = $1", docID)
})
media := NewMediaStore(pool)
if err := media.PutDocument(ctx, domain.Document{ID: docID, MimeType: "video/mp4", Size: 2048}); err != nil {
t.Fatalf("PutDocument: %v", err)
}
messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}))
_, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: sender.ID,
RecipientUserID: recipient.ID,
RandomID: time.Now().UnixNano(),
Media: &domain.MessageMedia{
Kind: domain.MessageMediaKindDocument,
Document: &domain.Document{
ID: docID, AccessHash: 1, MimeType: "video/mp4",
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 480, Duration: 5}},
},
},
Date: int(time.Now().Unix()),
})
if err != nil {
t.Fatalf("send video document: %v", err)
}
var category int16
if err := pool.QueryRow(ctx, "SELECT category FROM documents WHERE id = $1", docID).Scan(&category); err != nil {
t.Fatalf("read document category: %v", err)
}
if domain.MediaCategory(category) != domain.MediaCategoryVideo {
t.Fatalf("document category = %d, want %d (Video)", category, domain.MediaCategoryVideo)
}
}
// TestHardRetentionCategoryFilterOnlyReturnsMatchingCategory guards Part 2's
// query-layer change: ListDocumentIDsForHardRetentionOlderThan now takes a
// category argument and must only return documents in that exact bucket,
// even when another old, blob-owning document sits in a different category.
func TestHardRetentionCategoryFilterOnlyReturnsMatchingCategory(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
media := NewMediaStore(pool)
videoID := time.Now().UnixNano()
musicID := videoID + 1
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), "DELETE FROM documents WHERE id = ANY($1::bigint[])", []int64{videoID, musicID})
_, _ = pool.Exec(context.Background(), "DELETE FROM file_blobs WHERE location_key = ANY($1::text[])",
[]string{"doc:" + strconv.FormatInt(videoID, 10), "doc:" + strconv.FormatInt(musicID, 10)})
})
for _, d := range []struct {
id int64
category domain.MediaCategory
}{{videoID, domain.MediaCategoryVideo}, {musicID, domain.MediaCategoryMusic}} {
if err := media.PutDocument(ctx, domain.Document{ID: d.id, MimeType: "application/octet-stream", Size: 512}); err != nil {
t.Fatalf("PutDocument %d: %v", d.id, err)
}
if _, err := pool.Exec(ctx, "UPDATE documents SET created_at = now() - interval '100 days', category = $2 WHERE id = $1", d.id, int16(d.category)); err != nil {
t.Fatalf("backdate/categorize document %d: %v", d.id, err)
}
blob := postgresTestBlob("doc:"+strconv.FormatInt(d.id, 10), "cat-filter", 512, "application/octet-stream")
if err := media.PutFileBlob(ctx, blob); err != nil {
t.Fatalf("PutFileBlob %d: %v", d.id, err)
}
}
cutoff := time.Now().Add(-24 * time.Hour)
videoIDs, err := media.ListDocumentIDsForHardRetentionOlderThan(ctx, domain.MediaCategoryVideo, cutoff, 1000)
if err != nil {
t.Fatalf("ListDocumentIDsForHardRetentionOlderThan(Video): %v", err)
}
if !containsInt64(videoIDs, videoID) || containsInt64(videoIDs, musicID) {
t.Fatalf("video-category candidates = %v, want to include %d and exclude %d", videoIDs, videoID, musicID)
}
musicIDs, err := media.ListDocumentIDsForHardRetentionOlderThan(ctx, domain.MediaCategoryMusic, cutoff, 1000)
if err != nil {
t.Fatalf("ListDocumentIDsForHardRetentionOlderThan(Music): %v", err)
}
if !containsInt64(musicIDs, musicID) || containsInt64(musicIDs, videoID) {
t.Fatalf("music-category candidates = %v, want to include %d and exclude %d", musicIDs, musicID, videoID)
}
}
// TestEvictionListsOldestAcrossDocumentsAndPhotos guards Part 3's eviction
// query layer: ListOldestMediaForEviction must return candidates from both
// tables (interleaving by created_at is done by the caller in Go, see
// files.Service.EvictOldestMediaOverBudget) so the oldest-overall item can be
// picked regardless of which table it lives in.
func TestEvictionListsOldestAcrossDocumentsAndPhotos(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
media := NewMediaStore(pool)
docID := time.Now().UnixNano()
photoID := docID + 1
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), "DELETE FROM documents WHERE id = $1", docID)
_, _ = pool.Exec(context.Background(), "DELETE FROM photos WHERE id = $1", photoID)
_, _ = pool.Exec(context.Background(), "DELETE FROM file_blobs WHERE location_key = ANY($1::text[])",
[]string{"doc:" + strconv.FormatInt(docID, 10), "photo:" + strconv.FormatInt(photoID, 10)})
})
if err := media.PutDocument(ctx, domain.Document{ID: docID, MimeType: "application/octet-stream", Size: 256}); err != nil {
t.Fatalf("PutDocument: %v", err)
}
// The document is the older of the two (200 days vs 100 days) -- it must
// sort before the photo in the merged eviction candidate list.
if _, err := pool.Exec(ctx, "UPDATE documents SET created_at = now() - interval '200 days' WHERE id = $1", docID); err != nil {
t.Fatalf("backdate document: %v", err)
}
if err := media.PutFileBlob(ctx, postgresTestBlob("doc:"+strconv.FormatInt(docID, 10), "evict-doc", 256, "application/octet-stream")); err != nil {
t.Fatalf("PutFileBlob doc: %v", err)
}
if err := media.PutPhoto(ctx, domain.Photo{ID: photoID, AccessHash: 1}); err != nil {
t.Fatalf("PutPhoto: %v", err)
}
if _, err := pool.Exec(ctx, "UPDATE photos SET created_at = now() - interval '100 days' WHERE id = $1", photoID); err != nil {
t.Fatalf("backdate photo: %v", err)
}
if err := media.PutFileBlob(ctx, postgresTestBlob("photo:"+strconv.FormatInt(photoID, 10), "evict-photo", 256, "image/jpeg")); err != nil {
t.Fatalf("PutFileBlob photo: %v", err)
}
candidates, err := media.ListOldestMediaForEviction(ctx, 1000)
if err != nil {
t.Fatalf("ListOldestMediaForEviction: %v", err)
}
var doc, photo *domain.EvictionCandidate
for i := range candidates {
c := candidates[i]
switch {
case c.Kind == domain.MediaKindDocument && c.MediaID == docID:
doc = &candidates[i]
case c.Kind == domain.MediaKindPhoto && c.MediaID == photoID:
photo = &candidates[i]
}
}
if doc == nil || photo == nil {
t.Fatalf("eviction candidates missing doc/photo: %+v", candidates)
}
if !doc.CreatedAt.Before(photo.CreatedAt) {
t.Fatalf("document created_at %v not before photo created_at %v (document should be older)", doc.CreatedAt, photo.CreatedAt)
}
}