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
This commit is contained in:
+192
-100
@@ -1,128 +1,220 @@
|
||||
//go:build integration
|
||||
|
||||
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 (
|
||||
"os"
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Load environment variables
|
||||
func init() {
|
||||
// Load from .env file in the project root
|
||||
_ = godotenv.Load(".env")
|
||||
}
|
||||
|
||||
func skipWithoutCredentials(t *testing.T) {
|
||||
func skipWithoutCredentials(t *testing.T) Credentials {
|
||||
t.Helper()
|
||||
if os.Getenv("ONLYOFFICE_URL") == "" {
|
||||
t.Skip("ONLYOFFICE_URL is not set, skipping integration test")
|
||||
c := GetEnvironmentCredentials()
|
||||
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) {
|
||||
skipWithoutCredentials(t)
|
||||
func liveClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
c := NewClient(skipWithoutCredentials(t))
|
||||
c.SetDefaults(GetEnvironmentDefaults())
|
||||
return c
|
||||
}
|
||||
|
||||
// Create a new OnlyOffice client
|
||||
credentials := GetEnvironmentCredentials()
|
||||
client := NewClient(credentials)
|
||||
var token *Token
|
||||
var err error
|
||||
token, err = client.Auth(&credentials)
|
||||
const testProjectPrefix = "go-onlyoffice-test-"
|
||||
|
||||
func cleanupTestProjects(t *testing.T, c *Client) {
|
||||
t.Helper()
|
||||
projects, err := c.GetProjects()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get token: %v", err)
|
||||
t.Logf("cleanup: GetProjects: %v", err)
|
||||
return
|
||||
}
|
||||
if token == nil {
|
||||
t.Error("Value is empty")
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test create project
|
||||
project, err := client.CreateProject(NewProjectRequest{
|
||||
Title: "Test project",
|
||||
Description: "Test project description",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Failed to create project: %v", err)
|
||||
func TestIntegrationAuthenticateContext(t *testing.T) {
|
||||
c := liveClient(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := c.AuthenticateContext(ctx); err != nil {
|
||||
t.Fatalf("AuthenticateContext: %v", err)
|
||||
}
|
||||
|
||||
// Update project
|
||||
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)
|
||||
if c.token == nil || c.token.Value == "" {
|
||||
t.Fatal("token not cached after AuthenticateContext")
|
||||
}
|
||||
|
||||
if task == nil {
|
||||
t.Error("Value is empty")
|
||||
// Second call must hit the cache.
|
||||
cachedValue := c.token.Value
|
||||
if err := c.AuthenticateContext(ctx); err != nil {
|
||||
t.Fatalf("AuthenticateContext (cached): %v", err)
|
||||
}
|
||||
|
||||
// Delete project
|
||||
prj, err := client.DeleteProject(*project.ID)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to delete project: %v", err)
|
||||
if c.token.Value != cachedValue {
|
||||
t.Fatal("cached token was replaced unexpectedly")
|
||||
}
|
||||
|
||||
if *prj.ID != *project.ID {
|
||||
t.Errorf("Project ID is not equal: %v != %v", prj.ID, project.ID)
|
||||
// Invalidate forces re-auth.
|
||||
c.InvalidateToken()
|
||||
if c.token != nil {
|
||||
t.Fatal("InvalidateToken did not clear the cache")
|
||||
}
|
||||
|
||||
// Test list projects
|
||||
projects, err = client.GetProjects()
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get projects: %v", err)
|
||||
if err := c.AuthenticateContext(ctx); err != nil {
|
||||
t.Fatalf("AuthenticateContext (after invalidate): %v", err)
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
t.Error("Value is empty")
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user