Files
multica/server/internal/service/comment_event_fields_test.go
3881f6589f fix(realtime): stamp comment:created broadcasts with the real UTC offset (#7523)
The three comment:created payloads published from TaskService (agent
replies, delegated failure recovery, recovery exhaustion) formatted
created_at with Format("2006-01-02T15:04:05Z"). The trailing "Z" there is
a literal, not Go's Z07:00 offset token, and pgx decodes timestamptz into
the process location (no ScanLocation is configured) — so a deployment
whose server process TZ is not UTC published local wall-clock digits under
a UTC label.

Clients place the entry a whole offset away from its real instant, then
move it again once a refetch replaces the value with the REST rendering,
which uses util.TimestampToString. The web timeline sorts these strings
directly (sortTimelineEntriesAsc), so the two formats also break its
ordering key.

Render all three through util.TimestampToString — the same helper the REST
CommentResponse uses — via one shared commentEventFields renderer, so a
comment's created_at is byte-identical over WS and over HTTP. On a UTC
process the output is unchanged.

Also fixes a latent case: the old format rendered an unset timestamp as
0001-01-01T00:00:00Z, which would sort to the top of every timeline.
util.TimestampToString returns "" instead.

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-25 12:54:00 +08:00

63 lines
2.2 KiB
Go

package service
import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func commentAt(created time.Time) db.Comment {
return db.Comment{
AuthorType: "system",
Content: "task failed",
Type: "system",
CreatedAt: pgtype.Timestamptz{Time: created, Valid: true},
}
}
// A comment:created broadcast must carry the SAME created_at string the REST
// timeline returns for that comment. pgx decodes timestamptz into the process
// location, so formatting with a literal "Z" suffix published local wall-clock
// digits under a UTC label: on an Asia/Shanghai deployment every WS-delivered
// agent/system comment landed 8 hours from its real instant, then jumped back
// into place once a refetch replaced it with the REST value.
func TestCommentEventFieldsCreatedAtCarriesRealOffset(t *testing.T) {
shanghai := time.FixedZone("CST", 8*60*60)
created := time.Date(2026, 8, 24, 17, 7, 5, 0, shanghai)
got, _ := commentEventFields(commentAt(created))["created_at"].(string)
if want := "2026-08-24T17:07:05+08:00"; got != want {
t.Fatalf("created_at = %q, want %q", got, want)
}
parsed, err := time.Parse(time.RFC3339, got)
if err != nil {
t.Fatalf("parse created_at %q: %v", got, err)
}
if !parsed.Equal(created) {
t.Fatalf("created_at %q decodes to %s, want %s", got, parsed.UTC(), created.UTC())
}
}
// The overwhelmingly common deployment runs the server process in UTC, where
// the old literal-"Z" format was already correct. Pin that those clients see a
// byte-identical payload so the fix carries no migration for them.
func TestCommentEventFieldsCreatedAtUTCIsUnchanged(t *testing.T) {
created := time.Date(2026, 8, 24, 9, 7, 5, 0, time.UTC)
got, _ := commentEventFields(commentAt(created))["created_at"].(string)
if want := "2026-08-24T09:07:05Z"; got != want {
t.Fatalf("created_at = %q, want %q", got, want)
}
}
// An unset timestamp must not render as a zero-value instant that would sort
// to the beginning of every timeline.
func TestCommentEventFieldsCreatedAtInvalidIsEmpty(t *testing.T) {
got, ok := commentEventFields(db.Comment{})["created_at"].(string)
if !ok || got != "" {
t.Fatalf("created_at = %q (string=%v), want empty string", got, ok)
}
}