test(ci): add upgrade and startup smoke coverage

Exercise real server startup on backend test runs and gate releases on fresh-install, previous-stable upgrade, and entrypoint smoke checks across supported databases.
This commit is contained in:
johnnyjoygh 2026-07-26 21:21:15 +08:00
parent 415a3ec73d
commit 1236d9ba94
9 changed files with 814 additions and 4 deletions

View file

@ -69,6 +69,8 @@ jobs:
go test -v -coverprofile=coverage.out -covermode=atomic ./store/...
;;
server)
# Includes ./server/test, which boots the real server via
# server.NewServer and smokes every router it mounts.
go test -v -race -coverprofile=coverage.out -covermode=atomic ./server/...
;;
internal)

View file

@ -21,6 +21,10 @@ env:
ARTIFACT_PREFIX: memos
jobs:
upgrade-smoke:
name: Verify fresh install and stable upgrade
uses: ./.github/workflows/upgrade-smoke.yml
prepare:
name: Extract Version
runs-on: ubuntu-latest
@ -63,7 +67,7 @@ jobs:
build-frontend:
name: Build Frontend
needs: prepare
needs: [prepare, upgrade-smoke]
runs-on: ubuntu-latest
steps:
- name: Checkout code

107
.github/workflows/upgrade-smoke.yml vendored Normal file
View file

@ -0,0 +1,107 @@
name: Upgrade Smoke
# Verifies that a Memos instance carrying real data upgrades cleanly from the
# previous stable release to the current build, and that a fresh install still
# starts. These tiers need Docker and take minutes, so they run for relevant
# pull requests and before releases rather than on every backend change.
#
# Fast startup coverage (server.NewServer plus route smoke, SQLite only) lives in
# the `server` group of backend-tests.yml and runs on every pull request.
on:
workflow_call:
workflow_dispatch:
pull_request:
branches: [main]
paths:
# Anything that can change how an existing database is opened or upgraded.
- "store/migration/**"
- "store/migrator.go"
- "store/db/**"
- "store/test/containers.go"
- "store/test/migrator*_test.go"
- "store/test/store.go"
- "server/server.go"
- "scripts/Dockerfile"
- "scripts/entrypoint.sh"
- "scripts/release_smoke_test.sh"
- ".github/workflows/upgrade-smoke.yml"
concurrency:
# Keep this distinct from a caller's concurrency group when the workflow is
# invoked from release.yml; sharing a group with cancel-in-progress would
# cancel the parent release workflow.
group: ${{ github.workflow }}-upgrade-smoke-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: "1.26.2"
NODE_VERSION: "24"
PNPM_VERSION: "11.0.1"
jobs:
migration-upgrade:
name: Upgrade from previous stable (${{ matrix.driver }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
driver: [sqlite, mysql, postgres]
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
cache: true
cache-dependency-path: go.sum
# No -race here: testcontainers has known races in its reaper, which is why
# store/test/migrator_test.go documents skipping the race detector.
- name: Run migration and upgrade tests
run: |
go test -v -timeout 30m -run 'TestMigration|TestUpgrade|TestFreshInstall' ./store/test/...
env:
DRIVER: ${{ matrix.driver }}
release-smoke:
name: Fresh install and upgrade through the release image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
# release_smoke_test.sh resolves the previous stable release from Git
# tags, so it needs full history and tags.
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4.2.0
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install frontend dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Run release smoke test
run: ./scripts/release_smoke_test.sh
entrypoint:
name: Entrypoint secret handling
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run entrypoint tests
run: ./scripts/entrypoint_test.sh

View file

@ -274,6 +274,7 @@ func splitGatewayPathSegments(path string) []string {
segments = append(segments, trimmed[start:index])
start = index + 1
}
default:
}
}
return append(segments, trimmed[start:])
@ -291,6 +292,7 @@ func splitGatewayTemplateVerb(template string) (string, string) {
if depth == 0 {
return template[:index], template[index+1:]
}
default:
}
}
return template, ""

437
server/test/startup_test.go Normal file
View file

