Files
pocket-id-pocket-id-1/backend/internal/service/app_lock_service_test.go
2025-11-26 10:38:15 +01:00

190 lines
4.9 KiB
Go

package service
import (
"context"
"testing"
"time"
"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"
)
func newTestAppLockService(t *testing.T, db *gorm.DB) *AppLockService {
t.Helper()
return &AppLockService{
db: db,
processID: 1,
hostID: "test-host",
lockID: "a13c7673-c7ae-49f1-9112-2cd2d0d4b0c1",
}
}
func insertLock(t *testing.T, db *gorm.DB, value lockValue) {
t.Helper()
raw, err := value.Marshal()
require.NoError(t, err)
err = db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
require.NoError(t, err)
}
func readLockValue(t *testing.T, db *gorm.DB) lockValue {
t.Helper()
var row model.KV
err := db.Take(&row, "key = ?", lockKey).Error
require.NoError(t, err)
require.NotNil(t, row.Value)
var value lockValue
err = value.Unmarshal(*row.Value)
require.NoError(t, err)
return value
}
func TestAppLockServiceAcquire(t *testing.T) {
t.Run("creates new lock when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
stored := readLockValue(t, db)
require.Equal(t, service.processID, stored.ProcessID)
require.Equal(t, service.hostID, stored.HostID)
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
})
t.Run("returns ErrLockUnavailable when lock held by another process", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
existing := lockValue{
ProcessID: 99,
HostID: "other-host",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
insertLock(t, db, existing)
_, err := service.Acquire(context.Background(), false)
require.ErrorIs(t, err, ErrLockUnavailable)
current := readLockValue(t, db)
require.Equal(t, existing, current)
})
t.Run("force acquisition steals lock", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
insertLock(t, db, lockValue{
ProcessID: 99,
HostID: "other-host",
ExpiresAt: time.Now().Unix(),
})
_, err := service.Acquire(context.Background(), true)
require.NoError(t, err)
stored := readLockValue(t, db)
require.Equal(t, service.processID, stored.ProcessID)
require.Equal(t, service.hostID, stored.HostID)
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
})
}
func TestAppLockServiceRelease(t *testing.T) {
t.Run("removes owned lock", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
err = service.Release(context.Background())
require.NoError(t, err)
var row model.KV
err = db.Take(&row, "key = ?", lockKey).Error
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
})
t.Run("ignores lock held by another owner", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
existing := lockValue{
ProcessID: 2,
HostID: "other-host",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
insertLock(t, db, existing)
err := service.Release(context.Background())
require.NoError(t, err)
stored := readLockValue(t, db)
require.Equal(t, existing, stored)
})
}
func TestAppLockServiceRenew(t *testing.T) {
t.Run("extends expiration when lock is still owned", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
before := readLockValue(t, db)
err = service.renew(context.Background())
require.NoError(t, err)
after := readLockValue(t, db)
require.Equal(t, service.processID, after.ProcessID)
require.Equal(t, service.hostID, after.HostID)
require.GreaterOrEqual(t, after.ExpiresAt, before.ExpiresAt)
})
t.Run("returns ErrLockLost when lock is missing", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
err := service.renew(context.Background())
require.ErrorIs(t, err, ErrLockLost)
})
t.Run("returns ErrLockLost when ownership changed", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
// Simulate a different process taking the lock.
newOwner := lockValue{
ProcessID: 9,
HostID: "stolen-host",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
raw, marshalErr := newOwner.Marshal()
require.NoError(t, marshalErr)
updateErr := db.Model(&model.KV{}).
Where("key = ?", lockKey).
Update("value", raw).Error
require.NoError(t, updateErr)
err = service.renew(context.Background())
require.ErrorIs(t, err, ErrLockLost)
})
}