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") } 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) }