@ -0,0 +1,437 @@
// Package test contains black-box startup smoke tests for the full server.
//
// Every other server test constructs apiv1.APIV1Service directly, which skips
// server.NewServer entirely. That leaves route registration, gRPC-gateway
// wiring, MCP/RSS/fileserver/frontend mounting, CORS and the secret bootstrap
// covered only by the Docker release script. These tests boot the real server
// the same way cmd/memos/main.go does so a wiring regression fails in CI.
package test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
// sqlite driver.
_ "modernc.org/sqlite"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/version"
"github.com/usememos/memos/server"
"github.com/usememos/memos/store"
"github.com/usememos/memos/store/db"
)
const (
testAdminUsername = "startup-admin"
testAdminPassword = "startup-password"
)
// instanceOptions configures a single server boot.
type instanceOptions struct {
// demo enables demo mode, which seeds the database during migration.
demo bool
// instanceURL is the public instance URL. An empty value makes the
// instance private, which restricts anonymous access to bootstrap methods.
instanceURL string
// dataDir reuses an existing data directory instead of a fresh one, which
// is how a restart against an already-migrated database is simulated.
dataDir string
}
// instance is a booted server plus the HTTP plumbing needed to talk to it.
type instance struct {
server *server.Server
profile *profile.Profile
baseURL string
client *http.Client
}
// bootInstance starts a full server following the same sequence as
// cmd/memos/main.go: validate profile, open the driver, migrate, load
// deployment configuration, construct the server, then listen.
//
// Keep this in lockstep with main.go. The value of these tests comes from
// exercising the real startup path rather than a hand-assembled subset of it.
func bootInstance(ctx context.Context, t *testing.T, opts instanceOptions) *instance {
t.Helper()
dataDir := opts.dataDir
if dataDir == "" {
dataDir = t.TempDir()
}
instanceProfile := &profile.Profile{
Demo: opts.demo,
Addr: "127.0.0.1",
Port: unusedPort(t),
Data: dataDir,
Driver: "sqlite",
InstanceURL: opts.instanceURL,
Version: version.GetCurrentVersion(),
Commit: version.Commit,
}
require.NoError(t, instanceProfile.Validate(), "profile should validate")
dbDriver, err := db.NewDBDriver(instanceProfile)
require.NoError(t, err, "should open database driver")
storeInstance := store.New(dbDriver, instanceProfile)
require.NoError(t, storeInstance.Migrate(ctx), "should migrate database")
// main.go calls LoadDeploymentConfiguration, which scans the fixed
// /etc/secrets path. Point the scan at a per-test directory so the result
// cannot depend on host state; the missing-directory branch is identical.
require.NoError(t, storeInstance.LoadDeploymentConfigurationDir(ctx, filepath.Join(dataDir, "secrets")),
"should load deployment configuration")
s, err := server.NewServer(ctx, instanceProfile, storeInstance)
require.NoError(t, err, "should construct server")
require.NoError(t, s.Start(ctx), "should start server")
inst := &instance{
server: s,
profile: instanceProfile,
baseURL: fmt.Sprintf("http://127.0.0.1:%d", instanceProfile.Port),
client: &http.Client{Timeout: 10 * time.Second},
}
t.Cleanup(func() {
inst.shutdown(context.Background())
})
inst.waitUntilReady(t)
return inst
}
// shutdown stops the server. Server.Shutdown also closes the store, so a
// subsequent boot against the same data directory must build a new driver,
// which is what bootInstance does and what a real restart does.
func (i *instance) shutdown(ctx context.Context) {
if i.server == nil {
return
}
i.server.Shutdown(ctx)
i.server = nil
}
func (i *instance) waitUntilReady(t *testing.T) {
t.Helper()
require.Eventually(t, func() bool {
resp, err := i.client.Get(i.baseURL + "/healthz")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}, 30*time.Second, 100*time.Millisecond, "server should become ready")
}
// do issues a request against the instance and returns the status and body.
func (i *instance) do(t *testing.T, method, path, token string, body any) (int, []byte) {
t.Helper()
var reader *bytes.Reader
if body != nil {
encoded, err := json.Marshal(body)
require.NoError(t, err)
reader = bytes.NewReader(encoded)
} else {
reader = bytes.NewReader(nil)
}
req, err := http.NewRequestWithContext(context.Background(), method, i.baseURL+path, reader)
require.NoError(t, err)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := i.client.Do(req)
require.NoError(t, err, "%s %s should not fail at the transport level", method, path)
defer resp.Body.Close()
payload := new(bytes.Buffer)
_, err = payload.ReadFrom(resp.Body)
require.NoError(t, err)
return resp.StatusCode, payload.Bytes()
}
// createAdmin registers the first user, which the service promotes to admin.
func (i *instance) createAdmin(t *testing.T) {
t.Helper()
status, body := i.do(t, http.MethodPost, "/api/v1/users", "", map[string]any{
"username": testAdminUsername,
"password": testAdminPassword,
"email": "startup-admin@example.test",
})
require.Equal(t, http.StatusOK, status, "creating the first user should succeed: %s", body)
var created struct {
Username string `json:"username"`
Role string `json:"role"`
}
require.NoError(t, json.Unmarshal(body, &created))
require.Equal(t, testAdminUsername, created.Username)
require.Equal(t, "ADMIN", created.Role, "the first user should be an admin")
}
// signIn authenticates as the admin created by createAdmin.
func (i *instance) signIn(t *testing.T) string {
t.Helper()
status, body := i.do(t, http.MethodPost, "/api/v1/auth/signin", "", map[string]any{
"passwordCredentials": map[string]any{
"username": testAdminUsername,
"password": testAdminPassword,
},
})
require.Equal(t, http.StatusOK, status, "sign-in should succeed: %s", body)
var signedIn struct {
AccessToken string `json:"accessToken"`
}
require.NoError(t, json.Unmarshal(body, &signedIn))
require.NotEmpty(t, signedIn.AccessToken, "sign-in should return an access token")
return signedIn.AccessToken
}
// createMemo writes a memo with a caller-supplied id so it can be re-read by name.
func (i *instance) createMemo(t *testing.T, token, memoID, content string) {
t.Helper()
status, body := i.do(t, http.MethodPost, "/api/v1/memos?memoId="+memoID, token, map[string]any{
"content": content,
"visibility": "PRIVATE",
})
require.Equal(t, http.StatusOK, status, "creating a memo should succeed: %s", body)
var created struct {
Name string `json:"name"`
Content string `json:"content"`
}
require.NoError(t, json.Unmarshal(body, &created))
require.Equal(t, "memos/"+memoID, created.Name)
require.Equal(t, content, created.Content)
}
// requireMemo asserts a memo is readable and has the expected content.
func (i *instance) requireMemo(t *testing.T, token, memoID, content string) {
t.Helper()
status, body := i.do(t, http.MethodGet, "/api/v1/memos/"+memoID, token, nil)
require.Equal(t, http.StatusOK, status, "reading memo %s should succeed: %s", memoID, body)
var fetched struct {
Content string `json:"content"`
}
require.NoError(t, json.Unmarshal(body, &fetched))
require.Equal(t, content, fetched.Content)
}
func unusedPort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port
}
// TestStartupServesEveryRegisteredRouter boots a fresh instance and checks that
// each router mounted by server.NewServer actually answers. A registration
// order regression or a gateway conflict shows up here as a 404.
func TestStartupServesEveryRegisteredRouter(t *testing.T) {
ctx := context.Background()
inst := bootInstance(ctx, t, instanceOptions{instanceURL: "http://localhost"})
t.Run("healthz", func(t *testing.T) {
status, body := inst.do(t, http.MethodGet, "/healthz", "", nil)
require.Equal(t, http.StatusOK, status)
require.Equal(t, "Service ready.", string(body))
})
t.Run("frontend", func(t *testing.T) {
// CI only has the placeholder dist/index.html, so assert the route is
// mounted and serving HTML rather than asserting on built markup.
status, body := inst.do(t, http.MethodGet, "/", "", nil)
require.Equal(t, http.StatusOK, status)
require.Contains(t, strings.ToLower(string(body)), "<!doctype html>")
})
t.Run("api gateway", func(t *testing.T) {
status, body := inst.do(t, http.MethodGet, "/api/v1/instance/profile", "", nil)
require.Equal(t, http.StatusOK, status, "instance profile should be public: %s", body)
require.Contains(t, string(body), "version")
})
t.Run("api gateway form post fallback", func(t *testing.T) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, inst.baseURL+"/api/v1/instance/profile", strings.NewReader(""))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := inst.client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "public GET fallback should permit anonymous form posts")
})
t.Run("rss", func(t *testing.T) {
status, body := inst.do(t, http.MethodGet, "/explore/rss.xml", "", nil)
require.Equal(t, http.StatusOK, status, "rss route should be mounted: %s", body)
require.Contains(t, string(body), "<?xml")
})
t.Run("mcp", func(t *testing.T) {
// A bare POST is enough to prove the handler is mounted; the MCP
// protocol itself is covered by server/router/mcp tests.
status, _ := inst.do(t, http.MethodPost, "/mcp", "", map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
})
require.NotEqual(t, http.StatusNotFound, status, "mcp route should be mounted")
})
}
// TestStartupFreshInstallRoundTrip covers the new-install path end to end:
// register the first user, authenticate, then write and read a memo through
// the gRPC gateway.
func TestStartupFreshInstallRoundTrip(t *testing.T) {
ctx := context.Background()
inst := bootInstance(ctx, t, instanceOptions{instanceURL: "http://localhost"})
inst.createAdmin(t)
token := inst.signIn(t)
inst.createMemo(t, token, "startup-fresh", "fresh install sentinel")
inst.requireMemo(t, token, "startup-fresh", "fresh install sentinel")
}
// TestStartupRestartPreservesData boots, writes data, shuts down, then boots a
// second time against the same data directory. This is the path every upgrade
// and every container restart takes, and it verifies migration is idempotent
// through the real startup sequence rather than through store.Migrate alone.
func TestStartupRestartPreservesData(t *testing.T) {
ctx := context.Background()
dataDir := t.TempDir()
first := bootInstance(ctx, t, instanceOptions{
instanceURL: "http://localhost",
dataDir: dataDir,
})
first.createAdmin(t)
token := first.signIn(t)
first.createMemo(t, token, "startup-restart", "written before restart")
firstPort := first.profile.Port
first.shutdown(ctx)
second := bootInstance(ctx, t, instanceOptions{
instanceURL: "http://localhost",
dataDir: dataDir,
})
require.NotEqual(t, firstPort, second.profile.Port, "the second boot should bind a new port")
// Tokens are signed with the instance secret, which must survive a restart.
restartToken := second.signIn(t)
second.requireMemo(t, restartToken, "startup-restart", "written before restart")
second.createMemo(t, restartToken, "startup-after-restart", "written after restart")
second.requireMemo(t, restartToken, "startup-after-restart", "written after restart")
}
// TestStartupPrivateInstance verifies an instance with no InstanceURL boots,
// still exposes the auth bootstrap surface, and refuses anonymous callers on
// non-bootstrap procedures.
func TestStartupPrivateInstance(t *testing.T) {
ctx := context.Background()
inst := bootInstance(ctx, t, instanceOptions{instanceURL: ""})
require.False(t, inst.profile.AllowAnonymous(), "an instance without InstanceURL should be private")
// Bootstrap methods stay reachable so the sign-in page can render.
status, body := inst.do(t, http.MethodGet, "/api/v1/instance/profile", "", nil)
require.Equal(t, http.StatusOK, status, "instance profile is an auth bootstrap method: %s", body)
// Protected procedures are refused for anonymous callers.
status, _ = inst.do(t, http.MethodGet, "/api/v1/users", "", nil)
require.Equal(t, http.StatusUnauthorized, status, "anonymous ListUsers should be refused")
// ListMemos is public but not a bootstrap method, so the private-instance
// policy must refuse anonymous callers. The Connect transport enforces this.
status, _ = inst.do(t, http.MethodPost, "/memos.api.v1.MemoService/ListMemos", "", map[string]any{})
require.Equal(t, http.StatusUnauthorized, status,
"anonymous ListMemos over Connect should be refused on a private instance")
// Authenticated access is never affected by private mode.
inst.createAdmin(t)
token := inst.signIn(t)
inst.createMemo(t, token, "startup-private", "private instance sentinel")
inst.requireMemo(t, token, "startup-private", "private instance sentinel")
}
// TestStartupPrivateInstanceGatewayPolicy asserts the private-instance policy is
// enforced on the gRPC-Gateway transport, not just on Connect.
//
// This is a regression test for a real gap: the middleware used to read
// runtime.RPCMethod(ctx) to decide the procedure, but grpc-gateway wraps
// middlewares *around* the generated handler, and it is the generated handler
// that annotates the context with the RPC method. runtime.RPCMethod therefore
// always reported "not set", the guard skipped Authorizer.CheckAccess entirely,
// and anonymous callers could read PUBLIC memos over REST on a private instance.
// The gateway now resolves the procedure from the proto HTTP bindings instead.
func TestStartupPrivateInstanceGatewayPolicy(t *testing.T) {
ctx := context.Background()
inst := bootInstance(ctx, t, instanceOptions{instanceURL: ""})
inst.createAdmin(t)
token := inst.signIn(t)
inst.createMemo(t, token, "startup-private-public", "public on a private instance")
status, body := inst.do(t, http.MethodPatch,
"/api/v1/memos/startup-private-public?updateMask=visibility", token, map[string]any{
"visibility": "PUBLIC",
})
require.Equal(t, http.StatusOK, status, "should be able to make the memo public: %s", body)
status, _ = inst.do(t, http.MethodGet, "/api/v1/memos", "", nil)
require.Equal(t, http.StatusUnauthorized, status,
"anonymous ListMemos over REST should be refused on a private instance")
status, _ = inst.do(t, http.MethodGet, "/api/v1/memos/startup-private-public", "", nil)
require.Equal(t, http.StatusUnauthorized, status,
"anonymous GetMemo over REST should be refused on a private instance")
status, _ = inst.do(t, http.MethodGet, "/explore/rss.xml", "", nil)
require.Equal(t, http.StatusNotFound, status,
"anonymous RSS should be unavailable on a private instance")
}
// TestStartupDemoMode verifies demo mode boots, which exercises the seed path
// in store.Migrate that prod-mode startups never touch.
func TestStartupDemoMode(t *testing.T) {
ctx := context.Background()
inst := bootInstance(ctx, t, instanceOptions{
demo: true,
instanceURL: "http://localhost",
})
status, body := inst.do(t, http.MethodGet, "/api/v1/memos", "", nil)
require.Equal(t, http.StatusOK, status, "demo instances serve memos anonymously: %s", body)
var listed struct {
Memos []struct {
Name string `json:"name"`
} `json:"memos"`
}
require.NoError(t, json.Unmarshal(body, &listed))
require.NotEmpty(t, listed.Memos, "demo mode should seed memos")
}

