1package backup
2
3import (
4 "context"
5 "errors"
6 "io"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/gordon/internal/boundaries/out"
16 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20var (
21 shopData = domain.BackupTargetRef{Kind: domain.BackupKindVolume, App: "shop", Service: "api", Target: "data"}
22 shopOrders = domain.BackupTargetRef{Kind: domain.BackupKindDatabase, App: "shop", Service: "api", Target: "orders"}
23)
24
25// captureRuns records every RecordBackupRun call on the mock.
26func captureRuns(m *outmocks.MockBackupMetrics) *[]domain.BackupRunRecord {
27 runs := &[]domain.BackupRunRecord{}
28 m.EXPECT().RecordBackupRun(mock.Anything, mock.Anything).Run(func(_ context.Context, run domain.BackupRunRecord) {
29 *runs = append(*runs, run)
30 })
31 return runs
32}
33
34func expectShopVolume(state *outmocks.MockAppStateReader) {
35 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
36 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
37 "api": volumeSpec("api", "data", "/data"),
38 }, "ctr-api"), true, nil).Once()
39}
40
41// TestVolumeService_RecordsRunMetrics covers the success and failure of a
42// volume run: the record carries the declared target and the run's start,
43// which is also the time stored archives are named after.
44func TestVolumeService_RecordsRunMetrics(t *testing.T) {
45 for _, tc := range []struct {
46 name string
47 export error
48 success bool
49 }{
50 {"success", nil, true},
51 {"export failure", errors.New("helper failed"), false},
52 } {
53 t.Run(tc.name, func(t *testing.T) {
54 state := outmocks.NewMockAppStateReader(t)
55 expectShopVolume(state)
56 metrics := outmocks.NewMockBackupMetrics(t)
57 runs := captureRuns(metrics)
58 storage := &fakeVolumeBackupStorage{}
59 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{err: tc.export}, storage, true).WithMetrics(metrics)
60
61 jobs, _ := svc.RunVolumeBackups(context.Background(), "shop", "api", "data")
62 require.Len(t, jobs, 1)
63 require.Len(t, *runs, 1)
64 run := (*runs)[0]
65 assert.Equal(t, shopData, run.BackupTargetRef)
66 assert.Equal(t, tc.success, run.Succeeded)
67 assert.Equal(t, jobs[0].StartedAt, run.StartedAt)
68 assert.GreaterOrEqual(t, run.Duration, time.Duration(0))
69 if tc.success {
70 assert.Equal(t, int64(len("archive")), run.SizeBytes)
71 assert.Equal(t, jobs[0].StartedAt, storage.stored[0].StartedAt, "the archive is named after the recorded start")
72 }
73 })
74 }
75}
76
77// TestVolumeService_SeedMetrics proves seeding forgets undeclared targets,
78// restores the declared target's newest archive with its declared service,
79// and ignores archives of targets that are no longer declared.
80func TestVolumeService_SeedMetrics(t *testing.T) {
81 state := outmocks.NewMockAppStateReader(t)
82 state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
83 expectShopVolume(state)
84 started := time.Date(2026, 10, 17, 1, 30, 0, 0, time.UTC)
85 storage := &fakeVolumeBackupStorage{jobs: []domain.VolumeBackupJob{
86 {App: "shop", VolumeName: "data", StartedAt: started, SizeBytes: 42},
87 {App: "gone", VolumeName: "old", StartedAt: started, SizeBytes: 1},
88 }}
89 metrics := outmocks.NewMockBackupMetrics(t)
90 metrics.EXPECT().RetainBackupTargets(domain.BackupKindVolume, []domain.BackupTargetRef{shopData}, mock.Anything).Once()
91 metrics.EXPECT().ObserveStoredBackup(domain.BackupRunRecord{
92 BackupTargetRef: shopData, Succeeded: true, SizeBytes: 42, StartedAt: started,
93 }).Once()
94 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, storage, true).WithMetrics(metrics)
95
96 require.NoError(t, svc.SeedMetrics(context.Background()))
97}
98
99func expectShopDatabase(state *outmocks.MockAppStateReader) {
100 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
101 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
102 "api": appSpecWithDatabase("api", "orders", "daily"),
103 }, "ctr-api"), true, nil).Once()
104}
105
106// TestService_RecordsRunMetrics covers a failed and a successful dump. The
107// recorded start is the timestamp the dump is stored under.
108func TestService_RecordsRunMetrics(t *testing.T) {
109 t.Run("pg_dump failure", func(t *testing.T) {
110 state := outmocks.NewMockAppStateReader(t)
111 expectShopDatabase(state)
112 runtime, _, svc := backupTestService(t, state)
113 metrics := outmocks.NewMockBackupMetrics(t)
114 runs := captureRuns(metrics)
115 svc.WithMetrics(metrics)
116 runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).
117 Return(&out.ExecResult{ExitCode: 1, Stderr: []byte("boom")}, nil).Twice()
118
119 _, err := svc.RunBackup(context.Background(), "shop", "api", "orders")
120 require.Error(t, err)
121 require.Len(t, *runs, 1)
122 assert.Equal(t, shopOrders, (*runs)[0].BackupTargetRef)
123 assert.False(t, (*runs)[0].Succeeded)
124 assert.False(t, (*runs)[0].StartedAt.IsZero())
125 })
126
127 t.Run("success", func(t *testing.T) {
128 state := outmocks.NewMockAppStateReader(t)
129 expectShopDatabase(state)
130 runtime, storage, svc := backupTestService(t, state)
131 metrics := outmocks.NewMockBackupMetrics(t)
132 runs := captureRuns(metrics)
133 svc.WithMetrics(metrics)
134 runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.Anything).Return(&out.ExecResult{ExitCode: 0}, nil).Twice()
135 runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
136 Return(io.NopCloser(strings.NewReader("dump")), nil).Once()
137 var storedAt time.Time
138 storage.EXPECT().Store(mock.Anything, "shop", "api", "orders", domain.BackupSchedule(""), mock.Anything, mock.Anything).
139 RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, at time.Time, data io.Reader) (string, error) {
140 storedAt = at
141 _, err := io.Copy(io.Discard, data)
142 return "/b/file.bak", err
143 }).Once()
144
145 _, err := svc.RunBackup(context.Background(), "shop", "api", "orders")
146 require.NoError(t, err)
147 require.Len(t, *runs, 1)
148 assert.True(t, (*runs)[0].Succeeded)
149 assert.Equal(t, int64(len("dump")), (*runs)[0].SizeBytes)
150 assert.Equal(t, storedAt, (*runs)[0].StartedAt, "the dump is stored under the recorded start")
151 })
152}
153
154// TestService_SeedMetrics proves database seeding forgets undeclared
155// targets and only restores dumps of declared ones.
156func TestService_SeedMetrics(t *testing.T) {
157 state := outmocks.NewMockAppStateReader(t)
158 state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Twice()
159 expectShopDatabase(state)
160 started := time.Date(2026, 10, 17, 2, 0, 0, 0, time.UTC)
161
162 _, storage, svc := backupTestService(t, state)
163 metrics := outmocks.NewMockBackupMetrics(t)
164 svc.WithMetrics(metrics)
165 storage.EXPECT().List(mock.Anything, "shop", mock.Anything).Return([]domain.BackupJob{
166 {App: "shop", Service: "api", DBName: "orders", StartedAt: started, SizeBytes: 7},
167 {App: "shop", Service: "api", DBName: "dropped", StartedAt: started, SizeBytes: 1},
168 }, nil).Once()
169 metrics.EXPECT().RetainBackupTargets(domain.BackupKindDatabase, []domain.BackupTargetRef{shopOrders}, mock.Anything).Once()
170 metrics.EXPECT().ObserveStoredBackup(domain.BackupRunRecord{
171 BackupTargetRef: shopOrders, Succeeded: true, SizeBytes: 7, StartedAt: started,
172 }).Once()
173
174 require.NoError(t, svc.SeedMetrics(context.Background()))
175}