Compare commits

...
3 Commits
Author SHA1 Message Date
eSlider e2d481e7b8 refactor: DRY auth path, fix Sprintf/RE2 bugs; real integration tests only
Why
---
- Sprintf(*p.Title) fed the title as a format string — % in titles broke
  the String() method; also panicked on nil Title.
- internal/applications.buildSummary used RE2-unsupported `(?= ...)`
  lookahead inside regexp.MustCompile, panicking the first time the
  applications-sync path was exercised on Go 1.23+.
- Query() duplicated the auth-expiry check inline while ensureToken()
  already handled it — two code paths drifted.
- Request.Debug split Query() into two branches that both unmarshalled
  into the same target value. Dead code.
- httptest fixtures that emulated OnlyOffice endpoints were lying to us:
  they passed locally yet never caught a single real protocol regression.

What
----
- Project.String(): nil-safe, no Sprintf format-string interpretation.
- buildSummary regex: RE2-safe non-capturing trailing delimiter
  `(?:\n## |$)` replaces the lookahead.
- Query() routes through ensureToken(); body marshalling factored into
  an unexported requestBodyReader(). Debug flag retained for backwards
  compatibility, documented as a no-op, to be removed at next major.
- Dropped Debug: true stray flags in GetTasks/UpdateProjectTask.
- Deleted httptest-based OnlyOffice mocks. unit_test.go is now pure Go
  (parsers, helpers, env aliases, ctx cancellation against an unroutable
  address). client_test.go is `//go:build integration` and runs against
  a real OnlyOffice, skipping cleanly without ONLYOFFICE_URL/USER/PASS.
- AGENTS.md + .cursor/rules/no-synthetic-mocks.mdc document the new
  testing policy.

Verified
--------
- `go test ./...` green (15 unit tests across package + internal).
- `go test -tags=integration ./...` green against live
  office.produktor.io (5 integration tests: auth, projects, lifecycle,
  calendar+CRM read, task list).
- inventar-sync smoke dry-run against live OO project 33 + Gitea found
  30 tasks, 0 mutations.

Made-with: Cursor
2026-04-24 12:36:40 +01:00
eSlider 2fc2adadd1 feat(auth): AuthenticateContext + InvalidateToken for long-running syncs
Adds two helpers tailored for cron-driven or watcher-style clients:

- AuthenticateContext(ctx) — cancellable variant of Authenticate(). Bypasses
  the non-context Query() path and POSTs /api/2.0/authentication.json directly,
  so a stalled auth call never outlives the caller's deadline.
- InvalidateToken() — zeroes the cached *Token. Next request (or Authenticate*)
  forces a fresh auth. Intended for mid-sync 401 recovery when the server has
  revoked/rotated the session while local Expires still looks fresh.

Plain Authenticate() is unchanged; it remains a convenience wrapper.

Unit tests cover cache-hit, forced refresh, and context-cancellation paths.

Refs eSlider/inventar-sync#3, ASR-0008.

Made-with: Cursor
2026-04-24 12:22:26 +01:00
eSlider b63055d435 docs: add library examples for calendar, crm, subtasks, applications
Four new executable examples mirror the oo-cli subcommands at library level so
downstream Go consumers can see the typed API without reading the CLI wiring.
README table updated to list all examples.

