Files
multica/server/internal/util/pgx_test.go
Bohan Jiang 5900d8b637 fix(issues): make start_date/due_date timezone-stable calendar days (#3618) (#3692)
* fix(issues): store start_date/due_date as DATE, not timestamp (MUL-2925)

These fields are calendar days (the pickers offer no time-of-day), but were
stored as TIMESTAMPTZ. A client serializing local midnight via toISOString()
folded its timezone into the instant, so the day shifted by the local offset
(GH #3618). Migrate the columns to DATE and parse/serialize date-only
"YYYY-MM-DD". ParseCalendarDate still accepts legacy RFC3339 (truncated to the
UTC day) so older clients keep working.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): render start_date/due_date as timezone-stable calendar days (MUL-2925)

Pickers now emit date-only "YYYY-MM-DD" (local calendar day) instead of
toISOString(), and every read formats via the shared @multica/core/issues/date
helpers with timeZone:"UTC" so the day never shifts with the viewer's offset.
The Gantt's existing UTC bucketing is now correct. Covers web/desktop pickers,
quick-set menu, list/board/detail/activity, and the mobile due-date picker.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): address date-only review — loud-fail ambiguous dates, finish display sweep (MUL-2925)

Review follow-ups on #3692:
- ParseCalendarDate no longer silently truncates a legacy non-midnight RFC3339
  to the wrong UTC day; it accepts only YYYY-MM-DD or an exact UTC-midnight
  instant and rejects ambiguous ones loudly. Adds util unit tests.
- migration 112 pins the TIMESTAMPTZ->DATE conversion to UTC explicitly via
  AT TIME ZONE 'UTC' (was session-timezone dependent); down migration too.
- Convert remaining date-change display sites to formatDateOnly: inbox detail
  label (web) and mobile activity + inbox labels (were new Date()+local format).
- CLI --start-date/--due-date help now says YYYY-MM-DD, not RFC3339.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-03 14:34:01 +08:00

117 lines
3.3 KiB
Go

package util
import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
func TestParseUUID_Valid(t *testing.T) {
u, err := ParseUUID("550e8400-e29b-41d4-a716-446655440000")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if !u.Valid {
t.Fatalf("expected u.Valid = true")
}
}
func TestParseUUID_InvalidReturnsError(t *testing.T) {
cases := []string{"", "not-a-uuid", "MUL-123", "12345"}
for _, s := range cases {
t.Run(s, func(t *testing.T) {
u, err := ParseUUID(s)
if err == nil {
t.Fatalf("expected error for %q, got nil (u.Valid=%v)", s, u.Valid)
}
if u.Valid {
// Critical invariant: invalid input must NOT yield a valid UUID.
// Returning a valid zero-UUID was the root cause of #1661.
t.Fatalf("expected u.Valid = false for %q, got true", s)
}
})
}
}
func TestMustParseUUID_PanicsOnInvalid(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("expected MustParseUUID to panic on invalid input")
}
}()
MustParseUUID("not-a-uuid")
}
func TestMustParseUUID_RoundTrip(t *testing.T) {
const s = "550e8400-e29b-41d4-a716-446655440000"
u := MustParseUUID(s)
if got := UUIDToString(u); got != s {
t.Fatalf("round-trip mismatch: got %q want %q", got, s)
}
}
func TestParseCalendarDate_DateOnly(t *testing.T) {
d, err := ParseCalendarDate("2026-03-01")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got := DateToPtr(d); got == nil || *got != "2026-03-01" {
t.Fatalf("round-trip mismatch: got %v want 2026-03-01", got)
}
}
func TestParseCalendarDate_AcceptsUTCMidnight(t *testing.T) {
// A UTC-midnight instant unambiguously denotes that calendar day.
d, err := ParseCalendarDate("2026-03-01T00:00:00Z")
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got := DateToPtr(d); got == nil || *got != "2026-03-01" {
t.Fatalf("got %v want 2026-03-01", got)
}
}
func TestParseCalendarDate_RejectsNonMidnightInstant(t *testing.T) {
// The legacy bug: UTC+8 picking 2026-03-01 sent 2026-02-28T16:00:00Z. Its
// intended calendar day is unrecoverable, so reject instead of silently
// storing the wrong day (2026-02-28).
cases := []string{
"2026-02-28T16:00:00Z", // UTC+8 local midnight
"2026-03-01T05:00:00Z", // UTC-5 local midnight
"2026-03-01T00:00:00+08:00",
}
for _, s := range cases {
t.Run(s, func(t *testing.T) {
if _, err := ParseCalendarDate(s); err == nil {
t.Fatalf("expected error for non-midnight instant %q, got nil", s)
}
})
}
}
func TestParseCalendarDate_RejectsGarbage(t *testing.T) {
for _, s := range []string{"", "not-a-date", "03/01/2026", "2026-13-40"} {
t.Run(s, func(t *testing.T) {
if _, err := ParseCalendarDate(s); err == nil {
t.Fatalf("expected error for %q, got nil", s)
}
})
}
}
func TestDateToPtr_NullIsNil(t *testing.T) {
if got := DateToPtr(pgtype.Date{Valid: false}); got != nil {
t.Fatalf("expected nil for invalid date, got %v", *got)
}
}
// Guard against a localtime regression: DateToPtr must emit the stored calendar
// day regardless of the host process timezone.
func TestDateToPtr_StableAcrossTimezone(t *testing.T) {
d := pgtype.Date{Time: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), Valid: true}
if got := DateToPtr(d); got == nil || *got != "2026-03-01" {
t.Fatalf("got %v want 2026-03-01", got)
}
}