View file

@ -31,8 +31,13 @@ const (
testPassword = "test"
// Memos container settings for migration testing.
MemosDockerImage = "neosmemo/memos"
StableMemosVersion = "stable" // Always points to the latest stable release
MemosDockerImage = "neosmemo/memos"
// StableMemosVersion is the previous stable release upgrades are tested from.
// Pinned rather than tracking the floating "stable" tag so a Docker Hub retag
// cannot change what CI verifies. Bump this when a new stable ships.
// scripts/release_smoke_test.sh detects the previous release from Git tags
// instead, so the black-box tier still follows "stable" automatically.
StableMemosVersion = "0.29.1"
mysqlNetworkAlias = "memos-mysql"
postgresNetworkAlias = "memos-postgres"

View file

@ -0,0 +1,96 @@
package test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
// setSchemaVersion overwrites the recorded schema version so upgrade guard rails
// can be exercised without fabricating a whole legacy database.
func setSchemaVersion(ctx context.Context, t *testing.T, ts *store.Store, schemaVersion string) {
t.Helper()
basicSetting, err := ts.GetInstanceBasicSetting(ctx)
require.NoError(t, err)
basicSetting.SchemaVersion = schemaVersion
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_BASIC,
Value: &storepb.InstanceSetting_BasicSetting{BasicSetting: basicSetting},
})
require.NoError(t, err)
stored, err := ts.GetInstanceBasicSetting(ctx)
require.NoError(t, err)
require.Equal(t, schemaVersion, stored.SchemaVersion, "schema version should be persisted")
}
// TestMigrationRejectsDowngrade verifies a database written by a newer Memos is
// refused rather than silently re-migrated. Starting an old binary against a
// newer database is the most common way a rollback corrupts data.
func TestMigrationRejectsDowngrade(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
currentVersion, err := ts.GetCurrentSchemaVersion()
require.NoError(t, err)
// Pretend the database was written by a much newer release.
setSchemaVersion(ctx, t, ts, "99.0.0")
err = ts.Migrate(ctx)
require.Error(t, err, "migrating a newer database should fail")
require.Contains(t, err.Error(), "cannot downgrade schema version",
"error should explain the downgrade was refused")
require.Contains(t, err.Error(), currentVersion,
"error should name the version the binary supports")
}
// TestMigrationRejectsPreV022Installation verifies installations older than the
// supported floor are refused with actionable upgrade instructions instead of
// failing partway through a migration.
func TestMigrationRejectsPreV022Installation(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
// 0.21.x predates moving schema tracking from migration_history to system_setting.
setSchemaVersion(ctx, t, ts, "0.21.0")
err := ts.Migrate(ctx)
require.Error(t, err, "migrating a pre-0.22 installation should fail")
require.Contains(t, err.Error(), "too old to upgrade directly")
require.Contains(t, err.Error(), "0.25.3",
"error should name the intermediate version to upgrade through")
}
// TestMigrationAcceptsMinimumSupportedVersion pins the other side of the
// supported-version boundary: 0.22.0 must clear the floor check that rejects
// 0.21.x.
//
// This asserts only that the floor check passes, not that the whole migration
// succeeds. The store here has a current schema relabelled as 0.22.0, so
// replaying the 0.22-onward migrations against it legitimately fails on tables
// that were since renamed. Verifying a real 0.22 replay needs a genuine 0.22
// database, which is the container tier's job.
func TestMigrationAcceptsMinimumSupportedVersion(t *testing.T) {
t.Parallel()
ctx := context.Background()
ts := NewTestingStore(ctx, t)
setSchemaVersion(ctx, t, ts, "0.22.0")
if err := ts.Migrate(ctx); err != nil {
require.NotContains(t, err.Error(), "too old to upgrade directly",
"0.22.0 is the supported floor and must clear the minimum-version check")
}
}

