Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,26 @@ jobs:
gofmt -s -l . | grep -v vendor
exit 1
fi

- name: Run tests with coverage
run: go test -short -race -coverprofile=coverage.out ./...

- name: Extract coverage percentage
if: github.ref == 'refs/heads/main' && matrix.go-version == '1.24'
id: coverage
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"

- name: Update coverage badge
if: github.ref == 'refs/heads/main' && matrix.go-version == '1.24'
uses: schneegans/dynamic-badges-action@v1.7.0
with:
auth: ${{ secrets.GIST_TOKEN }}
gistID: 2c608589294aed9aa900256daeec0fd4
filename: coverage.json
label: coverage
message: ${{ steps.coverage.outputs.percentage }}%
valColorRange: ${{ steps.coverage.outputs.percentage }}
minColorRange: 40
maxColorRange: 90
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/tirthpatell/threads-go.svg)](https://pkg.go.dev/github.com/tirthpatell/threads-go)
[![Go Report Card](https://goreportcard.com/badge/github.com/tirthpatell/threads-go)](https://goreportcard.com/report/github.com/tirthpatell/threads-go)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/tirthpatell/2c608589294aed9aa900256daeec0fd4/raw/coverage.json)](https://github.com/tirthpatell/threads-go/actions)

Production-ready Go client for the Threads API with complete endpoint coverage, OAuth 2.0 authentication, rate limiting, and comprehensive error handling.

Expand Down
192 changes: 192 additions & 0 deletions auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package threads

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)

func TestExchangeCodeForToken_Success(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
_, _ = w.Write([]byte(`{
"access_token": "new_token_123",
"token_type": "bearer",
"expires_in": 3600,
"user_id": 99999
}`))
}

server := httptest.NewServer(http.HandlerFunc(handler))
t.Cleanup(server.Close)

config := &Config{
ClientID: "test-id",
ClientSecret: "test-secret",
RedirectURI: "https://example.com/callback",
}
config.SetDefaults()
config.BaseURL = server.URL

client, err := NewClient(config)
if err != nil {
t.Fatal(err)
}

err = client.ExchangeCodeForToken(context.Background(), "auth_code_123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !client.IsAuthenticated() {
t.Error("expected client to be authenticated")
}
tokenInfo := client.GetTokenInfo()
if tokenInfo.AccessToken != "new_token_123" {
t.Errorf("expected new_token_123, got %s", tokenInfo.AccessToken)
}
if tokenInfo.UserID != "99999" {
t.Errorf("expected user ID 99999, got %s", tokenInfo.UserID)
}
}

func TestExchangeCodeForToken_EmptyCode(t *testing.T) {
config := &Config{
ClientID: "test-id",
ClientSecret: "test-secret",
RedirectURI: "https://example.com/callback",
}
config.SetDefaults()
client, _ := NewClient(config)

err := client.ExchangeCodeForToken(context.Background(), "")
if err == nil {
t.Fatal("expected error for empty code")
}
}

func TestGetLongLivedToken_Success(t *testing.T) {
client := testClient(t, jsonHandler(200, `{
"access_token": "long_lived_token",
"token_type": "bearer",
"expires_in": 5184000
}`))

err := client.GetLongLivedToken(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tokenInfo := client.GetTokenInfo()
if tokenInfo.AccessToken != "long_lived_token" {
t.Errorf("expected long_lived_token, got %s", tokenInfo.AccessToken)
}
}

func TestRefreshToken_Success(t *testing.T) {
client := testClient(t, jsonHandler(200, `{
"access_token": "refreshed_token",
"token_type": "bearer",
"expires_in": 5184000
}`))

err := client.RefreshToken(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tokenInfo := client.GetTokenInfo()
if tokenInfo.AccessToken != "refreshed_token" {
t.Errorf("expected refreshed_token, got %s", tokenInfo.AccessToken)
}
}

func TestRefreshToken_NoToken(t *testing.T) {
config := &Config{
ClientID: "test-id",
ClientSecret: "test-secret",
RedirectURI: "https://example.com/callback",
}
config.SetDefaults()
client, _ := NewClient(config)

err := client.RefreshToken(context.Background())
if err == nil {
t.Fatal("expected error when no token")
}
}

func TestDebugToken_Success(t *testing.T) {
client := testClient(t, jsonHandler(200, `{
"data": {
"type": "USER",
"application": "Test App",
"is_valid": true,
"expires_at": 1735689600,
"issued_at": 1735603200,
"user_id": "12345",
"scopes": ["threads_basic"]
}
}`))

resp, err := client.DebugToken(context.Background(), "test-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !resp.Data.IsValid {
t.Error("expected valid token")
}
if resp.Data.UserID != "12345" {
t.Errorf("expected user ID 12345, got %s", resp.Data.UserID)
}
}

func TestGetAuthURL_ContainsRequiredParams(t *testing.T) {
config := &Config{
ClientID: "my-app-id",
ClientSecret: "secret",
RedirectURI: "https://example.com/callback",
}
config.SetDefaults()
client, _ := NewClient(config)

authURL := client.GetAuthURL([]string{"threads_basic"})
if authURL == "" {
t.Fatal("expected non-empty auth URL")
}
for _, param := range []string{"client_id=my-app-id", "response_type=code", "scope=threads_basic"} {
if !strings.Contains(authURL, param) {
t.Errorf("expected auth URL to contain %q, got %s", param, authURL)
}
}
}

func TestTokenExpiration(t *testing.T) {
config := &Config{
ClientID: "test-id",
ClientSecret: "test-secret",
RedirectURI: "https://example.com/callback",
}
config.SetDefaults()
client, _ := NewClient(config)

_ = client.SetTokenInfo(&TokenInfo{
AccessToken: "expired",
TokenType: "Bearer",
ExpiresAt: time.Now().Add(-time.Hour),
UserID: "12345",
CreatedAt: time.Now().Add(-2 * time.Hour),
})

if !client.IsTokenExpired() {
t.Error("expected token to be expired")
}
if !client.IsTokenExpiringSoon(time.Hour) {
t.Error("expected token to be expiring soon")
}
}
7 changes: 0 additions & 7 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -682,13 +682,6 @@ func TestCreateErrorFromResponseParsesIsTransient(t *testing.T) {
}
}

type noopLogger struct{}

func (n *noopLogger) Debug(msg string, fields ...any) {}
func (n *noopLogger) Info(msg string, fields ...any) {}
func (n *noopLogger) Warn(msg string, fields ...any) {}
func (n *noopLogger) Error(msg string, fields ...any) {}

func TestIsRetryableErrorWithTransientAPIError(t *testing.T) {
h := &HTTPClient{
logger: &noopLogger{},
Expand Down
5 changes: 2 additions & 3 deletions client_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ import (

// getUserID extracts user ID from token info
func (c *Client) getUserID() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.tokenInfo != nil && c.tokenInfo.UserID != "" {
return c.tokenInfo.UserID
}

// If user ID is not in token info, we might need to call /me endpoint
// For now, return empty string to trigger an error
return ""
}

Expand Down
14 changes: 12 additions & 2 deletions http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,23 @@ func NewHTTPClient(config *Config, rateLimiter *RateLimiter) *HTTPClient {
Timeout: config.HTTPTimeout,
}

baseURL := config.BaseURL
if baseURL == "" {
baseURL = "https://graph.threads.net"
}

userAgent := config.UserAgent
if userAgent == "" {
userAgent = DefaultUserAgent
}

return &HTTPClient{
client: httpClient,
logger: config.Logger,
retryConfig: config.RetryConfig,
rateLimiter: rateLimiter,
baseURL: "https://graph.threads.net",
userAgent: DefaultUserAgent,
baseURL: baseURL,
userAgent: userAgent,
}
}

Expand Down
Loading
Loading