Skip to content

Commit f4cf2e9

Browse files
committed
test: improve migration tests stability and maintainability
- Fix linting issues and address testcontainers deprecation in store/test/containers.go - Extract MemosStartupWaitStrategy for consistent container health checks - Refactor migrator_test.go to use a consolidated testMigration helper, reducing duplication - Add store/test/Dockerfile for optimized local test image builds
1 parent 013ea52 commit f4cf2e9

File tree

4 files changed

+375
-2
lines changed

4 files changed

+375
-2
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/aws/aws-sdk-go-v2/credentials v1.18.16
1010
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.4
1111
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
12+
github.com/docker/docker v28.5.1+incompatible
1213
github.com/go-sql-driver/mysql v1.9.3
1314
github.com/google/cel-go v0.26.1
1415
github.com/google/uuid v1.6.0
@@ -50,7 +51,6 @@ require (
5051
github.com/containerd/platforms v0.2.1 // indirect
5152
github.com/cpuguy83/dockercfg v0.3.2 // indirect
5253
github.com/distribution/reference v0.6.0 // indirect
53-
github.com/docker/docker v28.5.1+incompatible // indirect
5454
github.com/docker/go-connections v0.6.0 // indirect
5555
github.com/docker/go-units v0.5.0 // indirect
5656
github.com/dustin/go-humanize v1.0.1 // indirect

store/test/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM golang:1.25-alpine AS backend
2+
WORKDIR /backend-build
3+
COPY . .
4+
RUN go build -o memos ./cmd/memos
5+
6+
FROM alpine:latest
7+
WORKDIR /usr/local/memos
8+
COPY --from=backend /backend-build/memos /usr/local/memos/
9+
EXPOSE 5230
10+
RUN mkdir -p /var/opt/memos
11+
ENV MEMOS_MODE="prod"
12+
ENV MEMOS_PORT="5230"
13+
ENTRYPOINT ["./memos"]

store/test/containers.go

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import (
1010
"testing"
1111
"time"
1212

13+
"github.com/docker/docker/api/types/container"
1314
"github.com/pkg/errors"
1415
"github.com/testcontainers/testcontainers-go"
1516
"github.com/testcontainers/testcontainers-go/modules/mysql"
1617
"github.com/testcontainers/testcontainers-go/modules/postgres"
18+
"github.com/testcontainers/testcontainers-go/network"
1719
"github.com/testcontainers/testcontainers-go/wait"
1820

1921
// Database drivers for connection verification.
@@ -24,23 +26,58 @@ import (
2426
const (
2527
testUser = "root"
2628
testPassword = "test"
29+
30+
// Memos container settings for migration testing.
31+
MemosDockerImage = "neosmemo/memos"
32+
StableMemosVersion = "stable"
2733
)
2834

2935
var (
36+
// MemosStartupWaitStrategy defines the wait strategy for Memos container startup.
37+
// It waits for the "started" log message (compatible with both old and new versions)
38+
// and checks if port 5230 is listening.
39+
MemosStartupWaitStrategy = wait.ForAll(
40+
wait.ForLog("started"),
41+
wait.ForListeningPort("5230/tcp"),
42+
).WithDeadline(180 * time.Second)
43+
3044
mysqlContainer *mysql.MySQLContainer
3145
postgresContainer *postgres.PostgresContainer
3246
mysqlOnce sync.Once
3347
postgresOnce sync.Once
3448
mysqlBaseDSN string
3549
postgresBaseDSN string
3650
dbCounter atomic.Int64
51+
52+
// Network for container communication.
53+
testDockerNetwork *testcontainers.DockerNetwork
54+
testNetworkOnce sync.Once
3755
)
3856

57+
// getTestNetwork creates or returns the shared Docker network for container communication.
58+
func getTestNetwork(ctx context.Context) (*testcontainers.DockerNetwork, error) {
59+
var networkErr error
60+
testNetworkOnce.Do(func() {
61+
nw, err := network.New(ctx, network.WithDriver("bridge"))
62+
if err != nil {
63+
networkErr = err
64+
return
65+
}
66+
testDockerNetwork = nw
67+
})
68+
return testDockerNetwork, networkErr
69+
}
70+
3971
// GetMySQLDSN starts a MySQL container (if not already running) and creates a fresh database for this test.
4072
func GetMySQLDSN(t *testing.T) string {
4173
ctx := context.Background()
4274

4375
mysqlOnce.Do(func() {
76+
nw, err := getTestNetwork(ctx)
77+
if err != nil {
78+
t.Fatalf("failed to create test network: %v", err)
79+
}
80+
4481
container, err := mysql.Run(ctx,
4582
"mysql:8",
4683
mysql.WithDatabase("init_db"),
@@ -55,6 +92,7 @@ func GetMySQLDSN(t *testing.T) string {
5592
wait.ForListeningPort("3306/tcp"),
5693
).WithDeadline(120*time.Second),
5794
),
95+
network.WithNetwork(nil, nw),
5896
)
5997
if err != nil {
6098
t.Fatalf("failed to start MySQL container: %v", err)
@@ -130,6 +168,11 @@ func GetPostgresDSN(t *testing.T) string {
130168
ctx := context.Background()
131169

132170
postgresOnce.Do(func() {
171+
nw, err := getTestNetwork(ctx)
172+
if err != nil {
173+
t.Fatalf("failed to create test network: %v", err)
174+
}
175+
133176
container, err := postgres.Run(ctx,
134177
"postgres:18",
135178
postgres.WithDatabase("init_db"),
@@ -141,6 +184,7 @@ func GetPostgresDSN(t *testing.T) string {
141184
wait.ForListeningPort("5432/tcp"),
142185
).WithDeadline(120*time.Second),
143186
),
187+
network.WithNetwork(nil, nw),
144188
)
145189
if err != nil {
146190
t.Fatalf("failed to start PostgreSQL container: %v", err)
@@ -179,7 +223,106 @@ func GetPostgresDSN(t *testing.T) string {
179223
return strings.Replace(postgresBaseDSN, "/init_db?", "/"+dbName+"?", 1)
180224
}
181225

182-
// TerminateContainers cleans up all running containers.
226+
// GetDedicatedMySQLDSN starts a dedicated MySQL container for migration testing.
227+
// This is needed because older Memos versions have bugs when connecting to a MySQL
228+
// server that has other initialized databases (they incorrectly query migration_history
229+
// on a fresh database without checking if the DB is initialized).
230+
// Returns: DSN for host access, container hostname for internal network access, cleanup function.
231+
func GetDedicatedMySQLDSN(t *testing.T) (dsn string, containerHost string, cleanup func()) {
232+
ctx := context.Background()
233+
234+
nw, err := getTestNetwork(ctx)
235+
if err != nil {
236+
t.Fatalf("failed to create test network: %v", err)
237+
}
238+
239+
container, err := mysql.Run(ctx,
240+
"mysql:8",
241+
mysql.WithDatabase("memos"),
242+
mysql.WithUsername("root"),
243+
mysql.WithPassword(testPassword),
244+
testcontainers.WithEnv(map[string]string{
245+
"MYSQL_ROOT_PASSWORD": testPassword,
246+
}),
247+
testcontainers.WithWaitStrategy(
248+
wait.ForAll(
249+
wait.ForLog("ready for connections").WithOccurrence(2),
250+
wait.ForListeningPort("3306/tcp"),
251+
).WithDeadline(120*time.Second),
252+
),
253+
network.WithNetwork(nil, nw),
254+
)
255+
if err != nil {
256+
t.Fatalf("failed to start dedicated MySQL container: %v", err)
257+
}
258+
259+
hostDSN, err := container.ConnectionString(ctx, "multiStatements=true")
260+
if err != nil {
261+
container.Terminate(ctx)
262+
t.Fatalf("failed to get MySQL connection string: %v", err)
263+
}
264+
265+
if err := waitForDB("mysql", hostDSN, 30*time.Second); err != nil {
266+
container.Terminate(ctx)
267+
t.Fatalf("MySQL not ready for connections: %v", err)
268+
}
269+
270+
name, _ := container.Name(ctx)
271+
host := strings.TrimPrefix(name, "/")
272+
273+
return hostDSN, host, func() {
274+
container.Terminate(ctx)
275+
}
276+
}
277+
278+
// GetDedicatedPostgresDSN starts a dedicated PostgreSQL container for migration testing.
279+
// This is needed for isolation when testing migrations with older Memos versions.
280+
// Returns: DSN for host access, container hostname for internal network access, cleanup function.
281+
func GetDedicatedPostgresDSN(t *testing.T) (dsn string, containerHost string, cleanup func()) {
282+
ctx := context.Background()
283+
284+
nw, err := getTestNetwork(ctx)
285+
if err != nil {
286+
t.Fatalf("failed to create test network: %v", err)
287+
}
288+
289+
container, err := postgres.Run(ctx,
290+
"postgres:18",
291+
postgres.WithDatabase("memos"),
292+
postgres.WithUsername(testUser),
293+
postgres.WithPassword(testPassword),
294+
testcontainers.WithWaitStrategy(
295+
wait.ForAll(
296+
wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
297+
wait.ForListeningPort("5432/tcp"),
298+
).WithDeadline(120*time.Second),
299+
),
300+
network.WithNetwork(nil, nw),
301+
)
302+
if err != nil {
303+
t.Fatalf("failed to start dedicated PostgreSQL container: %v", err)
304+
}
305+
306+
hostDSN, err := container.ConnectionString(ctx, "sslmode=disable")
307+
if err != nil {
308+
container.Terminate(ctx)
309+
t.Fatalf("failed to get PostgreSQL connection string: %v", err)
310+
}
311+
312+
if err := waitForDB("postgres", hostDSN, 30*time.Second); err != nil {
313+
container.Terminate(ctx)
314+
t.Fatalf("PostgreSQL not ready for connections: %v", err)
315+
}
316+
317+
name, _ := container.Name(ctx)
318+
host := strings.TrimPrefix(name, "/")
319+
320+
return hostDSN, host, func() {
321+
container.Terminate(ctx)
322+
}
323+
}
324+
325+
// TerminateContainers cleans up all running containers and network.
183326
// This is typically called from TestMain.
184327
func TerminateContainers() {
185328
ctx := context.Background()
@@ -189,4 +332,101 @@ func TerminateContainers() {
189332
if postgresContainer != nil {
190333
_ = postgresContainer.Terminate(ctx)
191334
}
335+
if testDockerNetwork != nil {
336+
_ = testDockerNetwork.Remove(ctx)
337+
}
338+
}
339+
340+
// GetMySQLContainerHost returns the MySQL container hostname for use within the Docker network.
341+
func GetMySQLContainerHost() string {
342+
if mysqlContainer == nil {
343+
return ""
344+
}
345+
name, _ := mysqlContainer.Name(context.Background())
346+
// Remove leading slash from container name
347+
return strings.TrimPrefix(name, "/")
348+
}
349+
350+
// GetPostgresContainerHost returns the PostgreSQL container hostname for use within the Docker network.
351+
func GetPostgresContainerHost() string {
352+
if postgresContainer == nil {
353+
return ""
354+
}
355+
name, _ := postgresContainer.Name(context.Background())
356+
return strings.TrimPrefix(name, "/")
357+
}
358+
359+
// MemosContainerConfig holds configuration for starting a Memos container.
360+
type MemosContainerConfig struct {
361+
Version string // Memos version tag (e.g., "0.25")
362+
Driver string // Database driver: sqlite, mysql, postgres
363+
DSN string // Database DSN (for mysql/postgres)
364+
DataDir string // Host directory to mount for SQLite data
365+
}
366+
367+
// StartMemosContainer starts a Memos container for migration testing.
368+
// For SQLite, it mounts the dataDir to /var/opt/memos.
369+
// For MySQL/PostgreSQL, it connects to the provided DSN via the test network.
370+
// If Version is "local", builds the image from the local Dockerfile.
371+
func StartMemosContainer(ctx context.Context, cfg MemosContainerConfig) (testcontainers.Container, error) {
372+
env := map[string]string{
373+
"MEMOS_MODE": "prod",
374+
}
375+
376+
var mounts []testcontainers.ContainerMount
377+
var opts []testcontainers.ContainerCustomizer
378+
379+
switch cfg.Driver {
380+
case "sqlite":
381+
env["MEMOS_DRIVER"] = "sqlite"
382+
opts = append(opts, testcontainers.WithHostConfigModifier(func(hc *container.HostConfig) {
383+
hc.Binds = append(hc.Binds, fmt.Sprintf("%s:%s", cfg.DataDir, "/var/opt/memos"))
384+
}))
385+
case "mysql":
386+
env["MEMOS_DRIVER"] = "mysql"
387+
env["MEMOS_DSN"] = cfg.DSN
388+
opts = append(opts, network.WithNetwork(nil, testDockerNetwork))
389+
case "postgres":
390+
env["MEMOS_DRIVER"] = "postgres"
391+
env["MEMOS_DSN"] = cfg.DSN
392+
opts = append(opts, network.WithNetwork(nil, testDockerNetwork))
393+
default:
394+
return nil, errors.Errorf("unsupported driver: %s", cfg.Driver)
395+
}
396+
397+
req := testcontainers.ContainerRequest{
398+
Env: env,
399+
Mounts: testcontainers.Mounts(mounts...),
400+
ExposedPorts: []string{"5230/tcp"},
401+
WaitingFor: MemosStartupWaitStrategy,
402+
}
403+
404+
// Use local Dockerfile build or remote image
405+
if cfg.Version == "local" {
406+
req.FromDockerfile = testcontainers.FromDockerfile{
407+
Context: "../../",
408+
Dockerfile: "store/test/Dockerfile", // Simple Dockerfile without BuildKit requirements
409+
}
410+
} else {
411+
req.Image = fmt.Sprintf("%s:%s", MemosDockerImage, cfg.Version)
412+
}
413+
414+
genericReq := testcontainers.GenericContainerRequest{
415+
ContainerRequest: req,
416+
Started: true,
417+
}
418+
419+
// Apply network options
420+
for _, opt := range opts {
421+
if err := opt.Customize(&genericReq); err != nil {
422+
return nil, errors.Wrap(err, "failed to apply container option")
423+
}
424+
}
425+
426+
container, err := testcontainers.GenericContainer(ctx, genericReq)
427+
if err != nil {
428+
return nil, errors.Wrap(err, "failed to start memos container")
429+
}
430+
431+
return container, nil
192432
}

0 commit comments

Comments
 (0)