Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel/whatsapp"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
)
|
||||
|
||||
// ── Setup helpers ────────────────────────────────────────────────
|
||||
|
||||
func setupTikTokHandlerTest(t *testing.T) (*TikTokChannelHandler, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.Inbox{},
|
||||
&model.AgentBot{},
|
||||
&model.AgentBotInbox{},
|
||||
&model.WebhookSubscription{},
|
||||
&channelmodel.ChannelTikTok{},
|
||||
))
|
||||
t.Cleanup(func() {
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
})
|
||||
|
||||
// Build handler dependencies (nil provider — Create happy path skipped; Get/Update/Delete/List don't use provider)
|
||||
ttRepo := repository.NewChannelTikTokRepo(db)
|
||||
ttChannelSvc := service.NewChannelTikTokService(ttRepo)
|
||||
|
||||
inboxRepo := repository.NewInboxRepo(db)
|
||||
agentBotInboxRepo := repository.NewAgentBotInboxRepo(db)
|
||||
agentBotRepo := repository.NewAgentBotRepo(db)
|
||||
campaignRepo := repository.NewCampaignRepo(db)
|
||||
webhookSubRepo := repository.NewWebhookSubscriptionRepo(db)
|
||||
waRepo := whatsapp.NewRepository(db)
|
||||
waService := whatsapp.NewWhatsAppService(waRepo)
|
||||
inboxSvc := service.NewInboxService(inboxRepo, agentBotInboxRepo, agentBotRepo, campaignRepo, webhookSubRepo, waService, waRepo)
|
||||
|
||||
handler := NewTikTokChannelHandler(ttChannelSvc, nil, inboxSvc, ttRepo)
|
||||
|
||||
// Seed an account
|
||||
account := &model.Account{Name: "TikTokTestOrg", Locale: "en", Active: true}
|
||||
require.NoError(t, db.Create(account).Error)
|
||||
|
||||
return handler, db
|
||||
}
|
||||
|
||||
func tikTokAccountID(db *gorm.DB) string {
|
||||
var account model.Account
|
||||
db.First(&account)
|
||||
return strconv.FormatUint(uint64(account.ID), 10)
|
||||
}
|
||||
|
||||
func tikTokAccountIDUint(db *gorm.DB) uint {
|
||||
var account model.Account
|
||||
db.First(&account)
|
||||
return account.ID
|
||||
}
|
||||
|
||||
func setupTikTokTestRouter(handler *TikTokChannelHandler) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
ag := r.Group("/api/v1/accounts/:id")
|
||||
ag.POST("/tiktok_channel", handler.CreateTikTokChannel)
|
||||
ag.GET("/tiktok_channel", handler.ListTikTokChannels)
|
||||
ig := ag.Group("/inboxes/:inbox_id/tiktok_channel")
|
||||
ig.GET("/:tt_id", handler.GetTikTokChannel)
|
||||
ig.PATCH("/:tt_id", handler.UpdateTikTokChannel)
|
||||
ig.DELETE("/:tt_id", handler.DeleteTikTokChannel)
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────────────────────
|
||||
|
||||
func TestTikTokChannel_Create_InvalidAccountID(t *testing.T) {
|
||||
handler, _ := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
|
||||
body := CreateTikTokChannelRequest{
|
||||
Name: "Bad Account",
|
||||
TikTokBusinessID: "tt_biz_bad",
|
||||
AccessToken: "token",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/invalid/tiktok_channel", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "invalid account_id", resp["error"])
|
||||
}
|
||||
|
||||
func TestTikTokChannel_Create_InvalidRequestBody(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountID(db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+accountID+"/tiktok_channel", bytes.NewReader([]byte("{bad json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestTikTokChannel_Create_ChatwootSetupPayloadAndConfig(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountID(db)
|
||||
|
||||
body := CreateTikTokChannelRequest{
|
||||
Name: "TikTok Business",
|
||||
TikTokBusinessID: "tt_biz_123",
|
||||
AccessToken: "tt-access-token",
|
||||
InboxName: "TikTok Inbox",
|
||||
EnableAutoAssignment: true,
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+accountID+"/tiktok_channel", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.NotContains(t, resp, "success")
|
||||
assert.NotContains(t, resp, "data")
|
||||
|
||||
channelPayload := resp["channel"].(map[string]any)
|
||||
inboxPayload := resp["inbox"].(map[string]any)
|
||||
assert.Equal(t, "tt_biz_123", channelPayload["tiktok_business_id"])
|
||||
assert.Equal(t, "TikTok Inbox", inboxPayload["name"])
|
||||
assert.Equal(t, "tiktok", inboxPayload["channel_type"])
|
||||
assert.Equal(t, true, inboxPayload["enabled"])
|
||||
assert.Equal(t, true, inboxPayload["enable_auto_assignment"])
|
||||
assert.Equal(t, channelPayload["inbox_id"], inboxPayload["id"])
|
||||
|
||||
var inbox model.Inbox
|
||||
require.NoError(t, db.First(&inbox, uint(inboxPayload["id"].(float64))).Error)
|
||||
assert.Equal(t, "TikTok Inbox", inbox.Name)
|
||||
assert.Equal(t, "tiktok", inbox.ChannelType)
|
||||
assert.True(t, inbox.EnableAutoAssignment)
|
||||
var config map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(inbox.ChannelConfig), &config))
|
||||
assert.Equal(t, "tt_biz_123", config["tiktok_business_id"])
|
||||
assert.Equal(t, "tt-access-token", config["access_token"])
|
||||
|
||||
var channel channelmodel.ChannelTikTok
|
||||
require.NoError(t, db.First(&channel, uint(channelPayload["id"].(float64))).Error)
|
||||
assert.Equal(t, inbox.ID, channel.InboxID)
|
||||
assert.Equal(t, inbox.ID, uint(channelPayload["inbox_id"].(float64)))
|
||||
assert.Equal(t, "tt_biz_123", channel.TikTokBusinessID)
|
||||
assert.Equal(t, "tt-access-token", channel.AccessToken)
|
||||
}
|
||||
|
||||
// ── Get ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestTikTokChannel_Get_Success(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountIDUint(db)
|
||||
|
||||
// Seed a channel
|
||||
ch := &channelmodel.ChannelTikTok{
|
||||
TikTokBusinessID: "tt_get_123",
|
||||
AccessToken: "access_token_get",
|
||||
AccountID: accountID,
|
||||
InboxID: 500,
|
||||
}
|
||||
require.NoError(t, db.Create(ch).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/tiktok_channel/"+strconv.FormatUint(uint64(ch.ID), 10), nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "tt_get_123", resp["tiktok_business_id"])
|
||||
}
|
||||
|
||||
func TestTikTokChannel_Get_InvalidID(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountID(db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+accountID+"/inboxes/1/tiktok_channel/invalid", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "invalid tt_id", resp["error"])
|
||||
}
|
||||
|
||||
func TestTikTokChannel_Get_NotFound(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountID(db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+accountID+"/inboxes/1/tiktok_channel/99999", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────────────
|
||||
|
||||
func TestTikTokChannel_Update_Success(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountIDUint(db)
|
||||
|
||||
// Seed a channel
|
||||
ch := &channelmodel.ChannelTikTok{
|
||||
TikTokBusinessID: "tt_upd_123",
|
||||
AccessToken: "access_token_upd",
|
||||
AccountID: accountID,
|
||||
InboxID: 501,
|
||||
}
|
||||
require.NoError(t, db.Create(ch).Error)
|
||||
|
||||
body := UpdateTikTokChannelRequest{
|
||||
Name: "Updated TikTok Biz",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/tiktok_channel/"+strconv.FormatUint(uint64(ch.ID), 10), bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "TikTok channel updated successfully", resp["message"])
|
||||
}
|
||||
|
||||
func TestTikTokChannel_Update_WrongAccount(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountIDUint(db)
|
||||
|
||||
// Seed a channel owned by a different account
|
||||
ch := &channelmodel.ChannelTikTok{
|
||||
TikTokBusinessID: "tt_other",
|
||||
AccessToken: "token_other",
|
||||
AccountID: accountID + 999,
|
||||
InboxID: 502,
|
||||
}
|
||||
require.NoError(t, db.Create(ch).Error)
|
||||
|
||||
body := UpdateTikTokChannelRequest{
|
||||
Name: "Hacked Name",
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/tiktok_channel/"+strconv.FormatUint(uint64(ch.ID), 10), bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
func TestTikTokChannel_Update_InvalidTtID(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountID(db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/accounts/"+accountID+"/inboxes/1/tiktok_channel/invalid", bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────
|
||||
|
||||
func TestTikTokChannel_Delete_Success(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountIDUint(db)
|
||||
|
||||
// Seed a channel
|
||||
ch := &channelmodel.ChannelTikTok{
|
||||
TikTokBusinessID: "tt_del_123",
|
||||
AccessToken: "access_token_del",
|
||||
AccountID: accountID,
|
||||
InboxID: 503,
|
||||
}
|
||||
require.NoError(t, db.Create(ch).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/tiktok_channel/"+strconv.FormatUint(uint64(ch.ID), 10), nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify channel is soft-deleted
|
||||
var count int64
|
||||
db.Model(&channelmodel.ChannelTikTok{}).Where("id = ?", ch.ID).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestTikTokChannel_Delete_WrongAccount(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountIDUint(db)
|
||||
|
||||
// Seed a channel owned by a different account
|
||||
ch := &channelmodel.ChannelTikTok{
|
||||
TikTokBusinessID: "tt_other_del",
|
||||
AccessToken: "token_other_del",
|
||||
AccountID: accountID + 999,
|
||||
InboxID: 504,
|
||||
}
|
||||
require.NoError(t, db.Create(ch).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/tiktok_channel/"+strconv.FormatUint(uint64(ch.ID), 10), nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
func TestTikTokChannel_Delete_NotFound(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountID(db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/accounts/"+accountID+"/inboxes/1/tiktok_channel/99999", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
// ── List ──────────────────────────────────────────────────────
|
||||
|
||||
func TestTikTokChannel_List_Success(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountIDUint(db)
|
||||
|
||||
// Seed multiple channels
|
||||
for i := 0; i < 3; i++ {
|
||||
ch := &channelmodel.ChannelTikTok{
|
||||
TikTokBusinessID: "tt_list_" + strconv.Itoa(i),
|
||||
AccessToken: "token_list_" + strconv.Itoa(i),
|
||||
AccountID: accountID,
|
||||
InboxID: uint(i + 600), // unique inbox IDs
|
||||
}
|
||||
require.NoError(t, db.Create(ch).Error)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/tiktok_channel", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
channels, ok := resp["channels"].([]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, channels, 3)
|
||||
}
|
||||
|
||||
func TestTikTokChannel_List_InvalidAccountID(t *testing.T) {
|
||||
handler, _ := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/invalid/tiktok_channel", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestTikTokChannel_List_Empty(t *testing.T) {
|
||||
handler, db := setupTikTokHandlerTest(t)
|
||||
router := setupTikTokTestRouter(handler)
|
||||
accountID := tikTokAccountID(db)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+accountID+"/tiktok_channel", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
channels, ok := resp["channels"].([]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, channels, 0)
|
||||
}
|
||||
Reference in New Issue
Block a user