From 02096836c36c4450ef4f45ae4ed5ce83f5bc7f5f Mon Sep 17 00:00:00 2001 From: boojack Date: Fri, 8 May 2026 22:24:54 +0800 Subject: [PATCH] test: stabilize backend tests in CI - Avoid requiring built frontend assets in cache header tests. - Skip Testcontainers-backed store tests when Docker is unavailable. --- server/router/frontend/frontend.go | 26 ++++++- server/router/frontend/frontend_test.go | 96 ++++++++++++++++++------- store/test/containers.go | 12 ++++ store/test/migrator_test.go | 6 +- store/test/migrator_upgrade_test.go | 5 +- 5 files changed, 106 insertions(+), 39 deletions(-) diff --git a/server/router/frontend/frontend.go b/server/router/frontend/frontend.go index 2e90d396b..0d95ec781 100644 --- a/server/router/frontend/frontend.go +++ b/server/router/frontend/frontend.go @@ -39,6 +39,7 @@ func NewFrontendService(profile *profile.Profile, store *store.Store) *FrontendS } func (s *FrontendService) Serve(_ context.Context, e *echo.Echo) { + frontendFS := getFileSystem("dist") skipper := func(c *echo.Context) bool { requestPath := c.Request().URL.Path if shouldSkipFrontendStatic(requestPath) { @@ -49,16 +50,35 @@ func (s *FrontendService) Serve(_ context.Context, e *echo.Echo) { return false } - // Route to serve the main app with HTML5 fallback for SPA behavior. + // Route to serve the frontend static assets. e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ - Filesystem: getFileSystem("dist"), - HTML5: true, // Enable fallback to index.html + Filesystem: frontendFS, Skipper: skipper, })) + e.Use(spaFallbackMiddleware(frontendFS)) s.registerRoutes(e) } +func spaFallbackMiddleware(frontendFS fs.FS) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + err := next(c) + if err == nil { + return nil + } + + requestPath := c.Request().URL.Path + if shouldSkipFrontendStatic(requestPath) || !shouldServeFrontendHTML(requestPath) || echo.StatusCode(err) != http.StatusNotFound { + return err + } + + setFrontendCacheHeaders(c, requestPath) + return c.FileFS("index.html", frontendFS) + } + } +} + func shouldSkipFrontendStatic(requestPath string) bool { if requestPath == "/robots.txt" || requestPath == "/sitemap.xml" || strings.HasSuffix(requestPath, "/rss.xml") { return true diff --git a/server/router/frontend/frontend_test.go b/server/router/frontend/frontend_test.go index b623eeabf..61da642da 100644 --- a/server/router/frontend/frontend_test.go +++ b/server/router/frontend/frontend_test.go @@ -2,10 +2,8 @@ package frontend import ( "context" - "io/fs" "net/http" "net/http/httptest" - "strings" "testing" "github.com/labstack/echo/v5" @@ -16,15 +14,7 @@ import ( teststore "github.com/usememos/memos/store/test" ) -func TestFrontendService_StaticCacheHeaders(t *testing.T) { - ctx := context.Background() - testStore := teststore.NewTestingStore(ctx, t) - - e := echo.New() - NewFrontendService(&profile.Profile{}, testStore).Serve(ctx, e) - - hashedAssetPath := firstEmbeddedAssetPath(t, ".js") - +func TestFrontendService_CacheHeaderRules(t *testing.T) { tests := []struct { name string path string @@ -55,7 +45,7 @@ func TestFrontendService_StaticCacheHeaders(t *testing.T) { }, { name: "hashed asset is immutable", - path: hashedAssetPath, + path: "/assets/index-deadbeef.js", cacheControl: frontendHashedAssetCacheControl, }, { @@ -65,6 +55,59 @@ func TestFrontendService_StaticCacheHeaders(t *testing.T) { }, } + e := echo.New() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + setFrontendCacheHeaders(c, tt.path) + + require.Equal(t, tt.cacheControl, rec.Header().Get(echo.HeaderCacheControl)) + require.Equal(t, tt.pragma, rec.Header().Get("Pragma")) + require.Equal(t, tt.expires, rec.Header().Get("Expires")) + }) + } +} + +func TestFrontendService_StaticCacheHeaders(t *testing.T) { + ctx := context.Background() + testStore := teststore.NewTestingStore(ctx, t) + + e := echo.New() + NewFrontendService(&profile.Profile{}, testStore).Serve(ctx, e) + + tests := []struct { + name string + path string + cacheControl string + pragma string + expires string + }{ + { + name: "root html is not stored", + path: "/", + cacheControl: frontendHTMLCacheControl, + pragma: "no-cache", + expires: "0", + }, + { + name: "index html is not stored", + path: "/index.html", + cacheControl: frontendHTMLCacheControl, + pragma: "no-cache", + expires: "0", + }, + { + name: "spa fallback html is not stored", + path: "/memos/publicmemo", + cacheControl: frontendHTMLCacheControl, + pragma: "no-cache", + expires: "0", + }, + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, tt.path, nil) @@ -79,6 +122,20 @@ func TestFrontendService_StaticCacheHeaders(t *testing.T) { } } +func TestFrontendService_MissingAssetDoesNotFallbackToIndex(t *testing.T) { + ctx := context.Background() + testStore := teststore.NewTestingStore(ctx, t) + + e := echo.New() + NewFrontendService(&profile.Profile{}, testStore).Serve(ctx, e) + + req := httptest.NewRequest(http.MethodGet, "/assets/missing.js", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusNotFound, rec.Code) +} + func TestFrontendService_SkipsDynamicRoutes(t *testing.T) { ctx := context.Background() testStore := teststore.NewTestingStore(ctx, t) @@ -174,18 +231,3 @@ func TestFrontendService_SitemapRoutesRequireInstanceURL(t *testing.T) { require.Equal(t, http.StatusNotFound, rec.Code) } } - -func firstEmbeddedAssetPath(t *testing.T, suffix string) string { - t.Helper() - - var assetPath string - require.NoError(t, fs.WalkDir(getFileSystem("dist"), "assets", func(path string, d fs.DirEntry, err error) error { - require.NoError(t, err) - if assetPath == "" && !d.IsDir() && strings.HasSuffix(path, suffix) { - assetPath = "/" + path - } - return nil - })) - require.NotEmpty(t, assetPath) - return assetPath -} diff --git a/store/test/containers.go b/store/test/containers.go index 177d77feb..a2051c98b 100644 --- a/store/test/containers.go +++ b/store/test/containers.go @@ -78,8 +78,18 @@ func requireTestNetwork(ctx context.Context) (*testcontainers.DockerNetwork, err return nw, nil } +func skipIfContainerProviderUnavailable(t *testing.T) { + t.Helper() + if os.Getenv("SKIP_CONTAINER_TESTS") == "1" { + t.Skip("skipping container-based test (SKIP_CONTAINER_TESTS=1)") + } + testcontainers.SkipIfProviderIsNotHealthy(t) +} + // GetMySQLDSN starts a MySQL container (if not already running) and creates a fresh database for this test. func GetMySQLDSN(t *testing.T) string { + skipIfContainerProviderUnavailable(t) + ctx := context.Background() mysqlOnce.Do(func() { @@ -180,6 +190,8 @@ func waitForDB(driver, dsn string, timeout time.Duration) error { // GetPostgresDSN starts a PostgreSQL container (if not already running) and creates a fresh database for this test. func GetPostgresDSN(t *testing.T) string { + skipIfContainerProviderUnavailable(t) + ctx := context.Background() postgresOnce.Do(func() { diff --git a/store/test/migrator_test.go b/store/test/migrator_test.go index 3eb541381..86c8c8de0 100644 --- a/store/test/migrator_test.go +++ b/store/test/migrator_test.go @@ -3,7 +3,6 @@ package test import ( "context" "fmt" - "os" "testing" "time" @@ -128,10 +127,7 @@ func TestMigrationFromStableVersion(t *testing.T) { t.Skip("skipping upgrade test for non-sqlite driver") } - // Skip if explicitly disabled (e.g., in environments without Docker) - if os.Getenv("SKIP_CONTAINER_TESTS") == "1" { - t.Skip("skipping container-based test (SKIP_CONTAINER_TESTS=1)") - } + skipIfContainerProviderUnavailable(t) ctx := context.Background() dataDir := t.TempDir() diff --git a/store/test/migrator_upgrade_test.go b/store/test/migrator_upgrade_test.go index 4b8254ed1..86df9fdad 100644 --- a/store/test/migrator_upgrade_test.go +++ b/store/test/migrator_upgrade_test.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "fmt" - "os" "strings" "testing" "time" @@ -20,9 +19,7 @@ func TestMigrationFromV0262PreservesLegacyData(t *testing.T) { if testing.Short() { t.Skip("skipping container-based upgrade test in short mode") } - if os.Getenv("SKIP_CONTAINER_TESTS") == "1" { - t.Skip("skipping container-based test (SKIP_CONTAINER_TESTS=1)") - } + skipIfContainerProviderUnavailable(t) ctx := context.Background() driver := getDriverFromEnv()