v2.31.0-dev3 cbca441

service_test.go RawHistoryFolder

1package backup
2
3import (
4	"bytes"
5	"context"
6	"io"
7	"sync/atomic"
8	"testing"
9	"time"
10
11	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
12	outiface "github.com/bnema/gordon/internal/boundaries/out"
13	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14	"github.com/bnema/gordon/internal/domain"
15	"github.com/bnema/zerowrap"
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19)
20
21func TestService_DetectDatabases_PostgresAttachment(t *testing.T) {
22	runtime := outmocks.NewMockContainerRuntime(t)
23	storage := outmocks.NewMockBackupStorage(t)
24	containerSvc := inmocks.NewMockContainerService(t)
25
26	containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
27		{Name: "postgres", Image: "postgres:17", ContainerID: "db1", Status: "running"},
28	})
29
30	svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
31
32	dbs, err := svc.DetectDatabases(context.Background(), "app.example.com")
33	require.NoError(t, err)
34	require.Len(t, dbs, 1)
35	assert.Equal(t, domain.DBTypePostgreSQL, dbs[0].Type)
36	assert.Equal(t, "db1", dbs[0].ContainerID)
37}
38
39func TestService_RunBackup_Postgres(t *testing.T) {
40	runtime := outmocks.NewMockContainerRuntime(t)
41	storage := outmocks.NewMockBackupStorage(t)
42	containerSvc := inmocks.NewMockContainerService(t)
43
44	containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
45		{Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
46	})
47
48	runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
49		if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" {
50			return false
51		}
52		return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) &&
53			bytes.Contains([]byte(cmd[2]), []byte("${POSTGRES_DB:-postgres}")) &&
54			bytes.Contains([]byte(cmd[2]), []byte(" > "))
55	})).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)
56
57	runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool {
58		return path != ""
59	})).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil)
60
61	runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
62		return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
63	})).Return(&outiface.ExecResult{ExitCode: 0}, nil)
64
65	storage.EXPECT().Store(
66		mock.Anything,
67		"app.example.com",
68		"postgres",
69		domain.BackupSchedule(""),
70		mock.Anything,
71		mock.MatchedBy(func(r io.Reader) bool {
72			data, _ := io.ReadAll(r)
73			return string(data) == "backup-data"
74		}),
75	).Return("/tmp/backup.bak", nil)
76
77	svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
78
79	result, err := svc.RunBackup(context.Background(), "app.example.com", "postgres")
80	require.NoError(t, err)
81	require.NotNil(t, result)
82	assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
83	assert.Equal(t, "/tmp/backup.bak", result.Job.FilePath)
84	assert.Equal(t, int64(len("backup-data")), result.Job.SizeBytes)
85}
86
87func TestService_RunBackup_CleansUpDumpWhenPgDumpFails(t *testing.T) {
88	runtime := outmocks.NewMockContainerRuntime(t)
89	storage := outmocks.NewMockBackupStorage(t)
90	containerSvc := inmocks.NewMockContainerService(t)
91
92	containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
93		{Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
94	})
95
96	runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
97		return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc"))
98	})).Return(&outiface.ExecResult{ExitCode: 2, Stderr: []byte("dump failed")}, nil)
99
100	runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
101		return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
102	})).Return(&outiface.ExecResult{ExitCode: 0}, nil)
103
104	svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
105
106	result, err := svc.RunBackup(context.Background(), "app.example.com", "postgres")
107	require.Error(t, err)
108	assert.Nil(t, result)
109	assert.Contains(t, err.Error(), "pg_dump failed")
110}
111
112func TestSelectDatabaseRequiresExplicitNameWhenMultipleDetected(t *testing.T) {
113	db, err := selectDatabase([]domain.DBInfo{
114		{Name: "postgres"},
115		{Name: "analytics"},
116	}, "")
117	require.Error(t, err)
118	assert.Contains(t, err.Error(), "multiple database attachments detected")
119	assert.Equal(t, domain.DBInfo{}, db)
120}
121
122func TestSelectDatabaseAutoSelectsOnlyDatabaseWhenUnspecified(t *testing.T) {
123	db, err := selectDatabase([]domain.DBInfo{{Name: "postgres"}}, "")
124	require.NoError(t, err)
125	assert.Equal(t, "postgres", db.Name)
126}
127
128func TestPostgresDumpCommandUsesEnvVar(t *testing.T) {
129	cmd := postgresDumpCommand("ignored")
130	assert.Contains(t, cmd, "${POSTGRES_DB:-postgres}")
131	assert.Contains(t, cmd, "${POSTGRES_USER:-postgres}")
132}
133
134func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) {
135	runtime := outmocks.NewMockContainerRuntime(t)
136	storage := outmocks.NewMockBackupStorage(t)
137	containerSvc := inmocks.NewMockContainerService(t)
138
139	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
140		"a.example.com": {},
141		"b.example.com": {},
142		"c.example.com": {},
143		"d.example.com": {},
144		"e.example.com": {},
145	})
146
147	unblock := make(chan struct{})
148	defer close(unblock)
149
150	var started int32
151	startedFour := make(chan struct{})
152	storage.EXPECT().List(mock.Anything, mock.Anything, (*domain.BackupSchedule)(nil)).RunAndReturn(
153		func(context.Context, string, *domain.BackupSchedule) ([]domain.BackupJob, error) {
154			if atomic.AddInt32(&started, 1) == 4 {
155				close(startedFour)
156			}
157			<-unblock
158			return nil, nil
159		},
160	).Times(4)
161
162	svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
163
164	ctx, cancel := context.WithCancel(context.Background())
165	errCh := make(chan error, 1)
166	go func() {
167		_, err := svc.Status(ctx)
168		errCh <- err
169	}()
170
171	<-startedFour
172	cancel()
173
174	select {
175	case err := <-errCh:
176		require.ErrorIs(t, err, context.Canceled)
177	case <-time.After(250 * time.Millisecond):
178		t.Fatal("Status did not return after context cancellation while waiting for semaphore")
179	}
180}
181
182func TestService_RunForSchedule_StoresTierAndAppliesRetention(t *testing.T) {
183	runtime := outmocks.NewMockContainerRuntime(t)
184	storage := outmocks.NewMockBackupStorage(t)
185	containerSvc := inmocks.NewMockContainerService(t)
186
187	containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
188		"app.example.com": {},
189	})
190	containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
191		{Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
192	})
193
194	runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
195		if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" {
196			return false
197		}
198		return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc"))
199	})).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)
200
201	runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool {
202		return path != ""
203	})).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil)
204
205	runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
206		return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
207	})).Return(&outiface.ExecResult{ExitCode: 0}, nil)
208
209	storage.EXPECT().Store(
210		mock.Anything,
211		"app.example.com",
212		"postgres",
213		domain.ScheduleDaily,
214		mock.Anything,
215		mock.Anything,
216	).Return("/tmp/daily-backup.bak", nil)
217
218	storage.EXPECT().ApplyRetention(mock.Anything, "app.example.com", domain.RetentionPolicy{Daily: 7}).Return(0, nil)
219
220	svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{Retention: domain.RetentionPolicy{Daily: 7}}, zerowrap.Default())
221
222	err := svc.RunForSchedule(context.Background(), domain.ScheduleDaily)
223	require.NoError(t, err)
224}
225
226func TestService_RunForSchedule_RejectsInvalidSchedule(t *testing.T) {
227	runtime := outmocks.NewMockContainerRuntime(t)
228	storage := outmocks.NewMockBackupStorage(t)
229	containerSvc := inmocks.NewMockContainerService(t)
230
231	svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
232
233	err := svc.RunForSchedule(context.Background(), domain.BackupSchedule("every-minute"))
234	require.Error(t, err)
235	assert.Contains(t, err.Error(), "invalid backup schedule")
236}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close