feat(ops): чистка данных, reindex и PWA с офлайн-режимом
- internal/retention: истёкшие сессии, аудит старше RETENTION_AUDIT_DAYS и файлы без ссылок старше RETENTION_ORPHAN_HOURS; сиротой файл считается только если на него не ссылается ничего (вложения, аватары, оформление сервера, комнат, ролей, эмодзи, звуки, вебхуки, приглашения) - обслуживание запускается в приложении по RETENTION_INTERVAL_SECONDS; команды glchat cleanup и glchat reindex делают то же вручную - PWA: манифест с иконками и shortcuts, офлайн-страница, сервис-воркер (кэш только /assets/*, приватные запросы не кэшируются) - тесты: Go на чистку (сирота удаляется, используемые файлы и свежие сироты остаются, истёкшая сессия уходит), Vitest на манифест и регистрацию воркера
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Тесты чистки (AGENT.md 6.4): истёкшие сессии, старый аудит и файлы без
|
||||
// ссылок удаляются, а используемые файлы остаются.
|
||||
|
||||
func newRetentionStore(t *testing.T) (*store.Store, *database.DB, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := database.Open(context.Background(), database.Options{
|
||||
Path: filepath.Join(dir, "glchat.db"), ReadPool: 1, Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return store.New(db), db, dir
|
||||
}
|
||||
|
||||
func TestCleanupRemovesOrphansAndKeepsReferences(t *testing.T) {
|
||||
st, db, dataDir := newRetentionStore(t)
|
||||
ctx := context.Background()
|
||||
filesDir := filepath.Join(dataDir, "files")
|
||||
if err := os.MkdirAll(filesDir, 0o700); err != nil {
|
||||
t.Fatalf("prepare files dir: %v", err)
|
||||
}
|
||||
|
||||
writeFile := func(name string) string {
|
||||
path := filepath.Join(filesDir, name)
|
||||
if err := os.WriteFile(path, []byte("данные"), 0o600); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
user, err := st.CreateUser(ctx, store.CreateUserParams{
|
||||
Username: "cleanup", EmailEnc: "enc", EmailIndex: "cleanup@example.com",
|
||||
PasswordHash: "x", Locale: "ru",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
// Аватар пользователя: на файл есть ссылка, он должен остаться.
|
||||
avatarPath := writeFile("avatar.bin")
|
||||
avatar, err := st.CreateFile(ctx, store.CreateFileParams{
|
||||
UploaderID: user.ID, Filename: "avatar.png", ContentType: "image/png",
|
||||
SizeBytes: 6, StoragePath: avatarPath, SHA256: "a", Purpose: "avatar",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create avatar file: %v", err)
|
||||
}
|
||||
if _, err := st.UpdateUser(ctx, user.ID, store.UpdateUserParams{AvatarFileID: &avatar.ID}); err != nil {
|
||||
t.Fatalf("attach avatar: %v", err)
|
||||
}
|
||||
|
||||
// Вложение сообщения: ссылка в attachments_json.
|
||||
channelPath := writeFile("attachment.bin")
|
||||
attachment, err := st.CreateFile(ctx, store.CreateFileParams{
|
||||
UploaderID: user.ID, Filename: "note.txt", ContentType: "text/plain",
|
||||
SizeBytes: 6, StoragePath: channelPath, SHA256: "b",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create attachment: %v", err)
|
||||
}
|
||||
guild, err := st.CreateGuild(ctx, store.CreateGuildParams{Name: "Чистка", OwnerID: user.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("create guild: %v", err)
|
||||
}
|
||||
channel, err := st.CreateChannel(ctx, store.CreateChannelParams{
|
||||
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
message, err := st.CreateMessage(ctx, store.CreateMessageParams{
|
||||
ChannelID: channel.ID, AuthorID: user.ID, Content: "файл",
|
||||
Attachments: []store.Attachment{{FileID: attachment.ID, Filename: "note.txt"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
if err := st.AttachFileToMessage(ctx, attachment.ID, message.ID); err != nil {
|
||||
t.Fatalf("attach file: %v", err)
|
||||
}
|
||||
|
||||
// Сирота: файл без ссылок.
|
||||
orphanPath := writeFile("orphan.bin")
|
||||
orphan, err := st.CreateFile(ctx, store.CreateFileParams{
|
||||
UploaderID: user.ID, Filename: "orphan.bin", ContentType: "application/octet-stream",
|
||||
SizeBytes: 6, StoragePath: orphanPath, SHA256: "c",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create orphan: %v", err)
|
||||
}
|
||||
|
||||
// Сессии: одна истёкшая, одна действующая.
|
||||
expired, err := st.CreateSession(ctx, user.ID, store.CreateSessionParams{
|
||||
TokenHash: "expired", UserAgent: "test", IP: "127.0.0.1", TTL: time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create expired session: %v", err)
|
||||
}
|
||||
// Просрочиваем сессию напрямую: TTL меньше часа не принимается хранилищем.
|
||||
if _, err := db.Writer.ExecContext(ctx,
|
||||
`UPDATE sessions SET expires_at = ? WHERE token_hash = 'expired'`,
|
||||
"2020-01-01T00:00:00.000Z"); err != nil {
|
||||
t.Fatalf("expire session: %v", err)
|
||||
}
|
||||
active, err := st.CreateSession(ctx, user.ID, store.CreateSessionParams{
|
||||
TokenHash: "active", UserAgent: "test", IP: "127.0.0.1", TTL: time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create active session: %v", err)
|
||||
}
|
||||
|
||||
// Запись аудита: старую чистим.
|
||||
if err := st.RecordAudit(ctx, store.AuditParams{
|
||||
GuildID: &guild.ID, ActorID: &user.ID, Action: "test.old",
|
||||
}); err != nil {
|
||||
t.Fatalf("record audit: %v", err)
|
||||
}
|
||||
|
||||
// Сироту состариваем: свежие файлы не удаляются (OrphanHours > 0).
|
||||
if _, err := db.Writer.ExecContext(ctx,
|
||||
`UPDATE files SET created_at = '2020-01-01T00:00:00.000Z' WHERE id = ?`,
|
||||
int64(orphan.ID)); err != nil {
|
||||
t.Fatalf("age orphan file: %v", err)
|
||||
}
|
||||
runner := New(st, Options{DataDir: dataDir, AuditDays: 30, OrphanHours: 24})
|
||||
if _, err := runner.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("cleanup: %v", err)
|
||||
}
|
||||
|
||||
// Истёкшая сессия удалена, действующая осталась.
|
||||
if _, err := st.GetSessionByTokenHash(ctx, "expired"); err == nil {
|
||||
t.Fatal("истёкшая сессия не удалена")
|
||||
}
|
||||
if _, err := st.GetSessionByTokenHash(ctx, "active"); err != nil {
|
||||
t.Fatalf("действующая сессия удалена: %v", err)
|
||||
}
|
||||
_ = expired
|
||||
_ = active
|
||||
|
||||
// Файл-сирота удалён с диска и из базы, используемые файлы на месте.
|
||||
if _, err := os.Stat(orphanPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("файл-сирота остался на диске: %v", err)
|
||||
}
|
||||
if _, err := st.GetFile(ctx, orphan.ID); err == nil {
|
||||
t.Fatal("запись о сироте осталась в базе")
|
||||
}
|
||||
for _, kept := range []uint64{avatar.ID, attachment.ID} {
|
||||
if _, err := st.GetFile(ctx, kept); err != nil {
|
||||
t.Fatalf("используемый файл %d удалён: %v", kept, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(avatarPath); err != nil {
|
||||
t.Fatalf("аватар удалён с диска: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(channelPath); err != nil {
|
||||
t.Fatalf("вложение удалено с диска: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupKeepsFreshOrphansAndRecentAudit(t *testing.T) {
|
||||
st, _, dataDir := newRetentionStore(t)
|
||||
ctx := context.Background()
|
||||
filesDir := filepath.Join(dataDir, "files")
|
||||
if err := os.MkdirAll(filesDir, 0o700); err != nil {
|
||||
t.Fatalf("prepare files dir: %v", err)
|
||||
}
|
||||
path := filepath.Join(filesDir, "fresh.bin")
|
||||
if err := os.WriteFile(path, []byte("данные"), 0o600); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
user, err := st.CreateUser(ctx, store.CreateUserParams{
|
||||
Username: "fresh", EmailEnc: "enc", EmailIndex: "fresh@example.com",
|
||||
PasswordHash: "x", Locale: "ru",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if _, err := st.CreateFile(ctx, store.CreateFileParams{
|
||||
UploaderID: user.ID, Filename: "fresh.bin", ContentType: "text/plain",
|
||||
SizeBytes: 6, StoragePath: path, SHA256: "d",
|
||||
}); err != nil {
|
||||
t.Fatalf("create file: %v", err)
|
||||
}
|
||||
|
||||
// Свежий файл (OrphanHours = 24) не трогаем.
|
||||
runner := New(st, Options{DataDir: dataDir, AuditDays: 30, OrphanHours: 24})
|
||||
stats, err := runner.RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup: %v", err)
|
||||
}
|
||||
if stats.Files != 0 {
|
||||
t.Fatalf("удалено файлов: %d, ожидалось 0", stats.Files)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("свежий файл удалён: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user