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