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) } }