diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a28597c..d63e739 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -19,6 +19,10 @@ jobs: - name: Run unit tests env: + WENOVA_TOKEN: ${{ secrets.WENOVA_TOKEN }} + WENOVA_DEMO_PHONE_NUMBER: ${{ secrets.WENOVA_DEMO_PHONE_NUMBER }} + WENOVA_DEMO_HEADER: ${{ secrets.WENOVA_DEMO_HEADER }} + WENOVA_DEMO_MESSAGE: ${{ secrets.WENOVA_DEMO_MESSAGE }} MINECRAFT_MCSERVER_DEMO_IP: ${{ secrets.MINECRAFT_MCSERVER_DEMO_IP }} MINECRAFT_MCSERVER_DEMO_PORT: ${{ secrets.MINECRAFT_MCSERVER_DEMO_PORT }} run: go test ./... diff --git a/README.md b/README.md index 104fdfc..75971e5 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,13 @@ go test ./... GitHub Actions is configured to run the test suite on `push` and `pull_request`. -The live Bedrock status test reads these environment variables: +Some live integration tests read repository secrets through environment variables: - `MINECRAFT_MCSERVER_DEMO_IP` - `MINECRAFT_MCSERVER_DEMO_PORT` +- `WENOVA_TOKEN` +- `WENOVA_DEMO_PHONE_NUMBER` +- `WENOVA_DEMO_HEADER` +- `WENOVA_DEMO_MESSAGE` -In GitHub Actions, set them as repository secrets with the same names. When they are not set, the live Bedrock test is skipped. +In GitHub Actions, set these as repository secrets with the same names. When they are not set, the corresponding live tests are skipped. diff --git a/wenova/DOCS.md b/wenova/DOCS.md new file mode 100644 index 0000000..f040dcc --- /dev/null +++ b/wenova/DOCS.md @@ -0,0 +1,103 @@ +# wenova + +`wenova` provides Go helpers for calling Wenova microservice APIs from the `dextools` module. + +## Quick Start + +Use the package directly: + +```go +package main + +import ( + "context" + "fmt" + + "github.com/barluscuda/dextools/wenova" +) + +func main() { + ctx := context.Background() + client := wenova.Wenova{ + BaseUrl: "https://apimicroservices.wenova.fun", + Token: "your-token", + } + + smsResult, err := client.SendSMS(ctx, wenova.SendSMSRequest{ + Header: "WNV-OTP", + PhoneNumber: "2012345678", + Message: "Code: 123456", + }) + if err != nil { + panic(err) + } + + fmt.Println(smsResult) +} +``` + +## Packages + +The package currently supports: + +- `SendSMS` + +## Configuration + +Set `Wenova.BaseUrl` to customize the API host. If it is empty, the default base URL is `https://apimicroservices.wenova.fun`. + +## Structure + +Like `envtools`, `wenova` uses a flat package structure: + +- `wenova.go` defines the package type +- `sms.go` contains SMS helpers +- `wenova_test.go` covers shared behavior + +Call helpers from a configured `Wenova` value. + +## SMS + +`SendSMS` sends SMS requests to `POST /sms/package`. + +### Request Rules + +`SendSMS` requires `Wenova.Token`. If it is missing, it returns an error. + +### Example + +```go +client := wenova.Wenova{ + BaseUrl: "https://apimicroservices.wenova.fun", + Token: "your-token", +} + +result, err := client.SendSMS(ctx, wenova.SendSMSRequest{ + Header: "WNV-OTP", + PhoneNumber: "2012345678", + Message: "Code: 123456", +}) +``` + +## Error Behavior + +- return decoded JSON as `any` on success +- return `nil, nil` for empty successful response bodies +- parse API error payloads and include the HTTP status code in returned errors + +## Testing + +Run all tests with: + +```bash +go test ./... +``` + +The live Wenova SMS test reads: + +- `WENOVA_TOKEN` +- `WENOVA_DEMO_PHONE_NUMBER` +- `WENOVA_DEMO_HEADER` (optional) +- `WENOVA_DEMO_MESSAGE` (optional) + +The SMS test requires `WENOVA_TOKEN` and `WENOVA_DEMO_PHONE_NUMBER`. In GitHub Actions, provide these values as repository secrets with the same names. diff --git a/wenova/sms.go b/wenova/sms.go new file mode 100644 index 0000000..109032f --- /dev/null +++ b/wenova/sms.go @@ -0,0 +1,110 @@ +package wenova + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// SendSMSRequest matches the Wenova SMS payload. +type SendSMSRequest struct { + Header string + PhoneNumber string + Message string +} + +func (w Wenova) SendSMS(ctx context.Context, req SendSMSRequest) (any, error) { + token := strings.TrimSpace(w.Token) + if token == "" { + return nil, errors.New("token is required") + } + + base := strings.TrimSuffix(strings.TrimSpace(w.BaseUrl), "/") + if base == "" { + base = DefaultBaseURL + } + url := base + "/sms/package" + + body := map[string]any{ + "header": req.Header, + "phoneNumber": req.PhoneNumber, + "message": req.Message, + "token": token, + } + + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + + hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw)) + if err != nil { + return nil, err + } + hreq.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(hreq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, ErrorFromResponseBody(b, resp.StatusCode, "Failed to send SMS") + } + + var out any + if len(b) == 0 { + return nil, nil + } + if err := json.Unmarshal(b, &out); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return out, nil +} + +// ErrorFromResponseBody parses JSON error bodies like the Node SDKs. +func ErrorFromResponseBody(b []byte, status int, fallback string) error { + var wrap struct { + Message any `json:"message"` + Error any `json:"error"` + } + msg := fallback + if json.Unmarshal(b, &wrap) == nil { + if s, ok := stringifyErrPart(wrap.Message); ok && s != "" { + msg = s + } else if s, ok := stringifyErrPart(wrap.Error); ok && s != "" { + msg = s + } + } + return fmt.Errorf("%s (HTTP %d)", msg, status) +} + +func stringifyErrPart(v any) (string, bool) { + switch x := v.(type) { + case string: + return x, true + case map[string]any: + if m, ok := x["message"].(string); ok { + return m, true + } + if raw, err := json.Marshal(x); err == nil { + return string(raw), true + } + case float64, bool, json.Number: + return fmt.Sprint(x), true + } + return "", false +} diff --git a/wenova/wenova.go b/wenova/wenova.go new file mode 100644 index 0000000..4d38391 --- /dev/null +++ b/wenova/wenova.go @@ -0,0 +1,8 @@ +package wenova + +const DefaultBaseURL = "https://apimicroservices.wenova.fun" + +type Wenova struct { + BaseUrl string + Token string +} diff --git a/wenova/wenova_test.go b/wenova/wenova_test.go new file mode 100644 index 0000000..47f60d5 --- /dev/null +++ b/wenova/wenova_test.go @@ -0,0 +1,74 @@ +package wenova + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" +) + +func requiredTrimmedEnv(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +func TestSendSMSUsesWenovaBaseURL(t *testing.T) { + client := Wenova{BaseUrl: " https://example.com/ ", Token: "demo-token"} + if got := strings.TrimSuffix(strings.TrimSpace(client.BaseUrl), "/"); got != "https://example.com" { + t.Fatalf("expected trimmed base url, got %q", got) + } + if client.Token != "demo-token" { + t.Fatalf("expected token on client, got %q", client.Token) + } +} + +func TestErrorFromResponseBody(t *testing.T) { + err := ErrorFromResponseBody([]byte(`{"message":"bad request"}`), 400, "fallback") + if err == nil || err.Error() != "bad request (HTTP 400)" { + t.Fatalf("unexpected error: %v", err) + } + + err = ErrorFromResponseBody([]byte(`not-json`), 500, "fallback") + if err == nil || !strings.Contains(err.Error(), "fallback (HTTP 500)") { + t.Fatalf("unexpected fallback error: %v", err) + } +} + +func TestSendSMSLive(t *testing.T) { + phoneNumber := requiredTrimmedEnv("WENOVA_DEMO_PHONE_NUMBER") + if phoneNumber == "" { + t.Skip("skipping live Wenova SMS test: WENOVA_DEMO_PHONE_NUMBER is not set") + } + + token := requiredTrimmedEnv("WENOVA_TOKEN") + if token == "" { + t.Skip("skipping live Wenova SMS test: WENOVA_TOKEN is not set") + } + + header := requiredTrimmedEnv("WENOVA_DEMO_HEADER") + if header == "" { + header = "WNV-OTP" + } + + message := requiredTrimmedEnv("WENOVA_DEMO_MESSAGE") + if message == "" { + message = fmt.Sprintf("Code: %d", time.Now().Unix()%1000000) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := Wenova{Token: token} + result, err := client.SendSMS(ctx, SendSMSRequest{ + Header: header, + PhoneNumber: phoneNumber, + Message: message, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected SMS response, got nil") + } +}