package message import ( "context" "encoding/json" "strconv" "testing" "time" messagedomain "hyapp/services/activity-service/internal/domain/message" ) func TestConfirmIMPayloadActionsExposeAppButtonLabels(t *testing.T) { payloadJSON, err := confirmIMPayloadJSON("cfm_1", messagedomain.RoleInvitationConfirmEvent{ InvitationID: 901, InvitationType: "host", InviterUserID: 1001, TargetUserID: 2002, }, "Alice has invited you to join ABC guild", 1760000000000) if err != nil { t.Fatalf("confirmIMPayloadJSON failed: %v", err) } var payload struct { Type string `json:"type"` Actions []struct { Action string `json:"action"` Label string `json:"label"` Text string `json:"text"` Style string `json:"style"` } `json:"actions"` } if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { t.Fatalf("payload must be valid JSON: %v", err) } if payload.Type != "im_confirm" || len(payload.Actions) != 2 { t.Fatalf("confirm payload shape mismatch: %+v", payload) } // Flutter 现在线上按 label 渲染按钮,text 只作为旧解析兼容字段;两个字段必须一起存在。 if payload.Actions[0].Action != "reject" || payload.Actions[0].Label != "Reject" || payload.Actions[0].Text != "Reject" || payload.Actions[0].Style != "secondary" { t.Fatalf("reject action mismatch: %+v", payload.Actions[0]) } if payload.Actions[1].Action != "accept" || payload.Actions[1].Label != "Accept" || payload.Actions[1].Text != "Accept" || payload.Actions[1].Style != "primary" { t.Fatalf("accept action mismatch: %+v", payload.Actions[1]) } // 外管邀请使用独立 subtype 保护 Lalu 原有邀请状态机,但通知文案仍须 // 呈现实际业务动作,不能退化为无上下文的通用确认提示。 externalText := roleInvitationMessageText(messagedomain.RoleInvitationConfirmEvent{ InvitationType: "external_host", InviterUserID: 1001, AgencyName: "ABC", Inviter: messagedomain.RoleInvitationUserSnapshot{Username: "Alice"}, }) if externalText != "Alice has invited you to join the ABC guild" { t.Fatalf("external host invitation text mismatch: %q", externalText) } } func TestExternalRoleInvitationCreatesC2CConfirmAndProcessesAppAction(t *testing.T) { tests := []struct { invitationType string agencyName string wantText string action string wantStatus string }{ { invitationType: "external_bd", wantText: "Alice has invited you to become BD", action: messagedomain.ActionConfirmActionAccept, wantStatus: messagedomain.ActionConfirmStatusAccepted, }, { invitationType: "external_agency", agencyName: "Team Alpha", wantText: "Alice has invited you to create the Team Alpha guild", action: messagedomain.ActionConfirmActionAccept, wantStatus: messagedomain.ActionConfirmStatusAccepted, }, { invitationType: "external_host", agencyName: "Team Alpha", wantText: "Alice has invited you to join the Team Alpha guild", action: messagedomain.ActionConfirmActionReject, wantStatus: messagedomain.ActionConfirmStatusRejected, }, } for index, test := range tests { t.Run(test.invitationType, func(t *testing.T) { repository := &actionConfirmRepositoryFake{} roleClient := &roleInvitationClientFake{invitationType: test.invitationType} service := NewActionConfirm(Config{NodeID: "activity-test"}, repository, roleClient) service.SetClock(func() time.Time { return time.UnixMilli(1_760_000_000_000) }) invitationID := int64(901 + index) event := messagedomain.RoleInvitationConfirmEvent{ SourceEventID: "uout-external-" + test.invitationType, InvitationID: invitationID, InvitationType: test.invitationType, InviterUserID: 1001, TargetUserID: 2002, AgencyName: test.agencyName, Inviter: messagedomain.RoleInvitationUserSnapshot{Username: "Alice"}, CreatedAtMS: 1_760_000_000_000, RawPayloadJSON: `{"source":"user_outbox"}`, } confirm, created, err := service.ConsumeRoleInvitationCreated(context.Background(), event) if err != nil { t.Fatalf("ConsumeRoleInvitationCreated failed: %v", err) } if !created || confirm.BusinessSubtype != test.invitationType || confirm.BusinessID != strconv.FormatInt(invitationID, 10) || confirm.SenderUserID != 1001 || confirm.ReceiverUserID != 2002 || confirm.MessageText != test.wantText { t.Fatalf("external invitation confirm projection mismatch: %+v", confirm) } var payload confirmIMPayload if err := json.Unmarshal([]byte(repository.savedOutbox.PayloadJSON), &payload); err != nil { t.Fatalf("decode C2C confirm payload failed: %v", err) } if payload.Type != "im_confirm" || payload.BusinessType != messagedomain.ActionConfirmBusinessRoleInvitation || payload.BusinessSubtype != test.invitationType || payload.SenderUserID != "1001" || payload.ReceiverUserID != "2002" || payload.Msg != test.wantText || len(payload.Actions) != 2 { t.Fatalf("external invitation C2C payload mismatch: %+v", payload) } var processed messagedomain.ActionConfirm if test.action == messagedomain.ActionConfirmActionAccept { processed, err = service.AcceptActionConfirm(context.Background(), 2002, confirm.ConfirmID, "app-accept-"+test.invitationType) } else { processed, err = service.RejectActionConfirm(context.Background(), 2002, confirm.ConfirmID, "app-reject-"+test.invitationType, "not available") } if err != nil { t.Fatalf("process App confirmation failed: %v", err) } if processed.Status != test.wantStatus || processed.Action != test.action { t.Fatalf("App confirmation terminal state mismatch: %+v", processed) } if roleClient.lastInput.ActorUserID != 2002 || roleClient.lastInput.InvitationID != invitationID || roleClient.lastInput.Action != test.action { t.Fatalf("ProcessRoleInvitation command mismatch: %+v", roleClient.lastInput) } }) } } type actionConfirmRepositoryFake struct { savedConfirm messagedomain.ActionConfirm savedOutbox messagedomain.ActionConfirmOutbox } func (r *actionConfirmRepositoryFake) SaveActionConfirmWithOutbox(_ context.Context, confirm messagedomain.ActionConfirm, outbox messagedomain.ActionConfirmOutbox) (bool, messagedomain.ActionConfirm, error) { r.savedConfirm = confirm r.savedOutbox = outbox return true, confirm, nil } func (r *actionConfirmRepositoryFake) ClaimActionConfirm(_ context.Context, confirmID string, actorUserID int64, action string, commandID string, _ string, _ int64, _ int64) (messagedomain.ActionConfirm, bool, error) { confirm := r.savedConfirm if confirm.ConfirmID != confirmID || confirm.ReceiverUserID != actorUserID { return messagedomain.ActionConfirm{}, false, nil } confirm.Status = messagedomain.ActionConfirmStatusProcessing confirm.Action = action confirm.CommandID = commandID r.savedConfirm = confirm return confirm, true, nil } func (r *actionConfirmRepositoryFake) CompleteActionConfirm(_ context.Context, confirmID string, commandID string, status string, action string, processedAtMS int64) (messagedomain.ActionConfirm, error) { confirm := r.savedConfirm confirm.ConfirmID = confirmID confirm.CommandID = commandID confirm.Status = status confirm.Action = action confirm.ProcessedAtMS = processedAtMS r.savedConfirm = confirm return confirm, nil } func (r *actionConfirmRepositoryFake) ReleaseActionConfirm(context.Context, string, string, string, int64) error { return nil } func (r *actionConfirmRepositoryFake) BatchGetActionConfirms(context.Context, int64, []string) ([]messagedomain.ActionConfirm, error) { return nil, nil } func (r *actionConfirmRepositoryFake) ListConversationActionConfirms(context.Context, int64, int64, int, int64, string) ([]messagedomain.ActionConfirm, error) { return nil, nil } func (r *actionConfirmRepositoryFake) ClaimPendingActionConfirmOutbox(context.Context, string, int64, int64, int) ([]messagedomain.ActionConfirmOutbox, error) { return nil, nil } func (r *actionConfirmRepositoryFake) MarkActionConfirmOutboxDelivered(context.Context, string, int64) error { return nil } func (r *actionConfirmRepositoryFake) MarkActionConfirmOutboxRetryable(context.Context, string, string, int64, int64) error { return nil } type roleInvitationClientFake struct { invitationType string lastInput ProcessRoleInvitationInput } func (c *roleInvitationClientFake) ProcessRoleInvitation(_ context.Context, input ProcessRoleInvitationInput) (RoleInvitationState, error) { c.lastInput = input status := messagedomain.ActionConfirmStatusAccepted if input.Action == messagedomain.ActionConfirmActionReject { status = messagedomain.ActionConfirmStatusRejected } return RoleInvitationState{ InvitationID: input.InvitationID, InvitationType: c.invitationType, Status: status, }, nil } func (c *roleInvitationClientFake) GetRoleInvitation(_ context.Context, actorUserID int64, invitationID int64) (RoleInvitationState, error) { return RoleInvitationState{ InvitationID: invitationID, InvitationType: c.invitationType, Status: messagedomain.ActionConfirmStatusPending, }, nil }