diff --git a/examples/enterprise-contact/main.go b/examples/enterprise-contact/main.go new file mode 100644 index 0000000..585c5f3 --- /dev/null +++ b/examples/enterprise-contact/main.go @@ -0,0 +1,79 @@ +// enterprise-contact submits an Enterprise interest form to instanode.dev +// when a team's requirements exceed the self-serve Pro tier. +// +// Usage: +// +// INSTANT_TOKEN= go run . \ +// -email cto@acme.com \ +// -company "Acme Corp" \ +// -use_case "We need 10 TB Postgres, dedicated infra, and SOC 2 Type II compliance." +// +// INSTANT_TOKEN is optional — anonymous callers are accepted. When set, the +// lead is automatically linked to the caller's team so the instanode.dev team +// can see current usage without a back-and-forth. +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + + "github.com/InstaNode-dev/sdk-go/instant" +) + +// run is the testable core: creates the lead, writes output to out. +func run(ctx context.Context, c *instant.Client, params *instant.LeadParams, out io.Writer) error { + lead, err := c.CreateLead(ctx, params) + if err != nil { + return err + } + fmt.Fprintf(out, "Enterprise inquiry submitted.\nLead ID: %s\n", lead.ID) + fmt.Fprintf(out, "The instanode.dev team will follow up at %s\n", params.Email) + return nil +} + +// realMain is the testable entry point. It accepts args, I/O writers, and an +// env-lookup func so tests can drive every branch without spawning a subprocess. +func realMain(args []string, stdout, stderr io.Writer, lookupEnv func(string) string) int { + fs := flag.NewFlagSet("enterprise-contact", flag.ContinueOnError) + fs.SetOutput(stderr) + email := fs.String("email", "", "Contact email address (required)") + name := fs.String("name", "", "Contact full name (optional)") + company := fs.String("company", "", "Company name (optional)") + useCase := fs.String("use_case", "", "Description of requirements (optional)") + if err := fs.Parse(args); err != nil { + return 1 + } + if *email == "" { + fmt.Fprintln(stderr, "error: -email is required") + return 1 + } + opts := []instant.Option{} + if tok := lookupEnv("INSTANT_TOKEN"); tok != "" { + opts = append(opts, instant.WithAPIKey(tok)) + } + if u := lookupEnv("INSTANODE_API_URL"); u != "" { + opts = append(opts, instant.WithBaseURL(u)) + } + c := instant.New(opts...) + if err := run(context.Background(), c, &instant.LeadParams{ + Email: *email, + Name: *name, + Company: *company, + UseCase: *useCase, + }, stdout); err != nil { + fmt.Fprintf(stderr, "CreateLead: %v\n", err) + return 1 + } + return 0 +} + +// osExit is a variable so tests can capture the exit code without terminating +// the test binary. Production callers use the default os.Exit. +var osExit = os.Exit + +func main() { + osExit(realMain(os.Args[1:], os.Stdout, os.Stderr, os.Getenv)) +} diff --git a/examples/enterprise-contact/main_test.go b/examples/enterprise-contact/main_test.go new file mode 100644 index 0000000..06cedec --- /dev/null +++ b/examples/enterprise-contact/main_test.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/InstaNode-dev/sdk-go/instant" +) + +// testLookup returns a lookupEnv func that maps the given key/value pairs and +// falls back to "" for anything else. +func testLookup(kvs ...string) func(string) string { + m := make(map[string]string, len(kvs)/2) + for i := 0; i+1 < len(kvs); i += 2 { + m[kvs[i]] = kvs[i+1] + } + return func(key string) string { return m[key] } +} + +func TestRun_HappyPath(t *testing.T) { + const wantID = "550e8400-e29b-41d4-a716-446655440099" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + Company string `json:"company"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Email != "cto@acme.com" { + http.Error(w, "bad email", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"ok":true,"id":"`+wantID+`"}`) + })) + t.Cleanup(srv.Close) + + c := instant.New(instant.WithBaseURL(srv.URL)) + var out strings.Builder + err := run(context.Background(), c, &instant.LeadParams{ + Email: "cto@acme.com", + Company: "Acme Corp", + UseCase: "Need SOC 2 + 10 TB Postgres.", + }, &out) + if err != nil { + t.Fatalf("run: %v", err) + } + s := out.String() + if !strings.Contains(s, wantID) { + t.Errorf("output missing lead ID %q:\n%s", wantID, s) + } + if !strings.Contains(s, "cto@acme.com") { + t.Errorf("output missing email:\n%s", s) + } +} + +func TestRun_Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":"internal","message":"db down"}`) + })) + t.Cleanup(srv.Close) + + c := instant.New(instant.WithBaseURL(srv.URL)) + var out strings.Builder + err := run(context.Background(), c, &instant.LeadParams{Email: "a@b.com"}, &out) + if err == nil { + t.Fatal("expected error from server failure") + } +} + +func TestRealMain_HappyPath(t *testing.T) { + const wantID = "550e8400-e29b-41d4-a716-446655440077" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"ok":true,"id":"`+wantID+`"}`) + })) + t.Cleanup(srv.Close) + + var stdout, stderr strings.Builder + code := realMain( + []string{"-email", "cto@acme.com", "-company", "Acme Corp"}, + &stdout, + &stderr, + testLookup("INSTANODE_API_URL", srv.URL), + ) + if code != 0 { + t.Errorf("want exit 0, got %d; stderr: %q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), wantID) { + t.Errorf("stdout missing lead ID; got: %q", stdout.String()) + } +} + +func TestRealMain_MissingEmail(t *testing.T) { + var stdout, stderr strings.Builder + code := realMain([]string{}, &stdout, &stderr, testLookup()) + if code != 1 { + t.Errorf("want exit 1, got %d", code) + } + if !strings.Contains(stderr.String(), "error: -email is required") { + t.Errorf("stderr missing error message: %q", stderr.String()) + } +} + +func TestRealMain_BadFlag(t *testing.T) { + var stdout, stderr strings.Builder + code := realMain([]string{"-unknown-flag-xyz"}, &stdout, &stderr, testLookup()) + if code != 1 { + t.Errorf("unknown flag should exit 1, got %d", code) + } +} + +func TestRealMain_WithToken(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"ok":true,"id":"abc-token-test"}`) + })) + t.Cleanup(srv.Close) + + var stdout, stderr strings.Builder + code := realMain( + []string{"-email", "cto@acme.com"}, + &stdout, + &stderr, + testLookup("INSTANT_TOKEN", "test-token-xyz", "INSTANODE_API_URL", srv.URL), + ) + if code != 0 { + t.Errorf("want exit 0, got %d; stderr: %q", code, stderr.String()) + } + if !strings.Contains(gotAuth, "test-token-xyz") { + t.Errorf("Authorization header missing token; got: %q", gotAuth) + } +} + +func TestMain_ExitIsCalled(t *testing.T) { + // main() calls osExit(realMain(os.Args[1:], ...)) — we replace osExit to + // capture the code without terminating the test binary. + var gotCode int + osExit = func(code int) { gotCode = code } + defer func() { osExit = os.Exit }() + + // os.Args[1:] in the test binary contains -test.* flags that FlagSet + // doesn't recognise → realMain returns 1 → main() passes 1 to osExit. + main() + + if gotCode != 1 { + t.Errorf("main() via test args: want exit code 1, got %d", gotCode) + } +} + +func TestRealMain_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":"internal_error","message":"db down"}`) + })) + t.Cleanup(srv.Close) + + var stdout, stderr strings.Builder + code := realMain( + []string{"-email", "fail@acme.com"}, + &stdout, + &stderr, + testLookup("INSTANODE_API_URL", srv.URL), + ) + if code != 1 { + t.Errorf("server error should exit 1, got %d", code) + } + if !strings.Contains(stderr.String(), "CreateLead") { + t.Errorf("stderr missing error prefix; got: %q", stderr.String()) + } +} diff --git a/instant/leads.go b/instant/leads.go new file mode 100644 index 0000000..ee06578 --- /dev/null +++ b/instant/leads.go @@ -0,0 +1,98 @@ +package instant + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// LeadParams holds the fields for an enterprise contact / interest submission. +// Only Email is required; the other fields add context for the instanode.dev team. +type LeadParams struct { + // Email is the contact address. Required. Must be RFC 5322-compliant; max 254 chars. + Email string `json:"email"` + + // Name is the contact's full name. Optional — max 128 chars. + Name string `json:"name,omitempty"` + + // Company is the organisation name. Optional — max 128 chars. + Company string `json:"company,omitempty"` + + // UseCase describes the scale requirements driving the Enterprise inquiry. + // Optional — max 1024 chars. + UseCase string `json:"use_case,omitempty"` +} + +// LeadResult is returned by CreateLead on a successful 201 response. +type LeadResult struct { + // OK is always true on success. + OK bool `json:"ok"` + + // ID is the UUID of the created enterprise_leads row. + ID string `json:"id"` +} + +// CreateLead submits an enterprise contact / interest form to instanode.dev +// (POST /api/v1/leads). Only params.Email is required. +// +// No authentication is required — anonymous callers are accepted. When called +// with a bearer token (INSTANT_TOKEN / WithToken option), the lead is +// automatically linked to the caller's team so the instanode.dev team can see +// the account's current usage without asking for it. +// +// Use CreateLead when the user needs capacity or features beyond the Pro tier: +// dedicated infrastructure, SAML/SSO, SOC 2 compliance, a custom SLA, or any +// other requirement not met by a self-serve paid plan. +// +// Example: +// +// lead, err := client.CreateLead(ctx, &instant.LeadParams{ +// Email: "cto@acme.com", +// Name: "Alice Smith", +// Company: "Acme Corp", +// UseCase: "Multi-region Postgres with SOC 2 Type II compliance.", +// }) +// if err != nil { log.Fatal(err) } +// fmt.Println("lead ID:", lead.ID) +func (c *Client) CreateLead(ctx context.Context, params *LeadParams) (*LeadResult, error) { + if params == nil { + return nil, fmt.Errorf("CreateLead: params must not be nil") + } + if params.Email == "" { + return nil, fmt.Errorf("CreateLead: Email is required") + } + + raw, _ := json.Marshal(params) // LeadParams contains only string fields; Marshal never fails here. + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/leads", bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("CreateLead: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + // Authorization is handled by the client's RoundTripper (authTransport), + // which injects the Bearer token from c.apiKey when present. No manual + // header injection needed — the http.Client already has it wired. + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("CreateLead: request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + var apiErr APIError + if decErr := json.NewDecoder(resp.Body).Decode(&apiErr); decErr == nil && apiErr.Code != "" { + apiErr.StatusCode = resp.StatusCode + return nil, &apiErr + } + return nil, fmt.Errorf("CreateLead: unexpected status %d", resp.StatusCode) + } + + var result LeadResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("CreateLead: decode response: %w", err) + } + return &result, nil +} diff --git a/instant/leads_test.go b/instant/leads_test.go new file mode 100644 index 0000000..4f2df3c --- /dev/null +++ b/instant/leads_test.go @@ -0,0 +1,159 @@ +package instant + +// leads_test.go — unit tests for CreateLead against an httptest server. + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCreateLead_HappyPath(t *testing.T) { + const wantID = "550e8400-e29b-41d4-a716-446655440099" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %q, want POST", r.Method) + } + if r.URL.Path != "/api/v1/leads" { + t.Errorf("path = %q, want /api/v1/leads", r.URL.Path) + } + var body LeadParams + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Email != "cto@acme.com" { + t.Errorf("email = %q, want cto@acme.com", body.Email) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"ok":true,"id":"`+wantID+`"}`) + })) + t.Cleanup(srv.Close) + + c := New(WithBaseURL(srv.URL)) + res, err := c.CreateLead(context.Background(), &LeadParams{ + Email: "cto@acme.com", + Company: "Acme Corp", + UseCase: "Need SOC 2 + dedicated Postgres.", + }) + if err != nil { + t.Fatalf("CreateLead: %v", err) + } + if res.ID != wantID { + t.Errorf("ID = %q, want %q", res.ID, wantID) + } +} + +func TestCreateLead_NilParams(t *testing.T) { + c := New() + _, err := c.CreateLead(context.Background(), nil) + if err == nil { + t.Fatal("expected error for nil params") + } + if !strings.Contains(err.Error(), "params must not be nil") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCreateLead_MissingEmail(t *testing.T) { + c := New() + _, err := c.CreateLead(context.Background(), &LeadParams{Company: "Acme"}) + if err == nil { + t.Fatal("expected error for missing email") + } + if !strings.Contains(err.Error(), "Email is required") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCreateLead_RequestBuildError(t *testing.T) { + // A bad base URL causes NewRequestWithContext to return an error. + c := New(WithBaseURL("://bad url")) + _, err := c.CreateLead(context.Background(), &LeadParams{Email: "a@b.com"}) + if err == nil { + t.Fatal("expected error for bad base URL") + } + if !strings.Contains(err.Error(), "CreateLead: build request") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCreateLead_NetworkError(t *testing.T) { + // A closed server causes httpClient.Do to fail. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := srv.URL + srv.Close() // close before the request arrives + + c := New(WithBaseURL(url)) + _, err := c.CreateLead(context.Background(), &LeadParams{Email: "a@b.com"}) + if err == nil { + t.Fatal("expected network error") + } + if !strings.Contains(err.Error(), "CreateLead: request") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCreateLead_UnexpectedStatusNoCode(t *testing.T) { + // 500 with a body that can't be decoded as an APIError with a Code field — + // hits the fallback fmt.Errorf("unexpected status %d") path. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"ok":false}`) // no "error" field → Code = "" + })) + t.Cleanup(srv.Close) + + c := New(WithBaseURL(srv.URL)) + _, err := c.CreateLead(context.Background(), &LeadParams{Email: "a@b.com"}) + if err == nil { + t.Fatal("expected error for 500 with no code") + } + if !strings.Contains(err.Error(), "unexpected status 500") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCreateLead_DecodeResponseError(t *testing.T) { + // 201 with invalid JSON body — hits the decode-response error path. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{not-valid-json`) + })) + t.Cleanup(srv.Close) + + c := New(WithBaseURL(srv.URL)) + _, err := c.CreateLead(context.Background(), &LeadParams{Email: "a@b.com"}) + if err == nil { + t.Fatal("expected JSON decode error") + } + if !strings.Contains(err.Error(), "CreateLead: decode response") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCreateLead_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"ok":false,"error":"invalid_email_format","message":"email must be a valid address"}`) + })) + t.Cleanup(srv.Close) + + c := New(WithBaseURL(srv.URL)) + _, err := c.CreateLead(context.Background(), &LeadParams{Email: "not-an-email"}) + if err == nil { + t.Fatal("expected error for 400 response") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.Code != "invalid_email_format" { + t.Errorf("Code = %q, want invalid_email_format", apiErr.Code) + } +} diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f80429c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,10 @@ +# diff-cover configuration +# https://github.com/Bachmann1234/diff-cover +# +# examples/*/main.go files contain package main entry points whose main() +# functions are untestable via `go test` (the test framework replaces the +# main entry point and os.Exit terminates the process). All business logic +# is extracted into realMain() which IS 100% covered by the test suite. +[diff_cover] +exclude_patterns = + examples/*/main.go