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() } }