package main import ( "encoding/json" "net/http" "net/http/httptest" "sort" "strings" "testing" ) // La allowlist del proxy debe ser SOLO de lectura: cualquier escritura o búsqueda // de clientes se hace server-side en /api/book, nunca por el proxy. func TestEaRouteAllowed(t *testing.T) { cases := []struct { method string path string want bool }{ // Lecturas permitidas (las que usa el frontend) {"GET", "/ea-api/v1/categories", true}, {"GET", "/ea-api/v1/service_categories", true}, {"GET", "/ea-api/v1/services", true}, {"GET", "/ea-api/v1/availabilities", true}, {"GET", "/ea-api/v1/providers/1", true}, {"GET", "/ea-api/v1/settings/company_working_plan", true}, // Bloqueadas: clientes y citas ya NO pasan por el proxy {"GET", "/ea-api/v1/customers", false}, {"POST", "/ea-api/v1/customers", false}, {"POST", "/ea-api/v1/appointments", false}, {"GET", "/ea-api/v1/appointments", false}, {"DELETE", "/ea-api/v1/appointments/3", false}, {"PUT", "/ea-api/v1/customers/5", false}, {"GET", "/ea-api/v1/providers", false}, // listar todos {"GET", "/ea-api/v1/providers/abc", false}, // id no numérico {"GET", "/ea-api/v1/settings", false}, {"GET", "/ea-api/v1/settings/email_footer", false}, // otros settings no {"POST", "/ea-api/v1/settings/company_working_plan", false}, {"POST", "/ea-api/v1/services", false}, } for _, c := range cases { if got := eaRouteAllowed(c.method, c.path); got != c.want { t.Errorf("eaRouteAllowed(%q, %q) = %v, want %v", c.method, c.path, got, c.want) } } } func TestFlexIntUnmarshal(t *testing.T) { var s struct { A flexInt `json:"a"` B flexInt `json:"b"` C flexInt `json:"c"` D flexInt `json:"d"` } if err := json.Unmarshal([]byte(`{"a":30,"b":"45","c":"60.0","d":null}`), &s); err != nil { t.Fatalf("unmarshal: %v", err) } if s.A != 30 || s.B != 45 || s.C != 60 || s.D != 0 { t.Errorf("got %d,%d,%d,%d want 30,45,60,0", s.A, s.B, s.C, s.D) } } // TestHandleBook comprueba que una reserva se orquesta ENTERAMENTE contra la API // de EasyAppointments: lee servicios, crea el cliente y crea la cita, calculando // duración total y servicio principal en el servidor. func TestHandleBook(t *testing.T) { var gotAppt eaAppointment var gotCust eaCustomer var hits []string mux := http.NewServeMux() mux.HandleFunc("/index.php/api/v1/services", func(w http.ResponseWriter, r *http.Request) { hits = append(hits, "GET services") json.NewEncoder(w).Encode([]eaService{ {ID: 1, Name: "Corte", Duration: 30}, {ID: 2, Name: "Tinte", Duration: 45}, }) }) mux.HandleFunc("/index.php/api/v1/customers", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { hits = append(hits, "GET customers") json.NewEncoder(w).Encode([]eaCustomer{}) // no existe → se crea return } hits = append(hits, "POST customers") json.NewDecoder(r.Body).Decode(&gotCust) json.NewEncoder(w).Encode(eaCustomer{ID: 99}) }) mux.HandleFunc("/index.php/api/v1/appointments", func(w http.ResponseWriter, r *http.Request) { hits = append(hits, "POST appointments") json.NewDecoder(r.Body).Decode(&gotAppt) w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"id":5}`)) }) mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) { hits = append(hits, "GET providers") json.NewEncoder(w).Encode([]eaProvider{{ID: 7}}) }) srv := httptest.NewServer(mux) defer srv.Close() ea := &eaClient{base: srv.URL, http: srv.Client()} body := `{"nombre":"Ana","apellidos":"López Gil","email":"ana@x.com","telefono":"600","mensaje":"hola", "serviceIds":[1,2],"date":"2026-07-01","time":"10:00"}` req := httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body)) rec := httptest.NewRecorder() handleBook(ea)(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) } // Servicio principal = el de mayor duración (Tinte, id 2). if gotAppt.ServiceID != 2 { t.Errorf("serviceId = %d, want 2 (mayor duración)", gotAppt.ServiceID) } // Fin = inicio + 30 + 45 = 75 min. if gotAppt.Start != "2026-07-01 10:00:00" || gotAppt.End != "2026-07-01 11:15:00" { t.Errorf("start/end = %q/%q, want 10:00:00/11:15:00", gotAppt.Start, gotAppt.End) } // El provider lo resuelve el servidor contra EA, no el navegador. if gotAppt.CustomerID != 99 || gotAppt.ProviderID != 7 || gotAppt.Status != "booked" { t.Errorf("appt = %+v", gotAppt) } // Nombre y apellidos viajan por separado hasta EA, sin adivinar cuál es cuál. if gotCust.FirstName != "Ana" || gotCust.LastName != "López Gil" { t.Errorf("cliente = %q %q, want \"Ana\" \"López Gil\"", gotCust.FirstName, gotCust.LastName) } if !strings.Contains(gotAppt.Notes, "Duración total: 75 min") || !strings.Contains(gotAppt.Notes, "Servicios: Corte + Tinte") { t.Errorf("notes = %q", gotAppt.Notes) } // Toda la gestión pasó por EA y nada más. sort.Strings(hits) want := []string{"GET customers", "GET providers", "GET services", "POST appointments", "POST customers"} if strings.Join(hits, ",") != strings.Join(want, ",") { t.Errorf("llamadas a EA = %v, want %v", hits, want) } } // Servicio inválido (no existe en EA) ⇒ 400 y NO se crea ninguna cita. func TestHandleBookRejectsUnknownService(t *testing.T) { created := false mux := http.NewServeMux() mux.HandleFunc("/index.php/api/v1/services", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode([]eaService{{ID: 1, Name: "Corte", Duration: 30}}) }) mux.HandleFunc("/index.php/api/v1/appointments", func(w http.ResponseWriter, r *http.Request) { created = true }) srv := httptest.NewServer(mux) defer srv.Close() ea := &eaClient{base: srv.URL, http: srv.Client()} body := `{"nombre":"Ana","apellidos":"López","email":"a@x.com","telefono":"600","serviceIds":[999],"date":"2026-07-01","time":"10:00"}` rec := httptest.NewRecorder() handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rec.Code) } if created { t.Error("se creó una cita con un servicio inexistente") } } // El id del provider se resuelve contra EA (autodescubierto) o vía // EA_PROVIDER_ID, nunca hardcodeado: es distinto en cada entorno. func TestProviderID(t *testing.T) { calls := 0 mux := http.NewServeMux() mux.HandleFunc("/index.php/api/v1/providers", func(w http.ResponseWriter, r *http.Request) { calls++ json.NewEncoder(w).Encode([]eaProvider{{ID: 5}, {ID: 9}}) }) srv := httptest.NewServer(mux) defer srv.Close() ea := &eaClient{base: srv.URL, http: srv.Client()} t.Run("autodescubre el primero de EA", func(t *testing.T) { id, err := ea.providerID() if err != nil || id != 5 { t.Errorf("providerID() = %d, %v; want 5, nil", id, err) } }) t.Run("EA_PROVIDER_ID tiene prioridad y no llama a EA", func(t *testing.T) { t.Setenv("EA_PROVIDER_ID", "9") before := calls id, err := ea.providerID() if err != nil || id != 9 { t.Errorf("providerID() = %d, %v; want 9, nil", id, err) } if calls != before { t.Error("con EA_PROVIDER_ID definido no debería consultar EA") } }) t.Run("EA_PROVIDER_ID inválido es error", func(t *testing.T) { t.Setenv("EA_PROVIDER_ID", "mayra") if _, err := ea.providerID(); err == nil { t.Error("EA_PROVIDER_ID no numérico debería dar error") } }) t.Run("sin providers en EA es error", func(t *testing.T) { empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`[]`)) })) defer empty.Close() ea := &eaClient{base: empty.URL, http: empty.Client()} if _, err := ea.providerID(); err == nil { t.Error("sin providers debería dar error") } }) t.Run("/api/config lo expone al frontend", func(t *testing.T) { rec := httptest.NewRecorder() handleConfig(ea)(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil)) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var got struct { ProviderID int `json:"providerId"` } if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.ProviderID != 5 { t.Errorf("body = %s (err %v), want providerId 5", rec.Body.String(), err) } }) } // Sin apellidos ⇒ 400 sin llegar a llamar a EA. func TestHandleBookRequiresApellidos(t *testing.T) { called := false mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { called = true }) srv := httptest.NewServer(mux) defer srv.Close() ea := &eaClient{base: srv.URL, http: srv.Client()} body := `{"nombre":"Ana","email":"a@x.com","telefono":"600","serviceIds":[1],"date":"2026-07-01","time":"10:00"}` rec := httptest.NewRecorder() handleBook(ea)(rec, httptest.NewRequest(http.MethodPost, "/api/book", strings.NewReader(body))) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rec.Code) } if called { t.Error("se llamó a EA con datos incompletos") } }