chore: build
ci-api / test (push) Successful in 23m32s
ci-web / test (push) Successful in 14m7s

This commit is contained in:
Xor290
2026-09-20 18:52:09 +02:00
parent 9e11f01c2c
commit 8d857faa45
66 changed files with 3673 additions and 1564 deletions
@@ -71,6 +71,22 @@ func (h *Handler) List(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Details : GET /projects/:id/details — état live des pods du projet, même
// format que POST /demos/details (le panneau du back-office est commun).
// Le namespace vient de la base, jamais de la requête.
func (h *Handler) Details(c *gin.Context) {
p, state, err := h.svc.State(c.Request.Context(), c.Param("id"))
if err != nil {
if errors.Is(err, ErrNotFound) {
h.writeError(c, err)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "impossible de récupérer l'état des ressources"})
return
}
c.JSON(http.StatusOK, gin.H{"namespace": p.Namespace, "state": state})
}
// Get : GET /projects/:id.
func (h *Handler) Get(c *gin.Context) {
p, err := h.svc.Get(c.Param("id"))
@@ -1,12 +1,16 @@
package projects
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/omnex/control-plane/api/internal/demos"
)
func newTestRouter(t *testing.T) (*gin.Engine, *fakeProv) {
@@ -20,6 +24,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *fakeProv) {
r.GET("/projects/:id", h.Get)
r.DELETE("/projects/:id", h.Delete)
r.POST("/projects/:id/extend", h.Extend)
r.GET("/projects/:id/details", h.Details)
return r, prov
}
@@ -91,3 +96,42 @@ func TestHandlerExtend_BoundsAndNotFound(t *testing.T) {
t.Errorf("sans durée : HTTP %d, attendu 400", w.Code)
}
}
func TestHandlerDetails(t *testing.T) {
r, prov := newTestRouter(t)
prov.state = demos.ResourceState{API: demos.ComponentState{Phase: "Running"}}
if w := do(r, "GET", "/projects/inconnu/details", ""); w.Code != http.StatusNotFound {
t.Errorf("projet inconnu : %d, attendu 404", w.Code)
}
w := do(r, "POST", "/projects", body("1"))
if w.Code != http.StatusAccepted {
t.Fatalf("Create: %d %s", w.Code, w.Body)
}
var created Project
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
<-prov.provisioned
w = do(r, "GET", "/projects/"+created.ID+"/details", "")
if w.Code != http.StatusOK {
t.Fatalf("Details: %d %s", w.Code, w.Body)
}
var got struct {
Namespace string `json:"namespace"`
State demos.ResourceState `json:"state"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Namespace != created.Namespace || got.State.API.Phase != "Running" {
t.Errorf("réponse inattendue : %+v", got)
}
prov.stateErr = errors.New("api k8s indisponible")
if w := do(r, "GET", "/projects/"+created.ID+"/details", ""); w.Code != http.StatusInternalServerError || strings.Contains(w.Body.String(), "k8s") {
t.Errorf("erreur cluster : %d %s (500 sans détail interne attendu)", w.Code, w.Body)
}
}
@@ -31,6 +31,7 @@ var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{3,64}$`)
type Provisioner interface {
ProvisionProject(spec demos.ProjectSpec) error
TeardownProject(namespace string) error
ProjectState(ctx context.Context, namespace string) (demos.ResourceState, error)
}
// NoopProvisioner : aucun déploiement réel (dev en mémoire, tests).
@@ -38,6 +39,9 @@ type NoopProvisioner struct{}
func (NoopProvisioner) ProvisionProject(demos.ProjectSpec) error { return nil }
func (NoopProvisioner) TeardownProject(string) error { return nil }
func (NoopProvisioner) ProjectState(context.Context, string) (demos.ResourceState, error) {
return demos.ResourceState{}, nil
}
// Config du service (injectable pour les tests).
type Config struct {
@@ -176,6 +180,24 @@ func (s *Service) Get(id string) (Project, error) {
func (s *Service) List() ([]Project, error) { return s.store.List() }
// State : état live des composants du projet (phase + CPU/mémoire). Un projet
// qui n'a pas (ou plus) de pods (échec, suppression) renvoie un état vide sans
// interroger le cluster.
func (s *Service) State(ctx context.Context, id string) (Project, demos.ResourceState, error) {
p, ok := s.store.Get(id)
if !ok {
return Project{}, demos.ResourceState{}, ErrNotFound
}
if p.Status != StatusProvisioning && p.Status != StatusReady {
return p, demos.ResourceState{}, nil
}
state, err := s.prov.ProjectState(ctx, p.Namespace)
if err != nil {
return p, demos.ResourceState{}, err
}
return p, state, nil
}
// Extend ajoute months mois à l'abonnement, à partir de l'échéance courante
// (ou de maintenant si elle est dépassée).
func (s *Service) Extend(id string, months int) (Project, error) {
@@ -1,6 +1,7 @@
package projects
import (
"context"
"errors"
"sync"
"testing"
@@ -16,6 +17,9 @@ type fakeProv struct {
specs []demos.ProjectSpec
tornDown []string
provErr error
state demos.ResourceState
stateErr error
stateCalls []string
provisioned chan struct{}
}
@@ -35,6 +39,13 @@ func (f *fakeProv) TeardownProject(ns string) error {
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) {
@@ -277,3 +288,48 @@ func TestDelete_IsIdempotentAndImmediate(t *testing.T) {
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")
}
}