From 1ef0670624ecefdbb3af21f86120702ee16b13f6 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 19 Aug 2026 12:05:25 +0100 Subject: [PATCH 1/4] Add mail attachment download support for 2dph integration. This gives go-onlyoffice a cookie-backed attachment download path so 2dph can reuse the canonical OnlyOffice client instead of keeping a local mail adapter. --- client.go | 4 +++- mails.go | 34 ++++++++++++++++++++++++++++ mails_test.go | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/client.go b/client.go index f8805c6..b187cc9 100644 --- a/client.go +++ b/client.go @@ -11,6 +11,7 @@ package onlyoffice import ( "net/http" + "net/http/cookiejar" "os" "strings" ) @@ -35,8 +36,9 @@ type Client struct { // NewClient returns a new Client backed by http.DefaultClient. func NewClient(c Credentials) *Client { + jar, _ := cookiejar.New(nil) return &Client{ - client: http.DefaultClient, + client: &http.Client{Jar: jar}, credentials: &c, } } diff --git a/mails.go b/mails.go index 5689c6c..491f725 100644 --- a/mails.go +++ b/mails.go @@ -7,7 +7,9 @@ import ( "context" "encoding/json" "fmt" + "io" "net/mail" + "net/http" "net/url" "strconv" "strings" @@ -96,6 +98,38 @@ func (c *Client) GetMailMessage(ctx context.Context, messageID string) (map[stri return c.ResponseObject(ctx, "/api/2.0/mail/messages/"+url.PathEscape(id)) } +// DownloadMailAttachment fetches raw attachment bytes by mail attachment id via +// the mail addon's download.ashx handler. This path relies on the session +// cookie captured during authentication, so NewClient configures a cookie jar. +func (c *Client) DownloadMailAttachment(ctx context.Context, attachmentID string) ([]byte, error) { + id := strings.TrimSpace(attachmentID) + if id == "" { + return nil, fmt.Errorf("DownloadMailAttachment: attachment id is required") + } + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL()+"/addons/mail/httphandlers/download.ashx?attachid="+url.QueryEscape(id), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("DownloadMailAttachment %s: %d %s", id, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + // RemoveMailMessages deletes messages by id (PUT /api/2.0/mail/messages/remove). // The API response "response" field may be a number or object; success is HTTP 2xx. func (c *Client) RemoveMailMessages(ctx context.Context, ids ...int) (map[string]any, error) { diff --git a/mails_test.go b/mails_test.go index be81255..86b2374 100644 --- a/mails_test.go +++ b/mails_test.go @@ -1,8 +1,13 @@ package onlyoffice import ( + "context" + "net/http" + "net/http/cookiejar" + "net/http/httptest" "strings" "testing" + "time" ) func TestResolveMailFolder(t *testing.T) { @@ -97,3 +102,60 @@ func TestInt64FromMap(t *testing.T) { t.Fatal("string") } } + +func TestNewClientSetsCookieJar(t *testing.T) { + c := NewClient(Credentials{Url: "https://example.test", User: "u", Password: "p"}) + if c.client == nil { + t.Fatal("client is nil") + } + if c.client.Jar == nil { + t.Fatal("cookie jar is nil") + } + if _, ok := c.client.Jar.(*cookiejar.Jar); !ok { + t.Fatalf("unexpected jar type %T", c.client.Jar) + } +} + +func TestDownloadMailAttachmentUsesAuthCookie(t *testing.T) { + var gotAuth, gotCookie, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/2.0/authentication.json": + http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: "abc123", Path: "/"}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`)) + case "/addons/mail/httphandlers/download.ashx": + gotAuth = r.Header.Get("Authorization") + gotCookie = r.Header.Get("Cookie") + gotPath = r.URL.RequestURI() + if gotCookie == "" { + http.Error(w, "missing cookie", http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte("payload")) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + body, err := c.DownloadMailAttachment(ctx, "42") + if err != nil { + t.Fatalf("DownloadMailAttachment: %v", err) + } + if string(body) != "payload" { + t.Fatalf("body = %q", body) + } + if gotAuth != "tok" { + t.Fatalf("auth header = %q", gotAuth) + } + if !strings.Contains(gotCookie, "sessionid=abc123") { + t.Fatalf("cookie header = %q", gotCookie) + } + if gotPath != "/addons/mail/httphandlers/download.ashx?attachid=42" { + t.Fatalf("path = %q", gotPath) + } +} From 666be883dcb4e7610348e77fe460f88759d94e49 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 19 Aug 2026 12:10:21 +0100 Subject: [PATCH 2/4] Add oo CLI support for mail attachment downloads. This exposes the canonical attachment download path via oo mails so downstream tools like 2dph can migrate off local OnlyOffice mail adapters without taking a direct module dependency first. --- cmd/oo/mails.go | 50 ++++++++++++++++++++++++++++++++++++++++ cmd/oo/mails_cli_test.go | 28 ++++++++++++++++++++++ cmd/oo/main.go | 2 +- 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 cmd/oo/mails_cli_test.go diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go index 3b90c5a..d6d5b4d 100644 --- a/cmd/oo/mails.go +++ b/cmd/oo/mails.go @@ -22,6 +22,7 @@ func init() { mailsCmd.AddCommand(mailsFoldersCmd()) mailsCmd.AddCommand(mailsListCmd()) mailsCmd.AddCommand(mailsGetCmd()) + mailsCmd.AddCommand(mailsDownloadAttachmentCmd()) mailsCmd.AddCommand(mailsDraftCmd()) mailsCmd.AddCommand(mailsAttachCmd()) mailsCmd.AddCommand(mailsDraftInvoiceCmd()) @@ -131,6 +132,48 @@ func mailsGetCmd() *cobra.Command { } } +func mailsDownloadAttachmentCmd() *cobra.Command { + var outPath string + cmd := &cobra.Command{ + Use: "download-attachment ATTACHMENT_ID", + Short: "Download a mail attachment by attachment id", + Long: `Download a raw attachment from OnlyOffice Mail's download.ashx handler. + +Example: + oo mails download-attachment 12345 --out /tmp/attach.bin +`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(outPath) == "" { + return fmt.Errorf("--out is required") + } + c, err := newOO(cmd) + if err != nil { + return err + } + body, err := c.DownloadMailAttachment(cmd.Context(), args[0]) + if err != nil { + return err + } + if err := writeMailAttachment(outPath, body); err != nil { + return err + } + if outputFormat == "json" { + printObject(map[string]any{ + "attachmentId": args[0], + "bytes": len(body), + "path": outPath, + }) + return nil + } + fmt.Printf("saved %d bytes to %s\n", len(body), outPath) + return nil + }, + } + cmd.Flags().StringVar(&outPath, "out", "", "output file path") + return cmd +} + func mailsDraftCmd() *cobra.Command { var from, to, cc, bcc, subject, body, html string var id int64 @@ -297,6 +340,13 @@ func formatInvoiceCostEUR(v any) string { return s } +func writeMailAttachment(path string, body []byte) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("attachment output path is required") + } + return os.WriteFile(path, body, 0o644) +} + func mailsDeleteCmd() *cobra.Command { return &cobra.Command{ Use: "delete ID [ID...]", diff --git a/cmd/oo/mails_cli_test.go b/cmd/oo/mails_cli_test.go new file mode 100644 index 0000000..dd69516 --- /dev/null +++ b/cmd/oo/mails_cli_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteMailAttachment(t *testing.T) { + path := filepath.Join(t.TempDir(), "attach.bin") + body := []byte("payload") + if err := writeMailAttachment(path, body); err != nil { + t.Fatalf("writeMailAttachment: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != string(body) { + t.Fatalf("body = %q", got) + } +} + +func TestWriteMailAttachmentRequiresPath(t *testing.T) { + if err := writeMailAttachment("", []byte("x")); err == nil { + t.Fatal("expected error for empty path") + } +} diff --git a/cmd/oo/main.go b/cmd/oo/main.go index 13c38fc..30ba5b6 100644 --- a/cmd/oo/main.go +++ b/cmd/oo/main.go @@ -13,7 +13,7 @@ // oo cases list | create | delete | member-add // oo crm-tasks list | create | delete | categories // oo crm cleanup -// oo mails accounts | folders | list | get | draft | attach | draft-invoice | delete +// oo mails accounts | folders | list | get | download-attachment | draft | attach | draft-invoice | delete // oo invoices list | get | create | update | pdf | pdf-cleanup | status | delete | items … // // CRM association rules: docs/crm-associations.md From dda2bd3ca5cea6ebb89e1ac02b785b9838727bda Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 21 Aug 2026 16:43:00 +0100 Subject: [PATCH 3/4] =?UTF-8?q?feat(mail):=20oo=20mails=20send=20=E2=80=94?= =?UTF-8?q?=20SendMail=20+=20guard=20empty-by-id=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Client.SendMail: PUT /api/2.0/mail/messages/send.json, omits empty cc/bcc - oo mails send subcommand with --id/--to/--subject/--body/--cc/--bcc - guard: sending by --id without --body errors (send.json does not copy subject/body from the draft; prevents empty emails) - unit test: empty cc/bcc omitted, correct path --- cmd/oo/mails.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ mails.go | 45 ++++++++++++++++++++++++++++++++- mails_test.go | 59 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 166 insertions(+), 4 deletions(-) diff --git a/cmd/oo/mails.go b/cmd/oo/mails.go index d6d5b4d..c49f362 100644 --- a/cmd/oo/mails.go +++ b/cmd/oo/mails.go @@ -26,6 +26,7 @@ func init() { mailsCmd.AddCommand(mailsDraftCmd()) mailsCmd.AddCommand(mailsAttachCmd()) mailsCmd.AddCommand(mailsDraftInvoiceCmd()) + mailsCmd.AddCommand(mailsSendCmd()) mailsCmd.AddCommand(mailsDeleteCmd()) } @@ -347,6 +348,71 @@ func writeMailAttachment(path string, body []byte) error { return os.WriteFile(path, body, 0o644) } +func mailsSendCmd() *cobra.Command { + var from, to, cc, bcc, subject, body, html string + var id int64 + cmd := &cobra.Command{ + Use: "send", + Short: "Send a mail message (OnlyOffice Mail)", + Long: `Send via PUT /api/2.0/mail/messages/send.json. + + oo mails send --id 7803 --body "…" # send referencing a draft id + oo mails send --to a@b.com --subject "…" --body "…" + oo mails send --id 7803 --to a@b.com --subject "…" --body "…" --cc x@y.com + +IMPORTANT: send.json does NOT copy subject/body from the referenced draft — the +content must be in this request (--subject/--body). Cc/Bcc are omitted when empty +(the API 400s on empty strings). The API send does not append the UI signature — +put the chat line in --body if needed. +`, + RunE: func(cmd *cobra.Command, args []string) error { + if to == "" && id == 0 { + return fmt.Errorf("--to is required (or --id of an existing draft)") + } + htmlBody := body + if html != "" { + htmlBody = html + } + if htmlBody == "" && id != 0 { + // The send.json endpoint does NOT copy subject/body from the + // referenced draft — an empty body here sends an empty message. + // Warn instead of silently mailing an empty email. + return fmt.Errorf("--body/--html is required when sending by --id (send.json needs the content in the request)") + } + if htmlBody == "" && to == "" { + return fmt.Errorf("--body is required for a fresh message") + } + c, err := newOO(cmd) + if err != nil { + return err + } + raw, err := c.SendMail(cmd.Context(), onlyoffice.SendMailParams{ + ID: id, + From: from, + To: to, + Cc: cc, + Bcc: bcc, + Subject: subject, + Body: htmlBody, + }) + if err != nil { + return err + } + fmt.Println(string(raw)) + return nil + }, + } + cmd.Flags().Int64Var(&id, "id", 0, "existing draft id to send (0 = fresh message)") + cmd.Flags().StringVar(&from, "from", "", "from address (default: first enabled mailbox)") + cmd.Flags().StringVar(&to, "to", "", "recipient (required unless --id)") + cmd.Flags().StringVar(&cc, "cc", "", "cc") + cmd.Flags().StringVar(&bcc, "bcc", "", "bcc") + cmd.Flags().StringVar(&subject, "subject", "", "subject") + cmd.Flags().StringVar(&body, "body", "", "plain text or HTML body") + cmd.Flags().StringVar(&html, "html", "", "HTML body (alias of --body when set)") + return cmd +} + func mailsDeleteCmd() *cobra.Command { return &cobra.Command{ Use: "delete ID [ID...]", diff --git a/mails.go b/mails.go index 491f725..d15f821 100644 --- a/mails.go +++ b/mails.go @@ -8,8 +8,8 @@ import ( "encoding/json" "fmt" "io" - "net/mail" "net/http" + "net/mail" "net/url" "strconv" "strings" @@ -193,6 +193,49 @@ func (c *Client) SaveMailDraft(ctx context.Context, p SaveMailDraftParams) (map[ return c.putJSONObject(ctx, "/api/2.0/mail/drafts/save", body) } +// SendMailParams describes a message to send via PUT /api/2.0/mail/messages/send. +// ID refers to an existing draft/message id; From falls back to the first enabled +// mailbox. Cc/Bcc are omitted when empty (the API 400s on empty strings). Chat +// line goes into Body (API send does not append the UI signature). +type SendMailParams struct { + ID int64 + From string + To string + Cc string + Bcc string + Subject string + Body string // HTML +} + +// SendMail sends an existing draft (or a fresh message) via the OnlyOffice Mail +// send endpoint. Returns the raw send response. +func (c *Client) SendMail(ctx context.Context, p SendMailParams) (json.RawMessage, error) { + if strings.TrimSpace(p.To) == "" { + return nil, fmt.Errorf("SendMail: to is required") + } + if strings.TrimSpace(p.From) == "" { + from, err := c.defaultMailFrom(ctx) + if err != nil { + return nil, err + } + p.From = from + } + body := map[string]any{ + "id": p.ID, + "from": p.From, + "to": p.To, + "subject": p.Subject, + "body": p.Body, + } + if strings.TrimSpace(p.Cc) != "" { + body["cc"] = p.Cc + } + if strings.TrimSpace(p.Bcc) != "" { + body["bcc"] = p.Bcc + } + return c.putJSON(ctx, "/api/2.0/mail/messages/send.json", body) +} + func (c *Client) defaultMailFrom(ctx context.Context) (string, error) { accounts, err := c.ListMailAccounts(ctx) if err != nil { diff --git a/mails_test.go b/mails_test.go index 86b2374..bf9a35c 100644 --- a/mails_test.go +++ b/mails_test.go @@ -2,6 +2,7 @@ package onlyoffice import ( "context" + "encoding/json" "net/http" "net/http/cookiejar" "net/http/httptest" @@ -49,9 +50,9 @@ func TestMailMessagesPath(t *testing.T) { func TestParseMailAddress(t *testing.T) { tests := []struct { - raw string - wantName string - wantAddress string + raw string + wantName string + wantAddress string }{ {`"LinkedIn Jobbenachrichtigungen" `, "LinkedIn Jobbenachrichtigungen", "jobalerts-noreply@linkedin.com"}, {`"Bitfinex" `, "Bitfinex", "no-reply@bitfinex.com"}, @@ -159,3 +160,55 @@ func TestDownloadMailAttachmentUsesAuthCookie(t *testing.T) { t.Fatalf("path = %q", gotPath) } } + +func TestSendMailOmitsEmptyCcBcc(t *testing.T) { + var gotBody map[string]any + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/2.0/authentication.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"response":{"token":"tok","expires":"2099-01-01T00:00:00.0000000+00:00"}}`)) + case "/api/2.0/mail/messages/send.json": + gotPath = r.URL.Path + dec := json.NewDecoder(r.Body) + _ = dec.Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"response":{"id":1}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"}) + ctx := context.Background() + raw, err := c.SendMail(ctx, SendMailParams{ + ID: 99, + From: "me@x.com", + To: "a@b.com", + Subject: "hi", + Body: "

hello

", + }) + if err != nil { + t.Fatalf("SendMail: %v", err) + } + if gotPath != "/api/2.0/mail/messages/send.json" { + t.Fatalf("path = %q", gotPath) + } + if _, hasCC := gotBody["cc"]; hasCC { + t.Fatalf("empty cc should be omitted: %v", gotBody) + } + if _, hasBcc := gotBody["bcc"]; hasBcc { + t.Fatalf("empty bcc should be omitted: %v", gotBody) + } + if gotBody["to"] != "a@b.com" { + t.Fatalf("to = %v", gotBody["to"]) + } + if gotBody["id"] != float64(99) { + t.Fatalf("id = %v", gotBody["id"]) + } + if !strings.Contains(string(raw), `"id"`) { + t.Fatalf("raw = %s", raw) + } +} From 9c450a0352c25f12ac16d91062744ab026ffe660 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Fri, 21 Aug 2026 22:02:47 +0100 Subject: [PATCH 4/4] feat(crm): contact email & person-opportunity indexes, history entity whitelist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BuildContactEmailIndex: one-pass email→contact id map over all persons (O(N) instead of per-address FindPersonByEmail O(N×M)) - BuildPersonOpportunityIndex: person→lowest-id opportunity for history notes (OO has no person-level history; verified live) - HistoryEntity{Opportunity,Case} whitelist constants - IsCompany/ContactID/NumericIDLess/SortIDs helpers with unit tests --- contact_indexes.go | 96 +++++++++++++++++++++++++++++++++++++++++ contact_indexes_test.go | 65 ++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 contact_indexes.go create mode 100644 contact_indexes_test.go diff --git a/contact_indexes.go b/contact_indexes.go new file mode 100644 index 0000000..e056476 --- /dev/null +++ b/contact_indexes.go @@ -0,0 +1,96 @@ +package onlyoffice + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" +) + +// History entities that OnlyOffice CRM actually accepts for history notes. +// There is NO person/contact history in this API version: POST /api/2.0/crm/history.json +// returns 400 "Value does not fall within the expected range." for entityType +// contact/person/people/client/member. Verified against a live instance (#74). +const ( + HistoryEntityOpportunity = "opportunity" + HistoryEntityCase = "case" +) + +// IsCompany reports whether a CRM contact row is a company (vs a person). +// The field arrives as JSON bool; be liberal about what we accept. +func IsCompany(person map[string]any) bool { + b, _ := person["isCompany"].(bool) + return b +} + +// ContactID returns the CRM id of a contact row as a plain string. +func ContactID(row map[string]any) string { + return fmt.Sprint(row["id"]) +} + +// BuildContactEmailIndex scans all persons once and maps lowercase email → +// contact id. Use this instead of calling FindPersonByEmail per address: +// the index is O(N) over the whole CRM, the per-address lookup is O(N×M). +func (c *Client) BuildContactEmailIndex(ctx context.Context) (map[string]string, error) { + all, err := c.ListAllContacts(ctx) + if err != nil { + return nil, err + } + index := make(map[string]string, len(all)*2) + for _, person := range all { + if IsCompany(person) { + continue + } + id := ContactID(person) + for _, row := range ContactInfoRows(person) { + if NormalizeContactInfoType(fmt.Sprint(row["infoType"])) != "email" { + continue + } + email := strings.ToLower(strings.TrimSpace(fmt.Sprint(row["data"]))) + if email != "" && email != "" { + index[email] = id + } + } + } + return index, nil +} + +// BuildPersonOpportunityIndex maps every opportunity member's contact id to a +// deterministic representative opportunity: the one with the lowest numeric id. +// OnlyOffice has no person-level history, so notes for a person go on their +// deal — this index answers "which deal" in one pass. +func (c *Client) BuildPersonOpportunityIndex(ctx context.Context) (map[string]string, error) { + opps, err := c.ListAllOpportunities(ctx) + if err != nil { + return nil, err + } + index := map[string]string{} + for _, opp := range opps { + oppID := ContactID(opp) + for _, member := range OpportunityMembers(opp) { + pid := ContactID(member) + if cur, ok := index[pid]; !ok || NumericIDLess(oppID, cur) { + index[pid] = oppID + } + } + } + return index, nil +} + +// NumericIDLess compares two string ids numerically when possible, falling +// back to lexicographic order so results stay deterministic either way. +func NumericIDLess(a, b string) bool { + na, errA := strconv.Atoi(strings.TrimSpace(a)) + nb, errB := strconv.Atoi(strings.TrimSpace(b)) + if errA == nil && errB == nil && na != nb { + return na < nb + } + return a < b +} + +// SortIDs orders id strings deterministically (numeric first, then lexical). +func SortIDs(ids []string) { + sort.Strings(ids) + sort.SliceStable(ids, func(i, j int) bool { return NumericIDLess(ids[i], ids[j]) }) +} diff --git a/contact_indexes_test.go b/contact_indexes_test.go new file mode 100644 index 0000000..50677a9 --- /dev/null +++ b/contact_indexes_test.go @@ -0,0 +1,65 @@ +package onlyoffice + +import "testing" + +func TestIsCompany(t *testing.T) { + if IsCompany(map[string]any{"isCompany": true}) != true { + t.Fatal("true row not detected") + } + if IsCompany(map[string]any{"isCompany": false}) { + t.Fatal("false row detected as company") + } + if IsCompany(map[string]any{}) { + t.Fatal("missing field detected as company") + } + if IsCompany(nil) { + t.Fatal("nil row detected as company") + } +} + +func TestNumericIDLess(t *testing.T) { + cases := []struct { + a, b string + want bool + }{ + {"9", "10", true}, + {"1747", "1748", true}, + {"abc", "abd", true}, + {"10", "9", false}, + {" 12 ", "13", true}, + {"x1", "2", false}, // non-numeric falls back lexical: "x1" > "2" + } + for _, c := range cases { + if got := NumericIDLess(c.a, c.b); got != c.want { + t.Errorf("NumericIDLess(%q,%q)=%v want %v", c.a, c.b, got, c.want) + } + } +} + +func TestSortIDs(t *testing.T) { + ids := []string{"20", "3", "100", "1"} + SortIDs(ids) + want := "1 3 20 100" + got := "" + for i, id := range ids { + if i > 0 { + got += " " + } + got += id + } + if got != want { + t.Fatalf("SortIDs=%q want %q", got, want) + } +} + +func TestContactID(t *testing.T) { + if ContactID(map[string]any{"id": float64(42)}) != "42" { + t.Fatal("numeric id formatting broken") + } +} + +func TestHistoryEntityConstants(t *testing.T) { + if HistoryEntityOpportunity != "opportunity" || HistoryEntityCase != "case" { + t.Fatal("history entity whitelist drifted from live-verified values") + } +}