package logs import ( "context" "io" "os" "os/exec" "path/filepath" "testing" "time" "github.com/bnema/zerowrap" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/bnema/gordon/internal/boundaries/out/mocks" "github.com/bnema/gordon/internal/domain" ) func TestService_GetProcessLogs(t *testing.T) { log := zerowrap.New(zerowrap.Config{Level: "warn"}) t.Run("returns lines from log file", func(t *testing.T) { // Create temp log file with content tmpDir := t.TempDir() logPath := filepath.Join(tmpDir, "test.log") content := "line1\nline2\nline3\nline4\nline5\n" err := os.WriteFile(logPath, []byte(content), 0644) require.NoError(t, err) runtime := mocks.NewMockContainerRuntime(t) svc := NewService(logPath, true, runtime, log) lines, err := svc.GetProcessLogs(context.Background(), 3) require.NoError(t, err) assert.Len(t, lines, 3) assert.Equal(t, []string{"line3", "line4", "line5"}, lines) }) t.Run("returns empty slice for non-existent file", func(t *testing.T) { // The journalctl fallback must not depend on the host: CI containers have none. origExec := execCommandContext execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "true") // #nosec G204 } defer func() { execCommandContext = origExec }() runtime := mocks.NewMockContainerRuntime(t) svc := NewService("/nonexistent/file.log", true, runtime, log) lines, err := svc.GetProcessLogs(context.Background(), 10) require.NoError(t, err) assert.Empty(t, lines) }) t.Run("returns error when log file path not configured", func(t *testing.T) { runtime := mocks.NewMockContainerRuntime(t) svc := NewService("", true, runtime, log) _, err := svc.GetProcessLogs(context.Background(), 10) assert.Error(t, err) assert.Contains(t, err.Error(), "log file path not configured") }) t.Run("returns all lines when fewer than requested", func(t *testing.T) { tmpDir := t.TempDir() logPath := filepath.Join(tmpDir, "test.log") content := "line1\nline2\n" err := os.WriteFile(logPath, []byte(content), 0644) require.NoError(t, err) runtime := mocks.NewMockContainerRuntime(t) svc := NewService(logPath, true, runtime, log) lines, err := svc.GetProcessLogs(context.Background(), 10) require.NoError(t, err) assert.Equal(t, []string{"line1", "line2"}, lines) }) t.Run("falls back to journalctl when file logging disabled", func(t *testing.T) { origExec := execCommandContext execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { return exec.CommandContext(ctx, "bash", "-lc", "printf 'journal-line-1\\njournal-line-2\\n'") // #nosec G204 } defer func() { execCommandContext = origExec }() runtime := mocks.NewMockContainerRuntime(t) svc := NewService("/tmp/unused.log", false, runtime, log) lines, err := svc.GetProcessLogs(context.Background(), 10) require.NoError(t, err) assert.Equal(t, []string{"journal-line-1", "journal-line-2"}, lines) }) } func TestService_FollowProcessLogs(t *testing.T) { log := zerowrap.New(zerowrap.Config{Level: "warn"}) t.Run("streams lines and respects context cancellation", func(t *testing.T) { tmpDir := t.TempDir() logPath := filepath.Join(tmpDir, "test.log") content := "initial1\ninitial2\n" err := os.WriteFile(logPath, []byte(content), 0644) require.NoError(t, err) runtime := mocks.NewMockContainerRuntime(t) svc := NewService(logPath, true, runtime, log) ctx, cancel := context.WithCancel(context.Background()) defer cancel() ch, err := svc.FollowProcessLogs(ctx, 2) require.NoError(t, err) // Read initial lines var received []string for i := 0; i < 2; i++ { select { case line := <-ch: received = append(received, line) case <-time.After(time.Second): t.Fatal("timeout waiting for initial lines") } } assert.Equal(t, []string{"initial1", "initial2"}, received) // Cancel context to stop following cancel() // Channel should be closed select { case <-ch: // May receive one more line before closure, that's OK case <-time.After(500 * time.Millisecond): // OK, channel may already be closed } }) } func TestService_GetContainerLogs(t *testing.T) { log := zerowrap.New(zerowrap.Config{Level: "warn"}) t.Run("returns error when container not found", func(t *testing.T) { runtime := mocks.NewMockContainerRuntime(t) svc := NewService("/tmp/test.log", true, runtime, log) svc.WithAppState(stubActiveApps{}) _, err := svc.GetContainerLogs(context.Background(), "unknown/web", 10) assert.Error(t, err) assert.Contains(t, err.Error(), "active app/service not found") }) for _, tc := range []struct { name string ref string }{ {name: "raw Docker ID", ref: "abc123def456"}, {name: "foreign container ID", ref: "fedcba654321"}, {name: "stale container ID", ref: "012345abcdef"}, } { t.Run("rejects "+tc.name+" without calling the runtime", func(t *testing.T) { runtime := mocks.NewMockContainerRuntime(t) svc := NewService("/tmp/test.log", true, runtime, log) _, err := svc.GetContainerLogs(context.Background(), tc.ref, 10) require.Error(t, err) assert.Contains(t, err.Error(), "app/service") }) } t.Run("calls runtime with correct container ID", func(t *testing.T) { runtime := mocks.NewMockContainerRuntime(t) // Create a mock reader that returns empty content runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil) svc := NewService("/tmp/test.log", true, runtime, log) svc.WithAppState(stubActiveApps{"app": {App: "app", Services: map[string]domain.AppEffectiveService{ "web": {Container: "abc123"}, }}}) lines, err := svc.GetContainerLogs(context.Background(), "app/web", 10) require.NoError(t, err) assert.Empty(t, lines) }) } func TestService_FollowContainerLogs(t *testing.T) { log := zerowrap.New(zerowrap.Config{Level: "warn"}) t.Run("returns error when container not found", func(t *testing.T) { runtime := mocks.NewMockContainerRuntime(t) svc := NewService("/tmp/test.log", true, runtime, log) svc.WithAppState(stubActiveApps{}) _, err := svc.FollowContainerLogs(context.Background(), "unknown/web", 10) assert.Error(t, err) assert.Contains(t, err.Error(), "active app/service not found") }) t.Run("rejects raw container ID without calling the runtime", func(t *testing.T) { runtime := mocks.NewMockContainerRuntime(t) svc := NewService("/tmp/test.log", true, runtime, log) _, err := svc.FollowContainerLogs(context.Background(), "abc123def456", 10) require.Error(t, err) assert.Contains(t, err.Error(), "app/service") }) } type stubActiveApps map[string]domain.AppActive func (s stubActiveApps) LoadActive(_ context.Context, app string) (domain.AppActive, bool, error) { active, ok := s[app] return active, ok, nil } func TestTailLines(t *testing.T) { t.Run("returns correct last N lines", func(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "test.log") content := "a\nb\nc\nd\ne\n" err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) file, err := os.Open(path) require.NoError(t, err) defer file.Close() lines, err := tailLines(file, 3) require.NoError(t, err) assert.Equal(t, []string{"c", "d", "e"}, lines) }) t.Run("returns all lines when n exceeds line count", func(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "test.log") content := "x\ny\n" err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) file, err := os.Open(path) require.NoError(t, err) defer file.Close() lines, err := tailLines(file, 10) require.NoError(t, err) assert.Equal(t, []string{"x", "y"}, lines) }) t.Run("returns empty slice for empty file", func(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "test.log") err := os.WriteFile(path, []byte(""), 0644) require.NoError(t, err) file, err := os.Open(path) require.NoError(t, err) defer file.Close() lines, err := tailLines(file, 10) require.NoError(t, err) assert.Empty(t, lines) }) t.Run("returns empty slice when n is zero", func(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "test.log") content := "a\nb\n" err := os.WriteFile(path, []byte(content), 0644) require.NoError(t, err) file, err := os.Open(path) require.NoError(t, err) defer file.Close() lines, err := tailLines(file, 0) require.NoError(t, err) assert.Empty(t, lines) }) } // mockReader implements io.ReadCloser for testing. type mockReader struct{} func (m *mockReader) Read(p []byte) (n int, err error) { return 0, io.EOF } func (m *mockReader) Close() error { return nil }