mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-15 21:48:13 +03:00
191 lines
6.6 KiB
Go
191 lines
6.6 KiB
Go
|
|
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))
|
||
|
|
})
|
||
|
|
}
|