package projects import ( "errors" "sync" "testing" "time" "github.com/omnex/control-plane/api/internal/demos" ) // fakeProv enregistre les appels au cluster ; provisioned est fermé une fois // le déploiement (asynchrone) terminé. type fakeProv struct { mu sync.Mutex specs []demos.ProjectSpec tornDown []string provErr error provisioned chan struct{} } func newFakeProv() *fakeProv { return &fakeProv{provisioned: make(chan struct{}, 8)} } func (f *fakeProv) ProvisionProject(s demos.ProjectSpec) error { f.mu.Lock() f.specs = append(f.specs, s) f.mu.Unlock() defer func() { f.provisioned <- struct{}{} }() return f.provErr } func (f *fakeProv) TeardownProject(ns string) error { f.mu.Lock() defer f.mu.Unlock() f.tornDown = append(f.tornDown, ns) return nil } var t0 = time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC) func newTestService(t *testing.T) (*Service, *fakeProv, *time.Time) { t.Helper() prov := newFakeProv() svc := NewService(NewMemStore(), prov, Config{Domain: "vitrine-omnex.club"}) now := t0 svc.now = func() time.Time { return now } return svc, prov, &now } func validInput() CreateInput { return CreateInput{ClientName: "Boutique Dupont", Months: 3, AdminUsername: "admin", AdminPassword: "un-mot-de-passe-solide"} } // createReady crée un projet et attend la fin de son déploiement simulé. func createReady(t *testing.T, svc *Service, prov *fakeProv, in CreateInput) Project { t.Helper() p, err := svc.Create(in) if err != nil { t.Fatalf("Create: %v", err) } select { case <-prov.provisioned: case <-time.After(2 * time.Second): t.Fatal("déploiement simulé non terminé") } // La goroutine de Create met le statut à jour après ProvisionProject : // prendre le verrou attend qu'elle ait fini. svc.mu.Lock() svc.mu.Unlock() //nolint:staticcheck // barrière de synchronisation got, err := svc.Get(p.ID) if err != nil { t.Fatal(err) } return got } func TestCreate_MonthsBounds(t *testing.T) { svc, _, _ := newTestService(t) for _, months := range []int{-1, 0, 13, 100} { in := validInput() in.Months = months if _, err := svc.Create(in); !errors.Is(err, ErrInvalidMonths) { t.Errorf("months=%d : erreur %v, attendu ErrInvalidMonths", months, err) } } } func TestCreate_ExpiryIsCalendarMonths(t *testing.T) { for _, months := range []int{1, 6, 12} { svc, prov, _ := newTestService(t) in := validInput() in.Months = months p := createReady(t, svc, prov, in) want := t0.AddDate(0, months, 0) if !p.ExpiresAt.Equal(want) { t.Errorf("months=%d : ExpiresAt=%v, attendu %v", months, p.ExpiresAt, want) } if p.MonthsPurchased != months { t.Errorf("MonthsPurchased=%d, attendu %d", p.MonthsPurchased, months) } } } func TestCreate_ValidatesInput(t *testing.T) { svc, _, _ := newTestService(t) cases := map[string]struct { mutate func(*CreateInput) want error }{ "client vide": {func(i *CreateInput) { i.ClientName = " " }, ErrInvalidClient}, "admin invalide": {func(i *CreateInput) { i.AdminUsername = "a b" }, ErrInvalidAdmin}, "admin trop court": {func(i *CreateInput) { i.AdminUsername = "ab" }, ErrInvalidAdmin}, "mot de passe": {func(i *CreateInput) { i.AdminPassword = "court" }, ErrWeakAdminPass}, "nb admin trop": {func(i *CreateInput) { i.AdminNumber = 21 }, ErrInvalidAdminSize}, "nb admin négatif": {func(i *CreateInput) { i.AdminNumber = -1 }, ErrInvalidAdminSize}, } for name, c := range cases { in := validInput() c.mutate(&in) if _, err := svc.Create(in); !errors.Is(err, c.want) { t.Errorf("%s : erreur %v, attendu %v", name, err, c.want) } } } func TestCreate_PassesAdminSettingsToProvisioner(t *testing.T) { svc, prov, _ := newTestService(t) in := validInput() in.AdminNumber = 3 p := createReady(t, svc, prov, in) if len(prov.specs) != 1 { t.Fatalf("%d déploiements, attendu 1", len(prov.specs)) } spec := prov.specs[0] if spec.AdminNumber != 3 || spec.AdminUsername != "admin" || spec.AdminPassword != in.AdminPassword { t.Errorf("spec inattendue : %+v", spec) } if spec.Host != p.Namespace+".vitrine-omnex.club" || spec.URL != "https://"+spec.Host { t.Errorf("host/URL inattendus : %q %q", spec.Host, spec.URL) } if p.Status != StatusReady { t.Errorf("statut %s, attendu ready", p.Status) } } func TestCreate_DefaultAdminNumberIsOne(t *testing.T) { svc, prov, _ := newTestService(t) p := createReady(t, svc, prov, validInput()) if p.AdminNumber != 1 || prov.specs[0].AdminNumber != 1 { t.Errorf("AdminNumber=%d / spec=%d, attendu 1", p.AdminNumber, prov.specs[0].AdminNumber) } } func TestCreate_ProvisionFailureMarksFailed(t *testing.T) { svc, prov, _ := newTestService(t) prov.provErr = errors.New("helm en échec") p := createReady(t, svc, prov, validInput()) if p.Status != StatusFailed { t.Errorf("statut %s, attendu failed", p.Status) } if _, err := svc.Extend(p.ID, 1); !errors.Is(err, ErrNotExtendable) { t.Errorf("Extend sur un projet failed : %v, attendu ErrNotExtendable", err) } } func TestExtend_AddsMonthsFromCurrentExpiry(t *testing.T) { svc, prov, _ := newTestService(t) p := createReady(t, svc, prov, validInput()) // 3 mois got, err := svc.Extend(p.ID, 12) if err != nil { t.Fatal(err) } want := t0.AddDate(0, 3, 0).AddDate(0, 12, 0) if !got.ExpiresAt.Equal(want) { t.Errorf("ExpiresAt=%v, attendu %v (échéance courante + 12 mois)", got.ExpiresAt, want) } if got.MonthsPurchased != 15 { t.Errorf("MonthsPurchased=%d, attendu 15", got.MonthsPurchased) } } func TestExtend_MonthsBounds(t *testing.T) { svc, prov, _ := newTestService(t) p := createReady(t, svc, prov, validInput()) for _, months := range []int{0, 13, -2} { if _, err := svc.Extend(p.ID, months); !errors.Is(err, ErrInvalidMonths) { t.Errorf("months=%d : %v, attendu ErrInvalidMonths", months, err) } } got, _ := svc.Get(p.ID) if !got.ExpiresAt.Equal(p.ExpiresAt) { t.Error("une prolongation refusée a modifié l'échéance") } } func TestExtend_UnknownProject(t *testing.T) { svc, _, _ := newTestService(t) if _, err := svc.Extend("inconnu", 1); !errors.Is(err, ErrNotFound) { t.Errorf("erreur %v, attendu ErrNotFound", err) } } func TestExpiration_DeletesAtExpiry(t *testing.T) { svc, prov, now := newTestService(t) p := createReady(t, svc, prov, validInput()) // 3 mois // Avant l'échéance : rien ne bouge. *now = p.ExpiresAt.Add(-time.Hour) svc.expireOverdue() if len(prov.tornDown) != 0 { t.Fatal("suppression avant l'échéance") } // À l'échéance : supprimé, sans délai de grâce. *now = p.ExpiresAt.Add(time.Minute) svc.expireOverdue() got, _ := svc.Get(p.ID) if got.Status != StatusDeleted || len(prov.tornDown) != 1 || prov.tornDown[0] != p.Namespace { t.Errorf("statut %s, supprimés=%v", got.Status, prov.tornDown) } // Déjà supprimé : la boucle ne rappelle pas le cluster. svc.expireOverdue() if len(prov.tornDown) != 1 { t.Errorf("teardown répété : %v", prov.tornDown) } } func TestExtend_BeforeExpiryKeepsProject(t *testing.T) { svc, prov, now := newTestService(t) p := createReady(t, svc, prov, validInput()) *now = p.ExpiresAt.Add(-time.Hour) got, err := svc.Extend(p.ID, 1) if err != nil { t.Fatal(err) } if want := p.ExpiresAt.AddDate(0, 1, 0); !got.ExpiresAt.Equal(want) { t.Errorf("ExpiresAt=%v, attendu %v", got.ExpiresAt, want) } // L'ancienne échéance est passée : le projet renouvelé ne doit pas être supprimé. *now = p.ExpiresAt.Add(time.Minute) svc.expireOverdue() if len(prov.tornDown) != 0 { t.Errorf("suppression d'un projet renouvelé : %v", prov.tornDown) } } func TestExtend_DeletedProjectNotExtendable(t *testing.T) { svc, prov, now := newTestService(t) p := createReady(t, svc, prov, validInput()) *now = p.ExpiresAt.Add(time.Minute) svc.expireOverdue() if _, err := svc.Extend(p.ID, 1); !errors.Is(err, ErrNotExtendable) { t.Errorf("erreur %v, attendu ErrNotExtendable", err) } } func TestDelete_IsIdempotentAndImmediate(t *testing.T) { svc, prov, _ := newTestService(t) p := createReady(t, svc, prov, validInput()) got, err := svc.Delete(p.ID) if err != nil || got.Status != StatusDeleted { t.Fatalf("Delete: %v, statut %s", err, got.Status) } if _, err := svc.Delete(p.ID); err != nil { t.Fatalf("second Delete: %v", err) } if len(prov.tornDown) != 1 { t.Errorf("teardown appelé %d fois, attendu 1", len(prov.tornDown)) } if _, err := svc.Delete("inconnu"); !errors.Is(err, ErrNotFound) { t.Errorf("erreur %v, attendu ErrNotFound", err) } }