Files
pocket-id/backend/internal/instanceid/instanceid_test.go

191 lines
6.6 KiB
Go
Raw Normal View History

package instanceid
import (
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// readInstanceID returns the value stored in the kv table under the "instance_id" key, together with the number of rows that match (to detect duplicates)
func readInstanceID(t *testing.T, db *gorm.DB) (value string, count int) {
t.Helper()
var rows []model.KV
err := db.Where("key = ?", "instance_id").Find(&rows).Error
require.NoError(t, err)
if len(rows) == 0 {
return "", 0
}
require.NotNil(t, rows[0].Value, "instance_id value should not be NULL")
return *rows[0].Value, len(rows)
}
func TestLoad(t *testing.T) {
t.Run("generates and persists a new instance ID when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
id, err := Load(t.Context(), db)
require.NoError(t, err)
require.NotEmpty(t, id)
// The generated ID should be a valid UUID
_, err = uuid.Parse(id)
require.NoError(t, err, "generated instance ID should be a valid UUID")
// The value should have been persisted in the kv table
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, id, stored)
})
t.Run("returns the existing instance ID on subsequent calls", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
first, err := Load(t.Context(), db)
require.NoError(t, err)
require.NotEmpty(t, first)
// A second call must return the same ID and must not create a duplicate row
second, err := Load(t.Context(), db)
require.NoError(t, err)
require.Equal(t, first, second)
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, first, stored)
})
t.Run("returns an instance ID that was already stored", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
// Seed an instance ID directly in the database, e.g. as written by the migration that moved it out of the app config table
existing := "existing-instance-id"
err := db.Create(&model.KV{Key: "instance_id", Value: &existing}).Error
require.NoError(t, err)
id, err := Load(t.Context(), db)
require.NoError(t, err)
require.Equal(t, existing, id)
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, existing, stored)
})
t.Run("concurrent calls without an existing instance ID converge on a single value", func(t *testing.T) {
// This needs a database that allows concurrent access, so the goroutines race
db := testutils.NewConcurrentDatabaseForTest(t)
const parallel = 25
// Each goroutine writes to its own index, so no locking is needed to collect the results
ids := make([]string, parallel)
loadErrs := make([]error, parallel)
// The start channel is a barrier so all goroutines race their Load call at once
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(parallel)
for i := range parallel {
go func() {
defer wg.Done()
<-start
ids[i], loadErrs[i] = Load(t.Context(), db)
}()
}
close(start)
wg.Wait()
// No concurrent call should have failed
for i, err := range loadErrs {
require.NoErrorf(t, err, "goroutine %d errored", i)
}
// Every goroutine must have observed the same, non-empty instance ID, meaning only one of them actually generated and persisted a value
first := ids[0]
require.NotEmpty(t, first)
for _, id := range ids {
require.Equal(t, first, id)
}
// Only a single row must have been persisted, and it must match the shared value
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, first, stored)
})
}
// TestMigrateFromAppConfig validates the database migration that moves the instance ID out of the legacy app_config_variables table and into the kv table
// The value used to be stored as an app config variable under the "instanceId" key
func TestMigrateFromAppConfig(t *testing.T) {
// Version of the migration right before "20260708130000_move_instance_id_to_kv", which performs the move
// We seed the legacy table right after this version, so the following migration has data to operate on
const versionBeforeMove uint = 20260708120000
// seedAppConfigInstanceID inserts a legacy "instanceId" row into the app_config_variables table
seedAppConfigInstanceID := func(value string) func(t *testing.T, db *gorm.DB) {
return func(t *testing.T, db *gorm.DB) {
t.Helper()
err := db.Exec(`INSERT INTO app_config_variables ("key", "value") VALUES ('instanceId', ?)`, value).Error
require.NoError(t, err)
}
}
// countLegacyInstanceID returns the number of "instanceId" rows left in the app_config_variables table
countLegacyInstanceID := func(t *testing.T, db *gorm.DB) int64 {
t.Helper()
var count int64
err := db.Table("app_config_variables").Where(`"key" = ?`, "instanceId").Count(&count).Error
require.NoError(t, err)
return count
}
t.Run("moves an existing instance ID from app_config_variables into the kv table", func(t *testing.T) {
legacyID := uuid.NewString()
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMove, seedAppConfigInstanceID(legacyID))
// The migration should have copied the value into the kv table under the "instance_id" key
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, legacyID, stored)
// The legacy row must have been removed from app_config_variables
require.Zero(t, countLegacyInstanceID(t, db))
// Load must return the migrated value without generating a new one
id, err := Load(t.Context(), db)
require.NoError(t, err)
require.Equal(t, legacyID, id)
stored, count = readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, legacyID, stored)
})
t.Run("keeps the existing kv value when both tables have an instance ID", func(t *testing.T) {
legacyID := uuid.NewString()
db := testutils.NewDatabaseForTestWithMigrationSeed(t, versionBeforeMove, func(t *testing.T, db *gorm.DB) {
t.Helper()
// An instance ID is already present in the kv table before the move runs
err := db.Exec(`INSERT INTO kv ("key", "value") VALUES ('instance_id', ?)`, "kv-instance-id").Error
require.NoError(t, err)
// And a different value lingers in the legacy app_config_variables table
seedAppConfigInstanceID(legacyID)(t, db)
})
// The migration must not clobber the value already stored in the kv table
stored, count := readInstanceID(t, db)
require.Equal(t, 1, count)
require.Equal(t, "kv-instance-id", stored)
// The legacy row must still be removed regardless of the conflict
require.Zero(t, countLegacyInstanceID(t, db))
})
}