package store import ( "context" "errors" "path/filepath" "testing" "time" ) var ctx = context.Background() // fakeClock ist eine steuerbare Uhr für deterministische Zeitstempel. type fakeClock struct{ t time.Time } func (c *fakeClock) now() time.Time { return c.t } func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) } // openTest öffnet einen frischen Store in einem temporären Verzeichnis mit // steuerbarer Uhr. func openTest(t *testing.T) (*Store, *fakeClock) { t.Helper() s, err := Open(ctx, filepath.Join(t.TempDir(), "sultan.db")) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { s.Close() }) clock := &fakeClock{t: time.Date(2026, 10, 1, 12, 0, 0, 123456789, time.UTC)} s.now = clock.now return s, clock } func TestOpen_MigratesSchema(t *testing.T) { s, _ := openTest(t) v, err := s.SchemaVersion(ctx) if err != nil { t.Fatal(err) } if v != len(migrations) || v < 1 { t.Errorf("Schema-Version = %d, erwartet %d", v, len(migrations)) } } func TestOpen_ReopenKeepsData(t *testing.T) { path := filepath.Join(t.TempDir(), "sultan.db") s, err := Open(ctx, path) if err != nil { t.Fatal(err) } if _, err := s.CreateTenant(ctx, sampleTenant("t1")); err != nil { t.Fatal(err) } if err := s.Close(); err != nil { t.Fatal(err) } s, err = Open(ctx, path) if err != nil { t.Fatalf("erneutes Open: %v", err) } defer s.Close() if _, err := s.GetTenant(ctx, "t1"); err != nil { t.Errorf("Daten nach erneutem Öffnen weg: %v", err) } if v, _ := s.SchemaVersion(ctx); v != len(migrations) { t.Errorf("Schema-Version nach erneutem Öffnen = %d", v) } } func TestOpen_InvalidPath(t *testing.T) { _, err := Open(ctx, filepath.Join(t.TempDir(), "gibt", "es", "nicht", "sultan.db")) if err == nil { t.Error("erwartet Fehler für nicht existierendes Verzeichnis") } } func TestOpen_RejectsNewerSchema(t *testing.T) { path := filepath.Join(t.TempDir(), "sultan.db") s, err := Open(ctx, path) if err != nil { t.Fatal(err) } if _, err := s.db.ExecContext(ctx, "PRAGMA user_version = 999"); err != nil { t.Fatal(err) } s.Close() if _, err := Open(ctx, path); !errors.Is(err, ErrSchemaTooNew) { t.Errorf("erwartet ErrSchemaTooNew, bekommen %v", err) } } func TestOpen_ForeignKeysEnabled(t *testing.T) { s, _ := openTest(t) var on int if err := s.db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&on); err != nil { t.Fatal(err) } if on != 1 { t.Error("foreign_keys ist nicht aktiv – ON DELETE CASCADE würde nicht greifen") } } func TestDBTime_Scan(t *testing.T) { want := time.Date(2026, 12, 15, 10, 30, 0, 987654321, time.UTC) cet := want.In(time.FixedZone("CET", 3600)) for name, src := range map[string]any{ "Text": "2026-12-15T10:30:00.987654321Z", "Bytes": []byte("2026-12-15T10:30:00.987654321Z"), "time.Time (CET)": cet, } { t.Run(name, func(t *testing.T) { var got time.Time if err := (dbTime{&got}).Scan(src); err != nil { t.Fatal(err) } if !got.Equal(want) || got.Location() != time.UTC { t.Errorf("got %v, erwartet %v in UTC", got, want) } }) } var got time.Time if err := (dbTime{&got}).Scan("15.12.2026"); err == nil { t.Error("ungültiger Text: erwartet Fehler") } if err := (dbTime{&got}).Scan(int64(42)); err == nil { t.Error("unerwarteter Typ: erwartet Fehler") } } // Zeitstempel müssen als Text fester Breite gespeichert werden, sonst // funktionieren Vergleiche in SQL (z. B. PurgeEnrollmentTokens) nicht. func TestTimesStoredAsSortableText(t *testing.T) { s, _ := openTest(t) mustTenant(t, s, "t1") var typ, raw string if err := s.db.QueryRowContext(ctx, "SELECT typeof(created_at), CAST(created_at AS TEXT) FROM tenants").Scan(&typ, &raw); err != nil { t.Fatal(err) } if typ != "text" || raw != "2026-10-01T12:00:00.123456789Z" { t.Errorf("gespeichert als %s %q", typ, raw) } }