package hetznerapi import ( "context" "net/http" "net/http/httptest" "strings" "testing" "time" ) // compile-time: both the real client and the fake satisfy CloudAPI. var _ CloudAPI = (*Client)(nil) var _ CloudAPI = (*Fake)(nil) func newTestClient(t *testing.T, h http.Handler) (*Client, *httptest.Server) { t.Helper() srv := httptest.NewServer(h) t.Cleanup(srv.Close) c := NewClient(func() string { return "TESTTOKEN" }) c.BaseURL = srv.URL c.PollEvery = 5 * time.Millisecond c.PollMax = 2 * time.Second return c, srv } // The create request carries the bearer + JSON, and the {action,subaccount} envelope decodes. func TestClient_CreateSubaccount(t *testing.T) { var gotAuth, gotBody string c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") buf := make([]byte, r.ContentLength) r.Body.Read(buf) gotBody = string(buf) w.WriteHeader(201) w.Write([]byte(`{"action":{"id":42,"command":"create_subaccount","status":"success"},"subaccount":{"id":268917,"storage_box":611421}}`)) })) id, act, err := c.CreateSubaccount(context.Background(), 611421, CreateSubaccountRequest{HomeDirectory: "spike-sub", Password: "Xx1%", AccessSettings: AccessSettings{SSHEnabled: true}}) if err != nil { t.Fatal(err) } if gotAuth != "Bearer TESTTOKEN" { t.Errorf("bearer not set, got %q", gotAuth) } if !strings.Contains(gotBody, `"home_directory":"spike-sub"`) { t.Errorf("request body missing home_directory: %s", gotBody) } if id != 268917 || act.ID != 42 || act.Status != "success" { t.Fatalf("got id=%d action=%+v", id, act) } } // A non-2xx surfaces the API's error code/message (the 422 password shape). func TestClient_ErrorEnvelope(t *testing.T) { c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(422) w.Write([]byte(`{"error":{"code":"invalid_input","message":"invalid input in field password"}}`)) })) _, _, err := c.CreateSubaccount(context.Background(), 1, CreateSubaccountRequest{}) if err == nil || !strings.Contains(err.Error(), "invalid_input") { t.Fatalf("want invalid_input error, got %v", err) } } // WaitAction: an already-success action returns immediately; running→success polls; error fails. func TestClient_WaitAction(t *testing.T) { if err := (&Client{}).WaitAction(context.Background(), Action{Status: "success"}); err != nil { t.Fatalf("terminal success must return nil, got %v", err) } if err := (&Client{}).WaitAction(context.Background(), Action{ID: 9, Command: "x", Status: "error"}); err == nil { t.Fatal("terminal error must return an error") } calls := 0 c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ st := "running" if calls >= 3 { st = "success" } w.Write([]byte(`{"action":{"id":7,"command":"create","status":"` + st + `"}}`)) })) if err := c.WaitAction(context.Background(), Action{ID: 7, Command: "create", Status: "running"}); err != nil { t.Fatalf("running→success must resolve, got %v", err) } if calls < 3 { t.Fatalf("expected polling, only %d calls", calls) } } func TestClient_WaitActionTimeout(t *testing.T) { c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"action":{"id":7,"status":"running"}}`)) })) c.PollMax = 30 * time.Millisecond if err := c.WaitAction(context.Background(), Action{ID: 7, Status: "running"}); err == nil || !strings.Contains(err.Error(), "did not reach success") { t.Fatalf("want timeout error, got %v", err) } }