diff --git a/backend/internal/app/bootstrap.go b/backend/internal/app/bootstrap.go index 5e530679..a9f3edb2 100644 --- a/backend/internal/app/bootstrap.go +++ b/backend/internal/app/bootstrap.go @@ -782,7 +782,7 @@ func Bootstrap(env string) (*App, error) { Auth: v1.NewAuthHandler(authService, profileService), Account: v1.NewAccountHandler(accountService), EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService), - Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher).WithChannelDispatcher(channelDispatcher), + Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService).WithContactPresence(presenceTracker).WithEventPublisher(eventPublisher), Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService).WithContactPresence(presenceTracker), Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService), InboxMember: v1.NewInboxMemberHandler(inboxMemberService), diff --git a/backend/internal/handler/api/v1/contact_handler.go b/backend/internal/handler/api/v1/contact_handler.go index ecb433ac..c84bb7e3 100644 --- a/backend/internal/handler/api/v1/contact_handler.go +++ b/backend/internal/handler/api/v1/contact_handler.go @@ -12,7 +12,6 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" @@ -24,14 +23,13 @@ import ( // ContactHandler handles contact-related API endpoints. // Reference: Chatwoot app/controllers/api/v1/contacts_controller.rb type ContactHandler struct { - svc *service.ContactService - contactInboxSvc *service.ContactInboxService - mergeSvc *service.ContactMergeService - contactNoteSvc *service.ContactNoteService - conversationSvc *service.ConversationService - presence contactPresenceReader - eventPublisher *ws.EventPublisher - channelDispatcher *channel.Dispatcher + svc *service.ContactService + contactInboxSvc *service.ContactInboxService + mergeSvc *service.ContactMergeService + contactNoteSvc *service.ContactNoteService + conversationSvc *service.ConversationService + presence contactPresenceReader + eventPublisher *ws.EventPublisher } const chatwootContactResultsPerPage = 15 @@ -50,11 +48,6 @@ func (h *ContactHandler) WithEventPublisher(publisher *ws.EventPublisher) *Conta return h } -func (h *ContactHandler) WithChannelDispatcher(dispatcher *channel.Dispatcher) *ContactHandler { - h.channelDispatcher = dispatcher - return h -} - func (h *ContactHandler) WithContactPresence(presence contactPresenceReader) *ContactHandler { h.presence = presence return h @@ -276,7 +269,6 @@ func (h *ContactHandler) Update(c *gin.Context) { return } - before, _ := h.svc.GetByAccountAndID(c.Request.Context(), accountID, contactID) contact, svcErr := h.svc.Update(c.Request.Context(), accountID, contactID, req) if svcErr != nil { var validationErr *service.ContactValidationError @@ -290,12 +282,6 @@ func (h *ContactHandler) Update(c *gin.Context) { payload := contactPayloadResponse(h.requestContext(c), h.svc.DB(), contact, includeContactInboxes(c)) h.publishContactEvent(accountID, ws.EventContactUpdated, contact) - if h.channelDispatcher != nil && req.Name != "" && (before == nil || before.Name != contact.Name) { - event := channel.NewChannelEvent(channel.EventContactUpdated, channel.ChannelAPI, accountID, 0) - event.ContactID, event.Data["contact"] = contact.ID, contact - event.Data["changed_attributes"] = map[string]any{"name": contact.Name} - _ = h.channelDispatcher.Dispatch(c.Request.Context(), event) - } c.JSON(http.StatusOK, payload) } diff --git a/backend/internal/handler/api/v1/shangwutong_connector_handler.go b/backend/internal/handler/api/v1/shangwutong_connector_handler.go index fdd3c829..aa4ca9d1 100644 --- a/backend/internal/handler/api/v1/shangwutong_connector_handler.go +++ b/backend/internal/handler/api/v1/shangwutong_connector_handler.go @@ -2,6 +2,7 @@ package v1 import ( "encoding/base64" + "encoding/json" "errors" "fmt" "net/http" @@ -22,6 +23,50 @@ type ShangwutongConnectorHandler struct { messageSvc *service.MessageService } +type shangwutongContactMetadataRequest struct { + CID string `json:"cid"` +} + +func (h *ShangwutongConnectorHandler) UpdateContactMetadata(c *gin.Context) { + inbox, ok := h.authorizedInbox(c) + if !ok { + return + } + if strings.TrimSpace(c.Param("source_id")) == "" { + h.connectorError(c, http.StatusBadRequest, "invalid_source_id", "source_id is required", false) + return + } + var request shangwutongContactMetadataRequest + if err := c.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.CID) == "" { + h.connectorError(c, http.StatusUnprocessableEntity, "invalid_contact_metadata", "cid is required", false) + return + } + var contactInbox model.ContactInbox + if err := h.db.WithContext(c.Request.Context()).Where("inbox_id = ? AND source_id = ?", inbox.ID, c.Param("source_id")).First(&contactInbox).Error; err != nil { + h.connectorError(c, http.StatusNotFound, "not_found", "contact source not found", false) + return + } + metadata := map[string]any{} + if len(contactInbox.ChannelMetadata) > 0 { + _ = json.Unmarshal(contactInbox.ChannelMetadata, &metadata) + } + if metadata["cid"] == strings.TrimSpace(request.CID) { + c.JSON(http.StatusOK, gin.H{"updated": false}) + return + } + metadata["cid"] = strings.TrimSpace(request.CID) + encoded, err := json.Marshal(metadata) + if err != nil { + h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to encode contact metadata", true) + return + } + if err := h.db.WithContext(c.Request.Context()).Model(&contactInbox).Update("channel_metadata", encoded).Error; err != nil { + h.connectorError(c, http.StatusInternalServerError, "contact_metadata_update_failed", "failed to update contact metadata", true) + return + } + c.JSON(http.StatusOK, gin.H{"updated": true}) +} + func NewShangwutongConnectorHandler(db *gorm.DB, messageSvc *service.MessageService) *ShangwutongConnectorHandler { return &ShangwutongConnectorHandler{db: db, messageSvc: messageSvc} } diff --git a/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go b/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go index 9c7560cc..29d570f2 100644 --- a/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go +++ b/backend/internal/handler/api/v1/shangwutong_connector_handler_test.go @@ -124,6 +124,21 @@ func TestShangwutongConnectorMessageResultIsVersionedAndIdempotent(t *testing.T) require.JSONEq(t, `{"shangwutong":["123456","123457"]}`, string(message.ExternalSourceIDs)) } +func TestShangwutongConnectorContactMetadataIsScopedAndIdempotent(t *testing.T) { + router, db, token, inbox, _ := setupShangwutongConnectorAPI(t) + contact := &model.Contact{AccountID: inbox.AccountID, Name: "Visitor"} + require.NoError(t, db.Create(contact).Error) + ci := &model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "visitor"} + require.NoError(t, db.Create(ci).Error) + path := fmt.Sprintf("/api/v1/connector/shangwutong/inboxes/%d/contacts/visitor", inbox.ID) + response := connectorRequest(t, router, token, http.MethodPatch, path, map[string]string{"cid": "cid-1"}) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + response = connectorRequest(t, router, token, http.MethodPatch, path, map[string]string{"cid": "cid-1"}) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + require.NoError(t, db.First(ci, ci.ID).Error) + require.JSONEq(t, `{"cid":"cid-1"}`, string(ci.ChannelMetadata)) +} + func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string, *model.Inbox, *model.Inbox) { t.Helper() gin.SetMode(gin.TestMode) @@ -132,6 +147,7 @@ func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string, require.NoError(t, db.AutoMigrate( &model.Account{}, &model.Inbox{}, &model.ChannelShangwutongConfig{}, &channelmodel.ChannelAPI{}, &model.PlatformApp{}, &model.AccessToken{}, &model.Permissible{}, &model.Contact{}, &model.Conversation{}, + &model.ContactInbox{}, &model.Message{}, &model.Attachment{}, &model.BackgroundJob{}, )) active := true @@ -173,6 +189,7 @@ func setupShangwutongConnectorAPI(t *testing.T) (*gin.Engine, *gorm.DB, string, group.GET("/inboxes/:inbox_id", handler.GetInbox) group.PUT("/inboxes/:inbox_id/status", handler.UpdateInboxStatus) group.PUT("/inboxes/:inbox_id/messages/:message_id/status", handler.UpdateMessageStatus) + group.PATCH("/inboxes/:inbox_id/contacts/:source_id", handler.UpdateContactMetadata) return router, db, token, inboxes[0], inboxes[1] } diff --git a/backend/internal/handler/widget/coverage3_test.go b/backend/internal/handler/widget/coverage3_test.go index 1e1c5f56..91d68e6a 100644 --- a/backend/internal/handler/widget/coverage3_test.go +++ b/backend/internal/handler/widget/coverage3_test.go @@ -771,6 +771,20 @@ func TestWidgetHandler_Cov3_PublicCreateContact_Success_Cov3(t *testing.T) { assert.Equal(t, "New Contact", resp["name"]) } +func TestWidgetHandler_PublicContactIgnoresCID(t *testing.T) { + db, router, _ := setupWidgetHandlerTest(t) + _, _, channelAPI := seedPublicAPIInbox(t, db) + body, _ := json.Marshal(map[string]any{"name": "Visitor", "source_id": "visitor", "channel_metadata": map[string]any{"cid": "forged"}}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/public/api/v1/inboxes/"+channelAPI.Identifier+"/contacts", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var contactInbox model.ContactInbox + require.NoError(t, db.Where("source_id = ?", "visitor").First(&contactInbox).Error) + assert.NotContains(t, string(contactInbox.ChannelMetadata), "forged") +} + func TestWidgetHandler_Cov3_PublicUpdateContact_Success_Cov3(t *testing.T) { db, router, _ := setupWidgetHandlerTest(t) _, _, channelAPI := seedPublicAPIInbox(t, db) diff --git a/backend/internal/handler/widget/widget_handler.go b/backend/internal/handler/widget/widget_handler.go index 4a34fff7..ef414fe6 100644 --- a/backend/internal/handler/widget/widget_handler.go +++ b/backend/internal/handler/widget/widget_handler.go @@ -1402,7 +1402,6 @@ func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, err PhoneNumber string `json:"phone_number"` CustomAttributes map[string]any `json:"custom_attributes"` AdditionalAttributes map[string]any `json:"additional_attributes"` - ChannelMetadata map[string]any `json:"channel_metadata"` } if err := c.ShouldBindJSON(&body); err != nil && c.Request.ContentLength != 0 { return service.PublicContactRequest{}, err @@ -1426,7 +1425,6 @@ func bindPublicContactRequest(c *gin.Context) (service.PublicContactRequest, err PhoneNumber: body.PhoneNumber, CustomAttributes: body.CustomAttributes, AdditionalAttributes: body.AdditionalAttributes, - ChannelMetadata: body.ChannelMetadata, }, nil } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a3337d20..476b918e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -269,6 +269,13 @@ func RegisterRoutes( } handlers.ShangwutongConnector.UpdateMessageStatus(c) }) + connector.PATCH("/inboxes/:inbox_id/contacts/:source_id", func(c *gin.Context) { + if handlers == nil || handlers.ShangwutongConnector == nil { + webhookProviderUnavailable(c) + return + } + handlers.ShangwutongConnector.UpdateContactMetadata(c) + }) // API v1 routes — authenticated, account-scoped apiV1 := engine.Group("/api/v1") diff --git a/backend/internal/service/contact_service.go b/backend/internal/service/contact_service.go index 4adc7e07..e225bb04 100644 --- a/backend/internal/service/contact_service.go +++ b/backend/internal/service/contact_service.go @@ -18,6 +18,7 @@ import ( "gorm.io/datatypes" "gorm.io/gorm" + "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/search" @@ -549,6 +550,7 @@ func (s *ContactService) Update(ctx context.Context, accountID, id uint, req Upd if validationErr := s.validateContactUniqueness(ctx, accountID, contact.ID, req.Email, req.contactPhoneNumber(), req.Identifier); validationErr != nil { return nil, validationErr } + previousName := contact.Name if req.Name != "" { contact.Name = req.Name @@ -590,9 +592,28 @@ func (s *ContactService) Update(ctx context.Context, accountID, id uint, req Upd contact.CompanyID = req.CompanyID } - if err := s.repo.Update(ctx, contact); err != nil { + var eventJob *model.BackgroundJob + var eventCreated bool + if err := s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Save(contact).Error; err != nil { + return err + } + if s.worker != nil && req.Name != "" && req.Name != previousName { + event := channel.NewChannelEvent(channel.EventContactUpdated, channel.ChannelAPI, accountID, 0) + event.ContactID, event.Data["contact"] = contact.ID, contact + event.Data["changed_attributes"] = map[string]any{"name": contact.Name} + var err error + eventJob, eventCreated, err = s.worker.EnqueueInTransaction(ctx, tx, channel.TaskTypeEventDispatch, event, + worker.WithQueue("events"), worker.WithMaxAttempts(10), worker.WithIdempotencyKey(fmt.Sprintf("contact:%d:name:%d", contact.ID, contact.UpdatedAt.UnixNano()))) + return err + } + return nil + }); err != nil { return nil, err } + if eventCreated { + s.worker.Publish(ctx, eventJob) + } s.indexContact(ctx, contact) return contact, nil } diff --git a/backend/internal/service/widget_service.go b/backend/internal/service/widget_service.go index 5f3faa66..994205b1 100644 --- a/backend/internal/service/widget_service.go +++ b/backend/internal/service/widget_service.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "strconv" "strings" "time" @@ -779,7 +780,11 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier if err != nil { return nil, err } - setContactInboxChannelMetadata(existingInbox, req.ChannelMetadata) + if setPublicContactInboxChannelMetadata(existingInbox, req.ChannelMetadata) { + if err := s.contactInboxRepo.Update(ctx, existingInbox); err != nil { + return nil, err + } + } existingInbox.Contact = *contact return &PublicContactResponse{ContactInbox: existingInbox, Contact: contact}, nil } @@ -803,7 +808,7 @@ func (s *WidgetService) PublicCreateContact(ctx context.Context, inboxIdentifier HMACToken: hmacToken, HMACVerified: req.IdentifierHash != "", } - setContactInboxChannelMetadata(contactInbox, req.ChannelMetadata) + setPublicContactInboxChannelMetadata(contactInbox, req.ChannelMetadata) if err := s.contactInboxRepo.Create(ctx, contactInbox); err != nil { return nil, err } @@ -838,7 +843,7 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier if err != nil { return nil, err } - if metadataChanged := setContactInboxChannelMetadata(contactInbox, req.ChannelMetadata); metadataChanged { + if setPublicContactInboxChannelMetadata(contactInbox, req.ChannelMetadata) { if err := s.contactInboxRepo.Update(ctx, contactInbox); err != nil { return nil, err } @@ -852,16 +857,24 @@ func (s *WidgetService) PublicUpdateContact(ctx context.Context, inboxIdentifier return &PublicContactResponse{ContactInbox: contactInbox, Contact: contact}, nil } -func setContactInboxChannelMetadata(contactInbox *model.ContactInbox, metadata map[string]any) bool { +func setPublicContactInboxChannelMetadata(contactInbox *model.ContactInbox, metadata map[string]any) bool { if contactInbox == nil || len(metadata) == 0 { return false } - cid, ok := metadata["cid"].(string) - if !ok || strings.TrimSpace(cid) == "" { - return false + current := jsonMap(contactInbox.ChannelMetadata) + changed := false + for key, value := range metadata { + if key == "cid" { + continue + } + if !reflect.DeepEqual(current[key], value) { + current[key], changed = value, true + } } - contactInbox.ChannelMetadata = mustJSON(map[string]any{"cid": strings.TrimSpace(cid)}) - return true + if changed { + contactInbox.ChannelMetadata = mustJSON(current) + } + return changed } func (s *WidgetService) PublicListConversations(ctx context.Context, inboxIdentifier, sourceID string) ([]model.Conversation, error) { diff --git a/channels/shangwutong/db/migrations/002_add_rename_operation.down.sql b/channels/shangwutong/db/migrations/002_add_rename_operation.down.sql index 8a5b7fa4..bac84fb0 100644 --- a/channels/shangwutong/db/migrations/002_add_rename_operation.down.sql +++ b/channels/shangwutong/db/migrations/002_add_rename_operation.down.sql @@ -14,7 +14,9 @@ CREATE TABLE outbound_operations_old ( created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -INSERT INTO outbound_operations_old SELECT * FROM outbound_operations WHERE operation = 'end_conversation'; +-- The old CHECK constraint intentionally rejects change_contact_name rows. +-- SQLite aborts this INSERT before the source table is dropped, preserving data. +INSERT INTO outbound_operations_old SELECT * FROM outbound_operations; DROP TABLE outbound_operations; ALTER TABLE outbound_operations_old RENAME TO outbound_operations; CREATE INDEX idx_outbound_operations_ready ON outbound_operations(delivery_status, next_attempt_at, account_id, id); diff --git a/channels/shangwutong/internal/delivery/inbound.go b/channels/shangwutong/internal/delivery/inbound.go index f546ba5d..6ae4999b 100644 --- a/channels/shangwutong/internal/delivery/inbound.go +++ b/channels/shangwutong/internal/delivery/inbound.go @@ -23,6 +23,7 @@ const echoMatchWindow = 10 * time.Minute type InboundClient interface { EnsureContact(context.Context, string, string, gochat.ContactRequest) (gochat.Contact, error) UpdateContact(context.Context, string, string, string, gochat.ContactRequest) (gochat.Contact, error) + UpdateContactChannelMetadata(context.Context, int64, string, string) error EnsureConversation(context.Context, string, string, map[string]any) (gochat.PublicConversation, error) ImportMessage(context.Context, int64, int64, gochat.MessageImport) (gochat.ImportedMessage, error) ImportMessageWithAttachments(context.Context, int64, int64, gochat.MessageImport, []gochat.AttachmentUpload) (gochat.ImportedMessage, error) @@ -103,6 +104,12 @@ func (i *Inbound) processInbound(ctx context.Context) (bool, error) { if mapped.RetractionTarget != "" { return true, i.deliverRetraction(ctx, account, event, mapped) } + if mapped.ContactCID != "" { + if err := i.client.UpdateContactChannelMetadata(ctx, account.GochatInboxID, event.SwtSid, mapped.ContactCID); err != nil { + return true, i.retryOrFail(event, mapped, err) + } + return true, i.complete(event, mapped, nil) + } if mapped.RawOnly && !mapped.RequiresContact && !mapped.RequiresConversation && mapped.Message == nil { return true, i.complete(event, mapped, nil) } @@ -162,9 +169,6 @@ func (i *Inbound) ensureResources(ctx context.Context, account *dbgen.Account, e SourceID: event.SwtSid, Name: mapped.ContactName, PhoneNumber: mapped.ContactPhone, CustomAttributes: contactAttributes, AdditionalAttributes: contactAdditional, } - if mapped.ContactCID != "" { - contactRequest.ChannelMetadata = map[string]any{"cid": mapped.ContactCID} - } needsContactWrite := state.contactID == 0 || mapped.ContactName != "" || mapped.ContactPhone != "" || len(mapped.ContactAttributes) > 0 if mapped.RequiresContact && needsContactWrite { var contact gochat.Contact diff --git a/channels/shangwutong/internal/delivery/inbound_test.go b/channels/shangwutong/internal/delivery/inbound_test.go index bceb2a7e..43be10ef 100644 --- a/channels/shangwutong/internal/delivery/inbound_test.go +++ b/channels/shangwutong/internal/delivery/inbound_test.go @@ -417,6 +417,10 @@ func (r *inboundRecorder) UpdateContact(_ context.Context, _, _, sourceID string return gochat.Contact{ID: 456, SourceID: sourceID, Name: request.Name}, nil } +func (*inboundRecorder) UpdateContactChannelMetadata(context.Context, int64, string, string) error { + return nil +} + func (r *inboundRecorder) EnsureConversation(context.Context, string, string, map[string]any) (gochat.PublicConversation, error) { return gochat.PublicConversation{ID: 88, InternalID: 100, InboxID: 10, Status: "open"}, nil } diff --git a/channels/shangwutong/internal/gochat/messaging.go b/channels/shangwutong/internal/gochat/messaging.go index 618d6d74..1a65fcd3 100644 --- a/channels/shangwutong/internal/gochat/messaging.go +++ b/channels/shangwutong/internal/gochat/messaging.go @@ -31,7 +31,15 @@ type ContactRequest struct { AvatarURL string `json:"avatar_url,omitempty"` CustomAttributes map[string]any `json:"custom_attributes,omitempty"` AdditionalAttributes map[string]any `json:"additional_attributes,omitempty"` - ChannelMetadata map[string]any `json:"channel_metadata,omitempty"` +} + +func (c *Client) UpdateContactChannelMetadata(ctx context.Context, inboxID int64, sourceID, cid string) error { + if inboxID <= 0 || strings.TrimSpace(sourceID) == "" || strings.TrimSpace(cid) == "" { + return errors.New("contact channel metadata identifiers are required") + } + path := fmt.Sprintf("/api/v1/connector/shangwutong/inboxes/%d/contacts/%s", inboxID, url.PathEscape(sourceID)) + body := map[string]any{"cid": strings.TrimSpace(cid)} + return c.doJSON(ctx, http.MethodPatch, path, body, nil, fmt.Sprintf("swt-contact-metadata:%d:%s:%s", inboxID, sourceID, cid)) } type Contact struct { diff --git a/channels/shangwutong/internal/gochat/messaging_test.go b/channels/shangwutong/internal/gochat/messaging_test.go index 716fc629..c0c1a357 100644 --- a/channels/shangwutong/internal/gochat/messaging_test.go +++ b/channels/shangwutong/internal/gochat/messaging_test.go @@ -67,6 +67,30 @@ func TestMessagingClientEnsuresIdentityAndImportsWithStableKey(t *testing.T) { } } +func TestMessagingClientUpdatesContactMetadataThroughConnectorRoute(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPatch || request.URL.Path != "/api/v1/connector/shangwutong/inboxes/7/contacts/visitor" { + t.Fatalf("request = %s %s", request.Method, request.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["cid"] != "cid-7" { + t.Fatalf("body = %#v", body) + } + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + client, err := NewClient(server.URL, "token", server.Client()) + if err != nil { + t.Fatal(err) + } + if err := client.UpdateContactChannelMetadata(context.Background(), 7, "visitor", "cid-7"); err != nil { + t.Fatal(err) + } +} + func TestMessagingClientImportsMultipartAttachments(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.Header.Get("Idempotency-Key") != "swt:2:visitor:2:10:0" { diff --git a/channels/shangwutong/internal/store/store_test.go b/channels/shangwutong/internal/store/store_test.go index 8d3113b3..22684488 100644 --- a/channels/shangwutong/internal/store/store_test.go +++ b/channels/shangwutong/internal/store/store_test.go @@ -102,6 +102,36 @@ func TestMigrationVersionParser(t *testing.T) { } } +func TestRollbackMigrationRefusesRenameOperations(t *testing.T) { + db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "rollback.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE outbound_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, swt_sid TEXT NOT NULL, + event_id TEXT NOT NULL UNIQUE, operation TEXT NOT NULL, payload TEXT NOT NULL, + occurred_at DATETIME NOT NULL, delivery_status TEXT NOT NULL, claimed_at DATETIME, + attempts INTEGER NOT NULL, next_attempt_at DATETIME, last_error TEXT, + created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO outbound_operations(account_id,swt_sid,event_id,operation,payload,occurred_at,delivery_status,attempts,created_at,updated_at) VALUES (1,'sid','event','change_contact_name','{}',CURRENT_TIMESTAMP,'pending',0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`); err != nil { + t.Fatal(err) + } + rollback, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", "002_add_rename_operation.down.sql")) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(string(rollback)); err == nil { + t.Fatal("expected rollback to reject change_contact_name") + } + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM outbound_operations").Scan(&count); err != nil || count != 1 { + t.Fatalf("operation count = %d, err=%v", count, err) + } +} + func TestOutboundClaimStopsBehindUncertainMessage(t *testing.T) { ctx := context.Background() store, err := Open(ctx, filepath.Join(t.TempDir(), "connector.db"))