Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2d481e7b8 | ||
|
|
2fc2adadd1 | ||
|
|
b63055d435 |
@@ -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).
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+177
-85
@@ -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 liveClient(t *testing.T) *Client {
|
||||||
|
t.Helper()
|
||||||
|
c := NewClient(skipWithoutCredentials(t))
|
||||||
|
c.SetDefaults(GetEnvironmentDefaults())
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
const testProjectPrefix = "go-onlyoffice-test-"
|
||||||
|
|
||||||
|
func cleanupTestProjects(t *testing.T, c *Client) {
|
||||||
|
t.Helper()
|
||||||
|
projects, err := c.GetProjects()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("cleanup: GetProjects: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range projects {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestIntegrationAuthenticateContext(t *testing.T) {
|
||||||
skipWithoutCredentials(t)
|
c := liveClient(t)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
// Create a new OnlyOffice client
|
defer cancel()
|
||||||
credentials := GetEnvironmentCredentials()
|
if err := c.AuthenticateContext(ctx); err != nil {
|
||||||
client := NewClient(credentials)
|
t.Fatalf("AuthenticateContext: %v", err)
|
||||||
var token *Token
|
}
|
||||||
var err error
|
if c.token == nil || c.token.Value == "" {
|
||||||
token, err = client.Auth(&credentials)
|
t.Fatal("token not cached after AuthenticateContext")
|
||||||
|
}
|
||||||
|
// Second call must hit the cache.
|
||||||
|
cachedValue := c.token.Value
|
||||||
|
if err := c.AuthenticateContext(ctx); err != nil {
|
||||||
|
t.Fatalf("AuthenticateContext (cached): %v", err)
|
||||||
|
}
|
||||||
|
if c.token.Value != cachedValue {
|
||||||
|
t.Fatal("cached token was replaced unexpectedly")
|
||||||
|
}
|
||||||
|
// Invalidate forces re-auth.
|
||||||
|
c.InvalidateToken()
|
||||||
|
if c.token != nil {
|
||||||
|
t.Fatal("InvalidateToken did not clear the cache")
|
||||||
|
}
|
||||||
|
if err := c.AuthenticateContext(ctx); err != nil {
|
||||||
|
t.Fatalf("AuthenticateContext (after invalidate): %v", err)
|
||||||
|
}
|
||||||
|
if c.token == nil {
|
||||||
|
t.Fatal("post-invalidate auth left token nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationGetProjectsAndSelf(t *testing.T) {
|
||||||
|
c := liveClient(t)
|
||||||
|
projects, err := c.GetProjects()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to get token: %v", err)
|
t.Fatalf("GetProjects: %v", err)
|
||||||
}
|
}
|
||||||
if token == nil {
|
if projects == nil {
|
||||||
t.Error("Value is empty")
|
t.Fatal("nil slice from GetProjects")
|
||||||
}
|
}
|
||||||
|
ctx := context.Background()
|
||||||
// Clean up: remove all projects start with "Test project"
|
uid, err := c.SelfUserID(ctx)
|
||||||
projects, err := client.GetProjects()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to get projects: %v", err)
|
t.Fatalf("SelfUserID: %v", err)
|
||||||
}
|
}
|
||||||
|
if uid == "" {
|
||||||
|
t.Fatal("empty self user id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, project := range projects {
|
func TestIntegrationProjectAndTaskLifecycle(t *testing.T) {
|
||||||
if strings.HasPrefix(*project.Title, "Test project") {
|
c := liveClient(t)
|
||||||
prjStatus, err := client.DeleteProject(*project.ID)
|
t.Cleanup(func() { cleanupTestProjects(t, c) })
|
||||||
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
|
suffix := time.Now().UTC().Format("20060102-150405")
|
||||||
project, err := client.CreateProject(NewProjectRequest{
|
title := testProjectPrefix + suffix
|
||||||
Title: "Test project",
|
project, err := c.CreateProject(NewProjectRequest{
|
||||||
Description: "Test project description",
|
Title: title,
|
||||||
|
Description: "integration test from go-onlyoffice",
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to create project: %v", err)
|
t.Fatalf("CreateProject: %v", err)
|
||||||
|
}
|
||||||
|
if project.ID == nil {
|
||||||
|
t.Fatal("created project without id")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update project
|
updated, err := c.UpdateProject(ProjectUpdateRequest{
|
||||||
project, err = client.UpdateProject(ProjectUpdateRequest{
|
|
||||||
ID: *project.ID,
|
ID: *project.ID,
|
||||||
Title: "Test project updated",
|
Title: title + " (updated)",
|
||||||
|
Description: "updated",
|
||||||
ResponsibleID: *project.Responsible.ID,
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
var task *Task
|
start := Time(time.Now().AddDate(0, 0, -2))
|
||||||
// Test create project task
|
deadline := Time(time.Now().AddDate(0, 0, 2))
|
||||||
task, err = client.CreateProjectTask(NewProjectTaskRequest{
|
task, err := c.CreateProjectTask(NewProjectTaskRequest{
|
||||||
ProjectId: *project.ID,
|
ProjectId: *project.ID,
|
||||||
Title: "Test task",
|
Title: "integration parent task",
|
||||||
Description: "Test task description",
|
Description: "from go-onlyoffice integration suite",
|
||||||
Notify: true,
|
StartDate: start,
|
||||||
MilestoneId: 0,
|
Deadline: deadline,
|
||||||
Priority: 0,
|
Priority: int(TaskPriorityNormal),
|
||||||
// Deadline +2 days
|
|
||||||
StartDate: Time(time.Now().AddDate(0, 0, -2)),
|
|
||||||
Deadline: Time(time.Now().AddDate(0, 0, 2)),
|
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProjectTask: %v", err)
|
||||||
|
}
|
||||||
|
if task.ID == nil {
|
||||||
|
t.Fatal("created task without id")
|
||||||
|
}
|
||||||
|
|
||||||
// Update project task
|
newStart := Time(time.Now().AddDate(0, 0, -14))
|
||||||
startDate := Time(time.Now().AddDate(0, 0, -14))
|
newDeadline := Time(time.Now().AddDate(0, 0, 3))
|
||||||
deadline := Time(time.Now().AddDate(0, 0, 3))
|
if _, err := c.UpdateProjectTask(ProjectTaskUpdateRequest{
|
||||||
task, err = client.UpdateProjectTask(ProjectTaskUpdateRequest{
|
|
||||||
ID: *task.ID,
|
ID: *task.ID,
|
||||||
Title: "Test task updated",
|
Title: "integration parent task (updated)",
|
||||||
Description: "Test task description updated",
|
Description: "updated",
|
||||||
StartDate: &startDate,
|
StartDate: &newStart,
|
||||||
Deadline: &deadline,
|
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 {
|
if err != nil {
|
||||||
t.Errorf("Failed to create task: %v", err)
|
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 task == nil {
|
if _, err := c.ListCalendars(ctx, start, end); err != nil {
|
||||||
t.Error("Value is empty")
|
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 {
|
||||||
// Delete project
|
t.Errorf("ListOpportunities: %v", err)
|
||||||
prj, err := client.DeleteProject(*project.ID)
|
}
|
||||||
if err != nil {
|
if _, err := c.ListDealStages(ctx); err != nil {
|
||||||
t.Errorf("Failed to delete project: %v", err)
|
t.Errorf("ListDealStages: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if *prj.ID != *project.ID {
|
// TestIntegrationDryRunList covers the tiniest project-tasks read that
|
||||||
t.Errorf("Project ID is not equal: %v != %v", prj.ID, project.ID)
|
// 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)
|
||||||
// Test list projects
|
if err != nil || pid <= 0 {
|
||||||
projects, err = client.GetProjects()
|
t.Skipf("ONLYOFFICE_PROJECT_ID %q is not a positive int", defaults.ProjectID)
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Failed to get projects: %v", err)
|
|
||||||
}
|
}
|
||||||
if len(projects) == 0 {
|
if _, err := c.GetTasks(NewProjectGetTasksRequest(pid)); err != nil {
|
||||||
t.Error("Value is empty")
|
t.Fatalf("GetTasks(%d): %v", pid, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -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
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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] + "..."
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
+44
-59
@@ -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 {
|
|
||||||
// Check request body if type is not []byte or string the marshal it to JSON
|
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal request body: %v", err)
|
return 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 {
|
||||||
|
case request.Token != nil:
|
||||||
// Set token if available
|
|
||||||
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
|
|
||||||
if c.token != nil {
|
|
||||||
req.Header.Set("Authorization", c.token.Value)
|
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 {
|
|
||||||
// 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)
|
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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user