Made-with: Cursor
2026-04-24 11:46:09 +01:00
15 changed files with 813 additions and 371 deletions
+38
View File
@@ -0,0 +1,38 @@
---
description: No synthetic OnlyOffice/Gitea mocks; prefer real integration tests
globs:
- "**/*_test.go"
alwaysApply: false
---
# Testing policy — no synthetic vendor mockups
When authoring tests under `github.com/eslider/go-onlyoffice`, do **not**
build `httptest.NewServer` fixtures that emulate OnlyOffice, Gitea, or any
other third-party API. Simulated vendor responses drift from reality, give
false green signals, and hide protocol changes.
## What to do instead
1. **Unit tests** — pure Go, no network. Use them for parsers, encoders,
struct conversions, pure helpers. No `httptest` that fakes the vendor.
2. **Integration tests** — `//go:build integration` tag in a `*_integration_test.go`
file. Read credentials from env:
- `ONLYOFFICE_URL` / `ONLYOFFICE_HOST`
- `ONLYOFFICE_USER` / `ONLYOFFICE_NAME`
- `ONLYOFFICE_PASS` / `ONLYOFFICE_PASSWORD`
Call `t.Skip("ONLYOFFICE_URL not set")` when credentials are absent so the
regular `go test ./...` stays green in CI.
3. **Run integration**: `go test -tags=integration ./...`.
4. **Every new endpoint** ships with an integration test in the same PR.
## Narrow exception
`httptest.NewServer` is OK when verifying the **caller's own** HTTP
behaviour (e.g. a user's handler or middleware we are wrapping). It is **not**
OK when the test server is pretending to be OnlyOffice or Gitea.
## Migrating existing tests
If you find a test that handles routes like `/api/2.0/...` and returns canned
JSON, convert it to an integration test (or delete it if the behaviour is
already covered by integration).
+19 -1
View File
@@ -13,9 +13,27 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- Library must never call `godotenv.Load()` — the CLI does that. - Library must never call `godotenv.Load()` — the CLI does that.
- New endpoints go into the library first; CLI commands are thin wrappers. - New endpoints go into the library first; CLI commands are thin wrappers.
- No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only. - No secrets in the repo; use `.env` (gitignored). Commit `.env.example` only.
- Prefer httptest-based unit tests (see `httpx_test.go`); integration tests (`client_test.go`) auto-skip without `ONLYOFFICE_URL`.
- Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`. - Follow SemVer on tags; this repo is tagged at GitHub under `git@github.com:eSlider/go-onlyoffice.git`.
### Testing policy (2026-04-24)
**No synthetic OnlyOffice mockups.** Protocol-level behaviour must be
verified against a real OnlyOffice instance. `httptest.NewServer` is only
acceptable for testing the *caller's* logic that the library can't reach
(for example, the user's own HTTP handler). Anywhere we would otherwise
write `mux.HandleFunc("/api/2.0/...")` to emulate OnlyOffice, we write an
**integration test** instead.
- Unit tests (`*_test.go`, no build tag) — pure Go: parsers, encoders,
struct conversions. No network. No fake servers that emulate the vendor.
- Integration tests (`//go:build integration` tag in `*_integration_test.go`)
— hit a live OnlyOffice instance. Credentials come from `ONLYOFFICE_URL`,
`ONLYOFFICE_USER`, `ONLYOFFICE_PASS` (aliases `_HOST`/`_NAME`/`_PASSWORD`
also accepted). Tests **skip** cleanly when credentials are missing so
`go test ./...` remains green in CI.
- Run integration with: `go test -tags=integration ./...`.
- New endpoints **must** ship with an integration test before merge.
## Related ## Related
- [`eSlider/inventar`](https://git.produktor.io/eSlider/inventar) — ASR/ADR (see ASR-0008 Go library module conventions). - [`eSlider/inventar`](https://git.produktor.io/eSlider/inventar) — ASR/ADR (see ASR-0008 Go library module conventions).
+48
View File
@@ -6,6 +6,54 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
## [0.3.2] - 2026-04-24
### Fixed
- `Project.String()` no longer interprets the title as a format string
(`fmt.Sprintf(*p.Title)`) and is now nil-safe on a zero-value `Project`.
- `internal/applications.buildSummary` no longer panics at regex compile time
on Go 1.23+ — the previous `(?= ...)` lookahead is replaced with an RE2-safe
non-capturing trailing delimiter.
### Changed
- `Client.Query` now routes token acquisition through the shared
`ensureToken` path instead of duplicating the auth-expiry check inline.
- Request body marshalling is consolidated into an unexported
`requestBodyReader` helper (DRY; no change to the public surface).
- The `Request.Debug` field is preserved for backwards compatibility but no
longer changes behaviour — both branches used to unmarshal into the same
target value. We'll remove the field in a future major release.
### Tests
- Deleted `httptest.NewServer` fixtures that emulated OnlyOffice protocol
endpoints. Replaced them with:
- pure-Go unit tests in `unit_test.go` (no network);
- real integration tests in `client_test.go` guarded by
`//go:build integration`. Run with
`go test -tags=integration ./...`. Tests skip cleanly when
`ONLYOFFICE_URL/USER/PASS` (or aliases) are absent.
- New policy documented in `AGENTS.md` and
`.cursor/rules/no-synthetic-mocks.mdc`.
## [0.3.1] - 2026-04-24
### Added
- `AuthenticateContext(ctx)` — context-aware auth that honours cancellation and
deadlines. Preferred entry point for long-running syncs (cron, watchers).
- `InvalidateToken()` — clears the cached token to force re-auth on the next
request. Use this to recover from a mid-sync 401 when the server has revoked
the session while the local `Expires` timestamp still looks fresh.
### Notes
- Plain `Authenticate()` is unchanged and remains a convenience wrapper around
`AuthenticateContext(context.Background())`.
- No breaking changes; a patch release.
## [0.3.0] - 2026-04-24 ## [0.3.0] - 2026-04-24
### Added ### Added
+4
View File
@@ -566,6 +566,10 @@ the command reference.
| Example | Description | | Example | Description |
|---|---| |---|---|
| [basic](examples/basic/) | List projects and users | | [basic](examples/basic/) | List projects and users |
| [calendar](examples/calendar/) | List calendars and events, create a new event |
| [crm](examples/crm/) | List contacts and opportunities, add company/deal/history note |
| [subtasks](examples/subtasks/) | Create a parent task and attach subtasks |
| [applications](examples/applications/) | Job application folder tree → OnlyOffice CRM (library-level usage of the internal applications package) |
| [`cmd/oo-cli`](cmd/oo-cli/) | Full-featured CLI using all modules | | [`cmd/oo-cli`](cmd/oo-cli/) | Full-featured CLI using all modules |
## Related Libraries ## Related Libraries
+192 -100
View File
@@ -1,128 +1,220 @@
//go:build integration
package onlyoffice package onlyoffice
// Integration tests — hit a live OnlyOffice Workspace instance. Credentials
// come from ONLYOFFICE_URL / ONLYOFFICE_USER / ONLYOFFICE_PASS (aliases
// _HOST / _NAME / _PASSWORD also accepted). Without credentials every test
// here skips.
//
// Run with:
//
// go test -tags=integration ./...
//
// These tests are destructive on the target instance. They create projects
// with titles prefixed "go-onlyoffice-test-" and clean up afterwards. Do not
// run against an instance you don't own.
import ( import (
"os" "context"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/joho/godotenv"
) )
// Load environment variables func skipWithoutCredentials(t *testing.T) Credentials {
func init() {
// Load from .env file in the project root
_ = godotenv.Load(".env")
}
func skipWithoutCredentials(t *testing.T) {
t.Helper() t.Helper()
if os.Getenv("ONLYOFFICE_URL") == "" { c := GetEnvironmentCredentials()
t.Skip("ONLYOFFICE_URL is not set, skipping integration test") if c.Url == "" || c.User == "" || c.Password == "" {
t.Skip("ONLYOFFICE_URL/USER/PASS not set — skipping integration test")
} }
return c
} }
func TestNewClient(t *testing.T) { func liveClient(t *testing.T) *Client {
skipWithoutCredentials(t) t.Helper()
c := NewClient(skipWithoutCredentials(t))
c.SetDefaults(GetEnvironmentDefaults())
return c
}
// Create a new OnlyOffice client const testProjectPrefix = "go-onlyoffice-test-"
credentials := GetEnvironmentCredentials()
client := NewClient(credentials)
var token *Token
var err error
token, err = client.Auth(&credentials)
func cleanupTestProjects(t *testing.T, c *Client) {
t.Helper()
projects, err := c.GetProjects()
if err != nil { if err != nil {
t.Errorf("Failed to get token: %v", err) t.Logf("cleanup: GetProjects: %v", err)
return
} }
if token == nil { for _, p := range projects {
t.Error("Value is empty") if p.Title != nil && strings.HasPrefix(*p.Title, testProjectPrefix) {
} if _, err := c.DeleteProject(*p.ID); err != nil {
t.Logf("cleanup: DeleteProject %d: %v", *p.ID, err)
// Clean up: remove all projects start with "Test project"
projects, err := client.GetProjects()
if err != nil {
t.Errorf("Failed to get projects: %v", err)
}
for _, project := range projects {
if strings.HasPrefix(*project.Title, "Test project") {
prjStatus, err := client.DeleteProject(*project.ID)
if err != nil {
t.Errorf("Failed to delete project: %v", err)
}
if *prjStatus.ID != *project.ID {
t.Errorf("Project ID is not equal: %v != %v", prjStatus.ID, project.ID)
} }
} }
} }
}
// Test create project func TestIntegrationAuthenticateContext(t *testing.T) {
project, err := client.CreateProject(NewProjectRequest{ c := liveClient(t)
Title: "Test project", ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
Description: "Test project description", defer cancel()
}) if err := c.AuthenticateContext(ctx); err != nil {
t.Fatalf("AuthenticateContext: %v", err)
if err != nil {
t.Errorf("Failed to create project: %v", err)
} }
if c.token == nil || c.token.Value == "" {
// Update project t.Fatal("token not cached after AuthenticateContext")
project, err = client.UpdateProject(ProjectUpdateRequest{
ID: *project.ID,
Title: "Test project updated",
ResponsibleID: *project.Responsible.ID,
})
var task *Task
// Test create project task
task, err = client.CreateProjectTask(NewProjectTaskRequest{
ProjectId: *project.ID,
Title: "Test task",
Description: "Test task description",
Notify: true,
MilestoneId: 0,
Priority: 0,
// Deadline +2 days
StartDate: Time(time.Now().AddDate(0, 0, -2)),
Deadline: Time(time.Now().AddDate(0, 0, 2)),
})
// Update project task
startDate := Time(time.Now().AddDate(0, 0, -14))
deadline := Time(time.Now().AddDate(0, 0, 3))
task, err = client.UpdateProjectTask(ProjectTaskUpdateRequest{
ID: *task.ID,
Title: "Test task updated",
Description: "Test task description updated",
StartDate: &startDate,
Deadline: &deadline,
})
if err != nil {
t.Errorf("Failed to create task: %v", err)
} }
// Second call must hit the cache.
if task == nil { cachedValue := c.token.Value
t.Error("Value is empty") if err := c.AuthenticateContext(ctx); err != nil {
t.Fatalf("AuthenticateContext (cached): %v", err)
} }
if c.token.Value != cachedValue {
// Delete project t.Fatal("cached token was replaced unexpectedly")
prj, err := client.DeleteProject(*project.ID)
if err != nil {
t.Errorf("Failed to delete project: %v", err)
} }
// Invalidate forces re-auth.
if *prj.ID != *project.ID { c.InvalidateToken()
t.Errorf("Project ID is not equal: %v != %v", prj.ID, project.ID) if c.token != nil {
t.Fatal("InvalidateToken did not clear the cache")
} }
if err := c.AuthenticateContext(ctx); err != nil {
// Test list projects t.Fatalf("AuthenticateContext (after invalidate): %v", err)
projects, err = client.GetProjects()
if err != nil {
t.Errorf("Failed to get projects: %v", err)
} }
if len(projects) == 0 { if c.token == nil {
t.Error("Value is empty") t.Fatal("post-invalidate auth left token nil")
}
}
func TestIntegrationGetProjectsAndSelf(t *testing.T) {
c := liveClient(t)
projects, err := c.GetProjects()
if err != nil {
t.Fatalf("GetProjects: %v", err)
}
if projects == nil {
t.Fatal("nil slice from GetProjects")
}
ctx := context.Background()
uid, err := c.SelfUserID(ctx)
if err != nil {
t.Fatalf("SelfUserID: %v", err)
}
if uid == "" {
t.Fatal("empty self user id")
}
}
func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
c := liveClient(t)
t.Cleanup(func() { cleanupTestProjects(t, c) })
suffix := time.Now().UTC().Format("20060102-150405")
title := testProjectPrefix + suffix
project, err := c.CreateProject(NewProjectRequest{
Title: title,
Description: "integration test from go-onlyoffice",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
if project.ID == nil {
t.Fatal("created project without id")
}
updated, err := c.UpdateProject(ProjectUpdateRequest{
ID: *project.ID,
Title: title + " (updated)",
Description: "updated",
ResponsibleID: *project.Responsible.ID,
})
if err != nil {
t.Fatalf("UpdateProject: %v", err)
}
if updated.Title == nil || *updated.Title != title+" (updated)" {
t.Errorf("title not updated: %+v", updated.Title)
}
start := Time(time.Now().AddDate(0, 0, -2))
deadline := Time(time.Now().AddDate(0, 0, 2))
task, err := c.CreateProjectTask(NewProjectTaskRequest{
ProjectId: *project.ID,
Title: "integration parent task",
Description: "from go-onlyoffice integration suite",
StartDate: start,
Deadline: deadline,
Priority: int(TaskPriorityNormal),
})
if err != nil {
t.Fatalf("CreateProjectTask: %v", err)
}
if task.ID == nil {
t.Fatal("created task without id")
}
newStart := Time(time.Now().AddDate(0, 0, -14))
newDeadline := Time(time.Now().AddDate(0, 0, 3))
if _, err := c.UpdateProjectTask(ProjectTaskUpdateRequest{
ID: *task.ID,
Title: "integration parent task (updated)",
Description: "updated",
StartDate: &newStart,
Deadline: &newDeadline,
}); err != nil {
t.Fatalf("UpdateProjectTask: %v", err)
}
// Subtask uses the form-encoded helper; exercises httpx.postForm path.
ctx := context.Background()
sub, err := c.AddSubtask(ctx, strconv.Itoa(*task.ID), "integration subtask")
if err != nil {
t.Fatalf("AddSubtask: %v", err)
}
if _, ok := sub["id"]; !ok {
t.Errorf("subtask response missing id: %+v", sub)
}
}
func TestIntegrationCalendarAndCRMRead(t *testing.T) {
c := liveClient(t)
ctx := context.Background()
start := time.Now().Format("2006-01-02")
end := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
if _, err := c.ListCalendars(ctx, start, end); err != nil {
t.Errorf("ListCalendars: %v", err)
}
if _, err := c.ListEvents(ctx, start, end); err != nil {
t.Errorf("ListEvents: %v", err)
}
if _, _, err := c.ListContacts(ctx, 5, 0, ""); err != nil {
t.Errorf("ListContacts: %v", err)
}
if _, _, err := c.ListOpportunities(ctx, 5, 0); err != nil {
t.Errorf("ListOpportunities: %v", err)
}
if _, err := c.ListDealStages(ctx); err != nil {
t.Errorf("ListDealStages: %v", err)
}
}
// TestIntegrationDryRunList covers the tiniest project-tasks read that
// inventar-sync depends on; confirms GetTasks deserialises real responses.
func TestIntegrationListTasks(t *testing.T) {
c := liveClient(t)
defaults := GetEnvironmentDefaults()
// ProjectID default is "33"; skip the read-only list if the caller hasn't
// pointed us at a valid project (we don't know which projects exist).
if defaults.ProjectID == "" {
t.Skip("ONLYOFFICE_PROJECT_ID not configured")
}
pid, err := strconv.Atoi(defaults.ProjectID)
if err != nil || pid <= 0 {
t.Skipf("ONLYOFFICE_PROJECT_ID %q is not a positive int", defaults.ProjectID)
}
if _, err := c.GetTasks(NewProjectGetTasksRequest(pid)); err != nil {
t.Fatalf("GetTasks(%d): %v", pid, err)
} }
} }
+54
View File
@@ -0,0 +1,54 @@
// Package main shows how to drive the internal applications package — the same
// logic that powers `oo-cli applications-sync` — directly from Go code.
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// go run ./examples/applications /path/to/cv/applications
//
// Without --apply equivalent, it prints a summary but performs no writes.
package main
import (
"context"
"fmt"
"log"
"os"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/internal/applications"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: applications <applications-root>")
os.Exit(2)
}
root := os.Args[1]
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
paths, err := applications.Discover(root)
if err != nil {
log.Fatalf("discover: %v", err)
}
fmt.Printf("discovered %d application READMEs under %s\n", len(paths), root)
var apps []applications.Data
for _, p := range paths {
d, err := applications.ParseReadme(p)
if err != nil {
log.Printf("parse %s: %v", p, err)
continue
}
apps = append(apps, d)
}
stats := applications.Sync(context.Background(), client, apps, true /*dryRun*/, true /*verbose*/)
fmt.Printf("\ndry-run stats: %+v\n", stats)
}
+62
View File
@@ -0,0 +1,62 @@
// Package main shows how to list calendars and events, and create a new event
// via the OnlyOffice Calendar API.
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// # optional: default calendar for AddEvent
// export ONLYOFFICE_CALENDAR_ID="42"
// go run ./examples/calendar
package main
import (
"context"
"fmt"
"log"
"os"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
)
func main() {
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
client.SetDefaults(onlyoffice.GetEnvironmentDefaults())
ctx := context.Background()
start := time.Now().Format("2006-01-02")
end := time.Now().AddDate(0, 1, 0).Format("2006-01-02")
calendars, err := client.ListCalendars(ctx, start, end)
if err != nil {
log.Fatalf("list calendars: %v", err)
}
fmt.Printf("Calendars (%d):\n", len(calendars))
for _, cal := range calendars {
fmt.Printf(" - id=%v title=%v\n", cal["objectId"], cal["title"])
}
events, err := client.ListEvents(ctx, start, end)
if err != nil {
log.Fatalf("list events: %v", err)
}
fmt.Printf("\nEvents in [%s..%s]: %d\n", start, end, len(events))
for _, e := range events {
fmt.Printf(" - %v @ %v\n", e["title"], e["start"])
}
// Create demo event — uncomment to exercise.
// evStart := time.Now().Add(time.Hour).Format(time.RFC3339)
// evEnd := time.Now().Add(2 * time.Hour).Format(time.RFC3339)
// created, err := client.AddEvent(ctx, "", "Library demo", evStart, evEnd, "via examples/calendar", false)
// if err != nil {
// log.Fatalf("add event: %v", err)
// }
// fmt.Printf("created event id=%v\n", created["objectId"])
}
+63
View File
@@ -0,0 +1,63 @@
// Package main lists CRM contacts and opportunities, and shows how to create
// and remove a demo company + opportunity + history note in OnlyOffice CRM.
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// go run ./examples/crm
package main
import (
"context"
"fmt"
"log"
"os"
onlyoffice "github.com/eslider/go-onlyoffice"
)
func main() {
creds := onlyoffice.GetEnvironmentCredentials()
if creds.Url == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL is not set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
ctx := context.Background()
contacts, total, err := client.ListContacts(ctx, 10, 0, "")
if err != nil {
log.Fatalf("list contacts: %v", err)
}
fmt.Printf("CRM contacts: %d (total=%d)\n", len(contacts), total)
for _, c := range contacts {
fmt.Printf(" - id=%v name=%v\n", c["id"], c["displayName"])
}
opps, oppTotal, err := client.ListOpportunities(ctx, 10, 0)
if err != nil {
log.Fatalf("list opportunities: %v", err)
}
fmt.Printf("\nOpportunities: %d (total=%d)\n", len(opps), oppTotal)
for _, o := range opps {
fmt.Printf(" - id=%v title=%v\n", o["id"], o["title"])
}
// Full create/attach/delete cycle — uncomment to exercise.
// company, err := client.CreateCompany(ctx, "Demo GmbH")
// if err != nil { log.Fatalf("create company: %v", err) }
// cid := fmt.Sprint(company["id"])
// defer client.DeleteContact(ctx, cid)
//
// stages, err := client.ListDealStages(ctx)
// if err != nil || len(stages) == 0 { log.Fatalf("no deal stages: %v", err) }
// stageID := int(stages[0]["id"].(float64))
//
// opp, err := client.CreateOpportunity(ctx, "Demo opp", stageID, "", "EUR", "from examples/crm", 0)
// if err != nil { log.Fatalf("create opportunity: %v", err) }
// oid := fmt.Sprint(opp["id"])
// _, _ = client.AddOpportunityMember(ctx, oid, cid)
// _, _ = client.AddHistoryNote(ctx, "opportunity", int(opp["id"].(float64)), "Touched via library example", 0)
// _, _ = client.DeleteOpportunity(ctx, oid)
}
+51
View File
@@ -0,0 +1,51 @@
// Package main creates a parent task and attaches two subtasks using the
// project-tasks API (POST /api/2.0/project/task/{id}.json).
//
// export ONLYOFFICE_URL="https://your-instance.onlyoffice.com"
// export ONLYOFFICE_USER="admin@example.com"
// export ONLYOFFICE_PASS="your-password"
// export ONLYOFFICE_PROJECT_ID="123"
// go run ./examples/subtasks
package main
import (
"context"
"fmt"
"log"
"os"
onlyoffice "github.com/eslider/go-onlyoffice"
)
func main() {
creds := onlyoffice.GetEnvironmentCredentials()
defaults := onlyoffice.GetEnvironmentDefaults()
if creds.Url == "" || defaults.ProjectID == "" {
fmt.Fprintln(os.Stderr, "ONLYOFFICE_URL and ONLYOFFICE_PROJECT_ID must be set")
os.Exit(1)
}
client := onlyoffice.NewClient(creds)
client.SetDefaults(defaults)
ctx := context.Background()
parent, err := client.AddTask(ctx, defaults.ProjectID, "Example parent", "Created by examples/subtasks", 0, "")
if err != nil {
log.Fatalf("add parent task: %v", err)
}
parentID := fmt.Sprint(parent["id"])
fmt.Printf("parent task id=%s\n", parentID)
for _, title := range []string{"first subtask", "second subtask"} {
st, err := client.AddSubtask(ctx, parentID, title)
if err != nil {
log.Fatalf("add subtask %q: %v", title, err)
}
fmt.Printf(" + subtask id=%v title=%v\n", st["id"], st["title"])
}
// Cleanup — uncomment once you verified the subtasks in the OnlyOffice UI.
// if _, err := client.DeleteTask(ctx, parentID); err != nil {
// log.Printf("cleanup: %v", err)
// }
}
+55
View File
@@ -45,8 +45,63 @@ func (c *Client) authHeader() (string, error) {
// Authenticate validates credentials and primes the token. Library users may // Authenticate validates credentials and primes the token. Library users may
// call this eagerly to surface auth errors at startup; otherwise the token is // call this eagerly to surface auth errors at startup; otherwise the token is
// fetched lazily on the first request. // fetched lazily on the first request.
//
// Prefer AuthenticateContext in long-running jobs — it honours cancellation.
func (c *Client) Authenticate() error { return c.ensureToken() } func (c *Client) Authenticate() error { return c.ensureToken() }
// AuthenticateContext is the context-aware variant of Authenticate. If the
// cached token is still valid it returns immediately; otherwise it performs a
// POST to /api/2.0/authentication.json that is cancellable via ctx.
//
// This is the recommended entry point for long-running syncs (cron, watchers)
// because it guarantees that a stalled auth call will not block the caller
// past its deadline.
func (c *Client) AuthenticateContext(ctx context.Context) error {
if c.token != nil && !time.Time(c.token.Expires).Before(time.Now()) {
return nil
}
body, err := json.Marshal(c.credentials)
if err != nil {
return fmt.Errorf("marshal credentials: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+"/api/2.0/authentication.json", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("auth request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("auth: %d %s", resp.StatusCode, truncate(string(raw), 400))
}
var env struct {
Response *Token `json:"response"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return fmt.Errorf("auth decode: %w", err)
}
if env.Response == nil || env.Response.Value == "" {
return fmt.Errorf("auth: empty token in response")
}
c.token = env.Response
return nil
}
// InvalidateToken clears the cached authentication token. The next request
// (or call to Authenticate / AuthenticateContext) will re-authenticate.
//
// Use this to recover from a mid-sync 401 when the server has revoked or
// rotated the session while the Expires timestamp still looks fresh locally.
func (c *Client) InvalidateToken() { c.token = nil }
// baseURL returns the configured base URL without trailing slash. // baseURL returns the configured base URL without trailing slash.
func (c *Client) baseURL() string { func (c *Client) baseURL() string {
return strings.TrimRight(c.credentials.Url, "/") return strings.TrimRight(c.credentials.Url, "/")
-194
View File
@@ -1,194 +0,0 @@
package onlyoffice
// Unit tests for the form/multipart helpers and a few CRM/Calendar/Subtask
// methods. These do not require a real OnlyOffice server — they spin up a
// net/http/httptest.Server that emulates the expected endpoints.
import (
"context"
"encoding/json"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// fakeServer wires a minimal OnlyOffice-like API: it always returns the same
// token on /api/2.0/authentication.json and dispatches remaining requests via
// the caller-supplied handler.
func fakeServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/2.0/authentication.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"response":{"token":"TEST_TOKEN","expires":"2099-01-01T00:00:00Z"}}`)
})
mux.HandleFunc("/", h)
return httptest.NewServer(mux)
}
// newTestClient builds a Client pointed at the test server with a primed token
// so helpers skip the auth round-trip when the test does not exercise it.
func newTestClient(srv *httptest.Server) *Client {
c := NewClient(Credentials{Url: srv.URL, User: "u", Password: "p"})
c.token = &Token{Value: "TEST_TOKEN", Expires: Time(time.Now().Add(time.Hour))}
return c
}
func TestAddSubtaskFormEncoded(t *testing.T) {
var gotPath, gotAuth, gotCT, gotBody string
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotCT = r.Header.Get("Content-Type")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"response":{"id":99,"title":"child"}}`)
})
defer srv.Close()
c := newTestClient(srv)
out, err := c.AddSubtask(context.Background(), "42", "child")
if err != nil {
t.Fatalf("AddSubtask: %v", err)
}
if gotPath != "/api/2.0/project/task/42.json" {
t.Errorf("path = %q", gotPath)
}
if gotAuth != "TEST_TOKEN" {
t.Errorf("auth header = %q", gotAuth)
}
if !strings.HasPrefix(gotCT, "application/x-www-form-urlencoded") {
t.Errorf("content-type = %q", gotCT)
}
if gotBody != "title=child" {
t.Errorf("body = %q", gotBody)
}
if fm, ok := out["title"].(string); !ok || fm != "child" {
t.Errorf("out.title = %v", out["title"])
}
}
func TestAddEventUsesDefaultCalendar(t *testing.T) {
var gotPath, gotBody string
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"response":[{"id":"e1","name":"hi"}]}`)
})
defer srv.Close()
c := newTestClient(srv)
c.SetDefaults(Defaults{CalendarID: "7"})
ev, err := c.AddEvent(context.Background(), "", "hi", "2025-01-01T10:00:00Z", "2025-01-01T11:00:00Z", "desc", false)
if err != nil {
t.Fatalf("AddEvent: %v", err)
}
if gotPath != "/api/2.0/calendar/7/event.json" {
t.Errorf("path = %q", gotPath)
}
if !strings.Contains(gotBody, "name=hi") || !strings.Contains(gotBody, "isAllDayLong=false") {
t.Errorf("body = %q", gotBody)
}
if ev["id"] != "e1" {
t.Errorf("event id = %v", ev["id"])
}
}
func TestAddEventRequiresCalendar(t *testing.T) {
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server must not be hit without calendar id")
})
defer srv.Close()
c := newTestClient(srv)
if _, err := c.AddEvent(context.Background(), "", "t", "s", "e", "", false); err == nil {
t.Fatal("expected error when no calendar id and no default")
}
}
func TestListContactsTotal(t *testing.T) {
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/2.0/crm/contact/filter.json" {
t.Errorf("path = %q", r.URL.Path)
}
if r.URL.Query().Get("filterValue") != "acme" {
t.Errorf("filterValue = %q", r.URL.Query().Get("filterValue"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"response":[{"id":1,"displayName":"ACME"}],"total":7}`)
})
defer srv.Close()
c := newTestClient(srv)
items, total, err := c.ListContacts(context.Background(), 50, 0, "acme")
if err != nil {
t.Fatalf("ListContacts: %v", err)
}
if total != 7 {
t.Errorf("total = %d", total)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d", len(items))
}
}
func TestUploadOpportunityFileMultipart(t *testing.T) {
tmp := t.TempDir() + "/a.txt"
if err := writeFile(tmp, "hello"); err != nil {
t.Fatal(err)
}
var gotField, gotFilename, gotBody string
srv := fakeServer(t, func(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "multipart/form-data") {
t.Errorf("content-type = %q", ct)
}
_, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatal(err)
}
mr := multipart.NewReader(r.Body, params["boundary"])
part, err := mr.NextPart()
if err != nil {
t.Fatal(err)
}
gotField = part.FormName()
gotFilename = part.FileName()
b, _ := io.ReadAll(part)
gotBody = string(b)
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"response":{"id":1,"title":"a.txt"}}`)
})
defer srv.Close()
c := newTestClient(srv)
out, err := c.UploadOpportunityFile(context.Background(), "123", tmp)
if err != nil {
t.Fatalf("UploadOpportunityFile: %v", err)
}
if gotField != "file" || gotFilename != "a.txt" || gotBody != "hello" {
t.Errorf("multipart = field=%q filename=%q body=%q", gotField, gotFilename, gotBody)
}
if out["title"] != "a.txt" {
t.Errorf("out.title = %v", out["title"])
}
}
func writeFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0o600)
}
func TestResponseFieldMissing(t *testing.T) {
_, err := responseField(json.RawMessage(`{"other":1}`), "response")
if err == nil {
t.Fatal("expected error on missing field")
}
}
+16 -14
View File
@@ -24,19 +24,19 @@ type RecruiterInfo struct {
} }
type Data struct { type Data struct {
Path string Path string
Folder string Folder string
Position string Position string
Company string Company string
Location string Location string
Salary string Salary string
SalaryValue float64 SalaryValue float64
Contract string Contract string
Source string Source string
Link string Link string
Recruiter RecruiterInfo Recruiter RecruiterInfo
Summary string Summary string
Documents []string Documents []string
} }
func Discover(base string) ([]string, error) { func Discover(base string) ([]string, error) {
@@ -212,7 +212,9 @@ func buildSummary(app Data, text string) string {
if m := regexp.MustCompile(`(?i)## Application Status\s*\n((?:[-*\[\]xX ].+\n?)+)`).FindStringSubmatch(text); len(m) > 1 { if m := regexp.MustCompile(`(?i)## Application Status\s*\n((?:[-*\[\]xX ].+\n?)+)`).FindStringSubmatch(text); len(m) > 1 {
lines = append(lines, "", "Status:", strings.TrimSpace(m[1])) lines = append(lines, "", "Status:", strings.TrimSpace(m[1]))
} }
if m := regexp.MustCompile(`(?i)## (?:Fit Assessment|Match|Candidate Fit)\s*\n([\s\S]+?)(?=\n## |\z)`).FindStringSubmatch(text); len(m) > 1 { // RE2 does not support lookahead; consume the trailing delimiter (\n## or EOS)
// with a non-capturing alternation — the captured group 1 stops right before it.
if m := regexp.MustCompile(`(?i)## (?:Fit Assessment|Match|Candidate Fit)\s*\n([\s\S]+?)(?:\n## |$)`).FindStringSubmatch(text); len(m) > 1 {
ft := strings.TrimSpace(m[1]) ft := strings.TrimSpace(m[1])
if len(ft) > 500 { if len(ft) > 500 {
ft = ft[:500] + "..." ft = ft[:500] + "..."
+31
View File
@@ -20,3 +20,34 @@ func TestParseSalary(t *testing.T) {
t.Fatalf("daily: %v", v) t.Fatalf("daily: %v", v)
} }
} }
// TestBuildSummaryFitAssessment covers the RE2-safe replacement of a previously
// lookahead-based regex that would panic at MustCompile time on Go regexp.
func TestBuildSummaryFitAssessment(t *testing.T) {
app := Data{Position: "SRE", Company: "Acme", Folder: "/tmp"}
withNextSection := "## Fit Assessment\nGreat fit overall.\nStrong background.\n## Next section\nUnrelated."
summary := buildSummary(app, withNextSection)
if !contains(summary, "Fit Assessment:") {
t.Fatalf("missing header in summary:\n%s", summary)
}
if !contains(summary, "Great fit overall.") {
t.Fatalf("missing body in summary:\n%s", summary)
}
if contains(summary, "Unrelated.") {
t.Fatalf("bled into next section:\n%s", summary)
}
eof := "## Fit Assessment\nOnly content at EOF."
summary = buildSummary(app, eof)
if !contains(summary, "Only content at EOF.") {
t.Fatalf("EOF case missing body:\n%s", summary)
}
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
+47 -62
View File
@@ -3,15 +3,17 @@ package onlyoffice
// OnlyOffice client package // OnlyOffice client package
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/google/go-querystring/query"
"io" "io"
"net/http" "net/http"
"os" "os"
regexp "regexp" "regexp"
"strings" "strings"
"time" "time"
"github.com/google/go-querystring/query"
) )
// NewClient API // NewClient API
@@ -295,7 +297,10 @@ type ProjectOwner struct {
// String for Project to return title // String for Project to return title
func (p Project) String() string { func (p Project) String() string {
return fmt.Sprintf(*p.Title) if p.Title == nil {
return ""
}
return *p.Title
} }
// Token OnlyOffice // Token OnlyOffice
@@ -353,16 +358,16 @@ func (r Request) GetMethod() string {
// Query the OnlyOffice API // Query the OnlyOffice API
// - If request.Method is not set then it will default to GET // - If request.Method is not set then it will default to GET
// - If request.Code is not nil then it will be marshaled to JSON // - If request.Body is not nil then it will be marshaled to JSON (unless already []byte/string)
// - If request.Token is nil then it will get a token // - If request.Token is nil then it will get (or reuse) a cached token
// - If request.Token is not nil then it will be used as Authorization header // - If request.Token is not nil then it will be used as Authorization header
// - If request.NoAuth is true then it will skip automatic authentication // - If request.NoAuth is true then it will skip automatic authentication
func (c *Client) Query(request Request, result interface{}) (err error) { //
var url = fmt.Sprintf("%s%s", c.credentials.Url, request.Uri) // The Debug field is preserved for backwards compatibility; it no longer
var rdr io.Reader = nil // changes behaviour — both branches used to unmarshal into the same target.
var jsonRequestBody string func (c *Client) Query(request Request, result interface{}) error {
url := c.credentials.Url + request.Uri
// Add query parameters if available
if request.Params != nil { if request.Params != nil {
v, err := query.Values(request.Params) v, err := query.Values(request.Params)
if err != nil { if err != nil {
@@ -371,78 +376,62 @@ func (c *Client) Query(request Request, result interface{}) (err error) {
url = fmt.Sprintf("%s?%s", url, v.Encode()) url = fmt.Sprintf("%s?%s", url, v.Encode())
} }
// Create request rdr, err := requestBodyReader(request.Body)
if request.Body != nil { if err != nil {
// Check request body if type is not []byte or string the marshal it to JSON return err
switch request.Body.(type) {
case []byte:
jsonRequestBody = string(request.Body.([]byte))
rdr = strings.NewReader(string(request.Body.([]byte)))
case string:
jsonRequestBody = request.Body.(string)
rdr = strings.NewReader(request.Body.(string))
default:
// Marshal to JSON
b, err := json.Marshal(request.Body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %v", err)
}
jsonRequestBody = string(b)
rdr = strings.NewReader(string(b))
}
} }
req, err := http.NewRequest(request.GetMethod(), url, rdr) req, err := http.NewRequest(request.GetMethod(), url, rdr)
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Pragma", "no-cache") req.Header.Set("Pragma", "no-cache")
// Get token if not set?
if !request.NoAuth { if !request.NoAuth {
// Get token if not set or expired if err := c.ensureToken(); err != nil {
if c.token == nil || time.Time(c.token.Expires).Before(time.Now()) { return fmt.Errorf("failed to authenticate: %w", err)
c.token, err = c.Auth(c.credentials)
if err != nil {
return fmt.Errorf("failed to authenticate: %v", err)
}
} }
} }
switch {
// Set token if available case request.Token != nil:
if request.Token != nil {
req.Header.Set("Authorization", *request.Token) req.Header.Set("Authorization", *request.Token)
} else { case c.token != nil:
// Set token from a client if available req.Header.Set("Authorization", c.token.Value)
if c.token != nil {
req.Header.Set("Authorization", c.token.Value)
}
} }
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to send request: %v", err) return fmt.Errorf("failed to send request: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if result == nil { if result == nil {
return nil return nil
} }
if request.Debug { return json.NewDecoder(resp.Body).Decode(result)
// Rewind reader }
_ = jsonRequestBody
var buf = new(strings.Builder)
io.Copy(buf, resp.Body)
js := buf.String()
return json.Unmarshal([]byte(js), result)
} else {
// Unmarshal response using reader
return json.NewDecoder(resp.Body).Decode(result)
}
// requestBodyReader normalises Query() body input into an io.Reader.
// []byte and string are passed through verbatim; everything else is marshalled
// to JSON. Returns (nil, nil) for a nil body.
func requestBodyReader(body any) (io.Reader, error) {
if body == nil {
return nil, nil
}
switch b := body.(type) {
case []byte:
return bytes.NewReader(b), nil
case string:
return strings.NewReader(b), nil
default:
j, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
return bytes.NewReader(j), nil
}
} }
// Auth to authenticate by getting a token using credentials // Auth to authenticate by getting a token using credentials
@@ -489,7 +478,7 @@ func (c *Client) GetProjects() (list Projects, err error) {
func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) { func (c *Client) GetProjectMilestones(project *Project) ([]*Milestone, error) {
var list []*Milestone var list []*Milestone
err := c.Query(Request{Uri: fmt.Sprintf(`/api/2.0/project/%d/milestone`, *project.ID), Debug: false}, err := c.Query(Request{Uri: fmt.Sprintf(`/api/2.0/project/%d/milestone`, *project.ID)},
&struct { &struct {
MetaResponse `json:",inline"` MetaResponse `json:",inline"`
//Response *[]*Milestone //Response *[]*Milestone
@@ -656,9 +645,7 @@ func (c *Client) UpdateProjectTask(req ProjectTaskUpdateRequest) (task *Task, er
Uri: fmt.Sprintf("/api/2.0/project/task/%d.json", req.ID), Uri: fmt.Sprintf("/api/2.0/project/task/%d.json", req.ID),
Method: "PUT", Method: "PUT",
Body: req, Body: req,
Debug: true,
}, &struct { }, &struct {
//MetaResponse `json:",inline"`
Response *Task `json:"response"` Response *Task `json:"response"`
}{task}) }{task})
} }
@@ -692,10 +679,8 @@ func (c *Client) GetTasks(req ProjectGetTasksRequest) (tasks []*Task, err error)
Request{ Request{
Uri: "/api/2.0/project/task/filter.json", Uri: "/api/2.0/project/task/filter.json",
Params: req, Params: req,
Debug: true,
}, },
&struct { &struct {
//MetaResponse `json:",inline"`
Response *[]*Task `json:"response"` Response *[]*Task `json:"response"`
}{&tasks}) }{&tasks})
} }
+133
View File
@@ -0,0 +1,133 @@
package onlyoffice
// Pure unit tests — no network, no fake vendor HTTP servers.
// Protocol-level behaviour is covered by *_integration_test.go (build-tagged).
import (
"context"
"encoding/json"
"testing"
"time"
)
func TestResponseFieldMissing(t *testing.T) {
if _, err := responseField(json.RawMessage(`{"other":1}`), "response"); err == nil {
t.Fatal("expected error on missing field")
}
}
func TestResponseFieldPresent(t *testing.T) {
raw, err := responseField(json.RawMessage(`{"response":[1,2,3],"other":9}`), "response")
if err != nil {
t.Fatal(err)
}
if string(raw) != "[1,2,3]" {
t.Errorf("got %s", string(raw))
}
}
func TestRequestBodyReaderNil(t *testing.T) {
r, err := requestBodyReader(nil)
if err != nil {
t.Fatal(err)
}
if r != nil {
t.Errorf("expected nil reader for nil body, got %T", r)
}
}
func TestRequestBodyReaderBytes(t *testing.T) {
r, err := requestBodyReader([]byte(`{"k":1}`))
if err != nil {
t.Fatal(err)
}
if r == nil {
t.Fatal("nil reader")
}
}
func TestRequestBodyReaderStruct(t *testing.T) {
type payload struct {
Name string `json:"name"`
}
r, err := requestBodyReader(payload{Name: "x"})
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 64)
n, _ := r.Read(buf)
if string(buf[:n]) != `{"name":"x"}` {
t.Errorf("got %q", string(buf[:n]))
}
}
func TestProjectStringNilSafe(t *testing.T) {
var p Project
if got := p.String(); got != "" {
t.Errorf("nil Title should yield empty string, got %q", got)
}
title := "my project"
p.Title = &title
if got := p.String(); got != "my project" {
t.Errorf("got %q", got)
}
titleWithPercent := "100% coverage"
p.Title = &titleWithPercent
if got := p.String(); got != "100% coverage" {
t.Errorf("Sprintf format-string regression: got %q", got)
}
}
func TestAuthenticateContextRespectsCancellation(t *testing.T) {
// Point the client at a routable-but-unresponsive endpoint (TEST-NET-1
// per RFC 5737) and cancel the context almost immediately. The test
// verifies ctx plumbing, not OnlyOffice protocol — no vendor mock.
c := NewClient(Credentials{Url: "http://192.0.2.1:9", User: "u", Password: "p"})
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
if err := c.AuthenticateContext(ctx); err == nil {
t.Fatal("expected error on context timeout against unreachable endpoint")
}
}
func TestInvalidateTokenIsIdempotent(t *testing.T) {
c := NewClient(Credentials{Url: "http://example.invalid", User: "u", Password: "p"})
c.InvalidateToken()
c.InvalidateToken()
if c.token != nil {
t.Fatal("token should remain nil after double invalidate")
}
}
func TestGetEnvironmentCredentialsAliases(t *testing.T) {
t.Setenv("ONLYOFFICE_URL", "")
t.Setenv("ONLYOFFICE_HOST", "https://example/")
t.Setenv("ONLYOFFICE_USER", "")
t.Setenv("ONLYOFFICE_NAME", "alice")
t.Setenv("ONLYOFFICE_PASS", "")
t.Setenv("ONLYOFFICE_PASSWORD", "s3cret")
c := GetEnvironmentCredentials()
if c.Url != "https://example" {
t.Errorf("Url alias not applied / trailing slash not trimmed: %q", c.Url)
}
if c.User != "alice" {
t.Errorf("User alias not applied: %q", c.User)
}
if c.Password != "s3cret" {
t.Errorf("Password alias not applied: %q", c.Password)
}
}
func TestGetEnvironmentDefaultsFallbacks(t *testing.T) {
t.Setenv("ONLYOFFICE_CALENDAR_ID", "")
t.Setenv("ONLYOFFICE_PROJECT_ID", "")
t.Setenv("ONLYOFFICE_CALENDAR_PROJECT_ID", "")
d := GetEnvironmentDefaults()
if d.CalendarID != "1" || d.ProjectID != "33" {
t.Errorf("defaults: %+v", d)
}
t.Setenv("ONLYOFFICE_CALENDAR_PROJECT_ID", "7")
if got := GetEnvironmentDefaults().ProjectID; got != "7" {
t.Errorf("CalendarProjectId alias: %q", got)
}
}