@@ -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 (
2426const (
2527 testUser = "root"
2628 testPassword = "test"
29+
30+ // Memos container settings for migration testing.
31+ MemosDockerImage = "neosmemo/memos"
32+ StableMemosVersion = "stable"
2733)
2834
2935var (
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.
4072func 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.
184327func 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