diff --git a/internal/httpapi/payload_userid_test.go b/internal/httpapi/payload_userid_test.go new file mode 100644 index 0000000..85d3258 --- /dev/null +++ b/internal/httpapi/payload_userid_test.go @@ -0,0 +1,81 @@ +package httpapi + +import ( + "strings" + "testing" + "time" + + "git.118im.com/im_team/im_workbench/internal/store" +) + +func TestPayloadUserIDsAreStrings(t *testing.T) { + now := time.Date(2026, 9, 16, 9, 30, 0, 0, time.UTC) + userA, err := parseUserID("usrag7w9xJ6hto") + if err != nil { + t.Fatal(err) + } + userB, err := parseUserID("usr1RqTFgHSenM") + if err != nil { + t.Fatal(err) + } + + payloads := []map[string]any{ + organizationMemberPayload(store.OrganizationMember{UserID: userA}), + attendanceRecordPayload(&store.AttendanceRecord{RecordID: "att_1", UserID: userA, Date: now, CreatedAt: now, UpdatedAt: now}), + attendanceAppealPayload(&store.AttendanceAppeal{AppealID: "atta_1", UserID: userA, SubmittedAt: now, CreatedAt: now, UpdatedAt: now}), + meetingPayload(store.Meeting{ + MeetingID: "mtg_1", + OrganizerID: userA, + RecorderID: userB, + StartAt: now, + EndAt: now.Add(time.Hour), + CreatedAt: now, + UpdatedAt: now, + Participants: []store.MeetingParticipant{ + {UserID: userA, Response: "accepted"}, + {UserID: userB, Response: "pending"}, + }, + }, userA), + meetingNoteFilePayload(store.MeetingNoteFile{NoteFileID: "mnote_1", UploaderID: userA, CreatedAt: now, UpdatedAt: now}, userA), + dailyReportPayload(store.DailyReport{ReportID: "dr_1", AuthorID: userA, ReportDate: now, CreatedAt: now, UpdatedAt: now}), + monthlyReportPayload(store.MonthlyReport{ReportID: "mr_1", AuthorID: userA, CreatedAt: now, UpdatedAt: now}), + mailboxSubmissionPayload(store.MailboxSubmission{SubmissionID: "mb_1", SenderID: userA, SubmittedAt: now}), + } + + for _, payload := range payloads { + assertNoNumericUserIDs(t, payload) + } +} + +func assertNoNumericUserIDs(t *testing.T, value any) { + t.Helper() + switch typed := value.(type) { + case map[string]any: + for key, item := range typed { + if isUserIDKey(key) { + if _, ok := item.(string); !ok { + t.Fatalf("%s is %T, want string", key, item) + } + } + assertNoNumericUserIDs(t, item) + } + case []map[string]any: + for _, item := range typed { + assertNoNumericUserIDs(t, item) + } + case []any: + for _, item := range typed { + assertNoNumericUserIDs(t, item) + } + } +} + +func isUserIDKey(key string) bool { + return key == "user_id" || + key == "organizer_id" || + key == "recorder_user_id" || + key == "uploader_user_id" || + key == "author_id" || + key == "sender_id" || + strings.HasSuffix(key, "_user_id") +} diff --git a/internal/httpapi/uid.go b/internal/httpapi/uid.go index e88e67d..878e524 100644 --- a/internal/httpapi/uid.go +++ b/internal/httpapi/uid.go @@ -27,8 +27,6 @@ type optionalUserID struct { func (id *optionalUserID) UnmarshalJSON(raw []byte) error { if strings.TrimSpace(string(raw)) == "null" { - id.set = true - id.value = 0 return nil } value, err := parseUserIDJSON(raw) @@ -52,11 +50,7 @@ func parseUserIDJSON(raw []byte) (int64, error) { if err := json.Unmarshal(raw, &text); err == nil { return parseUserID(text) } - var number int64 - if err := json.Unmarshal(raw, &number); err == nil { - return number, nil - } - return 0, fmt.Errorf("invalid user id") + return 0, fmt.Errorf("user id must be a string") } func parseUserID(value string) (int64, error) { diff --git a/internal/httpapi/uid_test.go b/internal/httpapi/uid_test.go index 91be278..b61b5e8 100644 --- a/internal/httpapi/uid_test.go +++ b/internal/httpapi/uid_test.go @@ -22,12 +22,9 @@ func TestUserIDJSONAcceptsIMUserID(t *testing.T) { } } -func TestUserIDJSONStillAcceptsNumericID(t *testing.T) { +func TestUserIDJSONRejectsNumericID(t *testing.T) { var id userID - if err := json.Unmarshal([]byte(`1573862062872858624`), &id); err != nil { - t.Fatalf("unmarshal numeric: %v", err) - } - if id != 1573862062872858624 { - t.Fatalf("id=%d", id) + if err := json.Unmarshal([]byte(`1573862062872858624`), &id); err == nil { + t.Fatal("expected numeric user id to be rejected") } }