Files
multica/server/internal/handler/issue_payload_shape_test.go
5d5fe64672 MUL-6343: Add semantic issue activity timestamps (#7193)
* feat: add semantic issue activity timestamps

Co-authored-by: multica-agent <[email protected]>

* fix(migrations): register issue activity index cleanup

Co-authored-by: multica-agent <[email protected]>

* fix(issues): address last activity review

Co-authored-by: multica-agent <[email protected]>

* fix(issues): close last activity review gaps

Co-authored-by: multica-agent <[email protected]>

* fix(issues): harden activity rollout safeguards

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 16:08:40 +08:00

184 lines
6.6 KiB
Go

package handler
import (
"encoding/json"
"sort"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/service"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// An issue reaches clients rendered two different ways: the HTTP handler
// marshals IssueResponse, while events published outside it (autopilot and the
// channel engine's /issue command on issue:created, the background stuck-issue
// status reset on issue:updated) marshal service.IssueToMap.
// Clients type both as a complete Issue and insert the
// object straight into the list cache without runtime validation, so a key
// present in one rendering and absent from the other is a field that reads
// back undefined depending on which entry point created the issue — a project
// that never lands in its project view, a custom-property column that shows
// empty until the next refetch.
//
// Adding a field to IssueResponse without adding it to IssueToMap must fail
// here rather than in the UI.
func TestIssueToMap_KeysMatchIssueResponse(t *testing.T) {
const prefix = "MUL"
issue := fullyPopulatedIssue(t)
responseKeys := jsonKeys(t, issueToResponse(issue, prefix))
mapKeys := jsonKeys(t, service.IssueToMap(issue, prefix))
for _, k := range responseKeys {
if !hasKey(mapKeys, k) {
t.Errorf("service.IssueToMap is missing %q, which handler.IssueResponse emits; "+
"clients treat both as a complete Issue", k)
}
}
for _, k := range mapKeys {
if !hasKey(responseKeys, k) {
t.Errorf("service.IssueToMap emits %q, which handler.IssueResponse does not; "+
"the two renderings must describe the same issue", k)
}
}
}
// The two renderings must also agree on values and JSON types, not just on
// which keys exist — a number sent as a string, or a date formatted
// differently, breaks clients just as quietly as a missing field.
func TestIssueToMap_ValuesMatchIssueResponse(t *testing.T) {
const prefix = "MUL"
issue := fullyPopulatedIssue(t)
response, err := json.Marshal(issueToResponse(issue, prefix))
if err != nil {
t.Fatalf("marshal IssueResponse: %v", err)
}
fromMap, err := json.Marshal(service.IssueToMap(issue, prefix))
if err != nil {
t.Fatalf("marshal IssueToMap: %v", err)
}
var want, got map[string]any
if err := json.Unmarshal(response, &want); err != nil {
t.Fatalf("unmarshal IssueResponse: %v", err)
}
if err := json.Unmarshal(fromMap, &got); err != nil {
t.Fatalf("unmarshal IssueToMap: %v", err)
}
for k, wantValue := range want {
gotValue, ok := got[k]
if !ok {
continue // reported by the key test
}
wantJSON, _ := json.Marshal(wantValue)
gotJSON, _ := json.Marshal(gotValue)
if string(wantJSON) != string(gotJSON) {
t.Errorf("issue[%q]: IssueToMap has %s, IssueResponse has %s", k, gotJSON, wantJSON)
}
}
}
// metadata and properties are documented as always present so clients can
// index them without a nil guard. A row that never had either bag written
// must still broadcast {}, never null.
func TestIssueToMap_UnsetJSONBagsAreEmptyObjects(t *testing.T) {
issue := db.Issue{Number: 42}
raw, err := json.Marshal(service.IssueToMap(issue, "MUL"))
if err != nil {
t.Fatalf("marshal IssueToMap: %v", err)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("unmarshal IssueToMap: %v", err)
}
for _, key := range []string{"metadata", "properties"} {
if got := string(payload[key]); got != "{}" {
t.Errorf("issue[%q] = %s; want {} so clients can index it without a nil guard", key, got)
}
}
}
// A workspace lookup can fail on the chat path, which degrades the prefix to
// "". The identifier is then rendered from the number alone rather than as a
// stray "-42", and the chat reply and the broadcast payload must not disagree
// about it (both call service.IssueIdentifier).
func TestIssueIdentifier_DegradesWithoutPrefix(t *testing.T) {
if got := service.IssueIdentifier("MUL", 42); got != "MUL-42" {
t.Errorf("IssueIdentifier(\"MUL\", 42) = %q; want MUL-42", got)
}
if got := service.IssueIdentifier("", 42); got != "#42" {
t.Errorf("IssueIdentifier(\"\", 42) = %q; want #42", got)
}
}
// fullyPopulatedIssue returns a row with every nullable column set, so a
// rendering that drops a field cannot pass by coincidentally emitting the
// zero value both ways.
func fullyPopulatedIssue(t *testing.T) db.Issue {
t.Helper()
utcDate := func(y int, m time.Month, d int) time.Time {
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
return db.Issue{
ID: parseUUID("11111111-1111-1111-1111-111111111111"),
WorkspaceID: parseUUID("22222222-2222-2222-2222-222222222222"),
Number: 42,
Title: "Fix login",
Description: pgtype.Text{String: "details", Valid: true},
Status: "todo",
Priority: "high",
AssigneeType: pgtype.Text{String: "agent", Valid: true},
AssigneeID: parseUUID("33333333-3333-3333-3333-333333333333"),
CreatorType: "member",
CreatorID: parseUUID("44444444-4444-4444-4444-444444444444"),
ParentIssueID: parseUUID("55555555-5555-5555-5555-555555555555"),
ProjectID: parseUUID("66666666-6666-6666-6666-666666666666"),
Position: 1024.5,
Stage: pgtype.Int4{Int32: 2, Valid: true},
StartDate: pgtype.Date{Time: utcDate(2026, time.January, 1), Valid: true},
DueDate: pgtype.Date{Time: utcDate(2026, time.February, 1), Valid: true},
CreatedAt: pgtype.Timestamptz{Time: utcDate(2026, time.January, 1), Valid: true},
UpdatedAt: pgtype.Timestamptz{Time: utcDate(2026, time.January, 2), Valid: true},
LastActivityAt: pgtype.Timestamptz{Time: utcDate(2026, time.January, 3), Valid: true},
Metadata: []byte(`{"pr_url":"https://example.test/pr/1"}`),
Properties: []byte(`{"77777777-7777-7777-7777-777777777777":"done"}`),
}
}
// jsonKeys marshals a value and returns its top-level JSON keys. Fields tagged
// omitempty (reactions, attachments, labels) are absent from both renderings
// here by construction: they are optional decorations the client already
// nil-guards, not part of the core issue shape this test pins.
func jsonKeys(t *testing.T, v any) []string {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal %T: %v", v, err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatalf("unmarshal %T: %v", v, err)
}
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func hasKey(keys []string, key string) bool {
for _, k := range keys {
if k == key {
return true
}
}
return false
}