Files
omnex/control-plane/api/internal/projects/service_test.go
T
Xor290 76b1412968
ci-api / test (push) Successful in 24m6s
ci-web / test (push) Successful in 13m50s
chore: update
2026-09-20 11:50:02 +02:00

345 lines
10 KiB
Go

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
suspended []string
resumed []string
tornDown []string
provErr error
resumeErr 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
}
func (f *fakeProv) SuspendProject(ns string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.suspended = append(f.suspended, ns)
return nil
}
func (f *fakeProv) ResumeProject(ns string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.resumed = append(f.resumed, ns)
return f.resumeErr
}
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", Grace: 7 * 24 * time.Hour})
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.DeleteAt.Equal(want.Add(7 * 24 * time.Hour)) {
t.Errorf("months=%d : DeleteAt=%v (grâce de 7 jours attendue)", months, p.DeleteAt)
}
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)
}
if !got.DeleteAt.Equal(want.Add(7 * 24 * time.Hour)) {
t.Errorf("DeleteAt non recalculé : %v", got.DeleteAt)
}
}
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_SuspendsAtExpiryThenDeletesAfterGrace(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.suspended) != 0 {
t.Fatal("suspension avant l'échéance")
}
// À l'échéance : suspendu, jamais supprimé.
*now = p.ExpiresAt.Add(time.Minute)
svc.expireOverdue()
got, _ := svc.Get(p.ID)
if got.Status != StatusSuspended || got.SuspendedAt == nil {
t.Fatalf("statut %s, attendu suspended", got.Status)
}
if len(prov.suspended) != 1 || len(prov.tornDown) != 0 {
t.Fatalf("suspendus=%v supprimés=%v", prov.suspended, prov.tornDown)
}
// Pendant la grâce : toujours suspendu, données conservées.
*now = p.ExpiresAt.Add(6 * 24 * time.Hour)
svc.expireOverdue()
if len(prov.tornDown) != 0 {
t.Fatal("suppression pendant le délai de grâce")
}
if len(prov.suspended) != 1 {
t.Errorf("suspension répétée : %v", prov.suspended)
}
// Grâce dépassée : suppression définitive.
*now = p.DeleteAt.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)
}
}
func TestExtend_ResumesSuspendedProject(t *testing.T) {
svc, prov, now := newTestService(t)
p := createReady(t, svc, prov, validInput())
*now = p.ExpiresAt.Add(2 * 24 * time.Hour)
svc.expireOverdue()
got, err := svc.Extend(p.ID, 1)
if err != nil {
t.Fatal(err)
}
if got.Status != StatusReady || got.SuspendedAt != nil {
t.Errorf("statut %s suspended_at=%v, attendu ready sans suspension", got.Status, got.SuspendedAt)
}
if len(prov.resumed) != 1 || prov.resumed[0] != p.Namespace {
t.Errorf("relances=%v", prov.resumed)
}
// Échéance en retard : le nouveau mois part de maintenant, pas de l'ancienne échéance.
if want := now.AddDate(0, 1, 0); !got.ExpiresAt.Equal(want) {
t.Errorf("ExpiresAt=%v, attendu %v", got.ExpiresAt, want)
}
// Renouvelé : la boucle ne doit plus le suspendre.
svc.expireOverdue()
if len(prov.suspended) != 1 {
t.Errorf("re-suspension d'un projet renouvelé : %v", prov.suspended)
}
}
func TestExtend_ResumeFailureKeepsProjectSuspended(t *testing.T) {
svc, prov, now := newTestService(t)
p := createReady(t, svc, prov, validInput())
*now = p.ExpiresAt.Add(time.Hour)
svc.expireOverdue()
prov.resumeErr = errors.New("api k8s indisponible")
if _, err := svc.Extend(p.ID, 1); err == nil {
t.Fatal("erreur attendue")
}
got, _ := svc.Get(p.ID)
if got.Status != StatusSuspended || !got.ExpiresAt.Equal(p.ExpiresAt) {
t.Errorf("l'échec de relance a modifié le projet : %s %v", got.Status, got.ExpiresAt)
}
}
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)
}
}
func TestZeroGraceSuspendsThenDeletesNextTick(t *testing.T) {
prov := newFakeProv()
svc := NewService(NewMemStore(), prov, Config{})
now := t0
svc.now = func() time.Time { return now }
p := createReady(t, svc, prov, validInput())
now = p.ExpiresAt.Add(time.Minute)
svc.expireOverdue() // suspension
svc.expireOverdue() // DeleteAt == ExpiresAt : déjà dépassé
got, _ := svc.Get(p.ID)
if got.Status != StatusDeleted {
t.Errorf("statut %s, attendu deleted (grâce nulle)", got.Status)
}
}