View file

@ -0,0 +1,152 @@
package test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
colorpb "google.golang.org/genproto/googleapis/type/color"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
// TestUpgradeFromPreviousStableCopiesTagsToUserSettings covers the upgrade path
// users actually take: previous stable release to the current build, with data
// already in the database.
//
// The 0.30 tag migration is hand-written per driver with three different
// conflict forms (INSERT OR IGNORE, INSERT IGNORE, ON CONFLICT DO NOTHING) and
// different quoting of the reserved words "user" and "key". The existing
// fixture test for it only runs on SQLite, so this test drives the real
// previous-stable schema on whichever driver DRIVER selects.
func TestUpgradeFromPreviousStableCopiesTagsToUserSettings(t *testing.T) {
if testing.Short() {
t.Skip("skipping container-based upgrade test in short mode")
}
skipIfContainerProviderUnavailable(t)
ctx := context.Background()
driver := getDriverFromEnv()
cfg, hostDSN := prepareUpgradeFixture(t, driver, StableMemosVersion)
t.Logf("Starting Memos %s container for %s schema bootstrap...", cfg.Version, driver)
container, err := StartMemosContainer(ctx, cfg)
require.NoError(t, err, "failed to start memos %s container", StableMemosVersion)
t.Cleanup(func() {
if container != nil {
_ = container.Terminate(ctx)
}
})
legacyStore := NewTestingStoreWithDSN(ctx, t, driver, hostDSN)
require.Eventually(t, func() bool {
setting, err := legacyStore.GetInstanceBasicSetting(ctx)
return err == nil && setting != nil && setting.SchemaVersion != ""
}, 45*time.Second, 500*time.Millisecond, "previous stable should initialize its schema")
settingBeforeUpgrade, err := legacyStore.GetInstanceBasicSetting(ctx)
require.NoError(t, err)
t.Logf("Schema version written by %s: %s", StableMemosVersion, settingBeforeUpgrade.SchemaVersion)
require.NoError(t, container.Terminate(ctx), "failed to stop memos %s container", StableMemosVersion)
container = nil
// Seed through the store API. 0.29 shipped no migrations and 0.30 adds no
// DDL, so current store code reads and writes the previous stable schema.
seedStore := NewTestingStoreWithDSN(ctx, t, driver, hostDSN)
copiedUser, err := createTestingUserWithRole(ctx, seedStore, "tagcopy", store.RoleUser)
require.NoError(t, err)
keepsOwnUser, err := createTestingUserWithRole(ctx, seedStore, "keepsown", store.RoleUser)
require.NoError(t, err)
_, err = seedStore.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
Key: storepb.InstanceSettingKey_TAGS,
Value: &storepb.InstanceSetting_TagsSetting{
TagsSetting: &storepb.InstanceTagsSetting{
Tags: map[string]*storepb.InstanceTagMetadata{
"bug": {
BackgroundColor: &colorpb.Color{Red: 0.9, Green: 0.1, Blue: 0.1},
},
"private/.*": {
BlurContent: true,
},
},
},
},
})
require.NoError(t, err, "should seed the instance-level TAGS setting")
// One user already has their own TAGS setting; the migration must not clobber it.
_, err = seedStore.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: keepsOwnUser.ID,
Key: storepb.UserSetting_TAGS,
Value: &storepb.UserSetting_Tags{
Tags: &storepb.TagsUserSetting{
Tags: map[string]*storepb.UserTagMetadata{
"existing": {BlurContent: true},
},
},
},
})
require.NoError(t, err, "should seed a pre-existing user TAGS setting")
// The user that should receive a copy must not have one yet.
preExisting, err := seedStore.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &copiedUser.ID,
Key: storepb.UserSetting_TAGS,
})
require.NoError(t, err)
require.Nil(t, preExisting, "the seeded user should not have a TAGS setting before the upgrade")
// Upgrade with current code.
ts := NewTestingStoreWithDSN(ctx, t, driver, hostDSN)
require.NoError(t, ts.Migrate(ctx), "upgrade from %s should succeed for %s", StableMemosVersion, driver)
currentVersion, err := ts.GetCurrentSchemaVersion()
require.NoError(t, err)
upgradedSetting, err := ts.GetInstanceBasicSetting(ctx)
require.NoError(t, err)
require.Equal(t, currentVersion, upgradedSetting.SchemaVersion, "schema version should advance")
// The instance tags should now exist on the user that had none.
copied, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &copiedUser.ID,
Key: storepb.UserSetting_TAGS,
})
require.NoError(t, err)
require.NotNil(t, copied, "instance tags should be copied to the user")
require.Contains(t, copied.GetTags().GetTags(), "bug")
bugMetadata := copied.GetTags().GetTags()["bug"]
require.NotNil(t, bugMetadata.GetBackgroundColor())
require.InDelta(t, 0.9, bugMetadata.GetBackgroundColor().GetRed(), 1e-6)
require.InDelta(t, 0.1, bugMetadata.GetBackgroundColor().GetGreen(), 1e-6)
require.InDelta(t, 0.1, bugMetadata.GetBackgroundColor().GetBlue(), 1e-6)
require.True(t, copied.GetTags().GetTags()["private/.*"].GetBlurContent())
// The user with their own setting keeps it untouched.
kept, err := ts.GetUserSetting(ctx, &store.FindUserSetting{
UserID: &keepsOwnUser.ID,
Key: storepb.UserSetting_TAGS,
})
require.NoError(t, err)
require.NotNil(t, kept)
require.Contains(t, kept.GetTags().GetTags(), "existing")
require.NotContains(t, kept.GetTags().GetTags(), "bug", "existing user tags should not be overwritten")
// Re-running the upgrade must stay a no-op, which is what a container
// restart on an already-upgraded volume does.
require.NoError(t, ts.Migrate(ctx), "re-running the upgrade should be idempotent")
// The upgraded database must still accept writes.
postUpgradeMemo, err := ts.CreateMemo(ctx, &store.Memo{
UID: "post-stable-upgrade-memo",
CreatorID: copiedUser.ID,
Content: "created after upgrading from previous stable",
Visibility: store.Private,
})
require.NoError(t, err)
require.Equal(t, "created after upgrading from previous stable", postUpgradeMemo.Content)
}

View file

@ -119,8 +119,13 @@ func TestMigrationFromV0262PreservesLegacyData(t *testing.T) {
func prepareV0262MigrationTest(t *testing.T, driver string) (MemosContainerConfig, string) {
t.Helper()
return prepareUpgradeFixture(t, driver, "0.26.2")
}
const version = "0.26.2"
// prepareUpgradeFixture returns the container config needed to bootstrap a real
// schema for the given Memos version, plus the DSN the host uses to reach it.
func prepareUpgradeFixture(t *testing.T, driver, version string) (MemosContainerConfig, string) {
t.Helper()
switch driver {
case "sqlite":