270 lines
6.4 KiB
Go
270 lines
6.4 KiB
Go
package bus
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
natsserver "github.com/nats-io/nats-server/v2/server"
|
|
"github.com/nats-io/nats.go"
|
|
|
|
"github.com/staack/the-other-dude/poller/internal/store"
|
|
)
|
|
|
|
// mockDeviceStore implements a minimal device store for testing.
|
|
type mockDeviceStore struct {
|
|
device store.Device
|
|
err error
|
|
}
|
|
|
|
func (m *mockDeviceStore) GetDevice(_ context.Context, _ string) (store.Device, error) {
|
|
return m.device, m.err
|
|
}
|
|
|
|
// mockBackupExecutor implements BackupExecutor for testing.
|
|
type mockBackupExecutor struct {
|
|
hash string
|
|
err error
|
|
}
|
|
|
|
func (m *mockBackupExecutor) CollectAndPublish(_ context.Context, _ store.Device) (string, error) {
|
|
return m.hash, m.err
|
|
}
|
|
|
|
// mockLocker implements BackupLocker for testing.
|
|
type mockLocker struct {
|
|
obtained bool
|
|
err error
|
|
}
|
|
|
|
func (m *mockLocker) ObtainLock(_ context.Context, _ string, _ time.Duration) (BackupLockHandle, error) {
|
|
if !m.obtained {
|
|
return nil, ErrLockNotObtained
|
|
}
|
|
return &mockLockHandle{}, m.err
|
|
}
|
|
|
|
type mockLockHandle struct{}
|
|
|
|
func (h *mockLockHandle) Release(_ context.Context) error { return nil }
|
|
|
|
func TestBackupResponder_Subscribe(t *testing.T) {
|
|
nc, cleanup := startTestNATS(t)
|
|
defer cleanup()
|
|
|
|
br := NewBackupResponder(nc, &mockDeviceStore{}, &mockBackupExecutor{}, &mockLocker{obtained: true}, 30*time.Second)
|
|
if err := br.Subscribe(); err != nil {
|
|
t.Fatalf("Subscribe() returned error: %v", err)
|
|
}
|
|
defer br.Stop()
|
|
|
|
if br.sub == nil {
|
|
t.Fatal("expected subscription to be set after Subscribe()")
|
|
}
|
|
}
|
|
|
|
func TestBackupResponder_ValidRequest_Success(t *testing.T) {
|
|
nc, cleanup := startTestNATS(t)
|
|
defer cleanup()
|
|
|
|
deviceID := "test-device-123"
|
|
tenantID := "test-tenant-456"
|
|
expectedHash := "abc123def456"
|
|
|
|
br := NewBackupResponder(
|
|
nc,
|
|
&mockDeviceStore{device: store.Device{ID: deviceID, TenantID: tenantID, IPAddress: "10.0.0.1"}},
|
|
&mockBackupExecutor{hash: expectedHash},
|
|
&mockLocker{obtained: true},
|
|
30*time.Second,
|
|
)
|
|
if err := br.Subscribe(); err != nil {
|
|
t.Fatalf("Subscribe: %v", err)
|
|
}
|
|
defer br.Stop()
|
|
|
|
req := BackupTriggerRequest{DeviceID: deviceID, TenantID: tenantID}
|
|
reqData, _ := json.Marshal(req)
|
|
|
|
reply, err := nc.Request("config.backup.trigger", reqData, 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("NATS request failed: %v", err)
|
|
}
|
|
|
|
var resp BackupTriggerResponse
|
|
if err := json.Unmarshal(reply.Data, &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp.Status != "success" {
|
|
t.Errorf("expected status 'success', got %q", resp.Status)
|
|
}
|
|
if resp.SHA256Hash != expectedHash {
|
|
t.Errorf("expected hash %q, got %q", expectedHash, resp.SHA256Hash)
|
|
}
|
|
}
|
|
|
|
func TestBackupResponder_LockHeld_ReturnsLocked(t *testing.T) {
|
|
nc, cleanup := startTestNATS(t)
|
|
defer cleanup()
|
|
|
|
br := NewBackupResponder(
|
|
nc,
|
|
&mockDeviceStore{device: store.Device{ID: "dev1", TenantID: "ten1", IPAddress: "10.0.0.1"}},
|
|
&mockBackupExecutor{hash: "unused"},
|
|
&mockLocker{obtained: false}, // lock NOT obtained
|
|
30*time.Second,
|
|
)
|
|
if err := br.Subscribe(); err != nil {
|
|
t.Fatalf("Subscribe: %v", err)
|
|
}
|
|
defer br.Stop()
|
|
|
|
req := BackupTriggerRequest{DeviceID: "dev1", TenantID: "ten1"}
|
|
reqData, _ := json.Marshal(req)
|
|
|
|
reply, err := nc.Request("config.backup.trigger", reqData, 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("NATS request failed: %v", err)
|
|
}
|
|
|
|
var resp BackupTriggerResponse
|
|
if err := json.Unmarshal(reply.Data, &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp.Status != "locked" {
|
|
t.Errorf("expected status 'locked', got %q", resp.Status)
|
|
}
|
|
if resp.Message == "" {
|
|
t.Error("expected non-empty message for locked response")
|
|
}
|
|
}
|
|
|
|
func TestBackupResponder_InvalidJSON_ReturnsError(t *testing.T) {
|
|
nc, cleanup := startTestNATS(t)
|
|
defer cleanup()
|
|
|
|
br := NewBackupResponder(
|
|
nc,
|
|
&mockDeviceStore{},
|
|
&mockBackupExecutor{},
|
|
&mockLocker{obtained: true},
|
|
30*time.Second,
|
|
)
|
|
if err := br.Subscribe(); err != nil {
|
|
t.Fatalf("Subscribe: %v", err)
|
|
}
|
|
defer br.Stop()
|
|
|
|
reply, err := nc.Request("config.backup.trigger", []byte("{invalid json"), 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("NATS request failed: %v", err)
|
|
}
|
|
|
|
var resp BackupTriggerResponse
|
|
if err := json.Unmarshal(reply.Data, &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp.Error == "" {
|
|
t.Error("expected non-empty error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestBackupResponder_Stop_Unsubscribes(t *testing.T) {
|
|
nc, cleanup := startTestNATS(t)
|
|
defer cleanup()
|
|
|
|
br := NewBackupResponder(
|
|
nc,
|
|
&mockDeviceStore{},
|
|
&mockBackupExecutor{},
|
|
&mockLocker{obtained: true},
|
|
30*time.Second,
|
|
)
|
|
if err := br.Subscribe(); err != nil {
|
|
t.Fatalf("Subscribe: %v", err)
|
|
}
|
|
|
|
// Verify subscription is active
|
|
if !br.sub.IsValid() {
|
|
t.Fatal("expected subscription to be valid before Stop()")
|
|
}
|
|
|
|
br.Stop()
|
|
|
|
if br.sub.IsValid() {
|
|
t.Error("expected subscription to be invalid after Stop()")
|
|
}
|
|
}
|
|
|
|
func TestBackupResponder_DeviceNotFound_ReturnsError(t *testing.T) {
|
|
nc, cleanup := startTestNATS(t)
|
|
defer cleanup()
|
|
|
|
br := NewBackupResponder(
|
|
nc,
|
|
&mockDeviceStore{err: fmt.Errorf("device not found")},
|
|
&mockBackupExecutor{},
|
|
&mockLocker{obtained: true},
|
|
30*time.Second,
|
|
)
|
|
if err := br.Subscribe(); err != nil {
|
|
t.Fatalf("Subscribe: %v", err)
|
|
}
|
|
defer br.Stop()
|
|
|
|
req := BackupTriggerRequest{DeviceID: "nonexistent", TenantID: "ten1"}
|
|
reqData, _ := json.Marshal(req)
|
|
|
|
reply, err := nc.Request("config.backup.trigger", reqData, 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("NATS request failed: %v", err)
|
|
}
|
|
|
|
var resp BackupTriggerResponse
|
|
if err := json.Unmarshal(reply.Data, &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp.Status != "failed" {
|
|
t.Errorf("expected status 'failed', got %q", resp.Status)
|
|
}
|
|
if resp.Error == "" {
|
|
t.Error("expected non-empty error for device not found")
|
|
}
|
|
}
|
|
|
|
// startTestNATS starts an in-process NATS server and returns a connected client
|
|
// and cleanup function.
|
|
func startTestNATS(t *testing.T) (*nats.Conn, func()) {
|
|
t.Helper()
|
|
|
|
opts := &natsserver.Options{
|
|
Host: "127.0.0.1",
|
|
Port: -1, // random port
|
|
}
|
|
s, err := natsserver.NewServer(opts)
|
|
if err != nil {
|
|
t.Fatalf("failed to create test NATS server: %v", err)
|
|
}
|
|
s.Start()
|
|
if !s.ReadyForConnections(5 * time.Second) {
|
|
t.Fatal("NATS server not ready in time")
|
|
}
|
|
|
|
nc, err := nats.Connect(s.ClientURL())
|
|
if err != nil {
|
|
s.Shutdown()
|
|
t.Fatalf("failed to connect to test NATS: %v", err)
|
|
}
|
|
|
|
return nc, func() {
|
|
nc.Close()
|
|
s.Shutdown()
|
|
}
|
|
}
|