feat(integrations): expose oauth callbacks

This commit is contained in:
2026-06-06 09:57:47 +08:00
parent 6b9049d284
commit 85819489b9
10 changed files with 580 additions and 12 deletions
@@ -36,7 +36,7 @@ func (h *ShopifyIntegrationHandler) Delete(c *gin.Context) {
response.OK(c, gin.H{"message": "Shopify integration deleted"})
}
// Auth creates/updates Shopify OAuth credentials.
// Auth returns the Shopify OAuth authorize URL.
// POST /api/v1/accounts/:account_id/integrations/shopify/auth
func (h *ShopifyIntegrationHandler) Auth(c *gin.Context) {
accountID, err := parseUintParam(c, "account_id")
@@ -51,12 +51,12 @@ func (h *ShopifyIntegrationHandler) Auth(c *gin.Context) {
return
}
hook, svcErr := h.svc.Auth(c.Request.Context(), accountID, req)
redirect, svcErr := h.svc.BuildAuthRedirect(c.Request.Context(), accountID, req)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
response.OK(c, hook)
response.OK(c, redirect)
}
// GetOrders retrieves Shopify orders for an account.
+311
View File
@@ -0,0 +1,311 @@
package router
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/golang-jwt/jwt/v5"
"gorm.io/datatypes"
"gorm.io/gorm"
)
func linearIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("LINEAR_CLIENT_SECRET"))
redirectURL := integrationRedirectURL("linear", accountID)
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
c.Redirect(http.StatusFound, redirectURL)
return
}
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
TokenURL: envOrDefault("LINEAR_OAUTH_TOKEN_URL", "https://api.linear.app/oauth/token"),
ClientID: os.Getenv("LINEAR_CLIENT_ID"),
ClientSecret: os.Getenv("LINEAR_CLIENT_SECRET"),
Code: c.Query("code"),
RedirectURI: frontendBaseURL() + "/linear/callback",
})
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
c.Redirect(http.StatusFound, redirectURL)
return
}
settings := map[string]any{
"token_type": body["token_type"],
"scope": body["scope"],
"refresh_token": body["refresh_token"],
}
if body["expires_in"] != "" {
settings["expires_in"] = body["expires_in"]
if seconds, err := strconv.Atoi(body["expires_in"]); err == nil {
settings["expires_on"] = time.Now().UTC().Add(time.Duration(seconds) * time.Second).Format(time.RFC3339)
}
}
if err := upsertIntegrationHook(c, db, accountID, model.HookTypeLinear, "", body["access_token"], settings); err != nil {
c.Redirect(http.StatusFound, redirectURL)
return
}
c.Redirect(http.StatusFound, redirectURL)
}
}
func shopifyIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("SHOPIFY_CLIENT_SECRET"))
redirectURL := integrationRedirectURL("shopify", accountID)
shop := strings.TrimSpace(c.Query("shop"))
if !ok || db == nil || c.Query("code") == "" || shop == "" || !accountExists(c, db, accountID) {
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
return
}
tokenURL := os.Getenv("SHOPIFY_OAUTH_TOKEN_URL")
if tokenURL == "" {
tokenURL = fmt.Sprintf("https://%s/admin/oauth/access_token", shop)
}
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
TokenURL: tokenURL,
ClientID: os.Getenv("SHOPIFY_CLIENT_ID"),
ClientSecret: os.Getenv("SHOPIFY_CLIENT_SECRET"),
Code: c.Query("code"),
RedirectURI: frontendBaseURL() + "/shopify/callback",
})
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
return
}
if err := createIntegrationHook(c, db, accountID, model.HookTypeShopify, shop, body["access_token"], map[string]any{"scope": body["scope"]}); err != nil {
c.Redirect(http.StatusFound, redirectWithError(redirectURL))
return
}
c.Redirect(http.StatusFound, redirectURL)
}
}
func notionIntegrationCallback(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("NOTION_CLIENT_SECRET"))
redirectURL := integrationRedirectURL("notion", accountID)
if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) {
c.Redirect(http.StatusFound, frontendBaseURL())
return
}
body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{
TokenURL: envOrDefault("NOTION_OAUTH_TOKEN_URL", "https://api.notion.com/v1/oauth/token"),
ClientID: os.Getenv("NOTION_CLIENT_ID"),
ClientSecret: os.Getenv("NOTION_CLIENT_SECRET"),
Code: c.Query("code"),
RedirectURI: frontendBaseURL() + "/notion/callback",
BasicAuth: true,
})
if err != nil || strings.TrimSpace(body["access_token"]) == "" {
c.Redirect(http.StatusFound, frontendBaseURL())
return
}
settings := map[string]any{
"token_type": body["token_type"],
"workspace_name": body["workspace_name"],
"workspace_id": body["workspace_id"],
"workspace_icon": body["workspace_icon"],
"bot_id": body["bot_id"],
}
if owner := strings.TrimSpace(body["owner"]); owner != "" {
settings["owner"] = owner
}
if err := createIntegrationHook(c, db, accountID, model.HookTypeNotion, "", body["access_token"], settings); err != nil {
c.Redirect(http.StatusFound, frontendBaseURL())
return
}
c.Redirect(http.StatusFound, redirectURL)
}
}
type oauthTokenExchangeRequest struct {
TokenURL string
ClientID string
ClientSecret string
Code string
RedirectURI string
BasicAuth bool
}
func exchangeOAuthToken(c *gin.Context, req oauthTokenExchangeRequest) (map[string]string, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", req.Code)
form.Set("redirect_uri", req.RedirectURI)
if !req.BasicAuth {
form.Set("client_id", req.ClientID)
form.Set("client_secret", req.ClientSecret)
}
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, req.TokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpReq.Header.Set("Accept", "application/json")
if req.BasicAuth {
httpReq.SetBasicAuth(req.ClientID, req.ClientSecret)
}
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("oauth token exchange failed: %s", resp.Status)
}
var raw map[string]any
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
result := make(map[string]string, len(raw))
for key, value := range raw {
switch typed := value.(type) {
case string:
result[key] = typed
case float64:
result[key] = strconv.FormatInt(int64(typed), 10)
case nil:
result[key] = ""
default:
encoded, _ := json.Marshal(typed)
result[key] = string(encoded)
}
}
return result, nil
}
func callbackAccountID(state string, secret string) (uint, bool) {
state = strings.TrimSpace(state)
if state == "" {
return 0, false
}
if id, err := strconv.ParseUint(state, 10, 64); err == nil && id > 0 {
return uint(id), true
}
if secret == "" {
return 0, false
}
claims := jwt.MapClaims{}
token, err := jwt.ParseWithClaims(state, claims, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
return 0, false
}
if sub, ok := claims["sub"].(float64); ok && sub > 0 {
return uint(sub), true
}
if sub, ok := claims["sub"].(string); ok {
id, err := strconv.ParseUint(sub, 10, 64)
return uint(id), err == nil && id > 0
}
return 0, false
}
func accountExists(c *gin.Context, db *gorm.DB, accountID uint) bool {
var count int64
if err := db.WithContext(c.Request.Context()).Model(&model.Account{}).Where("id = ?", accountID).Count(&count).Error; err != nil {
return false
}
return count > 0
}
func upsertIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error {
settingsJSON, err := json.Marshal(compactMap(settings))
if err != nil {
return err
}
var hook model.IntegrationHook
err = db.WithContext(c.Request.Context()).
Where("account_id = ? AND (app_id = ? OR hook_type = ?)", accountID, string(hookType), hookType).
First(&hook).Error
if err == nil {
hook.AppID = string(hookType)
hook.HookType = hookType
hook.Status = model.HookStatusActive
hook.ReferenceID = referenceID
hook.AccessToken = accessToken
hook.Settings = datatypes.JSON(settingsJSON)
return db.WithContext(c.Request.Context()).Save(&hook).Error
}
if err != gorm.ErrRecordNotFound {
return err
}
return createIntegrationHook(c, db, accountID, hookType, referenceID, accessToken, settings)
}
func createIntegrationHook(c *gin.Context, db *gorm.DB, accountID uint, hookType model.HookType, referenceID string, accessToken string, settings map[string]any) error {
settingsJSON, err := json.Marshal(compactMap(settings))
if err != nil {
return err
}
hook := &model.IntegrationHook{
AccountID: accountID,
AppID: string(hookType),
HookType: hookType,
Status: model.HookStatusActive,
AccessToken: accessToken,
ReferenceID: referenceID,
Settings: datatypes.JSON(settingsJSON),
}
return db.WithContext(c.Request.Context()).Create(hook).Error
}
func compactMap(values map[string]any) map[string]any {
result := make(map[string]any, len(values))
for key, value := range values {
if value == nil || value == "" {
continue
}
result[key] = value
}
return result
}
func integrationRedirectURL(app string, accountID uint) string {
if accountID == 0 {
return frontendBaseURL()
}
return fmt.Sprintf("%s/app/accounts/%d/settings/integrations/%s", frontendBaseURL(), accountID, app)
}
func redirectWithError(location string) string {
if strings.Contains(location, "?") {
return location + "&error=true"
}
return location + "?error=true"
}
func frontendBaseURL() string {
return strings.TrimRight(envOrDefault("FRONTEND_URL", "http://localhost:3000"), "/")
}
func envOrDefault(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
+6
View File
@@ -207,6 +207,12 @@ func RegisterRoutes(
engine.GET("/.well-known/microsoft-identity-association.json", microsoftIdentityAssociation)
engine.GET("/.well-known/cf-custom-hostname-challenge/:id", customDomainChallenge(db))
// Root OAuth callbacks used by Chatwoot integration app redirects.
// Reference: Chatwoot routes.rb linear/shopify/notion callback routes.
engine.GET("/linear/callback", linearIntegrationCallback(db))
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
engine.GET("/notion/callback", notionIntegrationCallback(db))
// Dashboard shell routes used by Chatwoot mailer and push deep links.
// Reference: Chatwoot routes.rb `get '/app'`, `get '/app/*params'` -> DashboardController#index.
engine.GET("/app", dashboardIndex)
+174
View File
@@ -2,17 +2,20 @@ package router
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/middleware"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/golang-jwt/jwt/v5"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
@@ -45,6 +48,9 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
"GET /.well-known/apple-app-site-association",
"GET /.well-known/microsoft-identity-association.json",
"GET /.well-known/cf-custom-hostname-challenge/:id",
"GET /linear/callback",
"GET /shopify/callback",
"GET /notion/callback",
"GET /app",
"GET /app/*params",
"GET /api/v1/accounts/:account_id/captain/assistants/tools",
@@ -206,6 +212,106 @@ func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) {
}
}
func TestIntegrationCallbacksCreateHooksAndRedirect(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FRONTEND_URL", "https://app.example.test")
t.Setenv("LINEAR_CLIENT_ID", "linear-client")
t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret")
t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client")
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
t.Setenv("NOTION_CLIENT_ID", "notion-client")
t.Setenv("NOTION_CLIENT_SECRET", "notion-secret")
t.Setenv("LINEAR_OAUTH_TOKEN_URL", "https://oauth.example.test/linear")
t.Setenv("SHOPIFY_OAUTH_TOKEN_URL", "https://oauth.example.test/shopify")
t.Setenv("NOTION_OAUTH_TOKEN_URL", "https://oauth.example.test/notion")
withFakeOAuthTransport(t, map[string]map[string]any{
"/linear": {"access_token": "linear-token", "refresh_token": "linear-refresh", "token_type": "bearer", "expires_in": 3600, "scope": "read,write"},
"/shopify": {"access_token": "shopify-token", "scope": "read_orders"},
"/notion": {"access_token": "notion-token", "token_type": "bearer", "workspace_name": "Docs", "workspace_id": "workspace-1", "bot_id": "bot-1"},
})
db, account := setupRouterIntegrationCallbackDB(t)
engine := gin.New()
engine.GET("/linear/callback", linearIntegrationCallback(db))
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
engine.GET("/notion/callback", notionIntegrationCallback(db))
linearState := signedCallbackState(t, account.ID, "linear-secret")
linear := performGet(engine, "/linear/callback?code=linear-code&state="+url.QueryEscape(linearState))
if linear.Code != http.StatusFound || linear.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/linear" {
t.Fatalf("expected linear integration redirect, got %d %q", linear.Code, linear.Header().Get("Location"))
}
shopifyState := signedCallbackState(t, account.ID, "shopify-secret")
shopify := performGet(engine, "/shopify/callback?code=shopify-code&shop=store.myshopify.com&state="+url.QueryEscape(shopifyState))
if shopify.Code != http.StatusFound || shopify.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/shopify" {
t.Fatalf("expected shopify integration redirect, got %d %q", shopify.Code, shopify.Header().Get("Location"))
}
notionState := signedCallbackState(t, account.ID, "notion-secret")
notion := performGet(engine, "/notion/callback?code=notion-code&state="+url.QueryEscape(notionState))
if notion.Code != http.StatusFound || notion.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/integrations/notion" {
t.Fatalf("expected notion integration redirect, got %d %q", notion.Code, notion.Header().Get("Location"))
}
var hooks []model.IntegrationHook
if err := db.Order("app_id ASC").Find(&hooks).Error; err != nil {
t.Fatalf("failed to load hooks: %v", err)
}
if len(hooks) != 3 {
t.Fatalf("expected three integration hooks, got %d", len(hooks))
}
seen := map[string]model.IntegrationHook{}
for _, hook := range hooks {
seen[hook.AppID] = hook
}
if seen["linear"].AccessToken != "linear-token" || seen["linear"].Status != model.HookStatusActive {
t.Fatalf("expected active linear hook with token, got %+v", seen["linear"])
}
if seen["shopify"].AccessToken != "shopify-token" || seen["shopify"].ReferenceID != "store.myshopify.com" {
t.Fatalf("expected shopify hook with reference shop, got %+v", seen["shopify"])
}
if seen["notion"].AccessToken != "notion-token" || !strings.Contains(string(seen["notion"].Settings), "workspace_name") {
t.Fatalf("expected notion hook with workspace settings, got %+v settings=%s", seen["notion"], string(seen["notion"].Settings))
}
}
func TestIntegrationCallbacksRedirectSafelyOnInvalidState(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FRONTEND_URL", "https://app.example.test")
t.Setenv("LINEAR_CLIENT_SECRET", "linear-secret")
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
db, _ := setupRouterIntegrationCallbackDB(t)
engine := gin.New()
engine.GET("/linear/callback", linearIntegrationCallback(db))
engine.GET("/shopify/callback", shopifyIntegrationCallback(db))
engine.GET("/notion/callback", notionIntegrationCallback(db))
linear := performGet(engine, "/linear/callback?code=linear-code&state=bad-state")
if linear.Code != http.StatusFound || linear.Header().Get("Location") != "https://app.example.test" {
t.Fatalf("expected linear safe redirect, got %d %q", linear.Code, linear.Header().Get("Location"))
}
shopify := performGet(engine, "/shopify/callback?code=shopify-code&shop=store.myshopify.com&state=bad-state")
if shopify.Code != http.StatusFound || shopify.Header().Get("Location") != "https://app.example.test?error=true" {
t.Fatalf("expected shopify error redirect, got %d %q", shopify.Code, shopify.Header().Get("Location"))
}
notion := performGet(engine, "/notion/callback?code=notion-code&state=bad-state")
if notion.Code != http.StatusFound || notion.Header().Get("Location") != "https://app.example.test" {
t.Fatalf("expected notion safe redirect, got %d %q", notion.Code, notion.Header().Get("Location"))
}
var count int64
if err := db.Model(&model.IntegrationHook{}).Count(&count).Error; err != nil {
t.Fatalf("failed to count hooks: %v", err)
}
if count != 0 {
t.Fatalf("expected invalid callbacks not to create hooks, got %d", count)
}
}
func TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("ANDROID_BUNDLE_ID", "com.example.gochat")
@@ -331,6 +437,74 @@ func setupRouterPortalDB(t *testing.T) *gorm.DB {
return db
}
func setupRouterIntegrationCallbackDB(t *testing.T) (*gorm.DB, *model.Account) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Account{}, &model.IntegrationHook{}); err != nil {
t.Fatalf("failed to migrate integration callback models: %v", err)
}
account := &model.Account{Name: "Integration Account", Locale: "en"}
if err := db.Create(account).Error; err != nil {
t.Fatalf("failed to create account: %v", err)
}
return db, account
}
func signedCallbackState(t *testing.T, accountID uint, secret string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": accountID,
"iat": time.Now().Unix(),
})
signed, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("failed to sign callback state: %v", err)
}
return signed
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func withFakeOAuthTransport(t *testing.T, payloads map[string]map[string]any) {
t.Helper()
original := http.DefaultClient
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Fatalf("expected token exchange POST, got %s", req.Method)
}
if err := req.ParseForm(); err != nil {
t.Fatalf("failed to parse token exchange form: %v", err)
}
if req.Form.Get("code") == "" || req.Form.Get("redirect_uri") == "" {
t.Fatalf("expected code and redirect_uri in token exchange form: %v", req.Form)
}
payload, ok := payloads[req.URL.Path]
if !ok {
t.Fatalf("unexpected OAuth token URL path: %s", req.URL.Path)
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to encode fake OAuth payload: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(string(encoded))),
Request: req,
}, nil
})}
t.Cleanup(func() { http.DefaultClient = original })
}
func setupRouterTwilioVoiceDB(t *testing.T) (*gorm.DB, *model.Call) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
@@ -4,10 +4,15 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/golang-jwt/jwt/v5"
)
// ShopifyIntegrationService implements Shopify integration business logic.
@@ -28,6 +33,10 @@ type CreateShopifyAuthRequest struct {
AccessToken string `json:"access_token,omitempty"`
}
type ShopifyAuthResponse struct {
RedirectURL string `json:"redirect_url"`
}
// Delete removes a Shopify integration hook for an account.
func (s *ShopifyIntegrationService) Delete(ctx context.Context, accountID uint) error {
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeShopify)
@@ -93,6 +102,40 @@ func (s *ShopifyIntegrationService) Auth(ctx context.Context, accountID uint, re
return hook, nil
}
// BuildAuthRedirect returns the Chatwoot-compatible Shopify OAuth authorize URL.
// Reference: Api::V1::Accounts::Integrations::ShopifyController#auth.
func (s *ShopifyIntegrationService) BuildAuthRedirect(_ context.Context, accountID uint, req CreateShopifyAuthRequest) (*ShopifyAuthResponse, error) {
shopDomain := strings.TrimSpace(req.ShopDomain)
if shopDomain == "" {
return nil, fmt.Errorf("Shop domain is required")
}
clientID := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_ID"))
clientSecret := strings.TrimSpace(os.Getenv("SHOPIFY_CLIENT_SECRET"))
if clientID == "" || clientSecret == "" {
return nil, fmt.Errorf("Shopify OAuth is not configured")
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": accountID,
"iat": time.Now().Unix(),
})
state, err := token.SignedString([]byte(clientSecret))
if err != nil {
return nil, fmt.Errorf("failed to generate Shopify state: %w", err)
}
frontendURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
if frontendURL == "" {
frontendURL = "http://localhost:3000"
}
params := url.Values{}
params.Set("client_id", clientID)
params.Set("scope", "read_customers,read_orders,read_fulfillments")
params.Set("redirect_uri", frontendURL+"/shopify/callback")
params.Set("state", state)
return &ShopifyAuthResponse{RedirectURL: fmt.Sprintf("https://%s/admin/oauth/authorize?%s", shopDomain, params.Encode())}, nil
}
// GetOrders retrieves Shopify orders for an account (proxy to Shopify API).
// GET /api/v1/accounts/:account_id/integrations/shopify/orders
func (s *ShopifyIntegrationService) GetOrders(ctx context.Context, accountID uint) ([]map[string]interface{}, error) {
@@ -3,6 +3,8 @@ package service
import (
"context"
"encoding/json"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -87,6 +89,27 @@ func TestShopifyIntegrationService_Auth_CreateNew(t *testing.T) {
assert.Len(t, hooks, 1, "one Shopify hook should exist for the account")
}
func TestShopifyIntegrationService_BuildAuthRedirect_ChatwootPayload(t *testing.T) {
svc, db := setupShopifyIntegrationService(t)
accountID := seedShopifyAccount(db, t)
t.Setenv("FRONTEND_URL", "https://app.example.test/")
t.Setenv("SHOPIFY_CLIENT_ID", "shopify-client")
t.Setenv("SHOPIFY_CLIENT_SECRET", "shopify-secret")
result, err := svc.BuildAuthRedirect(context.Background(), accountID, CreateShopifyAuthRequest{ShopDomain: "my-store.myshopify.com"})
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, strings.HasPrefix(result.RedirectURL, "https://my-store.myshopify.com/admin/oauth/authorize?"))
parsed, err := url.Parse(result.RedirectURL)
require.NoError(t, err)
query := parsed.Query()
assert.Equal(t, "shopify-client", query.Get("client_id"))
assert.Equal(t, "read_customers,read_orders,read_fulfillments", query.Get("scope"))
assert.Equal(t, "https://app.example.test/shopify/callback", query.Get("redirect_uri"))
assert.NotEmpty(t, query.Get("state"))
}
func TestShopifyIntegrationService_Auth_UpdateExisting(t *testing.T) {
svc, db := setupShopifyIntegrationService(t)
accountID := seedShopifyAccount(db, t)
@@ -167,4 +190,4 @@ func TestShopifyIntegrationService_Delete_NotFound(t *testing.T) {
err := svc.Delete(ctx, accountID)
assert.Error(t, err, "Delete should fail when no Shopify integration exists")
assert.Contains(t, err.Error(), "not found", "error should indicate integration not found")
}
}