Files
Xor290 8d857faa45
ci-api / test (push) Successful in 23m32s
ci-web / test (push) Successful in 14m7s
chore: build
2026-09-20 18:52:09 +02:00

336 lines
10 KiB
Go

package projects
import (
"context"
"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
state demos.ResourceState
stateErr error
stateCalls []string
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) ProjectState(_ context.Context, ns string) (demos.ResourceState, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.stateCalls = append(f.stateCalls, ns)
return f.state, f.stateErr
}
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)
}
}
func TestState_ReadyProjectQueriesItsOwnNamespace(t *testing.T) {
svc, prov, _ := newTestService(t)
prov.state = demos.ResourceState{API: demos.ComponentState{Phase: "Running", CPUMilli: 12}}
p := createReady(t, svc, prov, validInput())
got, state, err := svc.State(context.Background(), p.ID)
if err != nil {
t.Fatal(err)
}
if got.Namespace != p.Namespace || state.API.Phase != "Running" || state.API.CPUMilli != 12 {
t.Errorf("namespace=%q state=%+v", got.Namespace, state)
}
if len(prov.stateCalls) != 1 || prov.stateCalls[0] != p.Namespace {
t.Errorf("namespace interrogé=%v, attendu [%s]", prov.stateCalls, p.Namespace)
}
}
func TestState_NoClusterCallWithoutPods(t *testing.T) {
svc, prov, _ := newTestService(t)
p := createReady(t, svc, prov, validInput())
if _, err := svc.Delete(p.ID); err != nil {
t.Fatal(err)
}
_, state, err := svc.State(context.Background(), p.ID)
if err != nil || state != (demos.ResourceState{}) {
t.Errorf("err=%v state=%+v, attendu un état vide", err, state)
}
if len(prov.stateCalls) != 0 {
t.Errorf("cluster interrogé pour un projet supprimé : %v", prov.stateCalls)
}
}
func TestState_UnknownProjectAndClusterError(t *testing.T) {
svc, prov, _ := newTestService(t)
if _, _, err := svc.State(context.Background(), "inconnu"); !errors.Is(err, ErrNotFound) {
t.Errorf("erreur %v, attendu ErrNotFound", err)
}
p := createReady(t, svc, prov, validInput())
prov.stateErr = errors.New("api k8s indisponible")
if _, _, err := svc.State(context.Background(), p.ID); err == nil {
t.Error("erreur du cluster avalée")
}
}