From 50a0b5c8cdb41fe04bc1f6e947ad35b8b6d02432 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 21 Oct 2026 02:20:12 +0000 Subject: [PATCH] test(addons): move the pin store tests to Ginkgo The pin service, file store, sweep and shared store contract tests used the testing package calls that forbidigo rejects. The store contract is now a function that registers its specs for each store, so the file store, the memory-only store and the database store run the same cases. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto --- .../services/addons/pins/service_gorm_test.go | 75 +++---- core/services/addons/pins/service_test.go | 207 ++++++++---------- .../addons/pins/store_contract_test.go | 152 ++++++------- core/services/addons/pins/store_file_test.go | 107 ++++----- core/services/addons/pins/store_gorm_test.go | 80 +++---- core/services/addons/pins/suite_test.go | 14 ++ core/services/addons/pins/sweep_test.go | 88 ++++---- 7 files changed, 333 insertions(+), 390 deletions(-) create mode 100644 core/services/addons/pins/suite_test.go diff --git a/core/services/addons/pins/service_gorm_test.go b/core/services/addons/pins/service_gorm_test.go index e65fb8a70..e5cba9eeb 100644 --- a/core/services/addons/pins/service_gorm_test.go +++ b/core/services/addons/pins/service_gorm_test.go @@ -5,49 +5,42 @@ package pins import ( "context" - "slices" - "testing" "github.com/mudler/LocalAI/core/http/auth" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -// Two frontends of one deployment share the database, so a save through one is -// read through the other. No process holds the only copy. -func TestTwoServicesOverOneDatabaseAgree(t *testing.T) { - db, err := auth.InitDB(":memory:") - if err != nil { - t.Fatal(err) - } - a, _ := NewGormStore(db) - b, _ := NewGormStore(db) - ctx := context.Background() - sa, sb := &Service{Store: a}, &Service{Store: b} - vis := map[string]bool{"studio": true, "talk": true, "x": true} - if _, err := sa.Put(ctx, "u1", []string{"x", "talk"}, vis); err != nil { - t.Fatal(err) - } - got, _ := sb.List(ctx, "u1", vis) - if !slices.Equal(got, []string{"x", "talk"}) { - t.Fatalf("the second frontend sees %v", got) - } -} +var _ = Describe("Two frontends over one database", func() { + // Two frontends of one deployment share the database, so a save through one is + // read through the other. No process holds the only copy. + It("agree on the saved pins", func() { + db, err := auth.InitDB(":memory:") + Expect(err).ToNot(HaveOccurred()) + a, _ := NewGormStore(db) + b, _ := NewGormStore(db) + ctx := context.Background() + sa, sb := &Service{Store: a}, &Service{Store: b} + vis := map[string]bool{"studio": true, "talk": true, "x": true} + _, err = sa.Put(ctx, "u1", []string{"x", "talk"}, vis) + Expect(err).ToNot(HaveOccurred()) + got, _ := sb.List(ctx, "u1", vis) + Expect(got).To(Equal([]string{"x", "talk"}), "the second frontend sees a different list") + }) -// Two frontends that start together run the upgrade seed once between them. -func TestTwoFrontendsStartingTogetherSeedOnce(t *testing.T) { - db, err := auth.InitDB(":memory:") - if err != nil { - t.Fatal(err) - } - a, _ := NewGormStore(db) - b, _ := NewGormStore(db) - ctx := context.Background() - ranA, errA := a.SeedExisting(ctx, []string{"u1"}, SeedIDs) - ranB, errB := b.SeedExisting(ctx, []string{"u1"}, SeedIDs) - if errA != nil || errB != nil || ranA == ranB { - t.Fatalf("exactly one frontend must seed: a=%v,%v b=%v,%v", ranA, errA, ranB, errB) - } - got, _ := a.List(ctx, "u1") - if !slices.Equal(idsOf(got), []string{"studio", "talk"}) { - t.Fatalf("seeded twice or not at all: %v", idsOf(got)) - } -} + // Two frontends that start together run the upgrade seed once between them. + It("seed once when they start together", func() { + db, err := auth.InitDB(":memory:") + Expect(err).ToNot(HaveOccurred()) + a, _ := NewGormStore(db) + b, _ := NewGormStore(db) + ctx := context.Background() + ranA, errA := a.SeedExisting(ctx, []string{"u1"}, SeedIDs) + ranB, errB := b.SeedExisting(ctx, []string{"u1"}, SeedIDs) + Expect(errA).ToNot(HaveOccurred()) + Expect(errB).ToNot(HaveOccurred()) + Expect(ranA).ToNot(Equal(ranB), "exactly one frontend must seed") + got, _ := a.List(ctx, "u1") + Expect(idsOf(got)).To(Equal([]string{"studio", "talk"}), "seeded twice or not at all") + }) +}) diff --git a/core/services/addons/pins/service_test.go b/core/services/addons/pins/service_test.go index fd4bef548..0b22112e3 100644 --- a/core/services/addons/pins/service_test.go +++ b/core/services/addons/pins/service_test.go @@ -3,11 +3,12 @@ package pins import ( "context" - "errors" "os" "path/filepath" "slices" - "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) func vis(ids ...string) map[string]bool { @@ -18,126 +19,104 @@ func vis(ids ...string) map[string]bool { return m } -func newService(t *testing.T) *Service { +func newService() *Service { s, err := NewFileStore("") - if err != nil { - t.Fatal(err) - } + Expect(err).ToNot(HaveOccurred()) return &Service{Store: s} } -func TestANewAccountStartsWithNoPins(t *testing.T) { - ctx := context.Background() - svc := newService(t) - // The upgrade ran earlier for the accounts that existed then. - if ran, err := svc.Store.SeedExisting(ctx, []string{"old"}, SeedIDs); err != nil || !ran { - t.Fatalf("upgrade seed: %v %v", ran, err) - } - got, err := svc.List(ctx, "created-after-the-upgrade", vis("studio", "talk")) - if err != nil || len(got) != 0 { - t.Fatalf("a new account must start with no pins, got %v %v", got, err) - } - if _, err := svc.Put(ctx, "created-after-the-upgrade", []string{"talk"}, vis("studio", "talk")); err != nil { - t.Fatal(err) - } - if got, _ := svc.List(ctx, "created-after-the-upgrade", vis("studio", "talk")); !slices.Equal(got, []string{"talk"}) { - t.Fatalf("a read or a save must never add a pin of its own: %v", got) - } -} +var _ = Describe("Service", func() { + var ( + ctx context.Context + svc *Service + ) -func TestUpgradeSeedPinsStudioAndTalkOnce(t *testing.T) { - ctx := context.Background() - svc := newService(t) - if ran, err := svc.Store.SeedExisting(ctx, []string{"u1"}, SeedIDs); err != nil || !ran { - t.Fatalf("upgrade seed: %v %v", ran, err) - } - got, err := svc.List(ctx, "u1", vis("studio", "talk")) - if err != nil || !slices.Equal(got, []string{"studio", "talk"}) { - t.Fatalf("an account that existed at the upgrade keeps Studio and Talk: %v %v", got, err) - } - if _, err := svc.Put(ctx, "u1", []string{"talk"}, vis("studio", "talk")); err != nil { - t.Fatal(err) - } - if ran, _ := svc.Store.SeedExisting(ctx, []string{"u1"}, SeedIDs); ran { - t.Fatal("the seed ran a second time") - } - got, _ = svc.List(ctx, "u1", vis("studio", "talk")) - if !slices.Equal(got, []string{"talk"}) { - t.Fatalf("an unpin did not stick: %v", got) - } -} + BeforeEach(func() { + ctx = context.Background() + svc = newService() + }) -func TestPutKeepsPinsTheCallerCannotSee(t *testing.T) { - ctx := context.Background() - svc := newService(t) - _ = svc.Store.Replace(ctx, "u1", []string{"a", "hidden", "b", "c"}) - got, err := svc.Put(ctx, "u1", []string{"c", "a", "b"}, vis("a", "b", "c")) - if err != nil || !slices.Equal(got, []string{"c", "a", "b"}) { - t.Fatalf("visible result: %v %v", got, err) - } - stored, _ := svc.Store.List(ctx, "u1") - if !slices.Equal(idsOf(stored), []string{"c", "a", "b", "hidden"}) { - t.Fatalf("the hidden pin was lost or the order is wrong: %v", idsOf(stored)) - } -} + It("starts a new account with no pins", func() { + // The upgrade ran earlier for the accounts that existed then. + ran, err := svc.Store.SeedExisting(ctx, []string{"old"}, SeedIDs) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + got, err := svc.List(ctx, "created-after-the-upgrade", vis("studio", "talk")) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(BeEmpty(), "a new account must start with no pins") + _, err = svc.Put(ctx, "created-after-the-upgrade", []string{"talk"}, vis("studio", "talk")) + Expect(err).ToNot(HaveOccurred()) + got, _ = svc.List(ctx, "created-after-the-upgrade", vis("studio", "talk")) + Expect(slices.Equal(got, []string{"talk"})).To(BeTrue(), "a read or a save must never add a pin of its own: %v", got) + }) -func TestPutDropsUnknownAndDuplicateIDs(t *testing.T) { - ctx := context.Background() - svc := newService(t) - got, err := svc.Put(ctx, "u1", []string{"a", "nope", "a", "b"}, vis("a", "b")) - if err != nil || !slices.Equal(got, []string{"a", "b"}) { - t.Fatalf("got %v %v", got, err) - } -} + It("pins Studio and Talk once at the upgrade seed", func() { + ran, err := svc.Store.SeedExisting(ctx, []string{"u1"}, SeedIDs) + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) + got, err := svc.List(ctx, "u1", vis("studio", "talk")) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal([]string{"studio", "talk"}), "an account that existed at the upgrade keeps Studio and Talk") + _, err = svc.Put(ctx, "u1", []string{"talk"}, vis("studio", "talk")) + Expect(err).ToNot(HaveOccurred()) + ran, _ = svc.Store.SeedExisting(ctx, []string{"u1"}, SeedIDs) + Expect(ran).To(BeFalse(), "the seed ran a second time") + got, _ = svc.List(ctx, "u1", vis("studio", "talk")) + Expect(got).To(Equal([]string{"talk"}), "an unpin did not stick") + }) -func TestPutRejectsAListOverTheHardLimit(t *testing.T) { - svc := newService(t) - long := make([]string, HardLimit+1) - for i := range long { - long[i] = "a" - } - _, err := svc.Put(context.Background(), "u1", long, vis("a")) - if !errors.Is(err, ErrTooMany) { - t.Fatalf("want ErrTooMany, got %v", err) - } -} + It("keeps pins the caller cannot see on Put", func() { + _ = svc.Store.Replace(ctx, "u1", []string{"a", "hidden", "b", "c"}) + got, err := svc.Put(ctx, "u1", []string{"c", "a", "b"}, vis("a", "b", "c")) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal([]string{"c", "a", "b"})) + stored, _ := svc.Store.List(ctx, "u1") + Expect(idsOf(stored)).To(Equal([]string{"c", "a", "b", "hidden"}), "the hidden pin was lost or the order is wrong") + }) -func TestListNeverRevealsAHiddenID(t *testing.T) { - ctx := context.Background() - svc := newService(t) - _ = svc.Store.Replace(ctx, "u1", []string{"secret", "studio"}) - got, _ := svc.List(ctx, "u1", vis("studio")) - if !slices.Equal(got, []string{"studio"}) { - t.Fatalf("a hidden id leaked: %v", got) - } -} + It("drops unknown and duplicate ids on Put", func() { + got, err := svc.Put(ctx, "u1", []string{"a", "nope", "a", "b"}, vis("a", "b")) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal([]string{"a", "b"})) + }) -// A corrupt pins file must stop startup with an error. Falling back to memory -// would lose every unpin at the next restart. -func TestACorruptPinsFileMakesTheConstructorFail(t *testing.T) { - path := filepath.Join(t.TempDir(), "addon-pins.json") - if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { - t.Fatal(err) - } - if s, err := NewFileStore(path); err == nil || s != nil { - t.Fatalf("want an error and no store, got %v %v", s, err) - } -} - -func TestPutAcceptsExactlyTheHardLimit(t *testing.T) { - svc := newService(t) - at := make([]string, HardLimit) - over := make([]string, HardLimit+1) - for i := range over { - over[i] = "a" - if i < HardLimit { - at[i] = "a" + It("rejects a list over the hard limit", func() { + long := make([]string, HardLimit+1) + for i := range long { + long[i] = "a" } - } - if _, err := svc.Put(context.Background(), "u1", at, vis("a")); err != nil { - t.Fatalf("%d ids must be accepted: %v", HardLimit, err) - } - if _, err := svc.Put(context.Background(), "u1", over, vis("a")); !errors.Is(err, ErrTooMany) { - t.Fatalf("%d ids must be refused with ErrTooMany: %v", HardLimit+1, err) - } -} + _, err := svc.Put(context.Background(), "u1", long, vis("a")) + Expect(err).To(MatchError(ErrTooMany)) + }) + + It("never reveals a hidden id on List", func() { + _ = svc.Store.Replace(ctx, "u1", []string{"secret", "studio"}) + got, _ := svc.List(ctx, "u1", vis("studio")) + Expect(got).To(Equal([]string{"studio"}), "a hidden id leaked") + }) + + // A corrupt pins file must stop startup with an error. Falling back to memory + // would lose every unpin at the next restart. + It("makes the constructor fail on a corrupt pins file", func() { + path := filepath.Join(GinkgoT().TempDir(), "addon-pins.json") + Expect(os.WriteFile(path, []byte("{not json"), 0o600)).To(Succeed()) + s, err := NewFileStore(path) + Expect(err).To(HaveOccurred()) + Expect(s).To(BeNil()) + }) + + It("accepts exactly the hard limit", func() { + at := make([]string, HardLimit) + over := make([]string, HardLimit+1) + for i := range over { + over[i] = "a" + if i < HardLimit { + at[i] = "a" + } + } + _, err := svc.Put(context.Background(), "u1", at, vis("a")) + Expect(err).ToNot(HaveOccurred(), "%d ids must be accepted", HardLimit) + _, err = svc.Put(context.Background(), "u1", over, vis("a")) + Expect(err).To(MatchError(ErrTooMany), "%d ids must be refused with ErrTooMany", HardLimit+1) + }) +}) diff --git a/core/services/addons/pins/store_contract_test.go b/core/services/addons/pins/store_contract_test.go index b09c1baac..b27f3f6ee 100644 --- a/core/services/addons/pins/store_contract_test.go +++ b/core/services/addons/pins/store_contract_test.go @@ -4,8 +4,10 @@ package pins import ( "context" "slices" - "testing" "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) func idsOf(ps []Pin) []string { @@ -16,129 +18,103 @@ func idsOf(ps []Pin) []string { return out } -// runStoreContract is run once per store implementation. -func runStoreContract(t *testing.T, open func(t *testing.T) Store) { - ctx := context.Background() - t.Run("replace and list round-trip in order", func(t *testing.T) { - s := open(t) - if err := s.Replace(ctx, "u1", []string{"b", "a", "c"}); err != nil { - t.Fatal(err) - } - got, _ := s.List(ctx, "u1") - if !slices.Equal(idsOf(got), []string{"b", "a", "c"}) { - t.Fatalf("order lost: %v", idsOf(got)) - } - if err := s.Replace(ctx, "u1", []string{"c", "b"}); err != nil { - t.Fatal(err) - } - got, _ = s.List(ctx, "u1") - if !slices.Equal(idsOf(got), []string{"c", "b"}) { - t.Fatalf("replace must drop what is not listed: %v", idsOf(got)) - } +// registerStoreContract registers the shared store specs. Call it inside a +// Describe once per store implementation; open returns a fresh empty store. +func registerStoreContract(open func() Store) { + var ( + ctx context.Context + s Store + ) + + BeforeEach(func() { + ctx = context.Background() + s = open() }) - t.Run("a second user does not see the first user's pins", func(t *testing.T) { - s := open(t) + + It("replace and list round-trip in order", func() { + Expect(s.Replace(ctx, "u1", []string{"b", "a", "c"})).To(Succeed()) + got, _ := s.List(ctx, "u1") + Expect(idsOf(got)).To(Equal([]string{"b", "a", "c"}), "order lost") + Expect(s.Replace(ctx, "u1", []string{"c", "b"})).To(Succeed()) + got, _ = s.List(ctx, "u1") + Expect(idsOf(got)).To(Equal([]string{"c", "b"}), "replace must drop what is not listed") + }) + + It("a second user does not see the first user's pins", func() { _ = s.Replace(ctx, "u1", []string{"a"}) got, _ := s.List(ctx, "u2") - if len(got) != 0 { - t.Fatalf("leak: %v", idsOf(got)) - } + Expect(got).To(BeEmpty(), "leak: %v", idsOf(got)) }) - t.Run("the upgrade seed pins existing accounts once and skips later ones", func(t *testing.T) { - s := open(t) + + It("the upgrade seed pins existing accounts once and skips later ones", func() { ran, err := s.SeedExisting(ctx, []string{"u1", "u2"}, SeedIDs) - if err != nil || !ran { - t.Fatalf("first run: %v %v", ran, err) - } + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue()) for _, u := range []string{"u1", "u2"} { got, _ := s.List(ctx, u) - if !slices.Equal(idsOf(got), []string{"studio", "talk"}) { - t.Fatalf("account %s that existed at the upgrade must have both pins: %v", u, idsOf(got)) - } + Expect(idsOf(got)).To(Equal([]string{"studio", "talk"}), "account %s that existed at the upgrade must have both pins", u) } // An account created after the upgrade has no pins. - if got, _ := s.List(ctx, "u3"); len(got) != 0 { - t.Fatalf("a later account must start empty: %v", idsOf(got)) - } + got, _ := s.List(ctx, "u3") + Expect(got).To(BeEmpty(), "a later account must start empty") _ = s.Replace(ctx, "u1", []string{"talk"}) // u1 unpins studio again, err := s.SeedExisting(ctx, []string{"u1", "u2", "u3"}, SeedIDs) - if err != nil || again { - t.Fatalf("the seed ran twice: %v %v", again, err) - } - if got, _ := s.List(ctx, "u1"); !slices.Equal(idsOf(got), []string{"talk"}) { - t.Fatalf("an unpin must stick: %v", idsOf(got)) - } - if got, _ := s.List(ctx, "u3"); len(got) != 0 { - t.Fatalf("a second run must not seed a later account: %v", idsOf(got)) - } + Expect(err).ToNot(HaveOccurred()) + Expect(again).To(BeFalse(), "the seed ran twice") + got, _ = s.List(ctx, "u1") + Expect(idsOf(got)).To(Equal([]string{"talk"}), "an unpin must stick") + got, _ = s.List(ctx, "u3") + Expect(got).To(BeEmpty(), "a second run must not seed a later account") }) - t.Run("a deployment with no account still records the seed, so a later account starts empty", func(t *testing.T) { - s := open(t) + + It("a deployment with no account still records the seed, so a later account starts empty", func() { ran, err := s.SeedExisting(ctx, nil, SeedIDs) - if err != nil || !ran { - t.Fatalf("an empty first run must count: %v %v", ran, err) - } + Expect(err).ToNot(HaveOccurred()) + Expect(ran).To(BeTrue(), "an empty first run must count") again, _ := s.SeedExisting(ctx, []string{"late"}, SeedIDs) - if again { - t.Fatal("the seed must not run after the first call") - } - if got, _ := s.List(ctx, "late"); len(got) != 0 { - t.Fatalf("a new account must start with no pins: %v", idsOf(got)) - } + Expect(again).To(BeFalse(), "the seed must not run after the first call") + got, _ := s.List(ctx, "late") + Expect(got).To(BeEmpty(), "a new account must start with no pins") }) - t.Run("missing pins are marked, cleared and deleted after the window", func(t *testing.T) { - s := open(t) + + It("missing pins are marked, cleared and deleted after the window", func() { _ = s.Replace(ctx, "u1", []string{"gone", "here"}) now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) - if err := s.MarkMissing(ctx, []string{"gone"}, now); err != nil { - t.Fatal(err) - } + Expect(s.MarkMissing(ctx, []string{"gone"}, now)).To(Succeed()) // A second mark must not move the start of the window. - if err := s.MarkMissing(ctx, []string{"gone"}, now.Add(24*time.Hour)); err != nil { - t.Fatal(err) - } + Expect(s.MarkMissing(ctx, []string{"gone"}, now.Add(24*time.Hour))).To(Succeed()) got, _ := s.List(ctx, "u1") for _, p := range got { - if p.AddonID == "gone" && (p.MissingSince == nil || !p.MissingSince.Equal(now)) { - t.Fatalf("the window start moved: %v", p.MissingSince) + if p.AddonID == "gone" { + Expect(p.MissingSince).ToNot(BeNil(), "the window start moved") + Expect(p.MissingSince.Equal(now)).To(BeTrue(), "the window start moved: %v", p.MissingSince) } } n, _ := s.DeleteMissingBefore(ctx, now.Add(-time.Second)) - if n != 0 { - t.Fatalf("deleted inside the window: %d", n) - } - if err := s.ClearMissing(ctx, []string{"gone"}); err != nil { - t.Fatal(err) - } + Expect(n).To(BeZero(), "deleted inside the window") + Expect(s.ClearMissing(ctx, []string{"gone"})).To(Succeed()) _ = s.MarkMissing(ctx, []string{"gone"}, now) n, _ = s.DeleteMissingBefore(ctx, now.Add(time.Second)) - if n != 1 { - t.Fatalf("expected one deleted pin, got %d", n) - } + Expect(n).To(BeEquivalentTo(1), "expected one deleted pin") got, _ = s.List(ctx, "u1") - if !slices.Equal(idsOf(got), []string{"here"}) { - t.Fatalf("unexpected list %v", idsOf(got)) - } + Expect(idsOf(got)).To(Equal([]string{"here"})) }) - t.Run("replace keeps the missing mark of a pin that stays", func(t *testing.T) { - s := open(t) + + It("replace keeps the missing mark of a pin that stays", func() { _ = s.Replace(ctx, "u1", []string{"gone"}) now := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) _ = s.MarkMissing(ctx, []string{"gone"}, now) _ = s.Replace(ctx, "u1", []string{"gone", "new"}) got, _ := s.List(ctx, "u1") - if got[0].MissingSince == nil { - t.Fatal("a save must not reset the retention window") - } + Expect(got).ToNot(BeEmpty()) + Expect(got[0].MissingSince).ToNot(BeNil(), "a save must not reset the retention window") }) - t.Run("distinct lists every pinned id once", func(t *testing.T) { - s := open(t) + + It("distinct lists every pinned id once", func() { _ = s.Replace(ctx, "u1", []string{"a", "b"}) _ = s.Replace(ctx, "u2", []string{"b", "c"}) d, _ := s.Distinct(ctx) slices.Sort(d) - if !slices.Equal(d, []string{"a", "b", "c"}) { - t.Fatalf("distinct: %v", d) - } + Expect(d).To(Equal([]string{"a", "b", "c"})) }) } diff --git a/core/services/addons/pins/store_file_test.go b/core/services/addons/pins/store_file_test.go index 52e093617..67f2f2364 100644 --- a/core/services/addons/pins/store_file_test.go +++ b/core/services/addons/pins/store_file_test.go @@ -4,71 +4,58 @@ package pins import ( "context" "path/filepath" - "slices" - "testing" "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestFileStoreContract(t *testing.T) { - runStoreContract(t, func(t *testing.T) Store { - s, err := NewFileStore(filepath.Join(t.TempDir(), "pins.json")) - if err != nil { - t.Fatal(err) - } - return s +var _ = Describe("File store", func() { + Context("contract with a file", func() { + registerStoreContract(func() Store { + s, err := NewFileStore(filepath.Join(GinkgoT().TempDir(), "pins.json")) + Expect(err).ToNot(HaveOccurred()) + return s + }) }) -} -func TestMemoryOnlyFileStoreContract(t *testing.T) { - runStoreContract(t, func(t *testing.T) Store { - s, err := NewFileStore("") - if err != nil { - t.Fatal(err) - } - return s + Context("contract in memory only", func() { + registerStoreContract(func() Store { + s, err := NewFileStore("") + Expect(err).ToNot(HaveOccurred()) + return s + }) }) -} -func TestFileStoreKeepsPinsAcrossAReopen(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "pins.json") - a, _ := NewFileStore(path) - if ran, _ := a.SeedExisting(ctx, []string{"local-owner"}, SeedIDs); !ran { - t.Fatal("first seed must run") - } - _ = a.Replace(ctx, "local-owner", []string{"talk"}) - b, err := NewFileStore(path) - if err != nil { - t.Fatal(err) - } - got, _ := b.List(ctx, "local-owner") - if !slices.Equal(idsOf(got), []string{"talk"}) { - t.Fatalf("pins lost across a restart: %v", idsOf(got)) - } - if again, _ := b.SeedExisting(ctx, []string{"local-owner"}, SeedIDs); again { - t.Fatal("the seed marker must survive a restart") - } -} + It("keeps pins across a reopen", func() { + ctx := context.Background() + path := filepath.Join(GinkgoT().TempDir(), "pins.json") + a, _ := NewFileStore(path) + ran, _ := a.SeedExisting(ctx, []string{"local-owner"}, SeedIDs) + Expect(ran).To(BeTrue(), "first seed must run") + _ = a.Replace(ctx, "local-owner", []string{"talk"}) + b, err := NewFileStore(path) + Expect(err).ToNot(HaveOccurred()) + got, _ := b.List(ctx, "local-owner") + Expect(idsOf(got)).To(Equal([]string{"talk"}), "pins lost across a restart") + again, _ := b.SeedExisting(ctx, []string{"local-owner"}, SeedIDs) + Expect(again).To(BeFalse(), "the seed marker must survive a restart") + }) -// The retention window of a removed add-on must not restart at each reopen. -func TestFileStoreKeepsTheMissingMarkAndTheOwnerAcrossAReopen(t *testing.T) { - ctx := context.Background() - path := filepath.Join(t.TempDir(), "pins.json") - a, _ := NewFileStore(path) - _ = a.Replace(ctx, "local-owner", []string{"notes", "studio"}) - mark := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) - if err := a.MarkMissing(ctx, []string{"notes"}, mark); err != nil { - t.Fatal(err) - } - b, err := NewFileStore(path) - if err != nil { - t.Fatal(err) - } - got, _ := b.List(ctx, "local-owner") - if len(got) != 2 || got[0].UserID != "local-owner" { - t.Fatalf("the owner of a pin was lost: %+v", got) - } - if got[0].MissingSince == nil || !got[0].MissingSince.Equal(mark) { - t.Fatalf("the start of the retention window was lost across a restart: %v", got[0].MissingSince) - } -} + // The retention window of a removed add-on must not restart at each reopen. + It("keeps the missing mark and the owner across a reopen", func() { + ctx := context.Background() + path := filepath.Join(GinkgoT().TempDir(), "pins.json") + a, _ := NewFileStore(path) + _ = a.Replace(ctx, "local-owner", []string{"notes", "studio"}) + mark := time.Date(2026, 10, 1, 8, 0, 0, 0, time.UTC) + Expect(a.MarkMissing(ctx, []string{"notes"}, mark)).To(Succeed()) + b, err := NewFileStore(path) + Expect(err).ToNot(HaveOccurred()) + got, _ := b.List(ctx, "local-owner") + Expect(got).To(HaveLen(2), "the owner of a pin was lost: %+v", got) + Expect(got[0].UserID).To(Equal("local-owner"), "the owner of a pin was lost: %+v", got) + Expect(got[0].MissingSince).ToNot(BeNil(), "the start of the retention window was lost across a restart") + Expect(got[0].MissingSince.Equal(mark)).To(BeTrue(), "the start of the retention window was lost across a restart: %v", got[0].MissingSince) + }) +}) diff --git a/core/services/addons/pins/store_gorm_test.go b/core/services/addons/pins/store_gorm_test.go index 8b162c3a0..26d57a619 100644 --- a/core/services/addons/pins/store_gorm_test.go +++ b/core/services/addons/pins/store_gorm_test.go @@ -5,58 +5,50 @@ package pins import ( "context" - "testing" "time" "github.com/mudler/LocalAI/core/http/auth" "github.com/mudler/LocalAI/core/services/advisorylock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -// The test of two frontends over one database uses Service, which Task 3 adds; -// it lives in Task 3 (service_gorm_test.go). -func TestGormStoreContract(t *testing.T) { - runStoreContract(t, func(t *testing.T) Store { - db, err := auth.InitDB(":memory:") - if err != nil { - t.Fatal(err) - } - s, err := NewGormStore(db) - if err != nil { - t.Fatal(err) - } - return s +var _ = Describe("Gorm store", func() { + Context("contract", func() { + registerStoreContract(func() Store { + db, err := auth.InitDB(":memory:") + Expect(err).ToNot(HaveOccurred()) + s, err := NewGormStore(db) + Expect(err).ToNot(HaveOccurred()) + return s + }) }) -} -// On SQLite the lock is an in-process mutex per key, which a test can hold. -// The migration must wait for the schema-migrate lock like the other shared tables. -func TestGormStoreMigratesUnderTheSchemaMigrateLock(t *testing.T) { - db, err := auth.InitDB(":memory:") - if err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - err = advisorylock.WithLockCtx(context.Background(), db, advisorylock.KeySchemaMigrate, func() error { - go func() { - _, e := NewGormStore(db) - done <- e - }() + // On SQLite the lock is an in-process mutex per key, which a test can hold. + // The migration must wait for the schema-migrate lock like the other shared tables. + It("migrates under the schema-migrate lock", func() { + db, err := auth.InitDB(":memory:") + Expect(err).ToNot(HaveOccurred()) + done := make(chan error, 1) + err = advisorylock.WithLockCtx(context.Background(), db, advisorylock.KeySchemaMigrate, func() error { + go func() { + defer GinkgoRecover() + _, e := NewGormStore(db) + done <- e + }() + select { + case <-done: + Fail("NewGormStore migrated while the schema-migrate lock was held") + case <-time.After(300 * time.Millisecond): + } + return nil + }) + Expect(err).ToNot(HaveOccurred()) select { - case <-done: - t.Error("NewGormStore migrated while the schema-migrate lock was held") - case <-time.After(300 * time.Millisecond): + case e := <-done: + Expect(e).ToNot(HaveOccurred()) + case <-time.After(5 * time.Second): + Fail("NewGormStore did not finish after the lock was released") } - return nil }) - if err != nil { - t.Fatal(err) - } - select { - case e := <-done: - if e != nil { - t.Fatal(e) - } - case <-time.After(5 * time.Second): - t.Fatal("NewGormStore did not finish after the lock was released") - } -} +}) diff --git a/core/services/addons/pins/suite_test.go b/core/services/addons/pins/suite_test.go new file mode 100644 index 000000000..85948cf4c --- /dev/null +++ b/core/services/addons/pins/suite_test.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +package pins + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPins(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Add-on pins suite") +} diff --git a/core/services/addons/pins/sweep_test.go b/core/services/addons/pins/sweep_test.go index f92e5f833..8c6bc6968 100644 --- a/core/services/addons/pins/sweep_test.go +++ b/core/services/addons/pins/sweep_test.go @@ -3,51 +3,53 @@ package pins import ( "context" - "slices" - "testing" "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) -func TestSweepKeepsARemovedPinForThirtyDaysAndRestoresItOnReinstall(t *testing.T) { - ctx := context.Background() - s, _ := NewFileStore("") - _ = s.Replace(ctx, "u1", []string{"studio", "notes"}) - t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) +var _ = Describe("Sweep", func() { + var ( + ctx context.Context + s Store + ) - // notes is uninstalled: the pin is marked, not deleted. - if n, err := Sweep(ctx, s, []string{"studio"}, t0); err != nil || n != 0 { - t.Fatalf("first sweep: %d %v", n, err) - } - // Day 29: still kept. - if n, _ := Sweep(ctx, s, []string{"studio"}, t0.Add(29*24*time.Hour)); n != 0 { - t.Fatalf("deleted inside the window: %d", n) - } - // The add-on is installed again on day 29: the mark is cleared. - _, _ = Sweep(ctx, s, []string{"studio", "notes"}, t0.Add(29*24*time.Hour)) - if n, _ := Sweep(ctx, s, []string{"studio"}, t0.Add(40*24*time.Hour)); n != 0 { - t.Fatalf("the window must restart after a reinstall: %d", n) - } - // 31 days after the new start it expires. - n, _ := Sweep(ctx, s, []string{"studio"}, t0.Add(40*24*time.Hour).Add(Retention).Add(time.Hour)) - if n != 1 { - t.Fatalf("expected the expired pin to be deleted, got %d", n) - } - got, _ := s.List(ctx, "u1") - if !slices.Equal(idsOf(got), []string{"studio"}) { - t.Fatalf("unexpected list %v", idsOf(got)) - } -} + BeforeEach(func() { + ctx = context.Background() + s, _ = NewFileStore("") + }) -func TestSweepNeverDeletesAPinWhenThePresentListIsEmptyDueToAnOutage(t *testing.T) { - ctx := context.Background() - s, _ := NewFileStore("") - _ = s.Replace(ctx, "u1", []string{"notes"}) - // A nil present list means the caller could not read the owner: do nothing. - if n, err := Sweep(ctx, s, nil, time.Now().Add(365*24*time.Hour)); err != nil || n != 0 { - t.Fatalf("a sweep without an answer must not delete: %d %v", n, err) - } - got, _ := s.List(ctx, "u1") - if len(got) != 1 || got[0].MissingSince != nil { - t.Fatal("a sweep without an answer must not mark either") - } -} + It("keeps a removed pin for thirty days and restores it on reinstall", func() { + _ = s.Replace(ctx, "u1", []string{"studio", "notes"}) + t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + + // notes is uninstalled: the pin is marked, not deleted. + n, err := Sweep(ctx, s, []string{"studio"}, t0) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeZero(), "first sweep") + // Day 29: still kept. + n, _ = Sweep(ctx, s, []string{"studio"}, t0.Add(29*24*time.Hour)) + Expect(n).To(BeZero(), "deleted inside the window") + // The add-on is installed again on day 29: the mark is cleared. + _, _ = Sweep(ctx, s, []string{"studio", "notes"}, t0.Add(29*24*time.Hour)) + n, _ = Sweep(ctx, s, []string{"studio"}, t0.Add(40*24*time.Hour)) + Expect(n).To(BeZero(), "the window must restart after a reinstall") + // 31 days after the new start it expires. + n, _ = Sweep(ctx, s, []string{"studio"}, t0.Add(40*24*time.Hour).Add(Retention).Add(time.Hour)) + Expect(n).To(BeEquivalentTo(1), "expected the expired pin to be deleted") + got, _ := s.List(ctx, "u1") + Expect(idsOf(got)).To(Equal([]string{"studio"})) + }) + + It("never deletes a pin when the present list is empty due to an outage", func() { + _ = s.Replace(ctx, "u1", []string{"notes"}) + // A nil present list means the caller could not read the owner: do nothing. + n, err := Sweep(ctx, s, nil, time.Now().Add(365*24*time.Hour)) + Expect(err).ToNot(HaveOccurred(), "a sweep without an answer must not delete") + Expect(n).To(BeZero(), "a sweep without an answer must not delete") + got, _ := s.List(ctx, "u1") + Expect(got).To(HaveLen(1)) + Expect(got[0].MissingSince).To(BeNil(), "a sweep without an answer must not mark either") + }) +})