mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
The /cluster/replication endpoint only returns job configuration (guest,
schedule, source, target), not status data (last_sync, next_sync,
duration, fail_count, state).
This fix enriches each replication job with status from the per-node
endpoint /nodes/{node}/replication/{id}/status to get timing and state
data needed for proper UI display.
Added integration tests to verify:
- Status endpoint is called and data is merged correctly
- Graceful handling when status endpoint fails
Fixes #992
960 lines
26 KiB
Go
960 lines
26 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestStringFromAny(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
want string
|
|
}{
|
|
// nil
|
|
{"nil", nil, ""},
|
|
|
|
// string types
|
|
{"empty string", "", ""},
|
|
{"simple string", "hello", "hello"},
|
|
{"string with spaces", " hello world ", "hello world"},
|
|
{"string number", "42", "42"},
|
|
|
|
// int types
|
|
{"int zero", int(0), "0"},
|
|
{"int positive", int(42), "42"},
|
|
{"int negative", int(-42), "-42"},
|
|
{"int64", int64(9223372036854775807), "9223372036854775807"},
|
|
{"int32", int32(2147483647), "2147483647"},
|
|
|
|
// uint types
|
|
{"uint", uint(42), "42"},
|
|
{"uint64", uint64(18446744073709551615), "18446744073709551615"},
|
|
{"uint32", uint32(4294967295), "4294967295"},
|
|
|
|
// float types
|
|
{"float64 integer", float64(42), "42"},
|
|
{"float64 decimal", float64(3.14159), "3.14159"},
|
|
{"float64 negative", float64(-1.5), "-1.5"},
|
|
{"float64 NaN", math.NaN(), ""},
|
|
{"float64 +Inf", math.Inf(1), ""},
|
|
{"float64 -Inf", math.Inf(-1), ""},
|
|
{"float32", float32(3.14), "3.14"},
|
|
|
|
// bool
|
|
{"bool true", true, "true"},
|
|
{"bool false", false, "false"},
|
|
|
|
// json.Number
|
|
{"json.Number int", json.Number("42"), "42"},
|
|
{"json.Number float", json.Number("3.14"), "3.14"},
|
|
|
|
// other types (fallback to fmt.Sprint)
|
|
{"slice", []int{1, 2, 3}, "[1 2 3]"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := stringFromAny(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("stringFromAny(%v) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIntFromAny(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
want int
|
|
wantOk bool
|
|
}{
|
|
// nil
|
|
{"nil", nil, 0, false},
|
|
|
|
// int types
|
|
{"int zero", int(0), 0, true},
|
|
{"int positive", int(42), 42, true},
|
|
{"int negative", int(-42), -42, true},
|
|
{"int8", int8(127), 127, true},
|
|
{"int16", int16(32767), 32767, true},
|
|
{"int32", int32(2147483647), 2147483647, true},
|
|
{"int64", int64(42), 42, true},
|
|
|
|
// uint types
|
|
{"uint", uint(42), 42, true},
|
|
{"uint8", uint8(255), 255, true},
|
|
{"uint16", uint16(65535), 65535, true},
|
|
{"uint32", uint32(42), 42, true},
|
|
{"uint64", uint64(42), 42, true},
|
|
|
|
// float types (rounded)
|
|
{"float32 integer", float32(42.0), 42, true},
|
|
{"float32 round down", float32(42.4), 42, true},
|
|
{"float32 round up", float32(42.6), 43, true},
|
|
{"float32 NaN", float32(math.NaN()), 0, false},
|
|
{"float32 +Inf", float32(math.Inf(1)), 0, false},
|
|
{"float32 -Inf", float32(math.Inf(-1)), 0, false},
|
|
{"float64 integer", float64(42.0), 42, true},
|
|
{"float64 round half", float64(42.5), 43, true},
|
|
{"float64 NaN", math.NaN(), 0, false},
|
|
{"float64 +Inf", math.Inf(1), 0, false},
|
|
{"float64 -Inf", math.Inf(-1), 0, false},
|
|
|
|
// json.Number
|
|
{"json.Number int", json.Number("42"), 42, true},
|
|
{"json.Number float", json.Number("42.6"), 43, true},
|
|
{"json.Number invalid", json.Number("abc"), 0, false},
|
|
|
|
// string
|
|
{"string int", "42", 42, true},
|
|
{"string negative", "-42", -42, true},
|
|
{"string float", "42.6", 43, true},
|
|
{"string empty", "", 0, false},
|
|
{"string whitespace", " 42 ", 42, true},
|
|
{"string invalid", "abc", 0, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, ok := intFromAny(tc.input)
|
|
if ok != tc.wantOk {
|
|
t.Errorf("intFromAny(%v) ok = %v, want %v", tc.input, ok, tc.wantOk)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("intFromAny(%v) = %d, want %d", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBoolFromAny(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
want bool
|
|
wantOk bool
|
|
}{
|
|
// nil
|
|
{"nil", nil, false, false},
|
|
|
|
// bool
|
|
{"bool true", true, true, true},
|
|
{"bool false", false, false, true},
|
|
|
|
// int types (non-zero = true)
|
|
{"int 0", int(0), false, true},
|
|
{"int 1", int(1), true, true},
|
|
{"int -1", int(-1), true, true},
|
|
{"int64 0", int64(0), false, true},
|
|
{"int64 1", int64(1), true, true},
|
|
|
|
// uint types
|
|
{"uint 0", uint(0), false, true},
|
|
{"uint 1", uint(1), true, true},
|
|
|
|
// float types
|
|
{"float64 0", float64(0), false, true},
|
|
{"float64 1", float64(1), true, true},
|
|
{"float64 0.5", float64(0.5), true, true},
|
|
|
|
// json.Number
|
|
{"json.Number 0", json.Number("0"), false, true},
|
|
{"json.Number 1", json.Number("1"), true, true},
|
|
|
|
// string truthy values
|
|
{"string true", "true", true, true},
|
|
{"string TRUE", "TRUE", true, true},
|
|
{"string yes", "yes", true, true},
|
|
{"string YES", "YES", true, true},
|
|
{"string 1", "1", true, true},
|
|
{"string on", "on", true, true},
|
|
{"string enabled", "enabled", true, true},
|
|
|
|
// string falsy values
|
|
{"string false", "false", false, true},
|
|
{"string FALSE", "FALSE", false, true},
|
|
{"string no", "no", false, true},
|
|
{"string NO", "NO", false, true},
|
|
{"string 0", "0", false, true},
|
|
{"string off", "off", false, true},
|
|
{"string disabled", "disabled", false, true},
|
|
|
|
// string with whitespace
|
|
{"string true with spaces", " true ", true, true},
|
|
|
|
// invalid string
|
|
{"string invalid", "maybe", false, false},
|
|
{"string empty", "", false, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, ok := boolFromAny(tc.input)
|
|
if ok != tc.wantOk {
|
|
t.Errorf("boolFromAny(%v) ok = %v, want %v", tc.input, ok, tc.wantOk)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("boolFromAny(%v) = %v, want %v", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFloatFromAny(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
want float64
|
|
wantOk bool
|
|
}{
|
|
// nil
|
|
{"nil", nil, 0, false},
|
|
|
|
// float types
|
|
{"float64 zero", float64(0), 0, true},
|
|
{"float64 positive", float64(3.14159), 3.14159, true},
|
|
{"float64 negative", float64(-3.14159), -3.14159, true},
|
|
{"float64 NaN", math.NaN(), 0, false},
|
|
{"float64 +Inf", math.Inf(1), 0, false},
|
|
{"float64 -Inf", math.Inf(-1), 0, false},
|
|
{"float32", float32(3.14), float64(float32(3.14)), true},
|
|
{"float32 NaN", float32(math.NaN()), 0, false},
|
|
{"float32 +Inf", float32(math.Inf(1)), 0, false},
|
|
{"float32 -Inf", float32(math.Inf(-1)), 0, false},
|
|
|
|
// int types
|
|
{"int", int(42), 42, true},
|
|
{"int64", int64(42), 42, true},
|
|
|
|
// uint types
|
|
{"uint", uint(42), 42, true},
|
|
{"uint64", uint64(42), 42, true},
|
|
|
|
// json.Number
|
|
{"json.Number int", json.Number("42"), 42, true},
|
|
{"json.Number float", json.Number("3.14159"), 3.14159, true},
|
|
{"json.Number invalid", json.Number("abc"), 0, false},
|
|
|
|
// string
|
|
{"string float", "3.14159", 3.14159, true},
|
|
{"string int", "42", 42, true},
|
|
{"string negative", "-3.14", -3.14, true},
|
|
{"string empty", "", 0, false},
|
|
{"string whitespace", " 3.14 ", 3.14, true},
|
|
{"string invalid", "abc", 0, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, ok := floatFromAny(tc.input)
|
|
if ok != tc.wantOk {
|
|
t.Errorf("floatFromAny(%v) ok = %v, want %v", tc.input, ok, tc.wantOk)
|
|
}
|
|
if ok && got != tc.want {
|
|
t.Errorf("floatFromAny(%v) = %v, want %v", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationTime(t *testing.T) {
|
|
// Fixed reference time for testing
|
|
refTime := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
|
|
refUnix := refTime.Unix()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
wantNil bool
|
|
wantUnix int64
|
|
}{
|
|
// nil
|
|
{"nil", nil, true, 0},
|
|
|
|
// time.Time
|
|
{"time.Time", refTime, false, refUnix},
|
|
{"*time.Time", &refTime, false, refUnix},
|
|
{"*time.Time nil", (*time.Time)(nil), true, 0},
|
|
|
|
// unix timestamps as int
|
|
{"int timestamp", int(refUnix), false, refUnix},
|
|
{"int64 timestamp", int64(refUnix), false, refUnix},
|
|
{"int zero", int(0), true, 0},
|
|
{"int negative", int(-1), true, 0},
|
|
|
|
// unix timestamps as uint
|
|
{"uint timestamp", uint(refUnix), false, refUnix},
|
|
{"uint64 timestamp", uint64(refUnix), false, refUnix},
|
|
|
|
// unix timestamps as float
|
|
{"float64 timestamp", float64(refUnix), false, refUnix},
|
|
{"float64 zero", float64(0), true, 0},
|
|
{"float64 negative", float64(-1), true, 0},
|
|
{"float32 timestamp", float32(1000000), false, 1000000}, // smaller value for float32 precision
|
|
{"float32 zero", float32(0), true, 0},
|
|
{"float32 negative", float32(-1), true, 0},
|
|
|
|
// json.Number
|
|
{"json.Number timestamp", json.Number("1736936400"), false, 1736936400},
|
|
{"json.Number zero", json.Number("0"), true, 0},
|
|
{"json.Number negative", json.Number("-1"), true, 0},
|
|
|
|
// int32 and uint32
|
|
{"int32 timestamp", int32(refUnix), false, refUnix},
|
|
{"uint32 timestamp", uint32(refUnix), false, refUnix},
|
|
|
|
// string unix timestamp
|
|
{"string unix", "1736936400", false, 1736936400},
|
|
{"string zero", "0", true, 0},
|
|
{"string negative", "-1", true, 0},
|
|
|
|
// string N/A values
|
|
{"string n/a", "n/a", true, 0},
|
|
{"string N/A", "N/A", true, 0},
|
|
{"string pending", "pending", true, 0},
|
|
{"string dash", "-", true, 0},
|
|
{"string empty", "", true, 0},
|
|
|
|
// RFC3339 format
|
|
{"string RFC3339", "2025-01-15T10:30:00Z", false, refUnix},
|
|
|
|
// Common date formats
|
|
{"string date time", "2025-01-15 10:30:00", false, refUnix},
|
|
{"string date time T", "2025-01-15T10:30:00", false, refUnix},
|
|
|
|
// Invalid date format (not matching any layout)
|
|
{"string invalid date", "invalid-date-format", true, 0},
|
|
{"string partial date", "2025-01-15", true, 0},
|
|
|
|
// Unsupported type
|
|
{"unsupported type bool", true, true, 0},
|
|
{"unsupported type slice", []int{1, 2, 3}, true, 0},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, gotUnix := parseReplicationTime(tc.input)
|
|
if tc.wantNil {
|
|
if got != nil {
|
|
t.Errorf("parseReplicationTime(%v) = %v, want nil", tc.input, got)
|
|
}
|
|
if gotUnix != 0 {
|
|
t.Errorf("parseReplicationTime(%v) unix = %d, want 0", tc.input, gotUnix)
|
|
}
|
|
} else {
|
|
if got == nil {
|
|
t.Errorf("parseReplicationTime(%v) = nil, want non-nil", tc.input)
|
|
}
|
|
if gotUnix != tc.wantUnix {
|
|
t.Errorf("parseReplicationTime(%v) unix = %d, want %d", tc.input, gotUnix, tc.wantUnix)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseDurationSeconds(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
wantSecs int
|
|
wantHuman string
|
|
}{
|
|
// nil
|
|
{"nil", nil, 0, ""},
|
|
|
|
// int types
|
|
{"int zero", int(0), 0, "0"},
|
|
{"int positive", int(120), 120, "120"},
|
|
{"int negative", int(-1), 0, "-1"},
|
|
{"int64", int64(3600), 3600, "3600"},
|
|
|
|
// uint types
|
|
{"uint", uint(120), 120, "120"},
|
|
{"uint64", uint64(3600), 3600, "3600"},
|
|
|
|
// float types
|
|
{"float64 integer", float64(120), 120, "120"},
|
|
{"float64 decimal", float64(120.5), 121, "120.5"},
|
|
{"float64 negative", float64(-1), 0, "-1"},
|
|
|
|
// json.Number
|
|
{"json.Number", json.Number("120"), 120, "120"},
|
|
{"json.Number float", json.Number("120.5"), 121, "120.5"},
|
|
|
|
// string numeric
|
|
{"string int", "120", 120, "120"},
|
|
{"string float", "120.5", 121, "120.5"},
|
|
{"string empty", "", 0, ""},
|
|
|
|
// string HH:MM:SS format
|
|
{"string MM:SS", "02:30", 150, "02:30"},
|
|
{"string HH:MM:SS", "01:02:30", 3750, "01:02:30"},
|
|
{"string HH:MM:SS zeros", "00:00:30", 30, "00:00:30"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gotSecs, gotHuman := parseDurationSeconds(tc.input)
|
|
if gotSecs != tc.wantSecs {
|
|
t.Errorf("parseDurationSeconds(%v) secs = %d, want %d", tc.input, gotSecs, tc.wantSecs)
|
|
}
|
|
if gotHuman != tc.wantHuman {
|
|
t.Errorf("parseDurationSeconds(%v) human = %q, want %q", tc.input, gotHuman, tc.wantHuman)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseHHMMSSToSeconds(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want int
|
|
wantOk bool
|
|
}{
|
|
// valid MM:SS
|
|
{"MM:SS zeros", "00:00", 0, true},
|
|
{"MM:SS simple", "02:30", 150, true},
|
|
{"MM:SS max minutes", "59:59", 3599, true},
|
|
|
|
// valid HH:MM:SS
|
|
{"HH:MM:SS zeros", "00:00:00", 0, true},
|
|
{"HH:MM:SS simple", "01:02:30", 3750, true},
|
|
{"HH:MM:SS one hour", "01:00:00", 3600, true},
|
|
{"HH:MM:SS large", "24:00:00", 86400, true},
|
|
|
|
// invalid formats
|
|
{"single value", "30", 0, false},
|
|
{"too many parts", "01:02:03:04", 0, false},
|
|
{"empty part", "01::30", 0, false},
|
|
{"invalid number", "01:ab:30", 0, false},
|
|
{"empty string", "", 0, false},
|
|
|
|
// whitespace handling
|
|
{"spaces in parts", " 01 : 02 : 30 ", 3750, true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, ok := parseHHMMSSToSeconds(tc.input)
|
|
if ok != tc.wantOk {
|
|
t.Errorf("parseHHMMSSToSeconds(%q) ok = %v, want %v", tc.input, ok, tc.wantOk)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("parseHHMMSSToSeconds(%q) = %d, want %d", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecodeRaw(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input json.RawMessage
|
|
want interface{}
|
|
}{
|
|
{"nil", nil, nil},
|
|
{"string", json.RawMessage(`"hello"`), "hello"},
|
|
{"number", json.RawMessage(`42`), float64(42)},
|
|
{"bool true", json.RawMessage(`true`), true},
|
|
{"bool false", json.RawMessage(`false`), false},
|
|
{"null", json.RawMessage(`null`), nil},
|
|
{"invalid json", json.RawMessage(`invalid`), nil},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := decodeRaw(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("decodeRaw(%s) = %v, want %v", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFirstNonNilRaw(t *testing.T) {
|
|
entry := map[string]json.RawMessage{
|
|
"key1": nil,
|
|
"key2": json.RawMessage(`"value2"`),
|
|
"key3": json.RawMessage(`"value3"`),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
keys []string
|
|
want interface{}
|
|
}{
|
|
{"first key nil, second exists", []string{"key1", "key2"}, "value2"},
|
|
{"first key exists", []string{"key2", "key3"}, "value2"},
|
|
{"only last key exists", []string{"key1", "nonexistent", "key3"}, "value3"},
|
|
{"no keys exist", []string{"nonexistent1", "nonexistent2"}, nil},
|
|
{"empty keys", []string{}, nil},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := firstNonNilRaw(entry, tc.keys...)
|
|
if got != tc.want {
|
|
t.Errorf("firstNonNilRaw(%v) = %v, want %v", tc.keys, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCopyFloat(t *testing.T) {
|
|
val := 3.14159
|
|
got := copyFloat(val)
|
|
|
|
if got == nil {
|
|
t.Fatal("copyFloat returned nil")
|
|
}
|
|
if *got != val {
|
|
t.Errorf("copyFloat(%v) = %v, want %v", val, *got, val)
|
|
}
|
|
|
|
// Verify it's a copy
|
|
val = 2.71828
|
|
if *got == val {
|
|
t.Error("copyFloat did not create a copy")
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob(t *testing.T) {
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"guest": json.RawMessage(`100`),
|
|
"source": json.RawMessage(`"node1"`),
|
|
"target": json.RawMessage(`"node2"`),
|
|
"schedule": json.RawMessage(`"*/15"`),
|
|
"type": json.RawMessage(`"local"`),
|
|
"state": json.RawMessage(`"ok"`),
|
|
"enabled": json.RawMessage(`1`),
|
|
"last_sync": json.RawMessage(`1736936400`),
|
|
"fail_count": json.RawMessage(`0`),
|
|
"rate": json.RawMessage(`10.5`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.ID != "100-0" {
|
|
t.Errorf("ID = %q, want %q", job.ID, "100-0")
|
|
}
|
|
if job.GuestID != 100 {
|
|
t.Errorf("GuestID = %d, want %d", job.GuestID, 100)
|
|
}
|
|
if job.JobNumber != 0 {
|
|
t.Errorf("JobNumber = %d, want %d", job.JobNumber, 0)
|
|
}
|
|
if job.Source != "node1" {
|
|
t.Errorf("Source = %q, want %q", job.Source, "node1")
|
|
}
|
|
if job.Target != "node2" {
|
|
t.Errorf("Target = %q, want %q", job.Target, "node2")
|
|
}
|
|
if job.Schedule != "*/15" {
|
|
t.Errorf("Schedule = %q, want %q", job.Schedule, "*/15")
|
|
}
|
|
if job.Type != "local" {
|
|
t.Errorf("Type = %q, want %q", job.Type, "local")
|
|
}
|
|
if !job.Enabled {
|
|
t.Error("Enabled should be true")
|
|
}
|
|
if job.State != "ok" {
|
|
t.Errorf("State = %q, want %q", job.State, "ok")
|
|
}
|
|
if job.FailCount != 0 {
|
|
t.Errorf("FailCount = %d, want %d", job.FailCount, 0)
|
|
}
|
|
if job.RateLimitMbps == nil || *job.RateLimitMbps != 10.5 {
|
|
t.Errorf("RateLimitMbps = %v, want 10.5", job.RateLimitMbps)
|
|
}
|
|
if job.LastSyncUnix != 1736936400 {
|
|
t.Errorf("LastSyncUnix = %d, want %d", job.LastSyncUnix, 1736936400)
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_Disabled(t *testing.T) {
|
|
// Test disabled via "disable" field
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"disable": json.RawMessage(`true`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
if job.Enabled {
|
|
t.Error("Job should be disabled via 'disable' field")
|
|
}
|
|
|
|
// Test disabled via "active" field
|
|
entry = map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"active": json.RawMessage(`false`),
|
|
}
|
|
|
|
job = parseReplicationJob(entry)
|
|
if job.Enabled {
|
|
t.Error("Job should be disabled via 'active' field")
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_AlternateFieldNames(t *testing.T) {
|
|
// Test alternate field names like source-storage vs source_storage
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"source-storage": json.RawMessage(`"local-zfs"`),
|
|
"target-storage": json.RawMessage(`"remote-zfs"`),
|
|
"last-sync": json.RawMessage(`1736936400`),
|
|
"fail-count": json.RawMessage(`2`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.SourceStorage != "local-zfs" {
|
|
t.Errorf("SourceStorage = %q, want %q", job.SourceStorage, "local-zfs")
|
|
}
|
|
if job.TargetStorage != "remote-zfs" {
|
|
t.Errorf("TargetStorage = %q, want %q", job.TargetStorage, "remote-zfs")
|
|
}
|
|
if job.FailCount != 2 {
|
|
t.Errorf("FailCount = %d, want %d", job.FailCount, 2)
|
|
}
|
|
if job.LastSyncUnix != 1736936400 {
|
|
t.Errorf("LastSyncUnix = %d, want %d", job.LastSyncUnix, 1736936400)
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_JobNumberFromID(t *testing.T) {
|
|
// Test parsing job number from ID when jobnum field is missing
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-5"`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.JobNumber != 5 {
|
|
t.Errorf("JobNumber = %d, want %d (parsed from ID)", job.JobNumber, 5)
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_StatusFallback(t *testing.T) {
|
|
// Test status fallback from state when status is empty
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"state": json.RawMessage(`"syncing"`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.Status != "syncing" {
|
|
t.Errorf("Status = %q, want %q (from state)", job.Status, "syncing")
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_JobIDFallback(t *testing.T) {
|
|
// Test fallback from "id" to "jobid" field when id is missing
|
|
entry := map[string]json.RawMessage{
|
|
"jobid": json.RawMessage(`"200-1"`),
|
|
"guest": json.RawMessage(`200`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.ID != "200-1" {
|
|
t.Errorf("ID = %q, want %q (from jobid fallback)", job.ID, "200-1")
|
|
}
|
|
if job.JobNumber != 1 {
|
|
t.Errorf("JobNumber = %d, want %d (parsed from ID)", job.JobNumber, 1)
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_JobNumField(t *testing.T) {
|
|
// Test jobnum field takes precedence over parsing from ID
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"jobnum": json.RawMessage(`5`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.JobNumber != 5 {
|
|
t.Errorf("JobNumber = %d, want %d (from jobnum field)", job.JobNumber, 5)
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_DurationFields(t *testing.T) {
|
|
// Test last_sync_duration field
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"last_sync_duration": json.RawMessage(`120`),
|
|
"duration": json.RawMessage(`60`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.LastSyncDurationSeconds != 120 {
|
|
t.Errorf("LastSyncDurationSeconds = %d, want %d", job.LastSyncDurationSeconds, 120)
|
|
}
|
|
if job.DurationSeconds != 60 {
|
|
t.Errorf("DurationSeconds = %d, want %d", job.DurationSeconds, 60)
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_DurationFallback(t *testing.T) {
|
|
// Test fallback to last-sync-duration when last_sync_duration is missing
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"last-sync-duration": json.RawMessage(`90`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.LastSyncDurationSeconds != 90 {
|
|
t.Errorf("LastSyncDurationSeconds = %d, want %d (from last-sync-duration fallback)", job.LastSyncDurationSeconds, 90)
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_NextSyncFields(t *testing.T) {
|
|
// Test next_sync field
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"next_sync": json.RawMessage(`1736936500`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.NextSyncUnix != 1736936500 {
|
|
t.Errorf("NextSyncUnix = %d, want %d", job.NextSyncUnix, 1736936500)
|
|
}
|
|
if job.NextSyncTime == nil {
|
|
t.Error("NextSyncTime should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestParseReplicationJob_NextSyncFallback(t *testing.T) {
|
|
// Test fallback to next-sync when next_sync is missing
|
|
entry := map[string]json.RawMessage{
|
|
"id": json.RawMessage(`"100-0"`),
|
|
"next-sync": json.RawMessage(`1736936600`),
|
|
}
|
|
|
|
job := parseReplicationJob(entry)
|
|
|
|
if job.NextSyncUnix != 1736936600 {
|
|
t.Errorf("NextSyncUnix = %d, want %d (from next-sync fallback)", job.NextSyncUnix, 1736936600)
|
|
}
|
|
}
|
|
|
|
// TestGetReplicationStatus_EnrichesWithStatusData tests that GetReplicationStatus
|
|
// fetches job config from /cluster/replication AND enriches with status data from
|
|
// /nodes/{node}/replication/{id}/status. This tests the fix for issue #992.
|
|
func TestGetReplicationStatus_EnrichesWithStatusData(t *testing.T) {
|
|
// Track which endpoints were called
|
|
var calledClusterReplication bool
|
|
var calledStatusEndpoint bool
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
switch r.URL.Path {
|
|
case "/api2/json/cluster/replication":
|
|
calledClusterReplication = true
|
|
// Return job config (this is what /cluster/replication returns - NO status data)
|
|
fmt.Fprint(w, `{
|
|
"data": [
|
|
{
|
|
"id": "100-0",
|
|
"guest": 100,
|
|
"source": "pve1",
|
|
"target": "pve2",
|
|
"schedule": "*/15",
|
|
"type": "local"
|
|
}
|
|
]
|
|
}`)
|
|
|
|
case "/api2/json/nodes/pve1/replication/100-0/status":
|
|
calledStatusEndpoint = true
|
|
// Return status data (this is what the per-node endpoint returns)
|
|
fmt.Fprint(w, `{
|
|
"data": [
|
|
{
|
|
"last_sync": 1735689600,
|
|
"next_sync": 1735690500,
|
|
"duration": 120,
|
|
"fail_count": 0,
|
|
"state": "ok"
|
|
}
|
|
]
|
|
}`)
|
|
|
|
default:
|
|
t.Logf("Unexpected request to: %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{
|
|
Host: server.URL,
|
|
TokenName: "test@pve!token",
|
|
TokenValue: "secret",
|
|
VerifySSL: false,
|
|
Timeout: 2 * time.Second,
|
|
}
|
|
|
|
client, err := NewClient(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewClient failed: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
jobs, err := client.GetReplicationStatus(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetReplicationStatus failed: %v", err)
|
|
}
|
|
|
|
// Verify both endpoints were called
|
|
if !calledClusterReplication {
|
|
t.Error("Expected /cluster/replication to be called")
|
|
}
|
|
if !calledStatusEndpoint {
|
|
t.Error("Expected /nodes/pve1/replication/100-0/status to be called")
|
|
}
|
|
|
|
// Verify we got one job
|
|
if len(jobs) != 1 {
|
|
t.Fatalf("Expected 1 job, got %d", len(jobs))
|
|
}
|
|
|
|
job := jobs[0]
|
|
|
|
// Verify config fields came from /cluster/replication
|
|
if job.ID != "100-0" {
|
|
t.Errorf("ID = %q, want %q", job.ID, "100-0")
|
|
}
|
|
if job.GuestID != 100 {
|
|
t.Errorf("GuestID = %d, want %d", job.GuestID, 100)
|
|
}
|
|
if job.Source != "pve1" {
|
|
t.Errorf("Source = %q, want %q", job.Source, "pve1")
|
|
}
|
|
if job.Target != "pve2" {
|
|
t.Errorf("Target = %q, want %q", job.Target, "pve2")
|
|
}
|
|
if job.Schedule != "*/15" {
|
|
t.Errorf("Schedule = %q, want %q", job.Schedule, "*/15")
|
|
}
|
|
|
|
// Verify status fields came from /nodes/{node}/replication/{id}/status
|
|
if job.LastSyncUnix != 1735689600 {
|
|
t.Errorf("LastSyncUnix = %d, want %d", job.LastSyncUnix, 1735689600)
|
|
}
|
|
if job.LastSyncTime == nil {
|
|
t.Error("LastSyncTime should not be nil")
|
|
}
|
|
if job.NextSyncUnix != 1735690500 {
|
|
t.Errorf("NextSyncUnix = %d, want %d", job.NextSyncUnix, 1735690500)
|
|
}
|
|
if job.NextSyncTime == nil {
|
|
t.Error("NextSyncTime should not be nil")
|
|
}
|
|
if job.DurationSeconds != 120 {
|
|
t.Errorf("DurationSeconds = %d, want %d", job.DurationSeconds, 120)
|
|
}
|
|
if job.LastSyncDurationSeconds != 120 {
|
|
t.Errorf("LastSyncDurationSeconds = %d, want %d", job.LastSyncDurationSeconds, 120)
|
|
}
|
|
if job.FailCount != 0 {
|
|
t.Errorf("FailCount = %d, want %d", job.FailCount, 0)
|
|
}
|
|
if job.State != "ok" {
|
|
t.Errorf("State = %q, want %q", job.State, "ok")
|
|
}
|
|
}
|
|
|
|
// TestGetReplicationStatus_StatusEndpointFails tests that GetReplicationStatus
|
|
// still returns job config even if the per-node status endpoint fails.
|
|
func TestGetReplicationStatus_StatusEndpointFails(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
switch r.URL.Path {
|
|
case "/api2/json/cluster/replication":
|
|
// Return job config
|
|
fmt.Fprint(w, `{
|
|
"data": [
|
|
{
|
|
"id": "100-0",
|
|
"guest": 100,
|
|
"source": "pve1",
|
|
"target": "pve2",
|
|
"schedule": "*/15",
|
|
"type": "local"
|
|
}
|
|
]
|
|
}`)
|
|
|
|
case "/api2/json/nodes/pve1/replication/100-0/status":
|
|
// Status endpoint fails (404)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
fmt.Fprint(w, `{"errors": "not found"}`)
|
|
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := ClientConfig{
|
|
Host: server.URL,
|
|
TokenName: "test@pve!token",
|
|
TokenValue: "secret",
|
|
VerifySSL: false,
|
|
Timeout: 2 * time.Second,
|
|
}
|
|
|
|
client, err := NewClient(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewClient failed: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
jobs, err := client.GetReplicationStatus(ctx)
|
|
if err != nil {
|
|
t.Fatalf("GetReplicationStatus failed: %v", err)
|
|
}
|
|
|
|
// Should still get the job config even though status failed
|
|
if len(jobs) != 1 {
|
|
t.Fatalf("Expected 1 job, got %d", len(jobs))
|
|
}
|
|
|
|
job := jobs[0]
|
|
|
|
// Config fields should be populated
|
|
if job.ID != "100-0" {
|
|
t.Errorf("ID = %q, want %q", job.ID, "100-0")
|
|
}
|
|
if job.GuestID != 100 {
|
|
t.Errorf("GuestID = %d, want %d", job.GuestID, 100)
|
|
}
|
|
|
|
// Status fields should be empty/zero (status endpoint failed)
|
|
if job.LastSyncUnix != 0 {
|
|
t.Errorf("LastSyncUnix = %d, want 0 (status endpoint failed)", job.LastSyncUnix)
|
|
}
|
|
if job.LastSyncTime != nil {
|
|
t.Error("LastSyncTime should be nil (status endpoint failed)")
|
|
}
|
|
}
|