226 lines
7.9 KiB
Go
226 lines
7.9 KiB
Go
|
|
package store
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"path/filepath"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"glchat/internal/database"
|
||
|
|
)
|
||
|
|
|
||
|
|
// newPushTestStore открывает временную БД с миграциями: подписки Web Push
|
||
|
|
// ссылаются на пользователя внешним ключом, поэтому нужен реальный аккаунт.
|
||
|
|
func newPushTestStore(t *testing.T) (*Store, uint64) {
|
||
|
|
t.Helper()
|
||
|
|
ctx := context.Background()
|
||
|
|
db, err := database.Open(ctx, database.Options{
|
||
|
|
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||
|
|
ReadPool: 2,
|
||
|
|
Migrate: true,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("открыть тестовую БД: %v", err)
|
||
|
|
}
|
||
|
|
t.Cleanup(func() {
|
||
|
|
if err := db.Close(); err != nil {
|
||
|
|
t.Errorf("закрыть тестовую БД: %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
st := New(db)
|
||
|
|
user, err := st.CreateUser(ctx, CreateUserParams{
|
||
|
|
Username: "push_store_user",
|
||
|
|
EmailEnc: "enc",
|
||
|
|
EmailIndex: "idx",
|
||
|
|
PasswordHash: "hash",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("создать пользователя: %v", err)
|
||
|
|
}
|
||
|
|
return st, user.ID
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPushSubscriptionLifecycle(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
st, userID := newPushTestStore(t)
|
||
|
|
|
||
|
|
saved, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||
|
|
UserID: userID,
|
||
|
|
Endpoint: "https://fcm.googleapis.com/fcm/send/one",
|
||
|
|
P256dh: "p256dh-1",
|
||
|
|
Auth: "auth-1",
|
||
|
|
UserAgent: "Chrome/140",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("SavePushSubscription: %v", err)
|
||
|
|
}
|
||
|
|
if saved.ID == 0 || saved.FailureCount != 0 || saved.LastSuccessAt != nil {
|
||
|
|
t.Fatalf("неожиданная подписка: %+v", saved)
|
||
|
|
}
|
||
|
|
|
||
|
|
byEndpoint, err := st.GetPushSubscriptionByEndpoint(ctx, saved.Endpoint)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("GetPushSubscriptionByEndpoint: %v", err)
|
||
|
|
}
|
||
|
|
if byEndpoint.UserID != userID || byEndpoint.P256dh != "p256dh-1" || byEndpoint.UserAgent != "Chrome/140" {
|
||
|
|
t.Fatalf("подписка прочитана неверно: %+v", byEndpoint)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Повторная подписка с того же устройства обновляет ключи и сбрасывает
|
||
|
|
// счётчик неудач: браузер мог перевыпустить ключи шифрования.
|
||
|
|
if _, err := st.FailPushSubscription(ctx, saved.ID); err != nil {
|
||
|
|
t.Fatalf("FailPushSubscription: %v", err)
|
||
|
|
}
|
||
|
|
updated, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||
|
|
UserID: userID,
|
||
|
|
Endpoint: saved.Endpoint,
|
||
|
|
P256dh: "p256dh-2",
|
||
|
|
Auth: "auth-2",
|
||
|
|
UserAgent: "Chrome/141",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("повторный SavePushSubscription: %v", err)
|
||
|
|
}
|
||
|
|
if updated.ID != saved.ID {
|
||
|
|
t.Fatalf("upsert создал новую запись: %d вместо %d", updated.ID, saved.ID)
|
||
|
|
}
|
||
|
|
if updated.P256dh != "p256dh-2" || updated.FailureCount != 0 {
|
||
|
|
t.Fatalf("upsert не обновил запись: %+v", updated)
|
||
|
|
}
|
||
|
|
|
||
|
|
count, err := st.CountPushSubscriptions(ctx, userID)
|
||
|
|
if err != nil || count != 1 {
|
||
|
|
t.Fatalf("CountPushSubscriptions = %d (%v), ожидалось 1", count, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Успешная доставка отмечается и обнуляет счётчик неудач.
|
||
|
|
if _, err := st.FailPushSubscription(ctx, saved.ID); err != nil {
|
||
|
|
t.Fatalf("FailPushSubscription: %v", err)
|
||
|
|
}
|
||
|
|
if err := st.TouchPushSubscription(ctx, saved.ID); err != nil {
|
||
|
|
t.Fatalf("TouchPushSubscription: %v", err)
|
||
|
|
}
|
||
|
|
touched, err := st.GetPushSubscriptionByEndpoint(ctx, saved.Endpoint)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("GetPushSubscriptionByEndpoint: %v", err)
|
||
|
|
}
|
||
|
|
if touched.LastSuccessAt == nil || touched.FailureCount != 0 {
|
||
|
|
t.Fatalf("успех не отмечен: %+v", touched)
|
||
|
|
}
|
||
|
|
|
||
|
|
if affected, err := st.DeletePushSubscription(ctx, userID, saved.Endpoint); err != nil || affected != 1 {
|
||
|
|
t.Fatalf("DeletePushSubscription = %d (%v), ожидалось 1", affected, err)
|
||
|
|
}
|
||
|
|
if _, err := st.GetPushSubscriptionByEndpoint(ctx, saved.Endpoint); !errors.Is(err, ErrNotFound) {
|
||
|
|
t.Fatalf("после удаления ошибка = %v, ожидалась ErrNotFound", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPushSubscriptionLimitAndBulkDelete(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
st, userID := newPushTestStore(t)
|
||
|
|
|
||
|
|
for i := 0; i < 3; i++ {
|
||
|
|
if _, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||
|
|
UserID: userID,
|
||
|
|
Endpoint: "https://push.example.com/" + string(rune('a'+i)),
|
||
|
|
P256dh: "key",
|
||
|
|
Auth: "auth",
|
||
|
|
}); err != nil {
|
||
|
|
t.Fatalf("SavePushSubscription %d: %v", i, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
subscriptions, err := st.ListPushSubscriptions(ctx, userID)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ListPushSubscriptions: %v", err)
|
||
|
|
}
|
||
|
|
if len(subscriptions) != 3 {
|
||
|
|
t.Fatalf("подписок %d, ожидалось 3", len(subscriptions))
|
||
|
|
}
|
||
|
|
|
||
|
|
// Пустой эндпоинт — отписка со всех устройств.
|
||
|
|
if affected, err := st.DeletePushSubscription(ctx, userID, ""); err != nil || affected != 3 {
|
||
|
|
t.Fatalf("массовая отписка = %d (%v), ожидалось 3", affected, err)
|
||
|
|
}
|
||
|
|
if count, err := st.CountPushSubscriptions(ctx, userID); err != nil || count != 0 {
|
||
|
|
t.Fatalf("после отписки подписок %d (%v)", count, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPushSubscriptionFailuresAndStaleCleanup(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
st, userID := newPushTestStore(t)
|
||
|
|
|
||
|
|
subscription, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||
|
|
UserID: userID,
|
||
|
|
Endpoint: "https://push.example.com/stale",
|
||
|
|
P256dh: "key",
|
||
|
|
Auth: "auth",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("SavePushSubscription: %v", err)
|
||
|
|
}
|
||
|
|
for i := 1; i <= 3; i++ {
|
||
|
|
count, err := st.FailPushSubscription(ctx, subscription.ID)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("FailPushSubscription: %v", err)
|
||
|
|
}
|
||
|
|
if count != i {
|
||
|
|
t.Fatalf("счётчик неудач = %d, ожидался %d", count, i)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Свежая неудача: подписка ещё не считается мёртвой.
|
||
|
|
if removed, err := st.DeleteStalePushSubscriptions(ctx, time.Now().UTC().Add(-time.Hour), 3); err != nil || removed != 0 {
|
||
|
|
t.Fatalf("удалено свежих подписок %d (%v), ожидалось 0", removed, err)
|
||
|
|
}
|
||
|
|
// Неудачная доставка была давно — подписка убирается.
|
||
|
|
if removed, err := st.DeleteStalePushSubscriptions(ctx, time.Now().UTC().Add(time.Hour), 3); err != nil || removed != 1 {
|
||
|
|
t.Fatalf("удалено мёртвых подписок %d (%v), ожидалось 1", removed, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPushSubscriptionsRemovedWithUser(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
st, userID := newPushTestStore(t)
|
||
|
|
|
||
|
|
if _, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||
|
|
UserID: userID,
|
||
|
|
Endpoint: "https://push.example.com/cascade",
|
||
|
|
P256dh: "key",
|
||
|
|
Auth: "auth",
|
||
|
|
}); err != nil {
|
||
|
|
t.Fatalf("SavePushSubscription: %v", err)
|
||
|
|
}
|
||
|
|
// Мягкое удаление пользователя сессии отзывает, но подписки остаются:
|
||
|
|
// их убирает явный вызов, иначе push уходил бы удалённому аккаунту.
|
||
|
|
if _, err := st.DeletePushSubscriptionsForUser(ctx, userID); err != nil {
|
||
|
|
t.Fatalf("DeletePushSubscriptionsForUser: %v", err)
|
||
|
|
}
|
||
|
|
if count, err := st.CountPushSubscriptions(ctx, userID); err != nil || count != 0 {
|
||
|
|
t.Fatalf("после удаления пользователя подписок %d (%v)", count, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDeletePushSubscriptionByID(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
st, userID := newPushTestStore(t)
|
||
|
|
|
||
|
|
subscription, err := st.SavePushSubscription(ctx, SavePushSubscriptionParams{
|
||
|
|
UserID: userID,
|
||
|
|
Endpoint: "https://push.example.com/by-id",
|
||
|
|
P256dh: "key",
|
||
|
|
Auth: "auth",
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("SavePushSubscription: %v", err)
|
||
|
|
}
|
||
|
|
if err := st.DeletePushSubscriptionByID(ctx, subscription.ID); err != nil {
|
||
|
|
t.Fatalf("DeletePushSubscriptionByID: %v", err)
|
||
|
|
}
|
||
|
|
if _, err := st.GetPushSubscriptionByEndpoint(ctx, subscription.Endpoint); !errors.Is(err, ErrNotFound) {
|
||
|
|
t.Fatalf("после удаления ошибка = %v, ожидалась ErrNotFound", err)
|
||
|
|
}
|
||
|
|
}
|