Files
gochat/backend/internal/handler/api/v1/line_channel_handler_test.go
T
rogee aeddedf2a3 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.
2026-07-07 14:44:12 +08:00

477 lines
17 KiB
Go

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 setupLINEHandlerTest(t *testing.T) (*LINEChannelHandler, *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.ChannelLINE{},
))
t.Cleanup(func() {
sqlDB, _ := db.DB()
sqlDB.Close()
})
lineRepo := repository.NewChannelLINERepo(db)
lineChannelSvc := service.NewChannelLINEService(lineRepo)
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 := NewLINEChannelHandler(lineChannelSvc, nil, inboxSvc, lineRepo)
// Seed an account
account := &model.Account{Name: "LINETestOrg", Locale: "en", Active: true}
require.NoError(t, db.Create(account).Error)
return handler, db
}
func lineAccountID(db *gorm.DB) string {
var account model.Account
db.First(&account)
return strconv.FormatUint(uint64(account.ID), 10)
}
func lineAccountIDUint(db *gorm.DB) uint {
var account model.Account
db.First(&account)
return account.ID
}
func setupLINETestRouter(handler *LINEChannelHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
ag := r.Group("/api/v1/accounts/:id")
ag.POST("/line_channel", handler.Create)
ag.GET("/line_channel", handler.List)
ig := ag.Group("/inboxes/:inbox_id/line_channel")
ig.GET("/:line_id", handler.Get)
ig.PATCH("/:line_id", handler.Update)
ig.DELETE("/:line_id", handler.Delete)
return r
}
// ── Create ──────────────────────────────────────────────────────
func TestLINEChannel_Create_Success(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountID(db)
body := CreateLINEChannelRequest{
ChannelID: "line_chan_123",
Name: "LINE Official",
ChannelAccessToken: "access_token_secret",
ChannelSecret: "secret_value",
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+accountID+"/line_channel", 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, "LINE Official", resp["name"])
assert.Equal(t, "Channel::Line", resp["channel_type"])
assert.Equal(t, "line_chan_123", resp["line_channel_id"])
assert.Equal(t, "secret_value", resp["line_channel_secret"])
assert.Equal(t, "access_token_secret", resp["line_channel_token"])
require.NotContains(t, resp, "channel")
require.NotContains(t, resp, "inbox")
var inbox model.Inbox
require.NoError(t, db.First(&inbox, uint(resp["id"].(float64))).Error)
assert.Equal(t, "LINE Official", inbox.Name)
assert.Equal(t, "line", inbox.ChannelType)
assert.Equal(t, uint(resp["channel_id"].(float64)), inbox.ChannelID)
var config map[string]any
require.NoError(t, json.Unmarshal([]byte(inbox.ChannelConfig), &config))
assert.Equal(t, "line_chan_123", config["line_channel_id"])
assert.Equal(t, "secret_value", config["line_channel_secret"])
assert.Equal(t, "access_token_secret", config["line_channel_token"])
assert.Equal(t, "line_chan_123", config["channel_id"])
assert.Equal(t, "secret_value", config["channel_secret"])
assert.Equal(t, "access_token_secret", config["channel_access_token"])
var channel channelmodel.ChannelLINE
require.NoError(t, db.First(&channel, inbox.ChannelID).Error)
assert.Equal(t, inbox.ID, channel.InboxID)
assert.Equal(t, "line_chan_123", channel.ChannelID)
}
func TestLINEChannel_CreateRejectsAccountInboxLimitWithoutChannelOrphan(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountIDUint(db)
require.NoError(t, db.Model(&model.Account{}).Where("id = ?", accountID).Update("inbox_limit", 1).Error)
require.NoError(t, db.Create(&model.Inbox{AccountID: accountID, Name: "Existing", ChannelType: "api"}).Error)
body := CreateLINEChannelRequest{
ChannelID: "line_limit_123",
Name: "Blocked LINE",
ChannelAccessToken: "access_token_secret",
ChannelSecret: "secret_value",
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/line_channel", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusPaymentRequired, w.Code, w.Body.String())
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, service.InboxLimitExceededMessage, resp["error"])
var inboxCount int64
require.NoError(t, db.Model(&model.Inbox{}).Where("account_id = ?", accountID).Count(&inboxCount).Error)
require.Equal(t, int64(1), inboxCount)
var channelCount int64
require.NoError(t, db.Model(&channelmodel.ChannelLINE{}).Where("account_id = ?", accountID).Count(&channelCount).Error)
require.Equal(t, int64(0), channelCount)
}
func TestLINEChannel_Create_InvalidAccountID(t *testing.T) {
handler, _ := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
body := CreateLINEChannelRequest{
ChannelID: "line_bad",
Name: "Bad Account",
ChannelAccessToken: "token",
ChannelSecret: "secret",
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/invalid/line_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 TestLINEChannel_Create_InvalidRequestBody(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountID(db)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/accounts/"+accountID+"/line_channel", bytes.NewReader([]byte("{bad json")))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// ── Get ──────────────────────────────────────────────────────────
func TestLINEChannel_Get_Success(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountIDUint(db)
// Seed a channel and its associated inbox.
ch := &channelmodel.ChannelLINE{
ChannelID: "line_get_123",
Name: "LINE Get Test",
AccountID: accountID,
}
require.NoError(t, db.Create(ch).Error)
inbox := createTestChannelInbox(t, db, accountID, ch.ID, "LINE Get Test", "line", map[string]any{
"line_channel_id": "line_get_123",
"line_channel_secret": "line-secret",
"line_channel_token": "line-token",
"channel_secret": "line-secret",
})
ch.InboxID = inbox.ID
require.NoError(t, db.Save(ch).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/line_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, "LINE Get Test", resp["name"])
assert.Equal(t, "Channel::Line", resp["channel_type"])
assert.Equal(t, "line_get_123", resp["line_channel_id"])
require.NotContains(t, resp, "channel")
}
func TestLINEChannel_Get_InvalidID(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountID(db)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+accountID+"/inboxes/1/line_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 line_id", resp["error"])
}
func TestLINEChannel_Get_NotFound(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountID(db)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+accountID+"/inboxes/1/line_channel/99999", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
// ── Update ──────────────────────────────────────────────────────
func TestLINEChannel_Update_Success(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountIDUint(db)
// Seed a channel and its associated inbox.
ch := &channelmodel.ChannelLINE{
ChannelID: "line_upd_123",
Name: "LINE Update Test",
AccountID: accountID,
}
require.NoError(t, db.Create(ch).Error)
inbox := createTestChannelInbox(t, db, accountID, ch.ID, "LINE Update Test", "line", map[string]any{
"line_channel_id": "line_upd_123",
"line_channel_secret": "old-secret",
"line_channel_token": "old-token",
"channel_secret": "old-secret",
})
ch.InboxID = inbox.ID
require.NoError(t, db.Save(ch).Error)
body := UpdateLINEChannelRequest{
Name: "Updated LINE",
}
b, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/line_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, "Updated LINE", resp["name"])
assert.Equal(t, "line_upd_123", resp["line_channel_id"])
assert.Equal(t, "old-secret", resp["line_channel_secret"])
require.NotContains(t, resp, "channel")
}
func TestLINEChannel_Update_WrongAccount(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountIDUint(db)
// Seed a channel owned by a different account
ch := &channelmodel.ChannelLINE{
ChannelID: "line_other",
Name: "LINE Other",
AccountID: accountID + 999,
InboxID: 702,
}
require.NoError(t, db.Create(ch).Error)
// The handler now correctly rejects cross-account updates.
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/inboxes/1/line_channel/"+strconv.FormatUint(uint64(ch.ID), 10), nil)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestLINEChannel_Update_InvalidLineID(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountID(db)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPatch, "/api/v1/accounts/"+accountID+"/inboxes/1/line_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 TestLINEChannel_Delete_Success(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountIDUint(db)
// Seed a channel
ch := &channelmodel.ChannelLINE{
ChannelID: "line_del_123",
Name: "LINE Delete Test",
AccountID: accountID,
InboxID: 703,
}
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/line_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.ChannelLINE{}).Where("id = ?", ch.ID).Count(&count)
assert.Equal(t, int64(0), count)
}
func TestLINEChannel_Delete_WrongAccount(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountIDUint(db)
// Seed a channel owned by a different account
ch := &channelmodel.ChannelLINE{
ChannelID: "line_other_del",
Name: "LINE Other Del",
AccountID: accountID + 999,
InboxID: 704,
}
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/line_channel/"+strconv.FormatUint(uint64(ch.ID), 10), nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestLINEChannel_Delete_NotFound(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountID(db)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/accounts/"+accountID+"/inboxes/1/line_channel/99999", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
// ── List ──────────────────────────────────────────────────────
func TestLINEChannel_List_Success(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountIDUint(db)
// Seed multiple channels and their associated inboxes.
for i := 0; i < 3; i++ {
ch := &channelmodel.ChannelLINE{
ChannelID: "line_list_" + strconv.Itoa(i),
Name: "LINE List " + strconv.Itoa(i),
AccountID: accountID,
}
require.NoError(t, db.Create(ch).Error)
inbox := createTestChannelInbox(t, db, accountID, ch.ID, ch.Name, "line", map[string]any{
"line_channel_id": ch.ChannelID,
"line_channel_secret": "line-secret-" + strconv.Itoa(i),
"line_channel_token": "line-token-" + strconv.Itoa(i),
"channel_secret": "line-secret-" + strconv.Itoa(i),
})
ch.InboxID = inbox.ID
require.NoError(t, db.Save(ch).Error)
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+strconv.FormatUint(uint64(accountID), 10)+"/line_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["payload"].([]interface{})
require.True(t, ok)
assert.Len(t, channels, 3)
}
func TestLINEChannel_List_InvalidAccountID(t *testing.T) {
handler, _ := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/invalid/line_channel", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestLINEChannel_List_Empty(t *testing.T) {
handler, db := setupLINEHandlerTest(t)
router := setupLINETestRouter(handler)
accountID := lineAccountID(db)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/accounts/"+accountID+"/line_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["payload"].([]interface{})
require.True(t, ok)
assert.Len(t, channels, 0)
}