mirror of
https://github.com/tiennm99/openai-status-bot.git
synced 2026-07-17 04:17:51 +00:00
543 lines
16 KiB
Go
543 lines
16 KiB
Go
//go:build integration
|
|
|
|
// Package mongostore integration tests run against a real mongod via
|
|
// testcontainers and are excluded from the default `go test ./...`. Run them
|
|
// with Docker available:
|
|
//
|
|
// go test -tags=integration ./internal/mongostore/...
|
|
package mongostore
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
|
|
|
tcmongo "github.com/testcontainers/testcontainers-go/modules/mongodb"
|
|
)
|
|
|
|
var (
|
|
sharedClientOnce sync.Once
|
|
sharedClient *mongo.Client
|
|
sharedClientErr error
|
|
)
|
|
|
|
// testMongo starts the shared mongod container once, then hands each test a
|
|
// Store bound to a unique database that is dropped on cleanup.
|
|
func testMongo(t *testing.T) *Store {
|
|
t.Helper()
|
|
store := testMongoWithoutIndexes(t)
|
|
if err := store.EnsureIndexes(context.Background()); err != nil {
|
|
t.Fatalf("EnsureIndexes: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func testMongoWithoutIndexes(t *testing.T) *Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
sharedClientOnce.Do(func() {
|
|
container, err := tcmongo.Run(ctx, "mongo:8")
|
|
if err != nil {
|
|
sharedClientErr = err
|
|
return
|
|
}
|
|
uri, err := container.ConnectionString(ctx)
|
|
if err != nil {
|
|
sharedClientErr = err
|
|
return
|
|
}
|
|
// The container is intentionally left running for the whole package
|
|
// run; testcontainers' reaper removes it after the process exits.
|
|
_ = container
|
|
sharedClient, sharedClientErr = mongo.Connect(options.Client().ApplyURI(uri))
|
|
})
|
|
if sharedClientErr != nil {
|
|
t.Fatalf("start mongo container: %v", sharedClientErr)
|
|
}
|
|
|
|
dbName := "test_" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
|
db := sharedClient.Database(dbName)
|
|
t.Cleanup(func() {
|
|
_ = db.Drop(context.Background())
|
|
})
|
|
|
|
return New(sharedClient, dbName)
|
|
}
|
|
|
|
func TestAddAndGetSubscriber(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
sub := NewSubscriber(123, nil)
|
|
if err := store.AddSubscriber(ctx, sub); err != nil {
|
|
t.Fatalf("AddSubscriber: %v", err)
|
|
}
|
|
|
|
got, exists, err := store.GetSubscriber(ctx, sub)
|
|
if err != nil || !exists {
|
|
t.Fatalf("GetSubscriber exists=%v err=%v", exists, err)
|
|
}
|
|
if len(got.Types) != 2 {
|
|
t.Fatalf("default Types = %v, want both defaults", got.Types)
|
|
}
|
|
if len(got.Components) != 0 {
|
|
t.Fatalf("default Components = %v, want empty", got.Components)
|
|
}
|
|
}
|
|
|
|
func TestAddSubscriberPreservesSettingsOnReStart(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
sub := NewSubscriber(123, nil)
|
|
if err := store.AddSubscriber(ctx, sub); err != nil {
|
|
t.Fatalf("AddSubscriber: %v", err)
|
|
}
|
|
if _, err := store.UpdateSubscriberSettings(ctx, sub, []string{SubscriptionTypeComponent}, []string{"c-api"}); err != nil {
|
|
t.Fatalf("UpdateSubscriberSettings: %v", err)
|
|
}
|
|
if err := store.AddSubscriber(ctx, sub); err != nil {
|
|
t.Fatalf("re-AddSubscriber: %v", err)
|
|
}
|
|
|
|
got, _, err := store.GetSubscriber(ctx, sub)
|
|
if err != nil {
|
|
t.Fatalf("GetSubscriber: %v", err)
|
|
}
|
|
if len(got.Types) != 1 || got.Types[0] != SubscriptionTypeComponent {
|
|
t.Fatalf("Types = %v, want preserved [component]", got.Types)
|
|
}
|
|
if len(got.Components) != 1 || got.Components[0] != "c-api" {
|
|
t.Fatalf("Components = %v, want preserved [c-api]", got.Components)
|
|
}
|
|
}
|
|
|
|
func TestRemoveSubscriberDeletesDocument(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
sub := NewSubscriber(123, nil)
|
|
if err := store.AddSubscriber(ctx, sub); err != nil {
|
|
t.Fatalf("AddSubscriber: %v", err)
|
|
}
|
|
if err := store.RemoveSubscriber(ctx, sub); err != nil {
|
|
t.Fatalf("RemoveSubscriber: %v", err)
|
|
}
|
|
_, exists, err := store.GetSubscriber(ctx, sub)
|
|
if err != nil {
|
|
t.Fatalf("GetSubscriber after remove: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("subscriber still exists after remove")
|
|
}
|
|
}
|
|
|
|
func TestGetSubscriberMissingReturnsFalse(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
_, exists, err := store.GetSubscriber(ctx, NewSubscriber(999, nil))
|
|
if err != nil {
|
|
t.Fatalf("GetSubscriber: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("exists = true, want false for missing subscriber")
|
|
}
|
|
}
|
|
|
|
func TestListSubscribersSelfHealsMalformedKey(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
good := NewSubscriber(1, nil)
|
|
if err := store.AddSubscriber(ctx, good); err != nil {
|
|
t.Fatalf("AddSubscriber: %v", err)
|
|
}
|
|
if _, err := store.subscribers.InsertOne(ctx, bson.M{"_id": "abc:def:ghi", "types": []string{}, "components": []string{}}); err != nil {
|
|
t.Fatalf("insert malformed subscriber: %v", err)
|
|
}
|
|
|
|
if _, err := store.ListSubscribers(ctx); err == nil {
|
|
t.Fatal("expected malformed key error on first list")
|
|
}
|
|
// Malformed doc was deleted; the next list succeeds with only the good one.
|
|
subs, err := store.ListSubscribers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("second ListSubscribers: %v", err)
|
|
}
|
|
if len(subs) != 1 || subs[0].Key() != good.Key() {
|
|
t.Fatalf("subscribers = %v, want only %q", subs, good.Key())
|
|
}
|
|
}
|
|
|
|
func TestListSubscribersSelfHealsMalformedSettings(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
if _, err := store.subscribers.InsertOne(ctx, bson.M{
|
|
"_id": "2",
|
|
"chatID": int64(2),
|
|
"types": "not-an-array",
|
|
"components": bson.A{123},
|
|
}); err != nil {
|
|
t.Fatalf("insert malformed subscriber settings: %v", err)
|
|
}
|
|
|
|
subs, err := store.ListSubscribers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListSubscribers: %v", err)
|
|
}
|
|
if len(subs) != 1 || subs[0].Key() != "2" {
|
|
t.Fatalf("subscribers = %v, want key 2", subs)
|
|
}
|
|
if len(subs[0].Types) != 2 || len(subs[0].Components) != 0 {
|
|
t.Fatalf("settings = types %v components %v, want defaults", subs[0].Types, subs[0].Components)
|
|
}
|
|
|
|
var raw bson.M
|
|
if err := store.subscribers.FindOne(ctx, bson.M{"_id": "2"}).Decode(&raw); err != nil {
|
|
t.Fatalf("load healed subscriber: %v", err)
|
|
}
|
|
types, ok := storedStringSlice(raw["types"])
|
|
if !ok || len(normalizeTypes(types)) != 2 {
|
|
t.Fatalf("healed types = %v ok=%v, want defaults", raw["types"], ok)
|
|
}
|
|
components, ok := storedStringSlice(raw["components"])
|
|
if !ok || len(components) != 0 {
|
|
t.Fatalf("healed components = %v ok=%v, want empty", raw["components"], ok)
|
|
}
|
|
}
|
|
|
|
func TestGetSubscriberSelfHealsMalformedSettings(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
if _, err := store.subscribers.InsertOne(ctx, bson.M{
|
|
"_id": "3",
|
|
"chatID": int64(3),
|
|
"types": bson.A{"incident"},
|
|
"components": int32(42),
|
|
}); err != nil {
|
|
t.Fatalf("insert malformed subscriber settings: %v", err)
|
|
}
|
|
|
|
got, exists, err := store.GetSubscriber(ctx, NewSubscriber(3, nil))
|
|
if err != nil || !exists {
|
|
t.Fatalf("GetSubscriber exists=%v err=%v", exists, err)
|
|
}
|
|
if len(got.Types) != 1 || got.Types[0] != SubscriptionTypeIncident || len(got.Components) != 0 {
|
|
t.Fatalf("settings = types %v components %v, want incident and empty components", got.Types, got.Components)
|
|
}
|
|
}
|
|
|
|
func TestUpdateSubscriberSettingsMatchedVsMissing(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
sub := NewSubscriber(1, nil)
|
|
|
|
updated, err := store.UpdateSubscriberSettings(ctx, sub, []string{SubscriptionTypeComponent}, []string{"c-api"})
|
|
if err != nil {
|
|
t.Fatalf("UpdateSubscriberSettings: %v", err)
|
|
}
|
|
if updated {
|
|
t.Fatal("updated = true for missing subscriber, want false")
|
|
}
|
|
|
|
if err := store.AddSubscriber(ctx, sub); err != nil {
|
|
t.Fatalf("AddSubscriber: %v", err)
|
|
}
|
|
updated, err = store.UpdateSubscriberSettings(ctx, sub, []string{SubscriptionTypeComponent}, []string{"c-api"})
|
|
if err != nil {
|
|
t.Fatalf("UpdateSubscriberSettings: %v", err)
|
|
}
|
|
if !updated {
|
|
t.Fatal("updated = false for existing subscriber, want true")
|
|
}
|
|
}
|
|
|
|
func TestUpdateSubscriberTypesMatchedVsMissing(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
sub := NewSubscriber(1, nil)
|
|
|
|
updated, err := store.UpdateSubscriberTypes(ctx, sub, []string{SubscriptionTypeComponent})
|
|
if err != nil {
|
|
t.Fatalf("UpdateSubscriberTypes missing: %v", err)
|
|
}
|
|
if updated {
|
|
t.Fatal("updated = true for missing subscriber, want false")
|
|
}
|
|
|
|
if err := store.AddSubscriber(ctx, sub); err != nil {
|
|
t.Fatalf("AddSubscriber: %v", err)
|
|
}
|
|
updated, err = store.UpdateSubscriberTypes(ctx, sub, []string{" COMPONENT ", "invalid"})
|
|
if err != nil {
|
|
t.Fatalf("UpdateSubscriberTypes existing: %v", err)
|
|
}
|
|
if !updated {
|
|
t.Fatal("updated = false for existing subscriber, want true")
|
|
}
|
|
got, _, err := store.GetSubscriber(ctx, sub)
|
|
if err != nil {
|
|
t.Fatalf("GetSubscriber: %v", err)
|
|
}
|
|
if len(got.Types) != 1 || got.Types[0] != SubscriptionTypeComponent {
|
|
t.Fatalf("Types = %v, want normalized [component]", got.Types)
|
|
}
|
|
}
|
|
|
|
func TestComponentStatusRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
if err := store.SaveComponentStatus(ctx, "c1", "degraded_performance"); err != nil {
|
|
t.Fatalf("SaveComponentStatus: %v", err)
|
|
}
|
|
if err := store.SaveComponentStatus(ctx, "c1", "operational"); err != nil {
|
|
t.Fatalf("SaveComponentStatus update: %v", err)
|
|
}
|
|
statuses, err := store.ComponentStatuses(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ComponentStatuses: %v", err)
|
|
}
|
|
if statuses["c1"] != "operational" {
|
|
t.Fatalf("status = %q, want operational", statuses["c1"])
|
|
}
|
|
}
|
|
|
|
func TestPendingComponentEventRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
event := PendingComponentEvent{
|
|
ComponentID: "c1",
|
|
ComponentName: "API",
|
|
Status: "degraded_performance",
|
|
UpdatedAt: "2026-01-01T00:00:00Z",
|
|
Position: 2,
|
|
PreviousStatus: "operational",
|
|
DeliveryKey: "component:c1:degraded_performance:2026-01-01T00:00:00Z",
|
|
}
|
|
if err := store.SavePendingComponentEvent(ctx, event); err != nil {
|
|
t.Fatalf("SavePendingComponentEvent: %v", err)
|
|
}
|
|
events, err := store.PendingComponentEvents(ctx)
|
|
if err != nil {
|
|
t.Fatalf("PendingComponentEvents: %v", err)
|
|
}
|
|
if got := events["c1"]; got != event {
|
|
t.Fatalf("event = %+v, want %+v", got, event)
|
|
}
|
|
if err := store.RemovePendingComponentEvent(ctx, "c1"); err != nil {
|
|
t.Fatalf("RemovePendingComponentEvent: %v", err)
|
|
}
|
|
events, err = store.PendingComponentEvents(ctx)
|
|
if err != nil {
|
|
t.Fatalf("PendingComponentEvents after remove: %v", err)
|
|
}
|
|
if len(events) != 0 {
|
|
t.Fatalf("events = %v, want empty after remove", events)
|
|
}
|
|
}
|
|
|
|
func TestIncidentUpdateVersionDedup(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
seen, err := store.HasIncidentUpdateVersion(ctx, "u1", "v1")
|
|
if err != nil {
|
|
t.Fatalf("HasIncidentUpdateVersion: %v", err)
|
|
}
|
|
if seen {
|
|
t.Fatal("unseen update reported as seen")
|
|
}
|
|
if err := store.MarkIncidentUpdateVersion(ctx, "u1", "v1"); err != nil {
|
|
t.Fatalf("MarkIncidentUpdateVersion: %v", err)
|
|
}
|
|
seen, err = store.HasIncidentUpdateVersion(ctx, "u1", "v1")
|
|
if err != nil || !seen {
|
|
t.Fatalf("same version seen=%v err=%v, want seen", seen, err)
|
|
}
|
|
// An edited update (new version) must notify again.
|
|
seen, err = store.HasIncidentUpdateVersion(ctx, "u1", "v2")
|
|
if err != nil {
|
|
t.Fatalf("HasIncidentUpdateVersion v2: %v", err)
|
|
}
|
|
if seen {
|
|
t.Fatal("edited update version reported as seen")
|
|
}
|
|
}
|
|
|
|
func TestDeliveryDedupAndClear(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
if err := store.MarkDelivered(ctx, "evt1", "sub-a"); err != nil {
|
|
t.Fatalf("MarkDelivered: %v", err)
|
|
}
|
|
if err := store.MarkDelivered(ctx, "evt1", "sub-b"); err != nil {
|
|
t.Fatalf("MarkDelivered: %v", err)
|
|
}
|
|
var marker struct {
|
|
ExpiresAt time.Time `bson:"expiresAt"`
|
|
}
|
|
if err := store.delivery.FindOne(ctx, bson.M{"_id": "evt1|sub-a"}).Decode(&marker); err != nil {
|
|
t.Fatalf("load delivery marker: %v", err)
|
|
}
|
|
if marker.ExpiresAt.Before(time.Now().Add(deliveryTTL-time.Minute)) || marker.ExpiresAt.After(time.Now().Add(deliveryTTL+time.Minute)) {
|
|
t.Fatalf("expiresAt = %s, want about %s from now", marker.ExpiresAt, deliveryTTL)
|
|
}
|
|
delivered, err := store.DeliveredSubscribers(ctx, "evt1")
|
|
if err != nil {
|
|
t.Fatalf("DeliveredSubscribers: %v", err)
|
|
}
|
|
if !delivered["sub-a"] || !delivered["sub-b"] || len(delivered) != 2 {
|
|
t.Fatalf("delivered = %v, want sub-a and sub-b", delivered)
|
|
}
|
|
if err := store.ClearDelivery(ctx, "evt1"); err != nil {
|
|
t.Fatalf("ClearDelivery: %v", err)
|
|
}
|
|
delivered, err = store.DeliveredSubscribers(ctx, "evt1")
|
|
if err != nil {
|
|
t.Fatalf("DeliveredSubscribers after clear: %v", err)
|
|
}
|
|
if len(delivered) != 0 {
|
|
t.Fatalf("delivered = %v, want empty after clear", delivered)
|
|
}
|
|
}
|
|
|
|
func TestDeliveryTTLIndexExists(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
assertDeliveryTTLIndex(t, ctx, store, int64(deliveryTTLIndexExpireAfterSeconds))
|
|
}
|
|
|
|
func TestEnsureIndexesReplacesLegacyDeliveryTTLIndex(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongoWithoutIndexes(t)
|
|
if _, err := store.delivery.Indexes().CreateOne(ctx, mongo.IndexModel{
|
|
Keys: bson.D{{Key: "expiresAt", Value: 1}},
|
|
Options: options.Index().SetExpireAfterSeconds(604800),
|
|
}); err != nil {
|
|
t.Fatalf("create legacy TTL index: %v", err)
|
|
}
|
|
if err := store.EnsureIndexes(ctx); err != nil {
|
|
t.Fatalf("EnsureIndexes: %v", err)
|
|
}
|
|
assertDeliveryTTLIndex(t, ctx, store, int64(deliveryTTLIndexExpireAfterSeconds))
|
|
}
|
|
|
|
func assertDeliveryTTLIndex(t *testing.T, ctx context.Context, store *Store, want int64) {
|
|
t.Helper()
|
|
cursor, err := store.delivery.Indexes().List(ctx)
|
|
if err != nil {
|
|
t.Fatalf("list indexes: %v", err)
|
|
}
|
|
var indexes []bson.M
|
|
if err := cursor.All(ctx, &indexes); err != nil {
|
|
t.Fatalf("decode indexes: %v", err)
|
|
}
|
|
found := false
|
|
for _, idx := range indexes {
|
|
if !isExpiresAtIndex(idx["key"]) {
|
|
continue
|
|
}
|
|
if asInt(idx["expireAfterSeconds"]) == want {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("no expiresAt TTL index with expireAfterSeconds=%d in %v", want, indexes)
|
|
}
|
|
}
|
|
|
|
func asInt(v any) int64 {
|
|
switch n := v.(type) {
|
|
case int:
|
|
return int64(n)
|
|
case int32:
|
|
return int64(n)
|
|
case int64:
|
|
return n
|
|
case float64:
|
|
return int64(n)
|
|
default:
|
|
return -1
|
|
}
|
|
}
|
|
|
|
func TestTelegramOffsetRoundTrip(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
offset, err := store.TelegramOffset(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TelegramOffset: %v", err)
|
|
}
|
|
if offset != 0 {
|
|
t.Fatalf("default offset = %d, want 0", offset)
|
|
}
|
|
if err := store.SaveTelegramOffset(ctx, 42); err != nil {
|
|
t.Fatalf("SaveTelegramOffset: %v", err)
|
|
}
|
|
offset, err = store.TelegramOffset(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TelegramOffset: %v", err)
|
|
}
|
|
if offset != 42 {
|
|
t.Fatalf("offset = %d, want 42", offset)
|
|
}
|
|
}
|
|
|
|
func TestTelegramOffsetAcceptsStringValue(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
if _, err := store.meta.InsertOne(ctx, bson.M{"_id": metaTelegramOffsetID, "value": "42"}); err != nil {
|
|
t.Fatalf("insert string offset: %v", err)
|
|
}
|
|
offset, err := store.TelegramOffset(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TelegramOffset: %v", err)
|
|
}
|
|
if offset != 42 {
|
|
t.Fatalf("offset = %d, want 42", offset)
|
|
}
|
|
}
|
|
|
|
func TestTelegramOffsetSelfHealsInvalidValue(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
if _, err := store.meta.InsertOne(ctx, bson.M{"_id": metaTelegramOffsetID, "value": "bad"}); err != nil {
|
|
t.Fatalf("insert invalid offset: %v", err)
|
|
}
|
|
offset, err := store.TelegramOffset(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TelegramOffset: %v", err)
|
|
}
|
|
if offset != 0 {
|
|
t.Fatalf("offset = %d, want 0", offset)
|
|
}
|
|
count, err := store.meta.CountDocuments(ctx, bson.M{"_id": metaTelegramOffsetID})
|
|
if err != nil {
|
|
t.Fatalf("CountDocuments: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("invalid offset doc count = %d, want 0", count)
|
|
}
|
|
}
|
|
|
|
func TestInitializedFlag(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := testMongo(t)
|
|
initialized, err := store.IsInitialized(ctx)
|
|
if err != nil {
|
|
t.Fatalf("IsInitialized: %v", err)
|
|
}
|
|
if initialized {
|
|
t.Fatal("fresh store reported initialized")
|
|
}
|
|
if err := store.SetInitialized(ctx); err != nil {
|
|
t.Fatalf("SetInitialized: %v", err)
|
|
}
|
|
initialized, err = store.IsInitialized(ctx)
|
|
if err != nil || !initialized {
|
|
t.Fatalf("IsInitialized after set = %v err=%v, want true", initialized, err)
|
|
}
|
|
}
|