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.
184 lines
8.7 KiB
Go
184 lines
8.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
)
|
|
|
|
type fakeWhatsAppCallProvider struct {
|
|
initiateID string
|
|
permissionRequired bool
|
|
permissionMessageID string
|
|
preAccepts int
|
|
accepts int
|
|
rejects int
|
|
terminates int
|
|
}
|
|
|
|
func (f *fakeWhatsAppCallProvider) InitiateCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) {
|
|
if f.permissionRequired {
|
|
return "", ErrWhatsAppCallPermissionRequired
|
|
}
|
|
if f.initiateID == "" {
|
|
return "wacid_outbound", nil
|
|
}
|
|
return f.initiateID, nil
|
|
}
|
|
|
|
func (f *fakeWhatsAppCallProvider) PreAcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error {
|
|
f.preAccepts++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeWhatsAppCallProvider) AcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error {
|
|
f.accepts++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeWhatsAppCallProvider) RejectCall(context.Context, *channelmodel.ChannelWhatsApp, string) error {
|
|
f.rejects++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeWhatsAppCallProvider) TerminateCall(context.Context, *channelmodel.ChannelWhatsApp, string) error {
|
|
f.terminates++
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeWhatsAppCallProvider) SendCallPermissionRequest(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) {
|
|
if f.permissionMessageID == "" {
|
|
return "wamid.req_xyz", nil
|
|
}
|
|
return f.permissionMessageID, nil
|
|
}
|
|
|
|
func setupWhatsAppCallServiceTest(t *testing.T, provider *fakeWhatsAppCallProvider) (*gorm.DB, *WhatsAppCallService, *model.Account, *model.Inbox, *model.Contact, *model.Conversation) {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Call{}, &model.Message{}, &model.Attachment{}, &channelmodel.ChannelWhatsApp{}))
|
|
|
|
account := &model.Account{Name: "Voice Account", Status: "active"}
|
|
require.NoError(t, db.Create(account).Error)
|
|
inbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 1, ChannelConfig: `{"voice_enabled":true}`}
|
|
require.NoError(t, db.Create(inbox).Error)
|
|
waChannel := &channelmodel.ChannelWhatsApp{AccountID: account.ID, InboxID: inbox.ID, PhoneNumber: "+15550000000", PhoneNumberID: "phone-1", BusinessAccountID: "waba-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"calling_enabled":true}`}
|
|
require.NoError(t, db.Create(waChannel).Error)
|
|
inbox.ChannelID = waChannel.ID
|
|
require.NoError(t, db.Save(inbox).Error)
|
|
contact := &model.Contact{AccountID: account.ID, Name: "Ada", PhoneNumber: "+15551234567"}
|
|
require.NoError(t, db.Create(contact).Error)
|
|
displayID := uint(42)
|
|
conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "whatsapp", Channel: "whatsapp"}
|
|
require.NoError(t, db.Create(conversation).Error)
|
|
|
|
return db, NewWhatsAppCallService(repository.NewWhatsAppCallRepo(db), provider), account, inbox, contact, conversation
|
|
}
|
|
|
|
func TestWhatsAppCallService_InitiateCreatesOutboundCallAndMessage(t *testing.T) {
|
|
provider := &fakeWhatsAppCallProvider{initiateID: "wacid_outbound"}
|
|
db, svc, account, _, _, conversation := setupWhatsAppCallServiceTest(t, provider)
|
|
|
|
result, err := svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "wacid_outbound", result.Call.ProviderCallID)
|
|
|
|
var call model.Call
|
|
require.NoError(t, db.First(&call, result.Call.ID).Error)
|
|
require.Equal(t, "whatsapp", call.Provider)
|
|
require.Equal(t, "outgoing", call.Direction)
|
|
require.Equal(t, "ringing", call.Status)
|
|
require.NotNil(t, call.MessageID)
|
|
|
|
var message model.Message
|
|
require.NoError(t, db.First(&message, *call.MessageID).Error)
|
|
require.Equal(t, "voice_call", message.ContentType)
|
|
require.Equal(t, "outgoing", message.MessageType)
|
|
}
|
|
|
|
func TestWhatsAppCallService_InitiatePermissionRequestRecordsConversation(t *testing.T) {
|
|
provider := &fakeWhatsAppCallProvider{permissionRequired: true, permissionMessageID: "wamid.req_xyz"}
|
|
db, svc, account, _, _, conversation := setupWhatsAppCallServiceTest(t, provider)
|
|
|
|
result, err := svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "permission_requested", result.PermissionStatus)
|
|
|
|
var stored model.Conversation
|
|
require.NoError(t, db.First(&stored, conversation.ID).Error)
|
|
attrs := conversationAttributes(&stored)
|
|
require.Equal(t, "wamid.req_xyz", attrs["call_permission_request_message_id"])
|
|
require.NotEmpty(t, attrs["call_permission_requested_at"])
|
|
}
|
|
|
|
func TestWhatsAppCallService_AcceptRejectTerminateAndRecording(t *testing.T) {
|
|
provider := &fakeWhatsAppCallProvider{}
|
|
db, svc, account, inbox, contact, conversation := setupWhatsAppCallServiceTest(t, provider)
|
|
call := &model.Call{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContactID: contact.ID, Provider: "whatsapp", Direction: "incoming", ProviderCallID: "wacid_in", Status: "ringing", CallerType: "Contact", CallerID: contact.ID, CallDirection: "inbound"}
|
|
require.NoError(t, db.Create(call).Error)
|
|
message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContentType: "voice_call", MessageType: "incoming", Content: "WhatsApp voice call"}
|
|
require.NoError(t, db.Create(message).Error)
|
|
require.NoError(t, db.Model(call).Update("message_id", message.ID).Error)
|
|
|
|
accepted, err := svc.Accept(context.Background(), account.ID, call.ID, 8, "sdp_answer")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "in_progress", accepted.Status)
|
|
require.Equal(t, 1, provider.preAccepts)
|
|
require.Equal(t, 1, provider.accepts)
|
|
require.NotNil(t, accepted.StartedAt)
|
|
|
|
terminated, err := svc.Terminate(context.Background(), account.ID, call.ID, 8)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "completed", terminated.Status)
|
|
require.Equal(t, 1, provider.terminates)
|
|
|
|
status, err := svc.UploadRecording(context.Background(), account.ID, call.ID, "call.webm", 100)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "uploaded", status)
|
|
status, err = svc.UploadRecording(context.Background(), account.ID, call.ID, "call.webm", 100)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "already_uploaded", status)
|
|
}
|
|
|
|
func TestWhatsAppCallService_InitiateValidatesReferenceGuards(t *testing.T) {
|
|
provider := &fakeWhatsAppCallProvider{}
|
|
db, svc, account, inbox, contact, conversation := setupWhatsAppCallServiceTest(t, provider)
|
|
|
|
_, err := svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, AgentID: 7})
|
|
require.True(t, errors.Is(err, ErrWhatsAppCallSDPOfferRequired))
|
|
|
|
require.NoError(t, db.Model(contact).Update("phone_number", "").Error)
|
|
_, err = svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7})
|
|
require.True(t, errors.Is(err, ErrWhatsAppCallContactPhoneRequired))
|
|
|
|
require.NoError(t, db.Model(contact).Update("phone_number", "+15551234567").Error)
|
|
require.NoError(t, db.Model(inbox).Update("channel_config", `{"voice_enabled":false}`).Error)
|
|
require.NoError(t, db.Model(&channelmodel.ChannelWhatsApp{}).Where("inbox_id = ?", inbox.ID).Update("provider_config", `{"calling_enabled":false}`).Error)
|
|
_, err = svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7})
|
|
require.True(t, errors.Is(err, ErrWhatsAppCallNotEnabled))
|
|
}
|
|
|
|
func TestWhatsAppCallService_TerminateUsesNoAnswerBeforeProgress(t *testing.T) {
|
|
provider := &fakeWhatsAppCallProvider{}
|
|
db, svc, account, inbox, contact, conversation := setupWhatsAppCallServiceTest(t, provider)
|
|
call := &model.Call{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContactID: contact.ID, Provider: "whatsapp", Direction: "outgoing", ProviderCallID: "wacid_out", Status: "ringing", CallerType: "User", CallerID: 7, CallDirection: "outbound", StartedAt: ptrTime(time.Now())}
|
|
require.NoError(t, db.Create(call).Error)
|
|
|
|
terminated, err := svc.Terminate(context.Background(), account.ID, call.ID, 7)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "no_answer", terminated.Status)
|
|
}
|
|
|
|
func ptrTime(t time.Time) *time.Time { return &t }
|