154 lines
4.7 KiB
Go
154 lines
4.7 KiB
Go
package userstore_test
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/userdb"
|
||
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
||
|
|
)
|
||
|
|
|
||
|
|
type staticUserLister struct {
|
||
|
|
users []*models.User
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (l staticUserLister) List(context.Context) ([]*models.User, error) {
|
||
|
|
return l.users, l.err
|
||
|
|
}
|
||
|
|
|
||
|
|
type mapStoreProvider struct {
|
||
|
|
stores map[int]userstore.UserStore
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p mapStoreProvider) ForUser(_ context.Context, userID int) (userstore.UserStore, error) {
|
||
|
|
store, ok := p.stores[userID]
|
||
|
|
if !ok {
|
||
|
|
return nil, errors.New("no store for user")
|
||
|
|
}
|
||
|
|
return store, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p mapStoreProvider) Close() error { return nil }
|
||
|
|
|
||
|
|
func newSweeperTestStore(t *testing.T) userstore.UserStore {
|
||
|
|
t.Helper()
|
||
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("open sqlite: %v", err)
|
||
|
|
}
|
||
|
|
t.Cleanup(func() { _ = db.Close() })
|
||
|
|
if err := userdb.InitSchema(db); err != nil {
|
||
|
|
t.Fatalf("InitSchema: %v", err)
|
||
|
|
}
|
||
|
|
return userdb.NewSQLiteUserStore(db)
|
||
|
|
}
|
||
|
|
|
||
|
|
func putReceipt(t *testing.T, store userstore.UserStore, mutationID string, expiresAt time.Time) {
|
||
|
|
t.Helper()
|
||
|
|
if _, _, err := store.PutSettingMutation(context.Background(), userstore.SettingMutationRecord{
|
||
|
|
MutationID: mutationID,
|
||
|
|
RequestHash: "hash-" + mutationID,
|
||
|
|
Result: json.RawMessage(`{"status":"applied"}`),
|
||
|
|
ExpiresAt: expiresAt,
|
||
|
|
}); err != nil {
|
||
|
|
t.Fatalf("PutSettingMutation(%s): %v", mutationID, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestSettingMutationSweeperDeletesExpiredReceipts pins the retention
|
||
|
|
// invariant: a sweep removes exactly the receipts whose expires_at has passed,
|
||
|
|
// in every user's store, and leaves unexpired receipts replayable.
|
||
|
|
func TestSettingMutationSweeperDeletesExpiredReceipts(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
storeOne := newSweeperTestStore(t)
|
||
|
|
storeTwo := newSweeperTestStore(t)
|
||
|
|
|
||
|
|
now := time.Now().UTC()
|
||
|
|
putReceipt(t, storeOne, "user1-expired", now.Add(-time.Hour))
|
||
|
|
putReceipt(t, storeOne, "user1-fresh", now.Add(30*24*time.Hour))
|
||
|
|
putReceipt(t, storeTwo, "user2-expired-a", now.Add(-48*time.Hour))
|
||
|
|
putReceipt(t, storeTwo, "user2-expired-b", now.Add(-time.Minute))
|
||
|
|
putReceipt(t, storeTwo, "user2-fresh", now.Add(time.Hour))
|
||
|
|
|
||
|
|
sweeper := userstore.NewSettingMutationSweeper(
|
||
|
|
staticUserLister{users: []*models.User{{ID: 1}, {ID: 2}}},
|
||
|
|
mapStoreProvider{stores: map[int]userstore.UserStore{1: storeOne, 2: storeTwo}},
|
||
|
|
)
|
||
|
|
|
||
|
|
var lastMessage string
|
||
|
|
stats, err := sweeper.Sweep(ctx, func(_ int, message string) { lastMessage = message })
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Sweep: %v", err)
|
||
|
|
}
|
||
|
|
if stats.UsersSwept != 2 || stats.UsersFailed != 0 || stats.ReceiptsDeleted != 3 {
|
||
|
|
t.Fatalf("stats = %+v, want 2 users swept, 0 failed, 3 receipts deleted", stats)
|
||
|
|
}
|
||
|
|
if lastMessage == "" {
|
||
|
|
t.Fatal("Sweep reported no progress")
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, gone := range []struct {
|
||
|
|
store userstore.UserStore
|
||
|
|
id string
|
||
|
|
}{
|
||
|
|
{storeOne, "user1-expired"},
|
||
|
|
{storeTwo, "user2-expired-a"},
|
||
|
|
{storeTwo, "user2-expired-b"},
|
||
|
|
} {
|
||
|
|
if got, err := gone.store.GetSettingMutation(ctx, gone.id); err != nil || got != nil {
|
||
|
|
t.Fatalf("GetSettingMutation(%s) = %+v (%v), want swept", gone.id, got, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, kept := range []struct {
|
||
|
|
store userstore.UserStore
|
||
|
|
id string
|
||
|
|
}{
|
||
|
|
{storeOne, "user1-fresh"},
|
||
|
|
{storeTwo, "user2-fresh"},
|
||
|
|
} {
|
||
|
|
if got, err := kept.store.GetSettingMutation(ctx, kept.id); err != nil || got == nil {
|
||
|
|
t.Fatalf("GetSettingMutation(%s) = %+v (%v), want the unexpired receipt", kept.id, got, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestSettingMutationSweeperSkipsFailedStores pins the skip-on-error behavior:
|
||
|
|
// a user whose store cannot be opened is counted as failed and does not stop
|
||
|
|
// the sweep from reaching the users after it.
|
||
|
|
func TestSettingMutationSweeperSkipsFailedStores(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
store := newSweeperTestStore(t)
|
||
|
|
putReceipt(t, store, "expired", time.Now().UTC().Add(-time.Hour))
|
||
|
|
|
||
|
|
sweeper := userstore.NewSettingMutationSweeper(
|
||
|
|
staticUserLister{users: []*models.User{{ID: 1}, {ID: 2}}},
|
||
|
|
mapStoreProvider{stores: map[int]userstore.UserStore{2: store}},
|
||
|
|
)
|
||
|
|
|
||
|
|
stats, err := sweeper.Sweep(ctx, nil)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("Sweep: %v", err)
|
||
|
|
}
|
||
|
|
if stats.UsersSwept != 1 || stats.UsersFailed != 1 || stats.ReceiptsDeleted != 1 {
|
||
|
|
t.Fatalf("stats = %+v, want 1 swept, 1 failed, 1 deleted", stats)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestSettingMutationSweeperListFailure pins that a user-listing failure is an
|
||
|
|
// error rather than a silent no-op sweep.
|
||
|
|
func TestSettingMutationSweeperListFailure(t *testing.T) {
|
||
|
|
sweeper := userstore.NewSettingMutationSweeper(
|
||
|
|
staticUserLister{err: errors.New("database unavailable")},
|
||
|
|
mapStoreProvider{},
|
||
|
|
)
|
||
|
|
if _, err := sweeper.Sweep(context.Background(), nil); err == nil {
|
||
|
|
t.Fatal("Sweep must report user listing failures")
|
||
|
|
}
|
||
|
|
}
|