feat: add relative last seen display option to account security (#2739)

Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Clément Fiere
2026-08-17 20:28:26 +00:00
committed by GitHub
parent 6c10072f5b
commit 92987a872c
40 changed files with 3890 additions and 352 deletions
@@ -520,6 +520,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str
"terminalFontFamily": nullString(settings.TerminalFontFamily),
"terminalFontSize": nullInt(settings.TerminalFontSize),
"changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout),
"lastSeenDisplay": lastSeenDisplay(settings.LastSeenDisplay),
"azureDevOpsBrowsePreferences": settings.AzureDevOpsBrowsePreferences,
"systemMetricsDisplay": map[string]any{
"showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar,
@@ -889,6 +890,11 @@ func changesPanelLayout(value string) string {
return "tree"
}
// lastSeenDisplay normalizes the last-seen display to absolute or relative.
func lastSeenDisplay(value string) string {
return usermodels.NormalizeLastSeenDisplay(value)
}
// logBootError logs a debug entry when optional boot data failed to load.
func (b bootStateBuilder) logBootError(operation string, err error) {
if err == nil || b.p.log == nil {
@@ -7,6 +7,7 @@ import (
usermodels "github.com/kandev/kandev/internal/user/models"
)
// TestMapUserSettingsStateIncludesArchiveConfirmation verifies boot state carries the archive confirmation flag.
func TestMapUserSettingsStateIncludesArchiveConfirmation(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{ConfirmTaskArchive: true},
@@ -18,6 +19,7 @@ func TestMapUserSettingsStateIncludesArchiveConfirmation(t *testing.T) {
}
}
// TestMapUserSettingsStateIncludesAgentGeneratedTaskTitles verifies boot state carries the agent-generated task titles flag.
func TestMapUserSettingsStateIncludesAgentGeneratedTaskTitles(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{AgentGeneratedTaskTitles: true},
@@ -29,6 +31,7 @@ func TestMapUserSettingsStateIncludesAgentGeneratedTaskTitles(t *testing.T) {
}
}
// TestMapUserSettingsStateIncludesTasksListShowDetails verifies boot state carries the tasks-list show-details flag.
func TestMapUserSettingsStateIncludesTasksListShowDetails(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{TasksListShowDetails: true},
@@ -39,6 +42,7 @@ func TestMapUserSettingsStateIncludesTasksListShowDetails(t *testing.T) {
}
}
// TestMapUserSettingsStateIncludesNormalizedMCPTaskAgentProfileDefault verifies boot state normalizes the MCP task agent profile default.
func TestMapUserSettingsStateIncludesNormalizedMCPTaskAgentProfileDefault(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{MCPTaskAgentProfileDefault: "future_value"},
@@ -50,6 +54,7 @@ func TestMapUserSettingsStateIncludesNormalizedMCPTaskAgentProfileDefault(t *tes
}
}
// TestMapUserSettingsStateIncludesNormalizedStartupPage verifies boot state normalizes the startup page.
func TestMapUserSettingsStateIncludesNormalizedStartupPage(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{StartupPage: "future_value"},
@@ -61,6 +66,7 @@ func TestMapUserSettingsStateIncludesNormalizedStartupPage(t *testing.T) {
}
}
// TestMapUserSettingsStateIncludesHiddenWorkflowStepIds verifies boot state carries hidden workflow step IDs under the store-field key.
func TestMapUserSettingsStateIncludesHiddenWorkflowStepIds(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{
@@ -85,6 +91,7 @@ func TestMapUserSettingsStateIncludesHiddenWorkflowStepIds(t *testing.T) {
}
}
// TestMapUserSettingsStateDefaultsHiddenWorkflowStepIdsToEmptyMap verifies boot state defaults hidden workflow step IDs to an empty map.
func TestMapUserSettingsStateDefaultsHiddenWorkflowStepIdsToEmptyMap(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{},
@@ -96,6 +103,7 @@ func TestMapUserSettingsStateDefaultsHiddenWorkflowStepIdsToEmptyMap(t *testing.
}
}
// TestMapUserSettingsStateIncludesAppStatusBarOrder verifies boot state carries the app status bar order.
func TestMapUserSettingsStateIncludesAppStatusBarOrder(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{AppStatusBarOrder: usermodels.AppStatusBarOrder{
@@ -113,6 +121,7 @@ func TestMapUserSettingsStateIncludesAppStatusBarOrder(t *testing.T) {
}
}
// TestMapUserSettingsStateNormalizesLspStatusLocation verifies boot state normalizes the LSP status location.
func TestMapUserSettingsStateNormalizesLspStatusLocation(t *testing.T) {
tests := []struct {
name string
@@ -137,6 +146,32 @@ func TestMapUserSettingsStateNormalizesLspStatusLocation(t *testing.T) {
}
}
// TestMapUserSettingsStateNormalizesLastSeenDisplay verifies boot state normalizes the last-seen display mode.
func TestMapUserSettingsStateNormalizesLastSeenDisplay(t *testing.T) {
tests := []struct {
name string
value string
want string
}{
{name: "relative is preserved", value: usermodels.LastSeenDisplayRelative, want: usermodels.LastSeenDisplayRelative},
{name: "empty uses absolute", value: "", want: usermodels.LastSeenDisplayAbsolute},
{name: "unknown uses absolute", value: "future_value", want: usermodels.LastSeenDisplayAbsolute},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{LastSeenDisplay: tt.value},
}, "workspace-1")
if got := state["lastSeenDisplay"]; got != tt.want {
t.Fatalf("lastSeenDisplay = %#v, want %q", got, tt.want)
}
})
}
}
// TestMapUserSettingsStateIncludesSystemMetricsDisplayPreference verifies boot state carries the system metrics display preference.
func TestMapUserSettingsStateIncludesSystemMetricsDisplayPreference(t *testing.T) {
state := mapUserSettingsState(userdto.UserSettingsResponse{
Settings: userdto.UserSettingsDTO{SystemMetricsDisplay: usermodels.SystemMetricsDisplaySettings{
@@ -102,6 +102,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
TerminalFontFamily: req.TerminalFontFamily,
TerminalFontSize: req.TerminalFontSize,
ChangesPanelLayout: req.ChangesPanelLayout,
LastSeenDisplay: req.LastSeenDisplay,
SystemMetricsDisplay: systemMetricsDisplayPatch(req.SystemMetricsDisplay),
AppStatusBarEnabled: req.AppStatusBarEnabled,
AppStatusBarOrder: req.AppStatusBarOrder,
@@ -21,35 +21,43 @@ type settingsRepository struct {
settings *models.UserSettings
}
// GetUser returns an error, asserting the settings service never fetches users.
func (r *settingsRepository) GetUser(context.Context, string) (*models.User, error) {
return nil, errors.New("unexpected GetUser call")
}
// GetDefaultUser returns an error, asserting the settings service never fetches the default user.
func (r *settingsRepository) GetDefaultUser(context.Context) (*models.User, error) {
return nil, errors.New("unexpected GetDefaultUser call")
}
// GetUserSettings returns a copy of the repository's stored settings.
func (r *settingsRepository) GetUserSettings(context.Context, string) (*models.UserSettings, error) {
copy := *r.settings
return &copy, nil
}
// UpsertUserSettingsPreservingTaskCreateLastUsed stores a copy of the settings and returns it.
func (r *settingsRepository) UpsertUserSettingsPreservingTaskCreateLastUsed(
_ context.Context,
settings *models.UserSettings,
_ *models.TaskCreateLastUsed,
_ int64,
) (*models.UserSettings, error) {
copy := *settings
r.settings = &copy
return &copy, nil
}
// UpdateTaskCreateLastUsed returns an error, asserting the settings service never updates task-creation history directly.
func (r *settingsRepository) UpdateTaskCreateLastUsed(context.Context, string, models.TaskCreateLastUsed) (*models.UserSettings, error) {
return nil, errors.New("unexpected UpdateTaskCreateLastUsed call")
}
// Close is a no-op, satisfying the repository interface.
func (r *settingsRepository) Close() error { return nil }
// TestUpdateUserSettingsMapsMCPTaskAgentProfileDefault verifies the controller persists an MCP task agent profile default patch.
func TestUpdateUserSettingsMapsMCPTaskAgentProfileDefault(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -72,6 +80,7 @@ func TestUpdateUserSettingsMapsMCPTaskAgentProfileDefault(t *testing.T) {
}
}
// TestUpdateUserSettingsMapsLspStatusLocation verifies the controller persists an LSP status location patch.
func TestUpdateUserSettingsMapsLspStatusLocation(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -94,6 +103,7 @@ func TestUpdateUserSettingsMapsLspStatusLocation(t *testing.T) {
}
}
// TestUpdateUserSettingsMapsStartupPage verifies the controller persists a startup page patch.
func TestUpdateUserSettingsMapsStartupPage(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -116,6 +126,30 @@ func TestUpdateUserSettingsMapsStartupPage(t *testing.T) {
}
}
// TestUpdateUserSettingsMapsLastSeenDisplay verifies the controller persists a last-seen display patch.
func TestUpdateUserSettingsMapsLastSeenDisplay(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatalf("logger.NewFromZap: %v", err)
}
repo := &settingsRepository{settings: &models.UserSettings{
LastSeenDisplay: models.LastSeenDisplayAbsolute,
}}
controller := NewController(service.NewService(repo, nil, log))
want := models.LastSeenDisplayRelative
response, err := controller.UpdateUserSettings(context.Background(), dto.UpdateUserSettingsRequest{
LastSeenDisplay: &want,
})
if err != nil {
t.Fatalf("UpdateUserSettings: %v", err)
}
if response.Settings.LastSeenDisplay != want {
t.Fatalf("LastSeenDisplay = %q, want %q", response.Settings.LastSeenDisplay, want)
}
}
// TestAzureDevOpsBrowsePreferencesRoundTripThroughSettingsAPI verifies Azure DevOps browse preferences survive update, read-back, omission, and null clearing.
func TestAzureDevOpsBrowsePreferencesRoundTripThroughSettingsAPI(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -185,6 +219,7 @@ func TestAzureDevOpsBrowsePreferencesRoundTripThroughSettingsAPI(t *testing.T) {
}
}
// TestSystemMetricsDisplayPatch verifies systemMetricsDisplayPatch nil, explicit-value, and omitted-field handling.
func TestSystemMetricsDisplayPatch(t *testing.T) {
t.Run("nil patch stays nil", func(t *testing.T) {
if got := systemMetricsDisplayPatch(nil); got != nil {
+3
View File
@@ -67,6 +67,7 @@ type UserSettingsDTO struct {
TerminalFontFamily string `json:"terminal_font_family"`
TerminalFontSize int `json:"terminal_font_size"`
ChangesPanelLayout string `json:"changes_panel_layout"`
LastSeenDisplay string `json:"last_seen_display"`
SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"`
AppStatusBarEnabled bool `json:"app_status_bar_enabled"`
AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"`
@@ -142,6 +143,7 @@ type UpdateUserSettingsRequest struct {
TerminalFontFamily *string `json:"terminal_font_family,omitempty"`
TerminalFontSize *int `json:"terminal_font_size,omitempty"`
ChangesPanelLayout *string `json:"changes_panel_layout,omitempty"`
LastSeenDisplay *string `json:"last_seen_display,omitempty"`
SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch `json:"system_metrics_display,omitempty"`
AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"`
AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"`
@@ -291,6 +293,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
TerminalFontFamily: settings.TerminalFontFamily,
TerminalFontSize: settings.TerminalFontSize,
ChangesPanelLayout: settings.ChangesPanelLayout,
LastSeenDisplay: models.NormalizeLastSeenDisplay(settings.LastSeenDisplay),
SystemMetricsDisplay: settings.SystemMetricsDisplay,
AppStatusBarEnabled: settings.AppStatusBarEnabled,
AppStatusBarOrder: settings.AppStatusBarOrder,
@@ -8,6 +8,7 @@ import (
"github.com/kandev/kandev/internal/user/models"
)
// TestFromUserSettingsIncludesAtomicRevision verifies the DTO carries the settings revision.
func TestFromUserSettingsIncludesAtomicRevision(t *testing.T) {
got := FromUserSettings(&models.UserSettings{Revision: 42}).Revision
if got != 42 {
@@ -15,6 +16,7 @@ func TestFromUserSettingsIncludesAtomicRevision(t *testing.T) {
}
}
// TestUpdateUserSettingsRequestExposesAzureDevOpsBrowsePreferences verifies the patch request exposes the Azure DevOps browse preferences field.
func TestUpdateUserSettingsRequestExposesAzureDevOpsBrowsePreferences(t *testing.T) {
field, ok := reflect.TypeFor[UpdateUserSettingsRequest]().FieldByName("AzureDevOpsBrowsePreferences")
if !ok || field.Tag.Get("json") != "azure_devops_browse_preferences,omitempty" {
@@ -22,6 +24,7 @@ func TestUpdateUserSettingsRequestExposesAzureDevOpsBrowsePreferences(t *testing
}
}
// TestFromUserSettingsMapsAzureDevOpsBrowsePreferences verifies the DTO passes the Azure DevOps browse preferences through unchanged.
func TestFromUserSettingsMapsAzureDevOpsBrowsePreferences(t *testing.T) {
preferences := json.RawMessage(`{"project-1":{"teamId":"team-1"}}`)
settings := FromUserSettings(&models.UserSettings{AzureDevOpsBrowsePreferences: preferences})
@@ -30,6 +33,7 @@ func TestFromUserSettingsMapsAzureDevOpsBrowsePreferences(t *testing.T) {
}
}
// TestUpdateUserSettingsRequestExposesKanbanHiddenStepIDs verifies the patch request exposes the kanban hidden step IDs field.
func TestUpdateUserSettingsRequestExposesKanbanHiddenStepIDs(t *testing.T) {
field, ok := reflect.TypeFor[UpdateUserSettingsRequest]().FieldByName("KanbanHiddenStepIDs")
if !ok || field.Tag.Get("json") != "kanban_hidden_step_ids,omitempty" {
@@ -37,6 +41,7 @@ func TestUpdateUserSettingsRequestExposesKanbanHiddenStepIDs(t *testing.T) {
}
}
// TestFromUserSettingsMapsKanbanHiddenStepIDs verifies the DTO carries the per-workflow hidden step ID map.
func TestFromUserSettingsMapsKanbanHiddenStepIDs(t *testing.T) {
hidden := map[string][]string{"wf-1": {"step-a", "step-b"}}
settings := FromUserSettings(&models.UserSettings{KanbanHiddenStepIDs: hidden})
@@ -45,6 +50,7 @@ func TestFromUserSettingsMapsKanbanHiddenStepIDs(t *testing.T) {
}
}
// TestUserSettingsDTOKanbanHiddenStepIDsAlwaysSerializesEvenWhenEmpty verifies an empty hidden step ID map serializes as {} instead of being omitted.
func TestUserSettingsDTOKanbanHiddenStepIDsAlwaysSerializesEvenWhenEmpty(t *testing.T) {
// Regression test: the response field must never use `omitempty`. A
// client that clears its hidden set to {} needs that {} to actually
@@ -72,6 +78,7 @@ func TestUserSettingsDTOKanbanHiddenStepIDsAlwaysSerializesEvenWhenEmpty(t *test
}
}
// TestKanbanHiddenStepIDsRequestDecode verifies decoding distinguishes an omitted field from an explicit empty map.
func TestKanbanHiddenStepIDsRequestDecode(t *testing.T) {
t.Run("omitted value stays nil", func(t *testing.T) {
var req UpdateUserSettingsRequest
@@ -94,6 +101,7 @@ func TestKanbanHiddenStepIDsRequestDecode(t *testing.T) {
})
}
// TestTasksListShowDetailsDTO verifies the DTO mapping and the nil-versus-explicit-false patch semantics.
func TestTasksListShowDetailsDTO(t *testing.T) {
if !FromUserSettings(&models.UserSettings{TasksListShowDetails: true}).TasksListShowDetails {
t.Fatal("TasksListShowDetails = false, want true")
@@ -120,6 +128,7 @@ func TestTasksListShowDetailsDTO(t *testing.T) {
})
}
// TestAppStatusBarOrderDTOAndPatchSemantics verifies the status bar order DTO mapping and its patch semantics.
func TestAppStatusBarOrderDTOAndPatchSemantics(t *testing.T) {
want := models.AppStatusBarOrder{
LeftItemIDs: []string{"builtin:connection", "plugin:left"},
@@ -151,6 +160,7 @@ func TestAppStatusBarOrderDTOAndPatchSemantics(t *testing.T) {
})
}
// TestLspStatusLocationDTOAndPatchSemantics verifies the LSP status location DTO normalization and its patch semantics.
func TestLspStatusLocationDTOAndPatchSemantics(t *testing.T) {
t.Run("response normalizes missing and unknown values to toolbar", func(t *testing.T) {
for _, value := range []string{"", "future_location"} {
@@ -191,6 +201,7 @@ func TestLspStatusLocationDTOAndPatchSemantics(t *testing.T) {
})
}
// TestUpdateUserSettingsRequestSystemMetricsDisplayPreservesOmittedFields verifies omitted system metrics display fields stay absent when re-marshaled.
func TestUpdateUserSettingsRequestSystemMetricsDisplayPreservesOmittedFields(t *testing.T) {
var req UpdateUserSettingsRequest
if err := json.Unmarshal([]byte(`{"system_metrics_display":{"show_in_topbar":true}}`), &req); err != nil {
@@ -210,6 +221,7 @@ func TestUpdateUserSettingsRequestSystemMetricsDisplayPreservesOmittedFields(t *
}
}
// TestFromUserSettingsIncludesArchiveConfirmation verifies the DTO carries the task archive confirmation flag.
func TestFromUserSettingsIncludesArchiveConfirmation(t *testing.T) {
for _, want := range []bool{true, false} {
dto := FromUserSettings(&models.UserSettings{ConfirmTaskArchive: want})
@@ -219,6 +231,7 @@ func TestFromUserSettingsIncludesArchiveConfirmation(t *testing.T) {
}
}
// TestAgentGeneratedTaskTitlesDTOAndPatchSemantics verifies the agent-generated task titles DTO mapping and its patch semantics.
func TestAgentGeneratedTaskTitlesDTOAndPatchSemantics(t *testing.T) {
if !FromUserSettings(&models.UserSettings{AgentGeneratedTaskTitles: true}).AgentGeneratedTaskTitles {
t.Fatal("AgentGeneratedTaskTitles = false, want true")
@@ -249,6 +262,7 @@ func TestAgentGeneratedTaskTitlesDTOAndPatchSemantics(t *testing.T) {
}
}
// TestFromUserSettingsIncludesNormalizedMCPTaskAgentProfileDefault verifies the DTO normalizes the MCP task agent profile default.
func TestFromUserSettingsIncludesNormalizedMCPTaskAgentProfileDefault(t *testing.T) {
tests := []struct {
name string
@@ -276,6 +290,7 @@ func TestFromUserSettingsIncludesNormalizedMCPTaskAgentProfileDefault(t *testing
}
}
// TestFromUserSettingsIncludesNormalizedStartupPage verifies the DTO normalizes the startup page.
func TestFromUserSettingsIncludesNormalizedStartupPage(t *testing.T) {
tests := []struct {
name string
@@ -303,6 +318,35 @@ func TestFromUserSettingsIncludesNormalizedStartupPage(t *testing.T) {
}
}
// TestFromUserSettingsIncludesNormalizedLastSeenDisplay verifies the DTO normalizes the last-seen display mode.
func TestFromUserSettingsIncludesNormalizedLastSeenDisplay(t *testing.T) {
tests := []struct {
name string
value string
want string
}{
{name: "relative", value: models.LastSeenDisplayRelative, want: models.LastSeenDisplayRelative},
{name: "unknown defaults to absolute", value: "future_value", want: models.LastSeenDisplayAbsolute},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw, err := json.Marshal(FromUserSettings(&models.UserSettings{LastSeenDisplay: tt.value}))
if err != nil {
t.Fatalf("marshal DTO: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode DTO: %v", err)
}
if got := payload["last_seen_display"]; got != tt.want {
t.Fatalf("last_seen_display = %#v, want %q", got, tt.want)
}
})
}
}
// TestUpdateUserSettingsRequestStartupPagePatchSemantics verifies startup page patch decoding distinguishes omitted and explicit values.
func TestUpdateUserSettingsRequestStartupPagePatchSemantics(t *testing.T) {
t.Run("omitted value stays nil", func(t *testing.T) {
var req UpdateUserSettingsRequest
@@ -325,6 +369,7 @@ func TestUpdateUserSettingsRequestStartupPagePatchSemantics(t *testing.T) {
})
}
// TestUpdateUserSettingsRequestMCPTaskAgentProfileDefaultPatchSemantics verifies MCP task agent profile default patch decoding distinguishes omitted and explicit values.
func TestUpdateUserSettingsRequestMCPTaskAgentProfileDefaultPatchSemantics(t *testing.T) {
t.Run("omitted value stays nil", func(t *testing.T) {
var req UpdateUserSettingsRequest
@@ -347,6 +392,7 @@ func TestUpdateUserSettingsRequestMCPTaskAgentProfileDefaultPatchSemantics(t *te
})
}
// TestNullableSidebarDraft verifies sidebar draft decoding distinguishes omitted, null, and object values.
func TestNullableSidebarDraft(t *testing.T) {
t.Run("omitted field is not set", func(t *testing.T) {
var req UpdateUserSettingsRequest
@@ -385,6 +431,7 @@ func TestNullableSidebarDraft(t *testing.T) {
})
}
// TestNullableRawMessage verifies raw-message decoding distinguishes omitted, null, and JSON values.
func TestNullableRawMessage(t *testing.T) {
t.Run("omitted field is not set", func(t *testing.T) {
var req UpdateUserSettingsRequest
@@ -89,6 +89,8 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
status := http.StatusInternalServerError
if errors.Is(err, service.ErrValidation) {
status = http.StatusBadRequest
} else if errors.Is(err, service.ErrUserSettingsConflict) {
status = http.StatusConflict
}
h.logger.Error("failed to update user settings", zap.Error(err))
c.JSON(status, gin.H{"error": "failed to update user settings"})
@@ -115,6 +117,8 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*
code := ws.ErrorCodeInternalError
if errors.Is(err, service.ErrValidation) {
code = ws.ErrorCodeBadRequest
} else if errors.Is(err, service.ErrUserSettingsConflict) {
code = ws.ErrorCodeConflict
}
return ws.NewError(msg.ID, msg.Action, code, "Failed to update user settings", nil)
}
@@ -2,6 +2,7 @@ package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -14,8 +15,10 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/user/controller"
"github.com/kandev/kandev/internal/user/dto"
"github.com/kandev/kandev/internal/user/models"
"github.com/kandev/kandev/internal/user/service"
userstore "github.com/kandev/kandev/internal/user/store"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
@@ -171,3 +174,76 @@ func TestHTTPUpdateUserSettingsBodyTooLarge(t *testing.T) {
t.Fatalf("PATCH oversized body status = %d, want %d: %s", response.Code, http.StatusRequestEntityTooLarge, response.Body.String())
}
}
// conflictSettingsRepository forces every conditional settings write to
// exercise the service retry limit without requiring a live database race.
type conflictSettingsRepository struct {
userstore.Repository
}
func (conflictSettingsRepository) GetUserSettings(_ context.Context, userID string) (*models.UserSettings, error) {
return &models.UserSettings{UserID: userID}, nil
}
func (conflictSettingsRepository) UpsertUserSettingsPreservingTaskCreateLastUsed(
context.Context,
*models.UserSettings,
*models.TaskCreateLastUsed,
int64,
) (*models.UserSettings, error) {
return nil, userstore.ErrUserSettingsRevisionConflict
}
func newConflictHandlers(t *testing.T) *Handlers {
t.Helper()
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
t.Fatalf("create logger: %v", err)
}
svc := service.NewService(conflictSettingsRepository{}, nil, log)
return NewHandlers(controller.NewController(svc), log)
}
func TestHTTPUpdateUserSettingsRevisionConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
newConflictHandlers(t).registerHTTP(router)
request := httptest.NewRequest(
http.MethodPatch,
"/api/v1/user/settings",
bytes.NewReader([]byte(`{"last_seen_display":"relative"}`)),
)
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusConflict {
t.Fatalf("PATCH revision conflict status = %d, want %d: %s", response.Code, http.StatusConflict, response.Body.String())
}
}
func TestWSUpdateUserSettingsRevisionConflict(t *testing.T) {
h := newConflictHandlers(t)
msg, err := ws.NewRequest("settings-1", ws.ActionUserSettingsUpdate, map[string]string{
"last_seen_display": "relative",
})
if err != nil {
t.Fatalf("create request: %v", err)
}
response, err := h.wsUpdateUserSettings(context.Background(), msg)
if err != nil {
t.Fatalf("wsUpdateUserSettings returned error: %v", err)
}
if response.Type != ws.MessageTypeError {
t.Fatalf("response type = %q, want %q", response.Type, ws.MessageTypeError)
}
var payload ws.ErrorPayload
if err := response.ParsePayload(&payload); err != nil {
t.Fatalf("parse error payload: %v", err)
}
if payload.Code != ws.ErrorCodeConflict {
t.Fatalf("error code = %q, want %q", payload.Code, ws.ErrorCodeConflict)
}
}
@@ -45,6 +45,20 @@ func NormalizeStartupPage(value string) string {
return StartupPageTaskOverview
}
const (
LastSeenDisplayAbsolute = "absolute"
LastSeenDisplayRelative = "relative"
)
// NormalizeLastSeenDisplay returns the canonical last-seen display mode:
// relative is accepted as-is, anything else is coerced to absolute.
func NormalizeLastSeenDisplay(value string) string {
if value == LastSeenDisplayRelative {
return value
}
return LastSeenDisplayAbsolute
}
const (
// RoleAdmin unlocks user management and system settings mutation when
// authentication is enabled. It does NOT grant visibility into other
@@ -123,6 +137,7 @@ type UserSettings struct {
TerminalFontFamily string `json:"terminal_font_family"`
TerminalFontSize int `json:"terminal_font_size"`
ChangesPanelLayout string `json:"changes_panel_layout"` // "flat" | "tree"
LastSeenDisplay string `json:"last_seen_display"` // "absolute" | "relative"
SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"`
AppStatusBarEnabled bool `json:"app_status_bar_enabled"`
AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"`
+109 -50
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"time"
@@ -18,14 +19,18 @@ import (
"go.uber.org/zap"
)
// ErrUserSettingsConflict marks an exhausted conditional settings update.
var (
ErrUserNotFound = errors.New("user not found")
ErrValidation = errors.New("validation error")
ErrUserNotFound = errors.New("user not found")
ErrValidation = errors.New("validation error")
ErrUserSettingsConflict = store.ErrUserSettingsRevisionConflict
)
const (
changesPanelLayoutFlat = "flat"
changesPanelLayoutTree = "tree"
maxUserSettingsCASAttempts = 3
)
type Service struct {
@@ -87,6 +92,7 @@ type UpdateUserSettingsRequest struct {
TerminalFontFamily *string
TerminalFontSize *int
ChangesPanelLayout *string
LastSeenDisplay *string
SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch
AppStatusBarEnabled *bool
AppStatusBarOrder *models.AppStatusBarOrder
@@ -166,45 +172,87 @@ func (s *Service) GetDefaultUtilityAgentProfileID(ctx context.Context) (string,
}
// UpdateUserSettings applies a partial settings patch field by field,
// validates each group, persists the result, and publishes the settings
// update event.
// validates each group, persists the result under expected-revision CAS, and
// publishes the settings update event.
func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSettingsRequest) (*models.UserSettings, error) {
settings, err := s.repo.GetUserSettings(ctx, s.settingsUserID(ctx))
if err != nil {
return nil, err
}
if err := applyBasicSettings(settings, req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := s.applyChatSubmitKey(settings, req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applyLSPSettings(settings, req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applySavedLayouts(settings, req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applySidebarViews(settings, req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applySidebarViewState(settings, req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applyUserPreferenceBlobs(settings, req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
settings.UpdatedAt = time.Now().UTC()
var taskCreatePatch *models.TaskCreateLastUsed
if req.TaskCreateLastUsed != nil && !taskCreateLastUsedPatchEmpty(*req.TaskCreateLastUsed) {
taskCreatePatch = req.TaskCreateLastUsed
}
settings, err = s.repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, taskCreatePatch)
if err != nil {
return nil, err
return s.updateUserSettingsCAS(ctx, func(settings *models.UserSettings) (bool, error) {
// The shallow copy is safe only while every apply* helper replaces
// reference fields (slices, maps, and json.RawMessage) instead of
// mutating them in place. In-place mutation would alias before and make
// DeepEqual miss the write.
before := *settings
if err := applyBasicSettings(settings, req); err != nil {
return false, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := s.applyChatSubmitKey(settings, req); err != nil {
return false, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applyLSPSettings(settings, req); err != nil {
return false, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applySavedLayouts(settings, req); err != nil {
return false, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applySidebarViews(settings, req); err != nil {
return false, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applySidebarViewState(settings, req); err != nil {
return false, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
if err := applyUserPreferenceBlobs(settings, req); err != nil {
return false, fmt.Errorf("%w: %s", ErrValidation, err.Error())
}
return !reflect.DeepEqual(*settings, before), nil
}, taskCreatePatch)
}
// updateUserSettingsCAS applies a full-blob user-settings write under
// expected-revision CAS with bounded retry. apply is evaluated against every
// freshly read model and reports whether it mutated it; the immutable
// taskCreatePatch is passed to every attempt so a retry re-applies the same
// task-create merge against the fresh row. A no-op (apply returned false and
// no patch) writes and publishes nothing and returns the freshly read
// (current) settings. Exactly one event is published per successful write.
func (s *Service) updateUserSettingsCAS(
ctx context.Context,
apply func(*models.UserSettings) (bool, error),
taskCreatePatch *models.TaskCreateLastUsed,
) (*models.UserSettings, error) {
userID := s.settingsUserID(ctx)
var lastErr error
for attempt := 0; attempt < maxUserSettingsCASAttempts; attempt++ {
settings, err := s.repo.GetUserSettings(ctx, userID)
if err != nil {
return nil, err
}
applied, err := apply(settings)
if err != nil {
return nil, err
}
if !applied && taskCreatePatch == nil {
// No-op: report the freshly read (current) settings, write nothing,
// publish nothing.
return settings, nil
}
updated, err := s.repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, taskCreatePatch, settings.Revision)
if err == nil {
s.publishUserSettingsEvent(ctx, updated)
return updated, nil
}
if !errors.Is(err, store.ErrUserSettingsRevisionConflict) {
return nil, err
}
lastErr = err
}
s.publishUserSettingsEvent(ctx, settings)
return settings, nil
s.logger.Warn("user settings update exhausted CAS retries",
zap.Int("attempts", maxUserSettingsCASAttempts),
zap.Error(lastErr),
)
return nil, fmt.Errorf("user settings update exhausted after %d attempts: %w", maxUserSettingsCASAttempts, lastErr)
}
// RecordTaskCreateLastUsed persists the last task-creation choices (a no-op
@@ -254,6 +302,9 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe
if err := applyChangesPanelLayout(settings, req.ChangesPanelLayout); err != nil {
return err
}
if err := applyLastSeenDisplay(settings, req.LastSeenDisplay); err != nil {
return err
}
applySystemMetricsDisplay(settings, req.SystemMetricsDisplay)
if req.AppStatusBarEnabled != nil {
settings.AppStatusBarEnabled = *req.AppStatusBarEnabled
@@ -546,6 +597,20 @@ func applyChangesPanelLayout(settings *models.UserSettings, value *string) error
return nil
}
// applyLastSeenDisplay validates and applies the last-seen display enum
// (absolute or relative).
func applyLastSeenDisplay(settings *models.UserSettings, value *string) error {
if value == nil {
return nil
}
v := strings.TrimSpace(*value)
if v != models.LastSeenDisplayAbsolute && v != models.LastSeenDisplayRelative {
return errors.New("last_seen_display must be 'absolute' or 'relative'")
}
settings.LastSeenDisplay = v
return nil
}
// applyChatSubmitKey validates and applies the chat_submit_key setting.
func (s *Service) applyChatSubmitKey(settings *models.UserSettings, req *UpdateUserSettingsRequest) error {
if req.ChatSubmitKey == nil {
@@ -813,6 +878,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
"terminal_font_family": settings.TerminalFontFamily,
"terminal_font_size": settings.TerminalFontSize,
"changes_panel_layout": settings.ChangesPanelLayout,
"last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay),
"system_metrics_display": settings.SystemMetricsDisplay,
"app_status_bar_enabled": settings.AppStatusBarEnabled,
"app_status_bar_order": settings.AppStatusBarOrder,
@@ -884,19 +950,12 @@ func (s *Service) ClearDefaultEditorID(ctx context.Context, editorID string) err
if editorID == "" {
return nil
}
settings, err := s.repo.GetUserSettings(ctx, s.settingsUserID(ctx))
if err != nil {
return err
}
if settings.DefaultEditorID != editorID {
return nil
}
settings.DefaultEditorID = ""
settings.UpdatedAt = time.Now().UTC()
settings, err = s.repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, nil)
if err != nil {
return err
}
s.publishUserSettingsEvent(ctx, settings)
return nil
_, err := s.updateUserSettingsCAS(ctx, func(settings *models.UserSettings) (bool, error) {
if settings.DefaultEditorID != editorID {
return false, nil
}
settings.DefaultEditorID = ""
return true, nil
}, nil)
return err
}
@@ -19,16 +19,20 @@ import (
"go.uber.org/zap"
)
// ptr returns a pointer to a copy of v.
func ptr[T any](v T) *T { return &v }
// rawPatch wraps v in a double pointer for use as a raw JSON patch value.
func rawPatch(v json.RawMessage) **json.RawMessage {
return ptr(ptr(v))
}
// rawClear returns a double pointer to a nil RawMessage for clearing a blob preference.
func rawClear() **json.RawMessage {
return ptr((*json.RawMessage)(nil))
}
// TestApplyBasicSettingsTasksListShowDetails verifies applyBasicSettings preserves TasksListShowDetails when omitted and applies explicit values.
func TestApplyBasicSettingsTasksListShowDetails(t *testing.T) {
t.Run("omission preserves saved value", func(t *testing.T) {
settings := &models.UserSettings{TasksListShowDetails: true}
@@ -53,6 +57,7 @@ func TestApplyBasicSettingsTasksListShowDetails(t *testing.T) {
}
}
// TestApplyBasicSettingsSystemMetricsDisplayPreservesOmittedFields verifies omitted SystemMetricsDisplay subfields are preserved by applyBasicSettings.
func TestApplyBasicSettingsSystemMetricsDisplayPreservesOmittedFields(t *testing.T) {
settings := &models.UserSettings{
SystemMetricsDisplay: models.SystemMetricsDisplaySettings{
@@ -72,6 +77,7 @@ func TestApplyBasicSettingsSystemMetricsDisplayPreservesOmittedFields(t *testing
}
}
// makeLayouts builds n SavedLayout fixtures with distinct IDs and names.
func makeLayouts(n int) []models.SavedLayout {
layouts := make([]models.SavedLayout, n)
for i := range layouts {
@@ -86,6 +92,7 @@ func makeLayouts(n int) []models.SavedLayout {
return layouts
}
// TestApplyBasicSettings_ReleaseNotes verifies release-note fields are preserved when nil and set or cleared when provided.
func TestApplyBasicSettings_ReleaseNotes(t *testing.T) {
t.Run("nil fields leave settings unchanged", func(t *testing.T) {
settings := &models.UserSettings{
@@ -149,6 +156,7 @@ func TestApplyBasicSettings_ReleaseNotes(t *testing.T) {
})
}
// TestApplyBasicSettings_ConfirmTaskArchive verifies archive confirmation stays enabled when omitted and is toggled by explicit values.
func TestApplyBasicSettings_ConfirmTaskArchive(t *testing.T) {
t.Run("omitted value leaves confirmation enabled", func(t *testing.T) {
settings := &models.UserSettings{ConfirmTaskArchive: true}
@@ -185,6 +193,7 @@ func TestApplyBasicSettings_ConfirmTaskArchive(t *testing.T) {
})
}
// TestApplyBasicSettings_PreventAutoStartAgentOnOpen verifies the auto-start setting is preserved when omitted and toggled by explicit values.
func TestApplyBasicSettings_PreventAutoStartAgentOnOpen(t *testing.T) {
t.Run("omitted value leaves the setting unchanged", func(t *testing.T) {
settings := &models.UserSettings{PreventAutoStartAgentOnOpen: true}
@@ -221,6 +230,7 @@ func TestApplyBasicSettings_PreventAutoStartAgentOnOpen(t *testing.T) {
})
}
// TestApplyBasicSettingsAgentGeneratedTaskTitles verifies AgentGeneratedTaskTitles is preserved when omitted and applied when provided.
func TestApplyBasicSettingsAgentGeneratedTaskTitles(t *testing.T) {
settings := &models.UserSettings{AgentGeneratedTaskTitles: false}
if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{}); err != nil {
@@ -245,6 +255,7 @@ func TestApplyBasicSettingsAgentGeneratedTaskTitles(t *testing.T) {
}
}
// TestApplyBasicSettingsAppStatusBarOrder verifies the status bar order is preserved when omitted and replaced when provided.
func TestApplyBasicSettingsAppStatusBarOrder(t *testing.T) {
saved := models.AppStatusBarOrder{
LeftItemIDs: []string{"builtin:connection"},
@@ -275,6 +286,7 @@ func TestApplyBasicSettingsAppStatusBarOrder(t *testing.T) {
})
}
// TestApplyLSPSettingsRejectsManualOnlyAutoInstallLanguage verifies manual-install-only languages are rejected without mutating settings.
func TestApplyLSPSettingsRejectsManualOnlyAutoInstallLanguage(t *testing.T) {
settings := &models.UserSettings{}
req := &UpdateUserSettingsRequest{
@@ -290,6 +302,7 @@ func TestApplyLSPSettingsRejectsManualOnlyAutoInstallLanguage(t *testing.T) {
}
}
// TestApplyLSPSettingsAcceptsTaskHostAutoInstallPreference verifies a task-host language auto-install preference is accepted and stored.
func TestApplyLSPSettingsAcceptsTaskHostAutoInstallPreference(t *testing.T) {
settings := &models.UserSettings{}
req := &UpdateUserSettingsRequest{
@@ -304,6 +317,7 @@ func TestApplyLSPSettingsAcceptsTaskHostAutoInstallPreference(t *testing.T) {
}
}
// TestApplyLspStatusLocation verifies LspStatusLocation is preserved when omitted, applied when valid, and rejected when invalid.
func TestApplyLspStatusLocation(t *testing.T) {
t.Run("omission preserves saved value", func(t *testing.T) {
settings := &models.UserSettings{LspStatusLocation: models.LspStatusLocationStatusBar}
@@ -347,6 +361,7 @@ func TestApplyLspStatusLocation(t *testing.T) {
})
}
// TestApplyBasicSettingsMCPTaskAgentProfileDefault verifies the MCP task-agent profile default is preserved, applied, and validated.
func TestApplyBasicSettingsMCPTaskAgentProfileDefault(t *testing.T) {
t.Run("omission preserves saved value", func(t *testing.T) {
settings := &models.UserSettings{MCPTaskAgentProfileDefault: models.MCPTaskAgentProfileDefaultWorkspaceDefault}
@@ -386,6 +401,7 @@ func TestApplyBasicSettingsMCPTaskAgentProfileDefault(t *testing.T) {
})
}
// TestApplyBasicSettingsShowAnchoredPromptBar verifies ShowAnchoredPromptBar is preserved when omitted and toggled by explicit values.
func TestApplyBasicSettingsShowAnchoredPromptBar(t *testing.T) {
t.Run("omission preserves saved value", func(t *testing.T) {
settings := &models.UserSettings{ShowAnchoredPromptBar: true}
@@ -418,6 +434,7 @@ func TestApplyBasicSettingsShowAnchoredPromptBar(t *testing.T) {
})
}
// TestApplyBasicSettingsTodoListPanel verifies ShowTodoListPanel is preserved when omitted and toggled by explicit values.
func TestApplyBasicSettingsTodoListPanel(t *testing.T) {
t.Run("omission preserves saved value", func(t *testing.T) {
settings := &models.UserSettings{ShowTodoListPanel: true}
@@ -450,6 +467,7 @@ func TestApplyBasicSettingsTodoListPanel(t *testing.T) {
})
}
// TestApplyBasicSettingsTodoListPanelOnlyWhenNotEmpty verifies ShowTodoListPanelOnlyWhenNotEmpty is preserved when omitted and toggled by explicit values.
func TestApplyBasicSettingsTodoListPanelOnlyWhenNotEmpty(t *testing.T) {
t.Run("omission preserves saved value", func(t *testing.T) {
settings := &models.UserSettings{ShowTodoListPanelOnlyWhenNotEmpty: true}
@@ -482,6 +500,7 @@ func TestApplyBasicSettingsTodoListPanelOnlyWhenNotEmpty(t *testing.T) {
})
}
// TestApplyBasicSettingsTranscriptNavigation verifies transcript navigation flags are applied while omitted flags are preserved.
func TestApplyBasicSettingsTranscriptNavigation(t *testing.T) {
settings := &models.UserSettings{
ShowScrollToLastPrompt: true,
@@ -507,6 +526,7 @@ func TestApplyBasicSettingsTranscriptNavigation(t *testing.T) {
}
}
// TestApplyBasicSettings_TasksListPreferences verifies tasks list sort and group are applied and invalid values rejected.
func TestApplyBasicSettings_TasksListPreferences(t *testing.T) {
t.Run("sets valid sort and group", func(t *testing.T) {
settings := &models.UserSettings{}
@@ -542,6 +562,7 @@ func TestApplyBasicSettings_TasksListPreferences(t *testing.T) {
})
}
// TestApplyBasicSettings_TerminalFontFamily verifies the terminal font family is preserved, set, trimmed, and cleared.
func TestApplyBasicSettings_TerminalFontFamily(t *testing.T) {
t.Run("nil leaves settings unchanged", func(t *testing.T) {
settings := &models.UserSettings{TerminalFontFamily: "Fira Code"}
@@ -588,6 +609,7 @@ func TestApplyBasicSettings_TerminalFontFamily(t *testing.T) {
})
}
// TestApplyChangesPanelLayout verifies the changes panel layout is preserved, set, trimmed, and validated.
func TestApplyChangesPanelLayout(t *testing.T) {
t.Run("nil leaves settings unchanged", func(t *testing.T) {
settings := &models.UserSettings{ChangesPanelLayout: "tree"}
@@ -642,6 +664,7 @@ func TestApplyChangesPanelLayout(t *testing.T) {
})
}
// TestApplyStartupPage verifies the startup page is preserved when omitted, applied when valid, and rejected when invalid.
func TestApplyStartupPage(t *testing.T) {
t.Run("omission preserves saved value", func(t *testing.T) {
settings := &models.UserSettings{StartupPage: models.StartupPageLastTask}
@@ -677,6 +700,7 @@ func TestApplyStartupPage(t *testing.T) {
})
}
// TestApplyBasicSettings_TerminalFontSize verifies font size application, reset to zero, and bounds validation.
func TestApplyBasicSettings_TerminalFontSize(t *testing.T) {
t.Run("nil leaves settings unchanged", func(t *testing.T) {
settings := &models.UserSettings{TerminalFontSize: 14}
@@ -728,6 +752,7 @@ func TestApplyBasicSettings_TerminalFontSize(t *testing.T) {
})
}
// TestApplySavedLayouts table-tests saved layout count limits and validation errors.
func TestApplySavedLayouts(t *testing.T) {
tests := []struct {
name string
@@ -907,6 +932,7 @@ func TestApplySavedLayouts(t *testing.T) {
}
}
// TestApplyWorkspaceAndTaskListPreferencesKanbanHiddenStepIDs table-tests kanban hidden step ID count and byte-budget limits.
func TestApplyWorkspaceAndTaskListPreferencesKanbanHiddenStepIDs(t *testing.T) {
makeIDs := func(n int) []string {
ids := make([]string, n)
@@ -1034,6 +1060,7 @@ func TestApplyWorkspaceAndTaskListPreferencesKanbanHiddenStepIDs(t *testing.T) {
}
}
// makeSidebarViews builds n SidebarView fixtures with distinct IDs and names.
func makeSidebarViews(n int) []models.SidebarView {
views := make([]models.SidebarView, n)
for i := range views {
@@ -1049,6 +1076,7 @@ func makeSidebarViews(n int) []models.SidebarView {
return views
}
// TestApplySidebarViews table-tests sidebar view count limits and validation errors.
func TestApplySidebarViews(t *testing.T) {
tests := []struct {
name string
@@ -1139,6 +1167,7 @@ func TestApplySidebarViews(t *testing.T) {
}
}
// TestApplySidebarViewState verifies active view and draft application, clearing, and validation.
func TestApplySidebarViewState(t *testing.T) {
t.Run("nil fields leave active view and draft unchanged", func(t *testing.T) {
settings := &models.UserSettings{
@@ -1221,6 +1250,7 @@ func TestApplySidebarViewState(t *testing.T) {
})
}
// TestApplyUserPreferenceBlobs verifies blob preferences are patched while task-create last-used is preserved.
func TestApplyUserPreferenceBlobs(t *testing.T) {
settings := &models.UserSettings{
TaskCreateLastUsed: models.TaskCreateLastUsed{
@@ -1250,6 +1280,7 @@ func TestApplyUserPreferenceBlobs(t *testing.T) {
}
}
// TestUpdateUserSettingsCombinesSettingsAndTaskCreatePatch verifies UpdateUserSettings folds the task-create patch into a single preserving write.
func TestUpdateUserSettingsCombinesSettingsAndTaskCreatePatch(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1302,6 +1333,7 @@ func TestUpdateUserSettingsCombinesSettingsAndTaskCreatePatch(t *testing.T) {
}
}
// TestPublishUserSettingsEventIncludesArchiveConfirmation verifies the settings event carries confirm_task_archive.
func TestPublishUserSettingsEventIncludesArchiveConfirmation(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1323,6 +1355,7 @@ func TestPublishUserSettingsEventIncludesArchiveConfirmation(t *testing.T) {
}
}
// TestPublishUserSettingsEventIncludesTasksListShowDetails verifies the settings event carries tasks_list_show_details.
func TestPublishUserSettingsEventIncludesTasksListShowDetails(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1344,6 +1377,7 @@ func TestPublishUserSettingsEventIncludesTasksListShowDetails(t *testing.T) {
}
}
// TestPublishUserSettingsEventIncludesNormalizedMCPTaskAgentProfileDefault verifies the event normalizes unknown profile defaults to current_task.
func TestPublishUserSettingsEventIncludesNormalizedMCPTaskAgentProfileDefault(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1367,6 +1401,7 @@ func TestPublishUserSettingsEventIncludesNormalizedMCPTaskAgentProfileDefault(t
}
}
// TestPublishUserSettingsEventIncludesNormalizedStartupPage verifies the event normalizes unknown startup pages to task_overview.
func TestPublishUserSettingsEventIncludesNormalizedStartupPage(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1385,6 +1420,7 @@ func TestPublishUserSettingsEventIncludesNormalizedStartupPage(t *testing.T) {
}
}
// TestPublishUserSettingsEventIncludesAppStatusBarOrder verifies the settings event carries the app status bar order.
func TestPublishUserSettingsEventIncludesAppStatusBarOrder(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1407,6 +1443,7 @@ func TestPublishUserSettingsEventIncludesAppStatusBarOrder(t *testing.T) {
}
}
// TestPublishUserSettingsEventIncludesLspStatusLocation verifies the settings event carries lsp_status_location.
func TestPublishUserSettingsEventIncludesLspStatusLocation(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1427,6 +1464,7 @@ func TestPublishUserSettingsEventIncludesLspStatusLocation(t *testing.T) {
}
}
// TestUpdateUserSettingsRejectsInvalidMCPTaskAgentProfileDefaultWithoutPersisting verifies an invalid profile default is rejected without persisting or publishing.
func TestUpdateUserSettingsRejectsInvalidMCPTaskAgentProfileDefaultWithoutPersisting(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1455,6 +1493,7 @@ func TestUpdateUserSettingsRejectsInvalidMCPTaskAgentProfileDefaultWithoutPersis
}
}
// TestClearDefaultEditorIDPreservesTaskCreateLastUsed verifies clearing the default editor preserves task-create last-used in the write and event.
func TestClearDefaultEditorIDPreservesTaskCreateLastUsed(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1501,6 +1540,7 @@ func TestClearDefaultEditorIDPreservesTaskCreateLastUsed(t *testing.T) {
}
}
// TestRecordTaskCreateLastUsed verifies RecordTaskCreateLastUsed skips empty patches and updates and publishes non-empty ones.
func TestRecordTaskCreateLastUsed(t *testing.T) {
newTestService := func(repo *recordingUserRepository, eventBus *recordingEventBus) *Service {
log, err := logger.NewFromZap(zap.NewNop())
@@ -1624,6 +1664,7 @@ func TestRecordTaskCreateLastUsed(t *testing.T) {
})
}
// TestApplyUserPreferenceBlobsValidation verifies blob preferences accept arrays, objects, and null and reject scalars and oversized blobs.
func TestApplyUserPreferenceBlobsValidation(t *testing.T) {
t.Run("accepts arrays objects and null", func(t *testing.T) {
settings := &models.UserSettings{}
@@ -1669,6 +1710,7 @@ func TestApplyUserPreferenceBlobsValidation(t *testing.T) {
}
// TestAzureDevOpsBrowsePreferencesArePatched verifies Azure DevOps browse preferences are patched, persisted, and published.
func TestAzureDevOpsBrowsePreferencesArePatched(t *testing.T) {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
@@ -1728,16 +1770,19 @@ type recordingUserRepository struct {
closeCalls int
}
// GetUser records the call and returns an unexpected-call error.
func (r *recordingUserRepository) GetUser(context.Context, string) (*models.User, error) {
r.getUserCalls++
return nil, errors.New("unexpected GetUser call")
}
// GetDefaultUser records the call and returns an unexpected-call error.
func (r *recordingUserRepository) GetDefaultUser(context.Context) (*models.User, error) {
r.getDefaultUserCalls++
return nil, errors.New("unexpected GetDefaultUser call")
}
// GetUserSettings records the call and returns the configured settings or error.
func (r *recordingUserRepository) GetUserSettings(_ context.Context, userID string) (*models.UserSettings, error) {
r.getUserSettingsCalls++
r.getSettingsUserID = userID
@@ -1750,10 +1795,12 @@ func (r *recordingUserRepository) GetUserSettings(_ context.Context, userID stri
return nil, errors.New("unexpected GetUserSettings call")
}
// UpsertUserSettingsPreservingTaskCreateLastUsed records the preserving-write inputs and returns the configured result or error.
func (r *recordingUserRepository) UpsertUserSettingsPreservingTaskCreateLastUsed(
_ context.Context,
settings *models.UserSettings,
patch *models.TaskCreateLastUsed,
_ int64,
) (*models.UserSettings, error) {
r.upsertUserSettingsPreservingLastUsedCalls++
settingsCopy := *settings
@@ -1771,6 +1818,7 @@ func (r *recordingUserRepository) UpsertUserSettingsPreservingTaskCreateLastUsed
return nil, errors.New("unexpected UpsertUserSettingsPreservingTaskCreateLastUsed call")
}
// UpdateTaskCreateLastUsed records the update inputs and returns the configured result or error.
func (r *recordingUserRepository) UpdateTaskCreateLastUsed(
_ context.Context,
userID string,
@@ -1785,6 +1833,7 @@ func (r *recordingUserRepository) UpdateTaskCreateLastUsed(
return r.updateSettings, nil
}
// Close records the close call.
func (r *recordingUserRepository) Close() error {
r.closeCalls++
return nil
@@ -1795,26 +1844,32 @@ type recordingEventBus struct {
publishedEvents []*bus.Event
}
// Publish records the published subject and event.
func (b *recordingEventBus) Publish(_ context.Context, subject string, event *bus.Event) error {
b.publishedSubjects = append(b.publishedSubjects, subject)
b.publishedEvents = append(b.publishedEvents, event)
return nil
}
// Subscribe returns an unexpected-call error.
func (b *recordingEventBus) Subscribe(string, bus.EventHandler) (bus.Subscription, error) {
return nil, errors.New("unexpected Subscribe call")
}
// QueueSubscribe returns an unexpected-call error.
func (b *recordingEventBus) QueueSubscribe(string, string, bus.EventHandler) (bus.Subscription, error) {
return nil, errors.New("unexpected QueueSubscribe call")
}
// Request returns an unexpected-call error.
func (b *recordingEventBus) Request(context.Context, string, *bus.Event, time.Duration) (*bus.Event, error) {
return nil, errors.New("unexpected Request call")
}
// Close is a no-op.
func (b *recordingEventBus) Close() {}
// IsConnected reports the fake bus as connected.
func (b *recordingEventBus) IsConnected() bool {
return true
}
@@ -0,0 +1,821 @@
package service
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/user/models"
"github.com/kandev/kandev/internal/user/store"
"go.uber.org/zap"
)
// casRead records the revision and editor observed by a single read.
type casRead struct {
revision int64
defaultEditorID string
}
// casUpsert records one revision-conditional write attempt.
type casUpsert struct {
expectedRevision int64
defaultEditorID string
committed bool
}
// casFakeRepo is a synchronized store.Repository fake for concurrent CAS
// service tests. It clones the current row on every read and commits an
// upsert only when the expected revision matches the current row revision
// (otherwise it returns store.ErrUserSettingsRevisionConflict), modeling the
// repository's revision-conditional write without a database.
type casFakeRepo struct {
mu sync.Mutex
row *models.UserSettings
reads []casRead
upserts []casUpsert
// forceConflictNext makes the next upsert return a conflict without
// committing, then clears itself (used to force a retry deterministically).
forceConflictNext bool
// forceConflictCount makes the next N upserts return a conflict without
// committing (used to force retry exhaustion deterministically).
forceConflictCount int
// blockNextUpsert, when true, blocks the next upsert until releaseUpsert
// is closed, then clears itself. Used to force a stale-CAS interleaving.
blockNextUpsert bool
releaseUpsert chan struct{}
// blockUpsert, when non-nil, blocks every upsert until the channel is
// closed. Used to force both concurrent readers to observe the same
// revision before either write commits.
blockUpsert chan struct{}
// upsertStarted and readDone are buffered phase-observation signals.
upsertStarted chan struct{}
readDone chan struct{}
}
// newCASFakeRepo builds a casFakeRepo seeded with a clone of row.
func newCASFakeRepo(row *models.UserSettings) *casFakeRepo {
return &casFakeRepo{row: cloneSettings(row)}
}
// snapshot returns a deep clone of the current row.
func (f *casFakeRepo) snapshot() *models.UserSettings {
f.mu.Lock()
defer f.mu.Unlock()
return cloneSettings(f.row)
}
// readLog returns a copy of the recorded reads.
func (f *casFakeRepo) readLog() []casRead {
f.mu.Lock()
defer f.mu.Unlock()
return append([]casRead(nil), f.reads...)
}
// upsertLog returns a copy of the recorded upsert attempts.
func (f *casFakeRepo) upsertLog() []casUpsert {
f.mu.Lock()
defer f.mu.Unlock()
return append([]casUpsert(nil), f.upserts...)
}
// GetUser returns an unexpected-call error.
func (f *casFakeRepo) GetUser(context.Context, string) (*models.User, error) {
return nil, errors.New("unexpected GetUser call")
}
// GetDefaultUser returns an unexpected-call error.
func (f *casFakeRepo) GetDefaultUser(context.Context) (*models.User, error) {
return nil, errors.New("unexpected GetDefaultUser call")
}
// GetUserSettings returns a clone of the row, records the read, and signals phase observers.
func (f *casFakeRepo) GetUserSettings(_ context.Context, _ string) (*models.UserSettings, error) {
f.mu.Lock()
row := cloneSettings(f.row)
f.reads = append(f.reads, casRead{revision: row.Revision, defaultEditorID: row.DefaultEditorID})
sig := f.readDone
f.mu.Unlock()
if sig != nil {
select {
case sig <- struct{}{}:
default:
}
}
return row, nil
}
// UpsertUserSettingsPreservingTaskCreateLastUsed commits revision-conditionally, merging the task-create patch, or returns a revision conflict.
func (f *casFakeRepo) UpsertUserSettingsPreservingTaskCreateLastUsed(
_ context.Context,
settings *models.UserSettings,
patch *models.TaskCreateLastUsed,
expectedRevision int64,
) (*models.UserSettings, error) {
f.mu.Lock()
block := f.blockNextUpsert
if block {
f.blockNextUpsert = false
}
rel := f.releaseUpsert
sig := f.upsertStarted
gate := f.blockUpsert
f.mu.Unlock()
if sig != nil {
select {
case sig <- struct{}{}:
default:
}
}
if block {
<-rel
}
if gate != nil {
<-gate
}
f.mu.Lock()
defer f.mu.Unlock()
record := casUpsert{expectedRevision: expectedRevision, defaultEditorID: settings.DefaultEditorID}
if f.forceConflictNext {
f.forceConflictNext = false
f.upserts = append(f.upserts, record)
return nil, store.ErrUserSettingsRevisionConflict
}
if f.forceConflictCount > 0 {
f.forceConflictCount--
f.upserts = append(f.upserts, record)
return nil, store.ErrUserSettingsRevisionConflict
}
if f.row.Revision != expectedRevision {
f.upserts = append(f.upserts, record)
return nil, store.ErrUserSettingsRevisionConflict
}
next := cloneSettings(settings)
next.TaskCreateLastUsed = applyTaskCreatePatchToFake(next.TaskCreateLastUsed, patch)
next.Revision = f.row.Revision + 1
f.row = next
record.committed = true
f.upserts = append(f.upserts, record)
return cloneSettings(next), nil
}
// UpdateTaskCreateLastUsed returns an unexpected-call error.
func (f *casFakeRepo) UpdateTaskCreateLastUsed(context.Context, string, models.TaskCreateLastUsed) (*models.UserSettings, error) {
return nil, errors.New("unexpected UpdateTaskCreateLastUsed call")
}
// Close is a no-op.
func (f *casFakeRepo) Close() error { return nil }
// casEventBus is a thread-safe recording bus for concurrent service tests.
type casEventBus struct {
mu sync.Mutex
subjects []string
events []*bus.Event
}
// Publish records the published subject and event.
func (b *casEventBus) Publish(_ context.Context, subject string, event *bus.Event) error {
b.mu.Lock()
defer b.mu.Unlock()
b.subjects = append(b.subjects, subject)
b.events = append(b.events, event)
return nil
}
// count returns the number of published events.
func (b *casEventBus) count() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.events)
}
// Subscribe returns an unexpected-call error.
func (b *casEventBus) Subscribe(string, bus.EventHandler) (bus.Subscription, error) {
return nil, errors.New("unexpected Subscribe call")
}
// QueueSubscribe returns an unexpected-call error.
func (b *casEventBus) QueueSubscribe(string, string, bus.EventHandler) (bus.Subscription, error) {
return nil, errors.New("unexpected QueueSubscribe call")
}
// Request returns an unexpected-call error.
func (b *casEventBus) Request(context.Context, string, *bus.Event, time.Duration) (*bus.Event, error) {
return nil, errors.New("unexpected Request call")
}
// Close is a no-op.
func (b *casEventBus) Close() {}
// IsConnected reports the fake bus as connected.
func (b *casEventBus) IsConnected() bool { return true }
// newCASService builds a Service wired to the given repository and bus with a nop logger.
func newCASService(repo store.Repository, eventBus bus.EventBus) *Service {
log, err := logger.NewFromZap(zap.NewNop())
if err != nil {
panic(err)
}
return NewService(repo, eventBus, log)
}
// cloneSettings deep-clones settings via JSON so nested maps and slices are never aliased.
func cloneSettings(s *models.UserSettings) *models.UserSettings {
if s == nil {
return nil
}
// Deep clone via JSON so nested slices/maps (TaskCreateLastUsed workflow
// maps, KanbanHiddenStepIDs, LspServerConfigs, KeyboardShortcuts, ...) are
// never aliased with the fake's stored row: a caller mutating a read result
// or a concurrent task-create patch must not corrupt the shared row.
raw, err := json.Marshal(s)
if err != nil {
panic(err)
}
var clone models.UserSettings
if err := json.Unmarshal(raw, &clone); err != nil {
panic(err)
}
return &clone
}
// applyTaskCreatePatchToFake mirrors the repository's task_create_last_used
// json merge for the fake: non-empty patch fields override the current value,
// and an empty branch is applied when the repository changed (clearing it).
func applyTaskCreatePatchToFake(current models.TaskCreateLastUsed, patch *models.TaskCreateLastUsed) models.TaskCreateLastUsed {
if patch == nil {
return current
}
if patch.RepositoryID != "" {
current.RepositoryID = patch.RepositoryID
}
if patch.Branch != "" || patch.RepositoryID != "" {
current.Branch = patch.Branch
}
if patch.AgentProfileID != "" {
current.AgentProfileID = patch.AgentProfileID
}
if patch.ExecutorProfileID != "" {
current.ExecutorProfileID = patch.ExecutorProfileID
}
if patch.WorkflowIDsByWorkspace != nil {
if current.WorkflowIDsByWorkspace == nil {
current.WorkflowIDsByWorkspace = map[string]string{}
}
for k, v := range patch.WorkflowIDsByWorkspace {
if v == "" {
delete(current.WorkflowIDsByWorkspace, k)
} else {
current.WorkflowIDsByWorkspace[k] = v
}
}
}
return current
}
// TestApplyLastSeenDisplay verifies applyLastSeenDisplay preserves nil, accepts absolute and relative, rejects invalid, and trims whitespace.
func TestApplyLastSeenDisplay(t *testing.T) {
t.Run("nil leaves settings unchanged", func(t *testing.T) {
settings := &models.UserSettings{LastSeenDisplay: models.LastSeenDisplayRelative}
if err := applyLastSeenDisplay(settings, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.LastSeenDisplay != models.LastSeenDisplayRelative {
t.Fatalf("LastSeenDisplay = %q, want relative", settings.LastSeenDisplay)
}
})
t.Run("accepts absolute", func(t *testing.T) {
settings := &models.UserSettings{LastSeenDisplay: models.LastSeenDisplayRelative}
if err := applyLastSeenDisplay(settings, ptr("absolute")); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.LastSeenDisplay != models.LastSeenDisplayAbsolute {
t.Fatalf("LastSeenDisplay = %q, want absolute", settings.LastSeenDisplay)
}
})
t.Run("accepts relative", func(t *testing.T) {
settings := &models.UserSettings{}
if err := applyLastSeenDisplay(settings, ptr("relative")); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.LastSeenDisplay != models.LastSeenDisplayRelative {
t.Fatalf("LastSeenDisplay = %q, want relative", settings.LastSeenDisplay)
}
})
t.Run("rejects invalid", func(t *testing.T) {
if err := applyLastSeenDisplay(&models.UserSettings{}, ptr("garbage")); err == nil {
t.Fatal("expected error for invalid display, got nil")
}
})
t.Run("trims whitespace", func(t *testing.T) {
settings := &models.UserSettings{}
if err := applyLastSeenDisplay(settings, ptr(" relative ")); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if settings.LastSeenDisplay != models.LastSeenDisplayRelative {
t.Fatalf("LastSeenDisplay = %q, want relative", settings.LastSeenDisplay)
}
})
}
// TestPublishUserSettingsEventIncludesNormalizedLastSeenDisplay verifies the event normalizes an unknown last_seen_display to absolute.
func TestPublishUserSettingsEventIncludesNormalizedLastSeenDisplay(t *testing.T) {
eventBus := &casEventBus{}
svc := newCASService(&casFakeRepo{row: &models.UserSettings{}}, eventBus)
svc.publishUserSettingsEvent(context.Background(), &models.UserSettings{LastSeenDisplay: "future_value"})
if eventBus.count() != 1 {
t.Fatalf("events = %d, want 1", eventBus.count())
}
eventData, ok := eventBus.events[0].Data.(map[string]interface{})
if !ok {
t.Fatalf("expected event data map, got %T", eventBus.events[0].Data)
}
if got := eventData["last_seen_display"]; got != models.LastSeenDisplayAbsolute {
t.Fatalf("last_seen_display = %#v, want %q", got, models.LastSeenDisplayAbsolute)
}
}
// TestUpdateUserSettingsPublishesLastSeenDisplay verifies UpdateUserSettings publishes the applied last_seen_display.
func TestUpdateUserSettingsPublishesLastSeenDisplay(t *testing.T) {
updated := &models.UserSettings{
UserID: store.DefaultUserID,
LastSeenDisplay: models.LastSeenDisplayRelative,
}
repo := &recordingUserRepository{
getSettings: &models.UserSettings{UserID: store.DefaultUserID},
preservingSettings: updated,
}
eventBus := &recordingEventBus{}
svc := newCASService(repo, eventBus)
if _, err := svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{LastSeenDisplay: ptr(models.LastSeenDisplayRelative)}); err != nil {
t.Fatalf("UpdateUserSettings: %v", err)
}
if len(eventBus.publishedEvents) != 1 {
t.Fatalf("events = %d, want 1", len(eventBus.publishedEvents))
}
eventData, ok := eventBus.publishedEvents[0].Data.(map[string]interface{})
if !ok {
t.Fatalf("expected event data map, got %T", eventBus.publishedEvents[0].Data)
}
if got := eventData["last_seen_display"]; got != models.LastSeenDisplayRelative {
t.Fatalf("last_seen_display = %#v, want %q", got, models.LastSeenDisplayRelative)
}
}
// TestUpdateUserSettingsRejectsInvalidLastSeenDisplay verifies an invalid last_seen_display is rejected without an upsert.
func TestUpdateUserSettingsRejectsInvalidLastSeenDisplay(t *testing.T) {
repo := &recordingUserRepository{getSettings: &models.UserSettings{UserID: store.DefaultUserID}}
eventBus := &recordingEventBus{}
svc := newCASService(repo, eventBus)
if _, err := svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{LastSeenDisplay: ptr("garbage")}); err == nil {
t.Fatal("expected validation error, got nil")
}
if repo.upsertUserSettingsPreservingLastUsedCalls != 0 {
t.Fatalf("expected no upsert on validation failure, got %d", repo.upsertUserSettingsPreservingLastUsedCalls)
}
}
// TestUpdateUserSettingsEmptyPatchIsNoop verifies an empty patch performs no write or publish.
func TestUpdateUserSettingsEmptyPatchIsNoop(t *testing.T) {
base := &models.UserSettings{
UserID: store.DefaultUserID,
LastSeenDisplay: models.LastSeenDisplayAbsolute,
Revision: 7,
}
repo := &recordingUserRepository{getSettings: base}
eventBus := &recordingEventBus{}
svc := newCASService(repo, eventBus)
got, err := svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{})
if err != nil {
t.Fatalf("UpdateUserSettings: %v", err)
}
if repo.upsertUserSettingsPreservingLastUsedCalls != 0 {
t.Fatalf("empty patch upserts = %d, want 0", repo.upsertUserSettingsPreservingLastUsedCalls)
}
if len(eventBus.publishedEvents) != 0 {
t.Fatalf("empty patch events = %d, want 0", len(eventBus.publishedEvents))
}
if got == nil || got.Revision != base.Revision {
t.Fatalf("no-op returned settings = %+v, want current revision %d", got, base.Revision)
}
}
// TestUpdateUserSettingsSameValuePatchIsNoop verifies a same-value patch performs no write or publish.
func TestUpdateUserSettingsSameValuePatchIsNoop(t *testing.T) {
base := &models.UserSettings{
UserID: store.DefaultUserID,
LastSeenDisplay: models.LastSeenDisplayRelative,
Revision: 7,
}
repo := &recordingUserRepository{getSettings: base}
eventBus := &recordingEventBus{}
svc := newCASService(repo, eventBus)
relative := models.LastSeenDisplayRelative
got, err := svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{LastSeenDisplay: &relative})
if err != nil {
t.Fatalf("UpdateUserSettings: %v", err)
}
if repo.upsertUserSettingsPreservingLastUsedCalls != 0 {
t.Fatalf("same-value patch upserts = %d, want 0", repo.upsertUserSettingsPreservingLastUsedCalls)
}
if len(eventBus.publishedEvents) != 0 {
t.Fatalf("same-value patch events = %d, want 0", len(eventBus.publishedEvents))
}
if got == nil || got.Revision != base.Revision {
t.Fatalf("no-op returned settings = %+v, want current revision %d", got, base.Revision)
}
}
// TestUpdateUserSettingsCASExhaustionWrapsConflict verifies callers can
// distinguish a bounded retry exhaustion from an unexpected storage failure.
func TestUpdateUserSettingsCASExhaustionWrapsConflict(t *testing.T) {
repo := newCASFakeRepo(&models.UserSettings{UserID: store.DefaultUserID})
repo.forceConflictCount = maxUserSettingsCASAttempts
svc := newCASService(repo, &casEventBus{})
_, err := svc.UpdateUserSettings(context.Background(), &UpdateUserSettingsRequest{
LastSeenDisplay: ptr(models.LastSeenDisplayRelative),
})
if err == nil {
t.Fatal("expected retry exhaustion error, got nil")
}
if !errors.Is(err, store.ErrUserSettingsRevisionConflict) {
t.Fatalf("error = %v, want ErrUserSettingsRevisionConflict", err)
}
if !strings.Contains(err.Error(), "exhausted") {
t.Fatalf("error = %v, want retry exhaustion context", err)
}
if got := len(repo.upsertLog()); got != maxUserSettingsCASAttempts {
t.Fatalf("upsert attempts = %d, want %d", got, maxUserSettingsCASAttempts)
}
}
// TestConcurrentPatchMergesLastSeenDisplay verifies concurrent patches merge via a single CAS retry.
func TestConcurrentPatchMergesLastSeenDisplay(t *testing.T) {
repo := newCASFakeRepo(&models.UserSettings{
UserID: store.DefaultUserID,
LastSeenDisplay: models.LastSeenDisplayAbsolute,
})
repo.readDone = make(chan struct{}, 4)
repo.blockUpsert = make(chan struct{})
eventBus := &casEventBus{}
svc := newCASService(repo, eventBus)
ctx := context.Background()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{LastSeenDisplay: ptr(models.LastSeenDisplayRelative)}); err != nil {
t.Errorf("relative patch: %v", err)
}
}()
go func() {
defer wg.Done()
if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{AppStatusBarEnabled: ptr(true)}); err != nil {
t.Errorf("status-bar patch: %v", err)
}
}()
// Barrier: both initial reads must observe the same revision before either
// write commits, so one stale upsert is forced to conflict and retry.
<-repo.readDone
<-repo.readDone
close(repo.blockUpsert)
wg.Wait()
row := repo.snapshot()
if row.LastSeenDisplay != models.LastSeenDisplayRelative {
t.Fatalf("final LastSeenDisplay = %q, want relative", row.LastSeenDisplay)
}
if !row.AppStatusBarEnabled {
t.Fatal("final AppStatusBarEnabled = false, want true")
}
if row.Revision != 2 {
t.Fatalf("final revision = %d, want 2", row.Revision)
}
if got := eventBus.count(); got != 2 {
t.Fatalf("events = %d, want 2", got)
}
assertOneStaleConflictAndRetry(t, repo)
}
// TestCASFakeCloneIsDeep pins the fake's clone contract: mutating a read
// result's nested maps must not corrupt the fake's stored row.
func TestCASFakeCloneIsDeep(t *testing.T) {
repo := newCASFakeRepo(&models.UserSettings{
UserID: store.DefaultUserID,
TaskCreateLastUsed: models.TaskCreateLastUsed{
WorkflowIDsByWorkspace: map[string]string{"w1": "f1"},
},
KeyboardShortcuts: map[string]interface{}{},
})
got, err := repo.GetUserSettings(context.Background(), store.DefaultUserID)
if err != nil {
t.Fatalf("read: %v", err)
}
got.TaskCreateLastUsed.WorkflowIDsByWorkspace["w1"] = "mutated"
got.KeyboardShortcuts["x"] = map[string]interface{}{"key": "y"}
row := repo.snapshot()
if row.TaskCreateLastUsed.WorkflowIDsByWorkspace["w1"] != "f1" {
t.Fatalf("read clone aliased the workflow map: %+v", row.TaskCreateLastUsed.WorkflowIDsByWorkspace)
}
if _, ok := row.KeyboardShortcuts["x"]; ok {
t.Fatal("read clone aliased the keyboard shortcuts map")
}
}
// TestConcurrentTaskCreateMapPatchesMerge proves two concurrent task-create
// patches with workflow maps survive the CAS retry and merge without
// corrupting the fake's stored row (deep clone requirement).
func TestConcurrentTaskCreateMapPatchesMerge(t *testing.T) {
repo := newCASFakeRepo(&models.UserSettings{
UserID: store.DefaultUserID,
TaskCreateLastUsed: models.TaskCreateLastUsed{
WorkflowIDsByWorkspace: map[string]string{"w0": "f0"},
},
})
repo.readDone = make(chan struct{}, 4)
repo.blockUpsert = make(chan struct{})
eventBus := &casEventBus{}
svc := newCASService(repo, eventBus)
ctx := context.Background()
patchA := models.TaskCreateLastUsed{WorkflowIDsByWorkspace: map[string]string{"wA": "fA"}}
patchB := models.TaskCreateLastUsed{WorkflowIDsByWorkspace: map[string]string{"wB": "fB"}}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{TaskCreateLastUsed: &patchA}); err != nil {
t.Errorf("patch A: %v", err)
}
}()
go func() {
defer wg.Done()
if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{TaskCreateLastUsed: &patchB}); err != nil {
t.Errorf("patch B: %v", err)
}
}()
<-repo.readDone
<-repo.readDone
close(repo.blockUpsert)
wg.Wait()
row := repo.snapshot()
workflows := row.TaskCreateLastUsed.WorkflowIDsByWorkspace
if workflows["w0"] != "f0" || workflows["wA"] != "fA" || workflows["wB"] != "fB" {
t.Fatalf("workflow maps did not merge: %+v", workflows)
}
assertOneStaleConflictAndRetry(t, repo)
}
// assertOneStaleConflictAndRetry pins the CAS retry interleaving: one write
// commits at the shared initial revision, the other conflicts once and
// re-reads/re-applies, so the repo sees two commits, exactly one stale
// conflict, and a fresh retry re-read.
func assertOneStaleConflictAndRetry(t *testing.T, repo *casFakeRepo) {
t.Helper()
var commits, conflicts int
for _, u := range repo.upsertLog() {
if u.committed {
commits++
} else {
conflicts++
}
}
if commits != 2 {
t.Fatalf("commits = %d, want 2", commits)
}
if conflicts != 1 {
t.Fatalf("stale conflicts = %d, want exactly 1", conflicts)
}
if reads := len(repo.readLog()); reads != 3 {
t.Fatalf("reads = %d, want 3 (two initial + one retry re-read)", reads)
}
}
// TestConcurrentClearEditorAndPatchMerge verifies a concurrent Clear and patch both commit via a single CAS retry.
func TestConcurrentClearEditorAndPatchMerge(t *testing.T) {
repo := newCASFakeRepo(&models.UserSettings{
UserID: store.DefaultUserID,
DefaultEditorID: "editor-1",
LastSeenDisplay: models.LastSeenDisplayAbsolute,
})
repo.readDone = make(chan struct{}, 4)
repo.blockUpsert = make(chan struct{})
eventBus := &casEventBus{}
svc := newCASService(repo, eventBus)
ctx := context.Background()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
if err := svc.ClearDefaultEditorID(ctx, "editor-1"); err != nil {
t.Errorf("clear editor: %v", err)
}
}()
go func() {
defer wg.Done()
if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{LastSeenDisplay: ptr(models.LastSeenDisplayRelative)}); err != nil {
t.Errorf("relative patch: %v", err)
}
}()
// Barrier: both initial reads must observe the same revision before either
// write commits, so one stale upsert is forced to conflict and retry.
<-repo.readDone
<-repo.readDone
close(repo.blockUpsert)
wg.Wait()
row := repo.snapshot()
if row.DefaultEditorID != "" {
t.Fatalf("final DefaultEditorID = %q, want empty (cleared)", row.DefaultEditorID)
}
if row.LastSeenDisplay != models.LastSeenDisplayRelative {
t.Fatalf("final LastSeenDisplay = %q, want relative", row.LastSeenDisplay)
}
if row.Revision != 2 {
t.Fatalf("final revision = %d, want 2", row.Revision)
}
if got := eventBus.count(); got != 2 {
t.Fatalf("events = %d, want 2", got)
}
assertOneStaleConflictAndRetry(t, repo)
}
// TestClearDefaultEditorIDConflictNoop forces a real CAS conflict for a Clear
// whose stale read saw the old editor: the callback re-evaluates against the
// retry's fresh read and returns applied=false, so the Clear writes and
// publishes nothing while the competing PATCH's effect survives.
func TestClearDefaultEditorIDConflictNoop(t *testing.T) {
repo := newCASFakeRepo(&models.UserSettings{
UserID: store.DefaultUserID,
DefaultEditorID: "old-editor",
})
repo.blockNextUpsert = true
repo.releaseUpsert = make(chan struct{})
repo.upsertStarted = make(chan struct{}, 4)
repo.readDone = make(chan struct{}, 4)
eventBus := &casEventBus{}
svc := newCASService(repo, eventBus)
ctx := context.Background()
clearDone := make(chan error, 1)
go func() {
clearDone <- svc.ClearDefaultEditorID(ctx, "old-editor")
}()
// PHASE 1: Clear reads the old editor, then blocks at its upsert.
<-repo.readDone
<-repo.upsertStarted
// PHASE 2: a competing PATCH commits a new editor.
if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{DefaultEditorID: ptr("new-editor")}); err != nil {
t.Fatalf("competing patch: %v", err)
}
// PHASE 3: release the stale Clear upsert; it conflicts and re-evaluates.
close(repo.releaseUpsert)
if err := <-clearDone; err != nil {
t.Fatalf("ClearDefaultEditorID: %v", err)
}
row := repo.snapshot()
if row.DefaultEditorID != "new-editor" {
t.Fatalf("final DefaultEditorID = %q, want new-editor", row.DefaultEditorID)
}
if row.Revision != 1 {
t.Fatalf("final revision = %d, want 1", row.Revision)
}
if got := eventBus.count(); got != 1 {
t.Fatalf("events = %d, want exactly 1 (the PATCH)", got)
}
// The Clear contributed exactly one upsert attempt (the forced stale CAS)
// and no successful write.
var clearAttempts, clearCommits int
for _, u := range repo.upsertLog() {
if u.defaultEditorID == "" {
clearAttempts++
if u.committed {
clearCommits++
}
}
}
if clearAttempts != 1 {
t.Fatalf("clear upsert attempts = %d, want 1", clearAttempts)
}
if clearCommits != 0 {
t.Fatalf("clear successful upserts = %d, want 0", clearCommits)
}
if len(repo.readLog()) < 3 {
t.Fatalf("reads = %d, want at least 3 (clear read, patch read, clear retry re-read)", len(repo.readLog()))
}
}
// TestClearDefaultEditorIDFreshReadNoop verifies Clear after the competing patch committed is an immediate no-op.
func TestClearDefaultEditorIDFreshReadNoop(t *testing.T) {
repo := newCASFakeRepo(&models.UserSettings{
UserID: store.DefaultUserID,
DefaultEditorID: "old-editor",
})
eventBus := &casEventBus{}
svc := newCASService(repo, eventBus)
ctx := context.Background()
if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{DefaultEditorID: ptr("new-editor")}); err != nil {
t.Fatalf("competing patch: %v", err)
}
// Clear starts after the PATCH committed: immediate no-op, no conflict.
if err := svc.ClearDefaultEditorID(ctx, "old-editor"); err != nil {
t.Fatalf("ClearDefaultEditorID: %v", err)
}
row := repo.snapshot()
if row.DefaultEditorID != "new-editor" {
t.Fatalf("final DefaultEditorID = %q, want new-editor", row.DefaultEditorID)
}
if got := eventBus.count(); got != 1 {
t.Fatalf("events = %d, want exactly 1 (the PATCH)", got)
}
if len(repo.upsertLog()) != 1 {
t.Fatalf("upserts = %d, want exactly 1 (the PATCH)", len(repo.upsertLog()))
}
}
// TestUpdateUserSettingsTaskCreateOnlyConflictRetry pins the task-create-only
// PATCH under the CAS rewrite: a forced conflict survives the retry, merges
// against the winner's row, and increments the revision once on its own
// successful retry.
func TestUpdateUserSettingsTaskCreateOnlyConflictRetry(t *testing.T) {
repo := newCASFakeRepo(&models.UserSettings{
UserID: store.DefaultUserID,
TaskCreateLastUsed: models.TaskCreateLastUsed{
RepositoryID: "repo-1",
},
})
eventBus := &casEventBus{}
svc := newCASService(repo, eventBus)
ctx := context.Background()
// Winner commits first, bumping R to R+1.
if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{AppStatusBarEnabled: ptr(true)}); err != nil {
t.Fatalf("winner patch: %v", err)
}
// Force the task-create-only PATCH's first upsert into a conflict.
repo.mu.Lock()
repo.forceConflictNext = true
repo.mu.Unlock()
patch := models.TaskCreateLastUsed{RepositoryID: "repo-2"}
if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{TaskCreateLastUsed: &patch}); err != nil {
t.Fatalf("task-create patch: %v", err)
}
row := repo.snapshot()
if !row.AppStatusBarEnabled {
t.Fatal("winner's AppStatusBarEnabled was reverted by the retry")
}
if row.TaskCreateLastUsed.RepositoryID != "repo-2" {
t.Fatalf("final task-create repository = %q, want repo-2", row.TaskCreateLastUsed.RepositoryID)
}
if row.Revision != 2 {
t.Fatalf("final revision = %d, want 2", row.Revision)
}
if got := eventBus.count(); got != 2 {
t.Fatalf("events = %d, want 2", got)
}
}
+53 -10
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
@@ -272,6 +273,7 @@ func (r *sqliteRepository) UpsertUserSettingsPreservingTaskCreateLastUsed(
ctx context.Context,
settings *models.UserSettings,
patch *models.TaskCreateLastUsed,
expectedRevision int64,
) (*models.UserSettings, error) {
settings.UpdatedAt = time.Now().UTC()
if settings.CreatedAt.IsZero() {
@@ -282,7 +284,7 @@ func (r *sqliteRepository) UpsertUserSettingsPreservingTaskCreateLastUsed(
return nil, err
}
if dialect.IsPostgres(r.db.DriverName()) {
return r.upsertUserSettingsPreservingTaskCreateLastUsedPostgres(ctx, settings, settingsPayload, patch)
return r.upsertUserSettingsPreservingTaskCreateLastUsedPostgres(ctx, settings, settingsPayload, patch, expectedRevision)
}
settingsExpr := `json_set(
json(?),
@@ -304,29 +306,30 @@ func (r *sqliteRepository) UpsertUserSettingsPreservingTaskCreateLastUsed(
query := fmt.Sprintf(`
UPDATE users
SET settings = %s, updated_at = ?, settings_revision = settings_revision + 1
WHERE id = ?
WHERE id = ? AND settings_revision = ?
RETURNING settings, updated_at, settings_revision
`, settingsExpr)
args = append(args, settings.UpdatedAt, settings.UserID)
return scanUpdatedUserSettings(
args = append(args, settings.UpdatedAt, settings.UserID, expectedRevision)
return r.scanConditionalSettingsUpdate(
ctx,
r.db.QueryRowContext(ctx, r.db.Rebind(query), args...),
settings.UserID,
)
}
// upsertUserSettingsPreservingTaskCreateLastUsedPostgres writes settings via
// a jsonb_set expression so task_create_last_used merges instead of
// replacing.
// upsertUserSettingsPreservingTaskCreateLastUsedPostgres writes the settings via a Postgres jsonb_set UPDATE that merges the task_create_last_used patch.
func (r *sqliteRepository) upsertUserSettingsPreservingTaskCreateLastUsedPostgres(
ctx context.Context,
settings *models.UserSettings,
settingsPayload []byte,
patch *models.TaskCreateLastUsed,
expectedRevision int64,
) (*models.UserSettings, error) {
query, patchArgs := buildPostgresUserSettingsPreservingTaskCreateLastUsedUpdate(patch)
args := append([]any{string(settingsPayload)}, patchArgs...)
args = append(args, settings.UpdatedAt, settings.UserID)
return scanUpdatedUserSettings(
args = append(args, settings.UpdatedAt, settings.UserID, expectedRevision)
return r.scanConditionalSettingsUpdate(
ctx,
r.db.QueryRowContext(ctx, r.db.Rebind(query), args...),
settings.UserID,
)
@@ -471,7 +474,7 @@ func buildPostgresUserSettingsPreservingTaskCreateLastUsedUpdate(patch *models.T
query := fmt.Sprintf(`
UPDATE users
SET settings = %s::text, updated_at = ?, settings_revision = settings_revision + 1
WHERE id = ?
WHERE id = ? AND settings_revision = ?
RETURNING settings, updated_at, settings_revision
`, expr)
return query, args
@@ -611,6 +614,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) {
"terminal_font_family": settings.TerminalFontFamily,
"terminal_font_size": settings.TerminalFontSize,
"changes_panel_layout": settings.ChangesPanelLayout,
"last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay),
"system_metrics_display": settings.SystemMetricsDisplay,
"app_status_bar_enabled": settings.AppStatusBarEnabled,
"app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder),
@@ -628,6 +632,26 @@ func scanUpdatedUserSettings(scanner interface{ Scan(dest ...any) error }, userI
return settings, err
}
// scanConditionalSettingsUpdate scans a revision-conditional settings UPDATE,
// mapping a zero-row result to either a missing user (user-not-found) or a
// stale revision (ErrUserSettingsRevisionConflict).
func (r *sqliteRepository) scanConditionalSettingsUpdate(ctx context.Context, scanner interface{ Scan(dest ...any) error }, userID string) (*models.UserSettings, error) {
settings, err := scanUserSettings(scanner, userID)
if err == nil {
return settings, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if _, uerr := r.getUserFromWriter(ctx, userID); uerr != nil {
if errors.Is(uerr, sql.ErrNoRows) {
return nil, fmt.Errorf("user not found: %s", userID)
}
return nil, uerr
}
return nil, ErrUserSettingsRevisionConflict
}
// scanUser scans a single user row into a models.User.
func scanUser(scanner interface{ Scan(dest ...any) error }) (*models.User, error) {
user := &models.User{}
@@ -667,6 +691,7 @@ func defaultUserSettings(userID string) *models.UserSettings {
KeyboardShortcuts: map[string]interface{}{},
TerminalLinkBehavior: "new_tab",
ChangesPanelLayout: defaultChangesPanelLayout,
LastSeenDisplay: models.LastSeenDisplayAbsolute,
SidebarViews: DefaultSidebarViews(),
SidebarActiveViewID: DefaultSidebarViewID,
SidebarTaskPrefs: normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}),
@@ -751,6 +776,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
TerminalFontFamily string `json:"terminal_font_family"`
TerminalFontSize int `json:"terminal_font_size"`
ChangesPanelLayout string `json:"changes_panel_layout"`
LastSeenDisplay json.RawMessage `json:"last_seen_display"`
SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"`
AppStatusBarEnabled *bool `json:"app_status_bar_enabled"`
AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"`
@@ -884,10 +910,27 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
} else {
settings.ChangesPanelLayout = defaultChangesPanelLayout
}
settings.LastSeenDisplay = normalizeLastSeenDisplayStored(payload.LastSeenDisplay)
settings.KanbanHiddenStepIDs = decodeKanbanHiddenStepIDs(payload.KanbanHiddenStepIDs)
return settings, nil
}
// normalizeLastSeenDisplayStored maps a stored JSON value to the canonical
// display mode: only a string exactly equal to "relative" is accepted. Every
// other JSON type (number, object, boolean, null) and every other string
// coerces to "absolute", so a hand-edited blob can never fail the settings
// read or produce a broken column.
func normalizeLastSeenDisplayStored(raw json.RawMessage) string {
if len(raw) == 0 {
return models.LastSeenDisplayAbsolute
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return models.LastSeenDisplayAbsolute
}
return models.NormalizeLastSeenDisplay(value)
}
// decodeKanbanHiddenStepIDs parses the persisted per-workflow hidden-step-id
// map, defaulting to an empty map on a missing or malformed value instead of
// failing the whole settings read. Unlike most structured settings fields,
@@ -0,0 +1,502 @@
package store
import (
"context"
"errors"
"strings"
"sync"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/testutil"
"github.com/kandev/kandev/internal/user/models"
)
// TestDefaultUserSettingsLastSeenDisplay verifies the default LastSeenDisplay is absolute.
func TestDefaultUserSettingsLastSeenDisplay(t *testing.T) {
settings := defaultUserSettings(DefaultUserID)
if settings.LastSeenDisplay != models.LastSeenDisplayAbsolute {
t.Fatalf("default LastSeenDisplay = %q, want %q", settings.LastSeenDisplay, models.LastSeenDisplayAbsolute)
}
}
// TestScanUserSettingsLastSeenDisplay verifies last_seen_display defaults to absolute, loads stored values, and coerces unknown values.
func TestScanUserSettingsLastSeenDisplay(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "default empty", raw: "{}", want: models.LastSeenDisplayAbsolute},
{name: "relative stored", raw: `{"last_seen_display":"relative"}`, want: models.LastSeenDisplayRelative},
{name: "absolute stored", raw: `{"last_seen_display":"absolute"}`, want: models.LastSeenDisplayAbsolute},
{name: "unknown coerced", raw: `{"last_seen_display":"garbage"}`, want: models.LastSeenDisplayAbsolute},
{name: "empty coerced", raw: `{"last_seen_display":""}`, want: models.LastSeenDisplayAbsolute},
{name: "number coerced", raw: `{"last_seen_display":123}`, want: models.LastSeenDisplayAbsolute},
{name: "object coerced", raw: `{"last_seen_display":{"x":1}}`, want: models.LastSeenDisplayAbsolute},
{name: "boolean coerced", raw: `{"last_seen_display":true}`, want: models.LastSeenDisplayAbsolute},
{name: "null coerced", raw: `{"last_seen_display":null}`, want: models.LastSeenDisplayAbsolute},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: tt.raw}, DefaultUserID)
if err != nil {
t.Fatalf("scan settings: %v", err)
}
if settings.LastSeenDisplay != tt.want {
t.Fatalf("LastSeenDisplay = %q, want %q", settings.LastSeenDisplay, tt.want)
}
})
}
}
// TestMarshalUserSettingsPersistsLastSeenDisplay verifies a relative LastSeenDisplay survives a marshal and scan round trip.
func TestMarshalUserSettingsPersistsLastSeenDisplay(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{LastSeenDisplay: models.LastSeenDisplayRelative})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
settings, err := scanUserSettings(settingsScanner{raw: string(raw)}, DefaultUserID)
if err != nil {
t.Fatalf("scan settings: %v", err)
}
if settings.LastSeenDisplay != models.LastSeenDisplayRelative {
t.Fatalf("round-trip LastSeenDisplay = %q, want %q", settings.LastSeenDisplay, models.LastSeenDisplayRelative)
}
}
// TestMarshalUserSettingsNormalizesUnknownLastSeenDisplay verifies an unknown LastSeenDisplay is marshaled as absolute.
func TestMarshalUserSettingsNormalizesUnknownLastSeenDisplay(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{LastSeenDisplay: "garbage"})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
if !strings.Contains(string(raw), `"last_seen_display":"absolute"`) {
t.Fatalf("marshaled payload = %s, want normalized absolute", raw)
}
}
// TestSQLiteRepositoryLastSeenDisplayRoundTrip verifies LastSeenDisplay round-trips through the SQLite repository.
func TestSQLiteRepositoryLastSeenDisplayRoundTrip(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
settings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
if settings.LastSeenDisplay != models.LastSeenDisplayAbsolute {
t.Fatalf("default LastSeenDisplay = %q, want %q", settings.LastSeenDisplay, models.LastSeenDisplayAbsolute)
}
settings.LastSeenDisplay = models.LastSeenDisplayRelative
updated, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, nil, settings.Revision)
if err != nil {
t.Fatalf("write relative: %v", err)
}
if updated.LastSeenDisplay != models.LastSeenDisplayRelative {
t.Fatalf("persisted LastSeenDisplay = %q, want %q", updated.LastSeenDisplay, models.LastSeenDisplayRelative)
}
updated.LastSeenDisplay = models.LastSeenDisplayAbsolute
if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, updated, nil, updated.Revision); err != nil {
t.Fatalf("write absolute: %v", err)
}
reloaded, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("reload settings: %v", err)
}
if reloaded.LastSeenDisplay != models.LastSeenDisplayAbsolute {
t.Fatalf("reloaded LastSeenDisplay = %q, want %q", reloaded.LastSeenDisplay, models.LastSeenDisplayAbsolute)
}
}
// TestSQLiteRepositoryUpsertSettingsRevisionConflict verifies a stale revision write fails with ErrUserSettingsRevisionConflict.
func TestSQLiteRepositoryUpsertSettingsRevisionConflict(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
settings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
staleRevision := settings.Revision
settings.AppStatusBarEnabled = true
if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, nil, settings.Revision); err != nil {
t.Fatalf("initial write: %v", err)
}
stale := *settings
stale.AppStatusBarEnabled = false
if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, &stale, nil, staleRevision); !errors.Is(err, ErrUserSettingsRevisionConflict) {
t.Fatalf("stale write error = %v, want ErrUserSettingsRevisionConflict", err)
}
}
// TestSQLiteRepositoryUpsertSettingsMissingUser verifies upserting settings for a missing user fails with a user-not-found error.
func TestSQLiteRepositoryUpsertSettingsMissingUser(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, &models.UserSettings{UserID: "missing-user"}, nil, 0); err == nil {
t.Fatal("missing user upsert = nil, want user not found")
} else if errors.Is(err, ErrUserSettingsRevisionConflict) {
t.Fatalf("missing user upsert returned conflict, want user not found: %v", err)
} else if !strings.Contains(err.Error(), "user not found") {
t.Fatalf("missing user upsert error = %v, want user not found", err)
}
}
// TestPostgresRepositoryUpsertSettingsRevisionConflict verifies a stale revision write fails with ErrUserSettingsRevisionConflict on Postgres.
func TestPostgresRepositoryUpsertSettingsRevisionConflict(t *testing.T) {
conn := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new postgres repo: %v", err)
}
ctx := context.Background()
settings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
staleRevision := settings.Revision
settings.AppStatusBarEnabled = true
if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, nil, settings.Revision); err != nil {
t.Fatalf("initial write: %v", err)
}
stale := *settings
stale.AppStatusBarEnabled = false
if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, &stale, nil, staleRevision); !errors.Is(err, ErrUserSettingsRevisionConflict) {
t.Fatalf("stale write error = %v, want ErrUserSettingsRevisionConflict", err)
}
}
// TestPostgresRepositoryUpsertSettingsMissingUser verifies upserting settings for a missing user fails with a user-not-found error on Postgres.
func TestPostgresRepositoryUpsertSettingsMissingUser(t *testing.T) {
conn := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new postgres repo: %v", err)
}
ctx := context.Background()
if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, &models.UserSettings{UserID: "missing-user"}, nil, 0); err == nil {
t.Fatal("missing user upsert = nil, want user not found")
} else if errors.Is(err, ErrUserSettingsRevisionConflict) {
t.Fatalf("missing user upsert returned conflict, want user not found: %v", err)
} else if !strings.Contains(err.Error(), "user not found") {
t.Fatalf("missing user upsert error = %v, want user not found", err)
}
}
// TestSQLiteRepositoryConcurrentUpsertOneSucceedsOneConflicts is the migrated
// unique-revision test: two candidates read the same revision, exactly one
// write commits and the other reports a revision conflict (retry lives in the
// service layer).
func TestSQLiteRepositoryConcurrentUpsertOneSucceedsOneConflicts(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
settings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
expectedRevision := settings.Revision
first := *settings
second := *settings
first.AppStatusBarEnabled = true
second.ChangesPanelLayout = "flat"
start := make(chan struct{})
revisions := make(chan int64, 2)
conflicts := make(chan error, 2)
other := make(chan error, 2)
var workers sync.WaitGroup
for _, candidate := range []*models.UserSettings{&first, &second} {
workers.Add(1)
go func(candidate *models.UserSettings) {
defer workers.Done()
<-start
updated, updateErr := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, candidate, nil, expectedRevision)
if updateErr != nil {
if errors.Is(updateErr, ErrUserSettingsRevisionConflict) {
conflicts <- updateErr
} else {
other <- updateErr
}
return
}
revisions <- updated.Revision
}(candidate)
}
close(start)
workers.Wait()
close(revisions)
close(conflicts)
close(other)
for updateErr := range other {
t.Fatalf("unexpected concurrent update error: %v", updateErr)
}
var got []int64
for revision := range revisions {
got = append(got, revision)
}
conflictCount := 0
for range conflicts {
conflictCount++
}
if len(got) != 1 || got[0] != 1 {
t.Fatalf("successful revisions = %v, want exactly [1]", got)
}
if conflictCount != 1 {
t.Fatalf("conflict count = %d, want exactly 1", conflictCount)
}
final, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get final settings: %v", err)
}
if final.Revision != 1 {
t.Fatalf("final revision = %d, want 1", final.Revision)
}
}
// TestPostgresRepositoryTaskCreateLastUsedRoundTrip verifies the full-blob
// write merges the current task_create_last_used over the passed blob when a
// stale caller re-reads the current revision.
func TestPostgresRepositoryTaskCreateLastUsedRoundTrip(t *testing.T) {
conn := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new postgres repo: %v", err)
}
ctx := context.Background()
staleSettings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
staleSettings.SidebarActiveViewID = "view-before"
staleSettings.TaskCreateLastUsed = models.TaskCreateLastUsed{
RepositoryID: "repo-before",
Branch: "main",
AgentProfileID: "agent-before",
ExecutorProfileID: "exec-before",
}
upsertUserSettingsForTest(t, repo, ctx, staleSettings)
got, err := repo.UpdateTaskCreateLastUsed(ctx, DefaultUserID, models.TaskCreateLastUsed{
RepositoryID: "repo-after",
Branch: "feature",
AgentProfileID: "agent-after",
})
if err != nil {
t.Fatalf("update postgres task-create last-used: %v", err)
}
if got.SidebarActiveViewID != "view-before" {
t.Fatalf("unrelated postgres setting should be preserved, got %q", got.SidebarActiveViewID)
}
if got.TaskCreateLastUsed.RepositoryID != "repo-after" ||
got.TaskCreateLastUsed.Branch != "feature" ||
got.TaskCreateLastUsed.AgentProfileID != "agent-after" ||
got.TaskCreateLastUsed.ExecutorProfileID != "exec-before" {
t.Fatalf("postgres task-create update mismatch: %+v", got.TaskCreateLastUsed)
}
if got.Revision < 2 {
t.Fatalf("postgres settings revision = %d, want at least 2", got.Revision)
}
firstRevision := got.Revision
// A stale caller re-reads the current revision, then writes an unrelated
// change carrying stale task-create fields; the merge must keep the stored
// current values.
current, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("re-read current settings: %v", err)
}
current.SidebarActiveViewID = "view-after"
current.TaskCreateLastUsed = models.TaskCreateLastUsed{
RepositoryID: "repo-stale",
Branch: "stale",
}
got, err = repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, current, nil, current.Revision)
if err != nil {
t.Fatalf("upsert preserving postgres task-create last-used: %v", err)
}
if got.SidebarActiveViewID != "view-after" {
t.Fatalf("expected unrelated postgres setting to update, got %q", got.SidebarActiveViewID)
}
if got.TaskCreateLastUsed.RepositoryID != "repo-after" ||
got.TaskCreateLastUsed.Branch != "feature" ||
got.TaskCreateLastUsed.AgentProfileID != "agent-after" ||
got.TaskCreateLastUsed.ExecutorProfileID != "exec-before" {
t.Fatalf("postgres preserving upsert should keep current task-create values: %+v", got.TaskCreateLastUsed)
}
if got.Revision != firstRevision+1 {
t.Fatalf("postgres settings revision = %d, want %d", got.Revision, firstRevision+1)
}
}
// TestSQLiteRepositoryUpsertSettingsPreservesCurrentTaskCreateLastUsed is the
// migrated stale-blob merge test: the caller re-reads the current revision
// before its unrelated write so the task_create_last_used merge survives.
func TestSQLiteRepositoryUpsertSettingsPreservesCurrentTaskCreateLastUsed(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
staleSettings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
staleSettings.SidebarActiveViewID = "view-before"
staleSettings.TaskCreateLastUsed = models.TaskCreateLastUsed{RepositoryID: "repo-before"}
upsertUserSettingsForTest(t, repo, ctx, staleSettings)
// Simulate a task-create write that lands after another settings caller
// read the old blob but before that caller writes its unrelated change.
if _, err := repo.UpdateTaskCreateLastUsed(ctx, DefaultUserID, models.TaskCreateLastUsed{
RepositoryID: "repo-after",
Branch: "feature",
}); err != nil {
t.Fatalf("update task-create last-used: %v", err)
}
current, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("re-read current settings: %v", err)
}
current.SidebarActiveViewID = "view-after"
got, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, current, nil, current.Revision)
if err != nil {
t.Fatalf("upsert preserving task-create last-used: %v", err)
}
if got.SidebarActiveViewID != "view-after" {
t.Fatalf("expected unrelated setting to update, got %q", got.SidebarActiveViewID)
}
if got.TaskCreateLastUsed.RepositoryID != "repo-after" {
t.Fatalf("expected current task-create repository to survive stale write, got %q", got.TaskCreateLastUsed.RepositoryID)
}
if got.TaskCreateLastUsed.Branch != "feature" {
t.Fatalf("expected current task-create branch to survive stale write, got %q", got.TaskCreateLastUsed.Branch)
}
}
// TestSQLiteRepositoryUpsertSettingsPreservingTaskCreateLastUsedAppliesPatch is
// the migrated patch-path test, re-reading the current revision before the
// direct upsert.
func TestSQLiteRepositoryUpsertSettingsPreservingTaskCreateLastUsedAppliesPatch(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
staleSettings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
staleSettings.SidebarActiveViewID = "view-before"
staleSettings.TaskCreateLastUsed = models.TaskCreateLastUsed{
RepositoryID: "repo-before",
Branch: "main",
AgentProfileID: "agent-before",
ExecutorProfileID: "exec-before",
}
upsertUserSettingsForTest(t, repo, ctx, staleSettings)
if _, err := repo.UpdateTaskCreateLastUsed(ctx, DefaultUserID, models.TaskCreateLastUsed{
RepositoryID: "repo-current",
Branch: "current",
ExecutorProfileID: "exec-current",
}); err != nil {
t.Fatalf("update current task-create last-used: %v", err)
}
current, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("re-read current settings: %v", err)
}
current.SidebarActiveViewID = "view-after"
current.TaskCreateLastUsed = models.TaskCreateLastUsed{
RepositoryID: "repo-stale",
Branch: "stale",
AgentProfileID: "agent-stale",
}
patch := models.TaskCreateLastUsed{AgentProfileID: "agent-after"}
got, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, current, &patch, current.Revision)
if err != nil {
t.Fatalf("upsert preserving task-create last-used with patch: %v", err)
}
if got.SidebarActiveViewID != "view-after" {
t.Fatalf("expected unrelated setting to update, got %q", got.SidebarActiveViewID)
}
if got.TaskCreateLastUsed.RepositoryID != "repo-current" {
t.Fatalf("expected current repository to survive stale write, got %q", got.TaskCreateLastUsed.RepositoryID)
}
if got.TaskCreateLastUsed.Branch != "current" {
t.Fatalf("expected current branch to survive stale write, got %q", got.TaskCreateLastUsed.Branch)
}
if got.TaskCreateLastUsed.AgentProfileID != "agent-after" {
t.Fatalf("expected patch agent profile to apply, got %q", got.TaskCreateLastUsed.AgentProfileID)
}
if got.TaskCreateLastUsed.ExecutorProfileID != "exec-current" {
t.Fatalf("expected current executor profile to survive stale write, got %q", got.TaskCreateLastUsed.ExecutorProfileID)
}
}
+50 -243
View File
@@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
@@ -22,17 +20,23 @@ type settingsScanner struct {
revision int64
}
// upsertUserSettingsForTest writes settings via UpsertUserSettingsPreservingTaskCreateLastUsed at the current stored revision.
func upsertUserSettingsForTest(t *testing.T, repo *sqliteRepository, ctx context.Context, settings *models.UserSettings) {
t.Helper()
current, err := repo.GetUserSettings(ctx, settings.UserID)
if err != nil {
t.Fatalf("read current settings: %v", err)
}
var patch *models.TaskCreateLastUsed
if !reflect.DeepEqual(settings.TaskCreateLastUsed, models.TaskCreateLastUsed{}) {
patch = &settings.TaskCreateLastUsed
}
if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, patch); err != nil {
if _, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, patch, current.Revision); err != nil {
t.Fatalf("upsert settings: %v", err)
}
}
// Scan copies the scanner's raw settings string, zero time, and revision into dest.
func (s settingsScanner) Scan(dest ...any) error {
*(dest[0].(*string)) = s.raw
*(dest[1].(*time.Time)) = time.Time{}
@@ -40,6 +44,7 @@ func (s settingsScanner) Scan(dest ...any) error {
return nil
}
// TestSQLiteRepositoryMigratesLegacySettingsRevision verifies the legacy settings revision migration against an in-memory SQLite database.
func TestSQLiteRepositoryMigratesLegacySettingsRevision(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -50,11 +55,13 @@ func TestSQLiteRepositoryMigratesLegacySettingsRevision(t *testing.T) {
assertLegacySettingsRevisionMigration(t, conn)
}
// TestPostgresRepositoryMigratesLegacySettingsRevision verifies the legacy settings revision migration against Postgres.
func TestPostgresRepositoryMigratesLegacySettingsRevision(t *testing.T) {
conn := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
assertLegacySettingsRevisionMigration(t, conn)
}
// assertLegacySettingsRevisionMigration verifies a legacy settings blob migrates to revision 0, bumps to 1 on write, and survives a migration replay.
func assertLegacySettingsRevisionMigration(t *testing.T, conn *sqlx.DB) {
t.Helper()
now := time.Now().UTC()
@@ -93,7 +100,7 @@ func assertLegacySettingsRevisionMigration(t *testing.T, conn *sqlx.DB) {
}
settings.AppStatusBarEnabled = true
settings.UpdatedAt = now.Add(time.Second)
updated, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, nil)
updated, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, nil, settings.Revision)
if err != nil {
t.Fatalf("write migrated settings: %v", err)
}
@@ -117,70 +124,7 @@ func assertLegacySettingsRevisionMigration(t *testing.T, conn *sqlx.DB) {
}
}
func TestSQLiteRepositoryAssignsUniqueAtomicSettingsRevisions(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
settings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
first := *settings
second := *settings
first.AppStatusBarEnabled = true
second.ChangesPanelLayout = "flat"
start := make(chan struct{})
revisions := make(chan int64, 2)
errors := make(chan error, 2)
var workers sync.WaitGroup
for _, candidate := range []*models.UserSettings{&first, &second} {
workers.Add(1)
go func(candidate *models.UserSettings) {
defer workers.Done()
<-start
updated, updateErr := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, candidate, nil)
if updateErr != nil {
errors <- updateErr
return
}
revisions <- updated.Revision
}(candidate)
}
close(start)
workers.Wait()
close(errors)
close(revisions)
for updateErr := range errors {
t.Fatalf("concurrent update: %v", updateErr)
}
got := make([]int64, 0, 2)
for revision := range revisions {
got = append(got, revision)
}
sort.Slice(got, func(i, j int) bool { return got[i] < got[j] })
if !reflect.DeepEqual(got, []int64{1, 2}) {
t.Fatalf("revisions = %v, want unique commit order [1 2]", got)
}
final, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get final settings: %v", err)
}
if final.Revision != 2 {
t.Fatalf("final revision = %d, want 2", final.Revision)
}
}
// TestScanUserSettingsStartupPage verifies startup_page defaults to task_overview and preserves an explicit last_task value.
func TestScanUserSettingsStartupPage(t *testing.T) {
tests := []struct {
name string
@@ -214,6 +158,7 @@ func TestScanUserSettingsStartupPage(t *testing.T) {
}
}
// TestScanUserSettingsSidebarDefaults verifies the canonical default sidebar view and that explicit sidebar settings are preserved.
func TestScanUserSettingsSidebarDefaults(t *testing.T) {
defaultView := models.SidebarView{
ID: "view-all-tasks",
@@ -260,6 +205,7 @@ func TestScanUserSettingsSidebarDefaults(t *testing.T) {
}
}
// TestScanUserSettingsPreservesExplicitEmptySidebarSettings verifies explicitly empty sidebar view lists survive scanning.
func TestScanUserSettingsPreservesExplicitEmptySidebarSettings(t *testing.T) {
for _, raw := range []string{
`{"sidebar_views":[],"sidebar_active_view_id":""}`,
@@ -280,6 +226,7 @@ func TestScanUserSettingsPreservesExplicitEmptySidebarSettings(t *testing.T) {
}
}
// TestScanUserSettingsChangesPanelLayoutDefault verifies changes_panel_layout defaults to tree and preserves an explicit flat value.
func TestScanUserSettingsChangesPanelLayoutDefault(t *testing.T) {
t.Run("empty settings default to tree", func(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID)
@@ -312,6 +259,7 @@ func TestScanUserSettingsChangesPanelLayoutDefault(t *testing.T) {
})
}
// TestScanUserSettingsConfirmTaskArchiveDefault verifies confirm_task_archive defaults to true and preserves an explicit false.
func TestScanUserSettingsConfirmTaskArchiveDefault(t *testing.T) {
tests := []struct {
name string
@@ -336,6 +284,7 @@ func TestScanUserSettingsConfirmTaskArchiveDefault(t *testing.T) {
}
}
// TestScanUserSettingsUnreadDividerDefault verifies unread_divider defaults to false and honors explicit values.
func TestScanUserSettingsUnreadDividerDefault(t *testing.T) {
tests := []struct {
name string
@@ -361,6 +310,7 @@ func TestScanUserSettingsUnreadDividerDefault(t *testing.T) {
}
}
// TestScanUserSettingsAgentGeneratedTaskTitlesDefault verifies agent_generated_task_titles defaults to true and honors explicit values.
func TestScanUserSettingsAgentGeneratedTaskTitlesDefault(t *testing.T) {
tests := []struct {
name string
@@ -386,6 +336,7 @@ func TestScanUserSettingsAgentGeneratedTaskTitlesDefault(t *testing.T) {
}
}
// TestScanUserSettingsMCPTaskAgentProfileDefault verifies mcp_task_agent_profile_default defaults to current_task and preserves workspace_default.
func TestScanUserSettingsMCPTaskAgentProfileDefault(t *testing.T) {
tests := []struct {
name string
@@ -419,6 +370,7 @@ func TestScanUserSettingsMCPTaskAgentProfileDefault(t *testing.T) {
}
}
// TestScanUserSettingsShowAnchoredPromptBarDefault verifies show_anchored_prompt_bar defaults to false.
func TestScanUserSettingsShowAnchoredPromptBarDefault(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID)
if err != nil {
@@ -437,6 +389,7 @@ func TestScanUserSettingsShowAnchoredPromptBarDefault(t *testing.T) {
}
}
// TestScanUserSettingsTranscriptNavigationDefaults verifies transcript navigation control defaults and stored preferences.
func TestScanUserSettingsTranscriptNavigationDefaults(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID)
if err != nil {
@@ -480,6 +433,7 @@ func TestScanUserSettingsTranscriptNavigationDefaults(t *testing.T) {
}
}
// TestScanUserSettingsDefaultsTranscriptAutoScrollControlToHidden verifies the transcript auto-scroll control defaults to hidden.
func TestScanUserSettingsDefaultsTranscriptAutoScrollControlToHidden(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID)
if err != nil {
@@ -490,6 +444,7 @@ func TestScanUserSettingsDefaultsTranscriptAutoScrollControlToHidden(t *testing.
}
}
// TestTranscriptNavigationSettingsRoundTripThroughMarshalAndScan verifies transcript navigation settings survive a marshal and scan round trip.
func TestTranscriptNavigationSettingsRoundTripThroughMarshalAndScan(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{
ShowScrollToLastPrompt: false,
@@ -513,6 +468,7 @@ func TestTranscriptNavigationSettingsRoundTripThroughMarshalAndScan(t *testing.T
}
}
// TestScanUserSettingsTodoListPanelDefault verifies show_todo_list_panel defaults to false and honors explicit values.
func TestScanUserSettingsTodoListPanelDefault(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID)
if err != nil {
@@ -545,6 +501,7 @@ func TestScanUserSettingsTodoListPanelDefault(t *testing.T) {
}
}
// TestScanUserSettingsTodoListPanelOnlyWhenNotEmptyDefault verifies show_todo_list_panel_only_when_not_empty defaults to false and honors explicit values.
func TestScanUserSettingsTodoListPanelOnlyWhenNotEmptyDefault(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID)
if err != nil {
@@ -577,6 +534,7 @@ func TestScanUserSettingsTodoListPanelOnlyWhenNotEmptyDefault(t *testing.T) {
}
}
// TestTodoListPanelSettingRoundTripThroughMarshalAndScan verifies the todo list panel setting survives a marshal and scan round trip.
func TestTodoListPanelSettingRoundTripThroughMarshalAndScan(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{ShowTodoListPanel: true})
if err != nil {
@@ -591,6 +549,7 @@ func TestTodoListPanelSettingRoundTripThroughMarshalAndScan(t *testing.T) {
}
}
// TestTodoListPanelOnlyWhenNotEmptyRoundTripThroughMarshalAndScan verifies the todo list panel visibility setting survives a marshal and scan round trip.
func TestTodoListPanelOnlyWhenNotEmptyRoundTripThroughMarshalAndScan(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{
ShowTodoListPanelOnlyWhenNotEmpty: true,
@@ -607,6 +566,7 @@ func TestTodoListPanelOnlyWhenNotEmptyRoundTripThroughMarshalAndScan(t *testing.
}
}
// TestMarshalUserSettingsPersistsDisabledArchiveConfirmation verifies marshaling preserves an explicit false confirm_task_archive.
func TestMarshalUserSettingsPersistsDisabledArchiveConfirmation(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{ConfirmTaskArchive: false})
if err != nil {
@@ -622,6 +582,7 @@ func TestMarshalUserSettingsPersistsDisabledArchiveConfirmation(t *testing.T) {
}
}
// TestShowAnchoredPromptBarRoundTripsThroughMarshalAndScan verifies show_anchored_prompt_bar survives a marshal and scan round trip.
func TestShowAnchoredPromptBarRoundTripsThroughMarshalAndScan(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{ShowAnchoredPromptBar: true})
if err != nil {
@@ -636,6 +597,7 @@ func TestShowAnchoredPromptBarRoundTripsThroughMarshalAndScan(t *testing.T) {
}
}
// TestMarshalUserSettingsPersistsTasksListShowDetails verifies marshaling preserves tasks_list_show_details true.
func TestMarshalUserSettingsPersistsTasksListShowDetails(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{TasksListShowDetails: true})
if err != nil {
@@ -651,6 +613,7 @@ func TestMarshalUserSettingsPersistsTasksListShowDetails(t *testing.T) {
}
}
// TestScanUserSettingsTasksListShowDetailsDefaultsAndLoads verifies tasks_list_show_details defaults to false and loads a stored true.
func TestScanUserSettingsTasksListShowDetailsDefaultsAndLoads(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID)
if err != nil {
@@ -669,6 +632,7 @@ func TestScanUserSettingsTasksListShowDetailsDefaultsAndLoads(t *testing.T) {
}
}
// TestMarshalUserSettingsPersistsMCPTaskAgentProfileDefault verifies marshaling preserves the workspace_default MCP task agent profile.
func TestMarshalUserSettingsPersistsMCPTaskAgentProfileDefault(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{
MCPTaskAgentProfileDefault: models.MCPTaskAgentProfileDefaultWorkspaceDefault,
@@ -686,6 +650,7 @@ func TestMarshalUserSettingsPersistsMCPTaskAgentProfileDefault(t *testing.T) {
}
}
// TestSQLiteRepositoryMCPTaskAgentProfileDefaultRoundTrip verifies the MCP task agent profile default round-trips through the SQLite repository.
func TestSQLiteRepositoryMCPTaskAgentProfileDefaultRoundTrip(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -715,6 +680,7 @@ func TestSQLiteRepositoryMCPTaskAgentProfileDefaultRoundTrip(t *testing.T) {
}
}
// TestScanUserSettingsLspStatusLocationDefaultsAndLoads verifies lsp_status_location defaults to toolbar and preserves stored values.
func TestScanUserSettingsLspStatusLocationDefaultsAndLoads(t *testing.T) {
tests := []struct {
name string
@@ -740,6 +706,7 @@ func TestScanUserSettingsLspStatusLocationDefaultsAndLoads(t *testing.T) {
}
}
// TestMarshalUserSettingsLspStatusLocation verifies marshaling preserves the stored LSP status location.
func TestMarshalUserSettingsLspStatusLocation(t *testing.T) {
raw, err := marshalUserSettingsPayload(&models.UserSettings{
LspStatusLocation: models.LspStatusLocationStatusBar,
@@ -756,6 +723,7 @@ func TestMarshalUserSettingsLspStatusLocation(t *testing.T) {
}
}
// TestSQLiteRepositoryLspStatusLocationRoundTrip verifies the LSP status location round-trips through the SQLite repository.
func TestSQLiteRepositoryLspStatusLocationRoundTrip(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -785,6 +753,7 @@ func TestSQLiteRepositoryLspStatusLocationRoundTrip(t *testing.T) {
}
}
// TestScanUserSettingsSystemMetricsDisplayDefault verifies the system metrics display defaults to disabled and honors stored preferences.
func TestScanUserSettingsSystemMetricsDisplayDefault(t *testing.T) {
settings, err := scanUserSettings(settingsScanner{raw: "{}"}, DefaultUserID)
if err != nil {
@@ -817,6 +786,7 @@ func TestScanUserSettingsSystemMetricsDisplayDefault(t *testing.T) {
}
}
// TestSQLiteRepositorySystemMetricsDisplayRoundTrip verifies the system metrics display preference round-trips through the SQLite repository.
func TestSQLiteRepositorySystemMetricsDisplayRoundTrip(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -845,6 +815,7 @@ func TestSQLiteRepositorySystemMetricsDisplayRoundTrip(t *testing.T) {
}
}
// TestSQLiteRepositoryAppStatusBarOrderDefaultAndRoundTrip verifies the status bar order defaults to non-nil empty arrays and round-trips through the SQLite repository.
func TestSQLiteRepositoryAppStatusBarOrderDefaultAndRoundTrip(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -879,6 +850,7 @@ func TestSQLiteRepositoryAppStatusBarOrderDefaultAndRoundTrip(t *testing.T) {
}
}
// TestSQLiteRepositoryKanbanHiddenStepIDsDefaultAndRoundTrip verifies kanban hidden step IDs default to empty and round-trip through the SQLite repository.
func TestSQLiteRepositoryKanbanHiddenStepIDsDefaultAndRoundTrip(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -913,6 +885,7 @@ func TestSQLiteRepositoryKanbanHiddenStepIDsDefaultAndRoundTrip(t *testing.T) {
}
}
// TestScanUserSettingsKanbanHiddenStepIDsCorruptFallsBackToEmpty verifies corrupt kanban_hidden_step_ids values fall back to empty while sibling fields still load.
func TestScanUserSettingsKanbanHiddenStepIDsCorruptFallsBackToEmpty(t *testing.T) {
tests := []struct {
name string
@@ -960,6 +933,7 @@ func TestScanUserSettingsKanbanHiddenStepIDsCorruptFallsBackToEmpty(t *testing.T
}
}
// TestSQLiteRepositoryUpdateTaskCreateLastUsedPatchesNonEmptyFields verifies updating task-create last-used patches only non-empty fields.
func TestSQLiteRepositoryUpdateTaskCreateLastUsedPatchesNonEmptyFields(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -1011,6 +985,7 @@ func TestSQLiteRepositoryUpdateTaskCreateLastUsedPatchesNonEmptyFields(t *testin
}
}
// TestSQLiteRepositoryUpdateTaskCreateLastUsedPreservesWorkflowHistory verifies workflow history entries are merged rather than replaced.
func TestSQLiteRepositoryUpdateTaskCreateLastUsedPreservesWorkflowHistory(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -1057,6 +1032,7 @@ func TestSQLiteRepositoryUpdateTaskCreateLastUsedPreservesWorkflowHistory(t *tes
}
}
// TestSQLiteRepositoryUpdateTaskCreateLastUsedClearsBranchOnRepositoryChange verifies the branch is cleared when the repository changes.
func TestSQLiteRepositoryUpdateTaskCreateLastUsedClearsBranchOnRepositoryChange(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
@@ -1103,6 +1079,7 @@ func TestSQLiteRepositoryUpdateTaskCreateLastUsedClearsBranchOnRepositoryChange(
}
}
// TestBuildPostgresTaskCreateLastUsedUpdatePatchesNonEmptyFields verifies the generated Postgres update patches task-create fields with jsonb_set.
func TestBuildPostgresTaskCreateLastUsedUpdatePatchesNonEmptyFields(t *testing.T) {
query, args := buildPostgresTaskCreateLastUsedUpdate(models.TaskCreateLastUsed{
RepositoryID: "repo-1",
@@ -1132,6 +1109,7 @@ func TestBuildPostgresTaskCreateLastUsedUpdatePatchesNonEmptyFields(t *testing.T
}
}
// TestBuildPostgresTaskCreateLastUsedUpdatePatchesWorkflowHistoryEntries verifies the generated Postgres update patches workflow history entries.
func TestBuildPostgresTaskCreateLastUsedUpdatePatchesWorkflowHistoryEntries(t *testing.T) {
query, args := buildPostgresTaskCreateLastUsedUpdate(models.TaskCreateLastUsed{
WorkflowIDsByWorkspace: map[string]string{
@@ -1154,6 +1132,7 @@ func TestBuildPostgresTaskCreateLastUsedUpdatePatchesWorkflowHistoryEntries(t *t
}
}
// TestMakeTaskCreateLastUsedJSONSetArgsRejectsUnsafeWorkspacePathKeys verifies unsafe workspace keys are excluded from JSON set arguments.
func TestMakeTaskCreateLastUsedJSONSetArgsRejectsUnsafeWorkspacePathKeys(t *testing.T) {
args := makeTaskCreateLastUsedJSONSetArgs(models.TaskCreateLastUsed{
WorkflowIDsByWorkspace: map[string]string{
@@ -1172,6 +1151,7 @@ func TestMakeTaskCreateLastUsedJSONSetArgsRejectsUnsafeWorkspacePathKeys(t *test
}
}
// TestBuildPostgresTaskCreateLastUsedUpdateClearsBranchOnRepositoryChange verifies the generated Postgres update clears the branch when the repository changes.
func TestBuildPostgresTaskCreateLastUsedUpdateClearsBranchOnRepositoryChange(t *testing.T) {
query, args := buildPostgresTaskCreateLastUsedUpdate(models.TaskCreateLastUsed{
RepositoryID: "repo-after",
@@ -1193,6 +1173,7 @@ func TestBuildPostgresTaskCreateLastUsedUpdateClearsBranchOnRepositoryChange(t *
}
}
// TestBuildPostgresUserSettingsPreservingTaskCreateLastUsedUpdateUsesJSONB verifies the generated Postgres preserving update merges the payload with jsonb_set.
func TestBuildPostgresUserSettingsPreservingTaskCreateLastUsedUpdateUsesJSONB(t *testing.T) {
patch := models.TaskCreateLastUsed{
RepositoryID: "repo-1",
@@ -1223,181 +1204,7 @@ func TestBuildPostgresUserSettingsPreservingTaskCreateLastUsedUpdateUsesJSONB(t
}
}
func TestPostgresRepositoryTaskCreateLastUsedRoundTrip(t *testing.T) {
conn := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new postgres repo: %v", err)
}
ctx := context.Background()
staleSettings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
staleSettings.SidebarActiveViewID = "view-before"
staleSettings.TaskCreateLastUsed = models.TaskCreateLastUsed{
RepositoryID: "repo-before",
Branch: "main",
AgentProfileID: "agent-before",
ExecutorProfileID: "exec-before",
}
upsertUserSettingsForTest(t, repo, ctx, staleSettings)
got, err := repo.UpdateTaskCreateLastUsed(ctx, DefaultUserID, models.TaskCreateLastUsed{
RepositoryID: "repo-after",
Branch: "feature",
AgentProfileID: "agent-after",
})
if err != nil {
t.Fatalf("update postgres task-create last-used: %v", err)
}
if got.SidebarActiveViewID != "view-before" {
t.Fatalf("unrelated postgres setting should be preserved, got %q", got.SidebarActiveViewID)
}
if got.TaskCreateLastUsed.RepositoryID != "repo-after" ||
got.TaskCreateLastUsed.Branch != "feature" ||
got.TaskCreateLastUsed.AgentProfileID != "agent-after" ||
got.TaskCreateLastUsed.ExecutorProfileID != "exec-before" {
t.Fatalf("postgres task-create update mismatch: %+v", got.TaskCreateLastUsed)
}
if got.Revision < 2 {
t.Fatalf("postgres settings revision = %d, want at least 2", got.Revision)
}
firstRevision := got.Revision
staleSettings.SidebarActiveViewID = "view-after"
staleSettings.TaskCreateLastUsed = models.TaskCreateLastUsed{
RepositoryID: "repo-stale",
Branch: "stale",
}
got, err = repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, staleSettings, nil)
if err != nil {
t.Fatalf("upsert preserving postgres task-create last-used: %v", err)
}
if got.SidebarActiveViewID != "view-after" {
t.Fatalf("expected unrelated postgres setting to update, got %q", got.SidebarActiveViewID)
}
if got.TaskCreateLastUsed.RepositoryID != "repo-after" ||
got.TaskCreateLastUsed.Branch != "feature" ||
got.TaskCreateLastUsed.AgentProfileID != "agent-after" ||
got.TaskCreateLastUsed.ExecutorProfileID != "exec-before" {
t.Fatalf("postgres preserving upsert should keep current task-create values: %+v", got.TaskCreateLastUsed)
}
if got.Revision != firstRevision+1 {
t.Fatalf("postgres settings revision = %d, want %d", got.Revision, firstRevision+1)
}
}
func TestSQLiteRepositoryUpsertSettingsPreservesCurrentTaskCreateLastUsed(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
staleSettings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
staleSettings.SidebarActiveViewID = "view-before"
staleSettings.TaskCreateLastUsed = models.TaskCreateLastUsed{RepositoryID: "repo-before"}
upsertUserSettingsForTest(t, repo, ctx, staleSettings)
// Simulate a task-create write that lands after another settings caller
// read the old blob but before that caller writes its unrelated change.
if _, err := repo.UpdateTaskCreateLastUsed(ctx, DefaultUserID, models.TaskCreateLastUsed{
RepositoryID: "repo-after",
Branch: "feature",
}); err != nil {
t.Fatalf("update task-create last-used: %v", err)
}
staleSettings.SidebarActiveViewID = "view-after"
got, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, staleSettings, nil)
if err != nil {
t.Fatalf("upsert preserving task-create last-used: %v", err)
}
if got.SidebarActiveViewID != "view-after" {
t.Fatalf("expected unrelated setting to update, got %q", got.SidebarActiveViewID)
}
if got.TaskCreateLastUsed.RepositoryID != "repo-after" {
t.Fatalf("expected current task-create repository to survive stale write, got %q", got.TaskCreateLastUsed.RepositoryID)
}
if got.TaskCreateLastUsed.Branch != "feature" {
t.Fatalf("expected current task-create branch to survive stale write, got %q", got.TaskCreateLastUsed.Branch)
}
}
func TestSQLiteRepositoryUpsertSettingsPreservingTaskCreateLastUsedAppliesPatch(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
conn.SetMaxOpenConns(1)
t.Cleanup(func() { _ = conn.Close() })
repo, err := newSQLiteRepositoryWithDB(conn, conn)
if err != nil {
t.Fatalf("new repo: %v", err)
}
ctx := context.Background()
staleSettings, err := repo.GetUserSettings(ctx, DefaultUserID)
if err != nil {
t.Fatalf("get defaults: %v", err)
}
staleSettings.SidebarActiveViewID = "view-before"
staleSettings.TaskCreateLastUsed = models.TaskCreateLastUsed{
RepositoryID: "repo-before",
Branch: "main",
AgentProfileID: "agent-before",
ExecutorProfileID: "exec-before",
}
upsertUserSettingsForTest(t, repo, ctx, staleSettings)
if _, err := repo.UpdateTaskCreateLastUsed(ctx, DefaultUserID, models.TaskCreateLastUsed{
RepositoryID: "repo-current",
Branch: "current",
ExecutorProfileID: "exec-current",
}); err != nil {
t.Fatalf("update current task-create last-used: %v", err)
}
staleSettings.SidebarActiveViewID = "view-after"
staleSettings.TaskCreateLastUsed = models.TaskCreateLastUsed{
RepositoryID: "repo-stale",
Branch: "stale",
AgentProfileID: "agent-stale",
}
patch := models.TaskCreateLastUsed{AgentProfileID: "agent-after"}
got, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, staleSettings, &patch)
if err != nil {
t.Fatalf("upsert preserving task-create last-used with patch: %v", err)
}
if got.SidebarActiveViewID != "view-after" {
t.Fatalf("expected unrelated setting to update, got %q", got.SidebarActiveViewID)
}
if got.TaskCreateLastUsed.RepositoryID != "repo-current" {
t.Fatalf("expected current repository to survive stale write, got %q", got.TaskCreateLastUsed.RepositoryID)
}
if got.TaskCreateLastUsed.Branch != "current" {
t.Fatalf("expected current branch to survive stale write, got %q", got.TaskCreateLastUsed.Branch)
}
if got.TaskCreateLastUsed.AgentProfileID != "agent-after" {
t.Fatalf("expected patch agent profile to apply, got %q", got.TaskCreateLastUsed.AgentProfileID)
}
if got.TaskCreateLastUsed.ExecutorProfileID != "exec-current" {
t.Fatalf("expected current executor profile to survive stale write, got %q", got.TaskCreateLastUsed.ExecutorProfileID)
}
}
// TestSQLiteRepositorySidebarViewStateRoundTrip verifies sidebar view state round-trips through the SQLite repository.
func TestSQLiteRepositorySidebarViewStateRoundTrip(t *testing.T) {
conn, err := sqlx.Open("sqlite3", ":memory:")
if err != nil {
+6 -1
View File
@@ -2,15 +2,20 @@ package store
import (
"context"
"errors"
"github.com/kandev/kandev/internal/user/models"
)
// ErrUserSettingsRevisionConflict reports a conditional settings write that
// matched no rows because the expected revision was stale.
var ErrUserSettingsRevisionConflict = errors.New("user settings revision conflict")
type Repository interface {
GetUser(ctx context.Context, id string) (*models.User, error)
GetDefaultUser(ctx context.Context) (*models.User, error)
GetUserSettings(ctx context.Context, userID string) (*models.UserSettings, error)
UpsertUserSettingsPreservingTaskCreateLastUsed(ctx context.Context, settings *models.UserSettings, patch *models.TaskCreateLastUsed) (*models.UserSettings, error)
UpsertUserSettingsPreservingTaskCreateLastUsed(ctx context.Context, settings *models.UserSettings, patch *models.TaskCreateLastUsed, expectedRevision int64) (*models.UserSettings, error)
UpdateTaskCreateLastUsed(ctx context.Context, userID string, patch models.TaskCreateLastUsed) (*models.UserSettings, error)
Close() error
}
@@ -0,0 +1,560 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDefaultUserSettings } from "@/lib/ssr/user-settings";
import type { UserSettingsState } from "@/lib/state/slices/settings/types";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import { SettingsTargetProvider } from "@/components/settings/settings-target-provider";
import { emitSettingsTargetRequest } from "@/lib/settings-discovery/target";
import { formatDateTime } from "@/lib/i18n/formats";
import { ApiError } from "@/lib/api/client";
import type { AuthSession } from "@/lib/api/domains/auth-api";
import { registerUsersHandlers } from "@/lib/ws/handlers/users";
import type { BackendMessageMap } from "@/lib/types/backend";
import { SecuritySettings } from "./security-settings";
import { SettingsSaveProvider } from "@/components/settings/settings-save-provider";
const authApiMocks = vi.hoisted(() => ({
listSessions: vi.fn(),
revokeSession: vi.fn(),
changePassword: vi.fn(),
}));
const settingsApiMocks = vi.hoisted(() => ({ updateUserSettings: vi.fn() }));
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
vi.mock("@/lib/api/domains/auth-api", () => ({
listSessions: (...args: unknown[]) => authApiMocks.listSessions(...args),
revokeSession: (...args: unknown[]) => authApiMocks.revokeSession(...args),
changePassword: (...args: unknown[]) => authApiMocks.changePassword(...args),
}));
vi.mock("@/lib/api/domains/settings-api", () => ({
updateUserSettings: (...args: unknown[]) => settingsApiMocks.updateUserSettings(...args),
}));
vi.mock("@/lib/toast/sonner", () => ({
toast: { error: (...args: unknown[]) => toastMocks.error(...args) },
}));
const LAST_SEEN_AT = "2026-08-15T15:42:00Z";
const RELATIVE_TEST_ID = "last-seen-relative";
const SELECT_TEST_ID = "last-seen-display-select";
const EMPTY_TEST_ID = "last-seen-empty";
const SESSIONS_CARD_TEST_ID = "account-sessions-card";
const DIRTY_ATTRIBUTE = "data-settings-dirty";
const DIRTY_TRUE = "true";
const DIRTY_FALSE = "false";
const RELATIVE_OPTION = "Relative time";
const ABSOLUTE_OPTION = "Absolute time";
const SESSION: AuthSession = {
id: "sess-1",
created_at: "2026-01-01T00:00:00Z",
last_seen_at: LAST_SEEN_AT,
user_agent: "Chrome on macOS",
ip: "203.0.113.7",
current: true,
};
/** Returns default user settings merged with the given overrides. */
function makeUserSettings(overrides: Partial<UserSettingsState> = {}): UserSettingsState {
return { ...createDefaultUserSettings(), ...overrides };
}
type StoreHolder = { current: ReturnType<typeof useAppStoreApi> | null };
/** Stores the app store API in the holder and renders nothing. */
function StoreCapture({ holder }: { holder: StoreHolder }) {
holder.current = useAppStoreApi();
return null;
}
/** Renders SecuritySettings inside a store provider, optionally capturing the store API. */
function renderSecurity(overrides: Partial<UserSettingsState> = {}, holder?: StoreHolder) {
return render(
<StateProvider initialState={{ userSettings: makeUserSettings({ revision: 1, ...overrides }) }}>
<SettingsSaveProvider>
<TooltipProvider>
{holder ? <StoreCapture holder={holder} /> : null}
<SecuritySettings />
</TooltipProvider>
</SettingsSaveProvider>
</StateProvider>,
);
}
/** Renders SecuritySettings and waits for the last-seen display select to appear. */
async function renderLoaded(overrides: Partial<UserSettingsState> = {}, holder?: StoreHolder) {
renderSecurity(overrides, holder);
await screen.findByTestId(SELECT_TEST_ID);
}
// RemountHarness keeps one StateProvider (and thus one store) mounted while
// toggling only SecuritySettings, so a remounted component reads the same
// converged store the pre-unmount component mutated.
function RemountHarness({ holder, mounted }: { holder: StoreHolder; mounted: boolean }) {
return (
<StateProvider initialState={{ userSettings: makeUserSettings({ revision: 1 }) }}>
<SettingsSaveProvider>
<TooltipProvider>
<StoreCapture holder={holder} />
{mounted ? <SecuritySettings /> : null}
</TooltipProvider>
</SettingsSaveProvider>
</StateProvider>
);
}
// renderRelativeWithFakeTime mounts the card in relative mode under fake
// timers at a fixed clock, optionally overriding the session's last_seen_at.
async function renderRelativeWithFakeTime(lastSeenAt?: string) {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-15T15:45:00Z"));
if (lastSeenAt) {
authApiMocks.listSessions.mockResolvedValue({
sessions: [{ ...SESSION, last_seen_at: lastSeenAt }],
});
}
renderSecurity({ lastSeenDisplay: "relative" });
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
/** Opens the last-seen display select and clicks the option with the given name. */
async function chooseDisplay(name: "Absolute time" | "Relative time") {
fireEvent.click(screen.getByTestId(SELECT_TEST_ID));
fireEvent.click(await screen.findByRole("option", { name }));
}
/** Runs the shared settings save action for the current draft. */
async function saveChanges() {
fireEvent.click(await screen.findByRole("button", { name: "Save changes" }));
}
type PatchResponse = {
settings: {
user_id: string;
workspace_id: string;
repository_ids: string[];
updated_at: string;
revision: number;
[key: string]: unknown;
};
};
/** Returns a default settings PATCH response merged with the given overrides. */
function patchResponse(overrides: Record<string, unknown> = {}): PatchResponse {
return {
settings: {
user_id: "default-user",
workspace_id: "workspace-1",
repository_ids: [],
updated_at: "2026-08-15T15:43:00Z",
last_seen_display: "relative",
revision: 2,
...overrides,
},
};
}
/** Builds a user.settings.updated WebSocket message from the given payload. */
function settingsUpdatedMessage(
payload: Partial<BackendMessageMap["user.settings.updated"]["payload"]>,
): BackendMessageMap["user.settings.updated"] {
return {
type: "notification",
action: "user.settings.updated",
payload: {
user_id: "default-user",
workspace_id: "workspace-1",
repository_ids: [],
...payload,
},
};
}
/** Feeds a user.settings.updated snapshot through the registered WS handler. */
function dispatchUserSettingsSnapshot(
holder: StoreHolder,
payload: Partial<BackendMessageMap["user.settings.updated"]["payload"]>,
) {
registerUsersHandlers(holder.current!)["user.settings.updated"]?.(
settingsUpdatedMessage(payload),
);
}
/** Creates a PATCH promise with externally callable resolve and reject functions. */
function deferredPatch(): {
promise: Promise<PatchResponse>;
resolve: (value: PatchResponse) => void;
reject: (error: ApiError) => void;
} {
let resolve!: (value: PatchResponse) => void;
let reject!: (error: ApiError) => void;
const promise = new Promise<PatchResponse>((complete, fail) => {
resolve = complete;
reject = fail;
});
return { promise, resolve, reject };
}
beforeEach(() => {
authApiMocks.listSessions.mockReset().mockResolvedValue({ sessions: [SESSION] });
authApiMocks.revokeSession.mockReset().mockResolvedValue(undefined);
authApiMocks.changePassword.mockReset().mockResolvedValue(undefined);
settingsApiMocks.updateUserSettings.mockReset().mockResolvedValue(patchResponse());
toastMocks.error.mockReset();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("Last seen display rendering", () => {
it("renders absolute time by default", async () => {
await renderLoaded();
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
expect(screen.queryByTestId(EMPTY_TEST_ID)).toBeNull();
expect(screen.getByText(formatDateTime(new Date(LAST_SEEN_AT)))).toBeTruthy();
});
it("renders a relative label when the store baseline is relative", async () => {
await renderLoaded({ lastSeenDisplay: "relative" });
const trigger = screen.getByTestId(RELATIVE_TEST_ID);
expect(trigger.textContent).not.toBe("");
});
it("exposes the absolute timestamp as accessible name and native title", async () => {
await renderLoaded({ lastSeenDisplay: "relative" });
const trigger = screen.getByTestId(RELATIVE_TEST_ID);
const absolute = formatDateTime(new Date(LAST_SEEN_AT));
// The trigger is a semantic, focusable time element, not a bare span.
expect(trigger.tagName).toBe("TIME");
expect(trigger.getAttribute("dateTime")).toBe(new Date(LAST_SEEN_AT).toISOString());
expect(trigger.getAttribute("aria-label")).toBe(absolute);
expect(trigger.getAttribute("title")).toBe(absolute);
});
it("opens the absolute-time tooltip on keyboard focus of the relative trigger", async () => {
await renderLoaded({ lastSeenDisplay: "relative" });
const trigger = screen.getByTestId(RELATIVE_TEST_ID);
fireEvent.focus(trigger);
await waitFor(() => expect(screen.getByRole("tooltip")).toBeTruthy());
expect(screen.getByRole("tooltip").textContent).toBe(formatDateTime(new Date(LAST_SEEN_AT)));
});
it("renders an empty cell for an unparseable last_seen_at", async () => {
authApiMocks.listSessions.mockResolvedValue({
sessions: [{ ...SESSION, last_seen_at: "not-a-date" }],
});
await renderLoaded({ lastSeenDisplay: "relative" });
expect(screen.getByTestId(EMPTY_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
});
it("keeps the Last seen select accessible when no sessions are returned", async () => {
authApiMocks.listSessions.mockResolvedValue({ sessions: [] });
await renderLoaded({ lastSeenDisplay: "absolute" });
// The display option lives on the card independent of table rows.
expect(screen.getByTestId(SELECT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId("account-sessions-table")).toBeNull();
});
it("keeps the Last seen select reachable when loading sessions fails", async () => {
authApiMocks.listSessions.mockRejectedValue(new Error("boom"));
await renderLoaded({ lastSeenDisplay: "absolute" });
await screen.findByTestId("account-sessions-error");
// A transient sessions API failure must not hide the persisted preference.
expect(screen.getByTestId(SELECT_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId("account-sessions-table")).toBeNull();
});
});
describe("Last seen display persistence", () => {
it("keeps a selection local until the shared save action runs", async () => {
await renderLoaded();
await chooseDisplay(RELATIVE_OPTION);
expect(settingsApiMocks.updateUserSettings).not.toHaveBeenCalled();
expect(screen.getByTestId(SESSIONS_CARD_TEST_ID).getAttribute(DIRTY_ATTRIBUTE)).toBe(
DIRTY_TRUE,
);
fireEvent.click(await screen.findByRole("button", { name: "Save changes" }));
await waitFor(() =>
expect(settingsApiMocks.updateUserSettings).toHaveBeenCalledWith({
last_seen_display: "relative",
}),
);
});
it("discards a local selection through the shared reset action", async () => {
await renderLoaded();
await chooseDisplay(RELATIVE_OPTION);
fireEvent.click(await screen.findByRole("button", { name: "Reset" }));
expect(settingsApiMocks.updateUserSettings).not.toHaveBeenCalled();
expect(screen.getByTestId(SELECT_TEST_ID).textContent).toContain(ABSOLUTE_OPTION);
expect(screen.getByTestId(SESSIONS_CARD_TEST_ID).getAttribute(DIRTY_ATTRIBUTE)).toBe(
DIRTY_FALSE,
);
});
it("persists a relative selection through the shared save action", async () => {
await renderLoaded();
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
await waitFor(() =>
expect(settingsApiMocks.updateUserSettings).toHaveBeenCalledWith({
last_seen_display: "relative",
}),
);
});
it("surfaces a revoke failure in the sessions card", async () => {
authApiMocks.revokeSession.mockRejectedValue(new ApiError("boom", 400, null));
authApiMocks.listSessions.mockResolvedValue({
sessions: [{ ...SESSION, current: false, id: "sess-2" }],
});
await renderLoaded();
fireEvent.click(screen.getByTestId("account-sessions-revoke"));
await waitFor(() =>
expect(screen.getByTestId("account-sessions-error").textContent).toContain("boom"),
);
});
it("shows the error toast and keeps the draft for a failed save", async () => {
settingsApiMocks.updateUserSettings.mockRejectedValue(new Error("boom"));
await renderLoaded();
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
// The draft stays visible while the write is in flight.
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
await waitFor(() => expect(toastMocks.error).toHaveBeenCalled());
// The failed draft remains available for a retry through Save changes.
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
expect(screen.getByTestId(SESSIONS_CARD_TEST_ID).getAttribute(DIRTY_ATTRIBUTE)).toBe(
DIRTY_TRUE,
);
});
it("keeps the optimistic value while a write is in flight even when the store re-asserts the baseline", async () => {
const { promise: pending, resolve: resolvePatch } = deferredPatch();
settingsApiMocks.updateUserSettings.mockReturnValue(pending);
const holder: StoreHolder = { current: null };
await renderLoaded({}, holder);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
await waitFor(() => expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy());
// An unrelated full snapshot re-asserts the baseline value while the write
// is still in flight; the optimistic override must survive and still render.
act(() => {
dispatchUserSettingsSnapshot(holder, { last_seen_display: "absolute", revision: 3 });
});
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
// The write settles: the confirmed relative value renders.
await act(async () => {
resolvePatch(patchResponse({ last_seen_display: "relative", revision: 4 }));
});
await waitFor(() => expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy());
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("relative");
});
});
describe("Last seen display queued write ordering", () => {
it("keeps a newer draft while an earlier save is in flight", async () => {
const { promise: pendingA, resolve: resolveA } = deferredPatch();
settingsApiMocks.updateUserSettings
.mockReturnValueOnce(pendingA)
.mockResolvedValueOnce(patchResponse({ last_seen_display: "absolute", revision: 3 }));
const holder: StoreHolder = { current: null };
await renderLoaded({}, holder);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
await chooseDisplay(ABSOLUTE_OPTION);
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
// A settles first. The newer local draft remains visible and dirty.
await act(async () => {
resolveA(patchResponse({ last_seen_display: "relative", revision: 2 }));
});
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
expect(screen.getByTestId(SESSIONS_CARD_TEST_ID).getAttribute(DIRTY_ATTRIBUTE)).toBe(
DIRTY_TRUE,
);
await saveChanges();
await waitFor(() =>
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("absolute"),
);
});
});
describe("Last seen display write and lifecycle races", () => {
it("discards a deferred PATCH response older than a newer WS snapshot", async () => {
const { promise: pending, resolve: resolvePatch } = deferredPatch();
settingsApiMocks.updateUserSettings.mockReturnValue(pending);
const holder: StoreHolder = { current: null };
await renderLoaded({}, holder);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
// A newer WS snapshot (foreign change at rev 5) lands first through the
// real user.settings.updated handler.
act(() => {
dispatchUserSettingsSnapshot(holder, { last_seen_display: "absolute", revision: 5 });
});
// The deferred PATCH response (rev 2) is discarded by the revision guard;
// the cell renders the newer foreign value.
await act(async () => {
resolvePatch(patchResponse({ last_seen_display: "relative", revision: 2 }));
});
await waitFor(() => expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull());
expect(screen.getByText(formatDateTime(new Date(LAST_SEEN_AT)))).toBeTruthy();
expect(holder.current!.getState().userSettings.revision).toBe(5);
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("absolute");
});
it("converges the store when the component unmounts mid-write and remounts", async () => {
const { promise: pending, resolve: resolvePatch } = deferredPatch();
settingsApiMocks.updateUserSettings.mockReturnValue(pending);
const holder: StoreHolder = { current: null };
const view = render(<RemountHarness holder={holder} mounted />);
await screen.findByTestId(SELECT_TEST_ID);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
await waitFor(() => expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy());
// Unmount only the component; the provider and store stay mounted. A newer
// WS snapshot lands after unmount through the real handler, then the PATCH
// resolves (its older revision is discarded), so the store converges.
view.rerender(<RemountHarness holder={holder} mounted={false} />);
act(() => {
dispatchUserSettingsSnapshot(holder, { last_seen_display: "absolute", revision: 5 });
});
await act(async () => {
resolvePatch(patchResponse({ last_seen_display: "relative", revision: 2 }));
});
await waitFor(() =>
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("absolute"),
);
expect(holder.current!.getState().userSettings.revision).toBe(5);
// Re-mount the component under the SAME store: it must render the converged
// store value, not a seeded baseline.
view.rerender(<RemountHarness holder={holder} mounted />);
await screen.findByTestId(SELECT_TEST_ID);
expect(screen.queryByTestId(RELATIVE_TEST_ID)).toBeNull();
expect(screen.getByText(formatDateTime(new Date(LAST_SEEN_AT)))).toBeTruthy();
view.unmount();
});
it("handles a rejected write after unmount and remounts without a stale override", async () => {
const { promise: pending, reject: rejectPatch } = deferredPatch();
settingsApiMocks.updateUserSettings.mockReturnValue(pending);
const holder: StoreHolder = { current: null };
const view = render(<RemountHarness holder={holder} mounted />);
await screen.findByTestId(SELECT_TEST_ID);
await chooseDisplay(RELATIVE_OPTION);
await saveChanges();
await waitFor(() => expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy());
// Unmount only the component; the provider and store stay mounted. A newer
// WS snapshot lands after unmount through the real handler, then the PATCH
// is rejected: the toast fires, the store keeps the WS value.
view.rerender(<RemountHarness holder={holder} mounted={false} />);
act(() => {
dispatchUserSettingsSnapshot(holder, { last_seen_display: "relative", revision: 3 });
});
await act(async () => {
rejectPatch(new ApiError("boom", 400, null));
});
await waitFor(() => expect(toastMocks.error).toHaveBeenCalled());
await waitFor(() =>
expect(holder.current!.getState().userSettings.lastSeenDisplay).toBe("relative"),
);
// Re-mount the component under the SAME store: it renders the converged WS
// value, with no stale optimistic override.
view.rerender(<RemountHarness holder={holder} mounted />);
await screen.findByTestId(SELECT_TEST_ID);
expect(screen.getByTestId(RELATIVE_TEST_ID)).toBeTruthy();
view.unmount();
});
it("ticks every second while the timestamp is under a minute old", async () => {
// last_seen 55s before now: "55 seconds ago", well past the just-now
// threshold so a one-second advance changes the label.
await renderRelativeWithFakeTime("2026-08-15T15:44:05Z");
const before = screen.getByTestId(RELATIVE_TEST_ID).textContent;
act(() => {
vi.advanceTimersByTime(1_000);
});
expect(screen.getByTestId(RELATIVE_TEST_ID).textContent).not.toBe(before);
});
it("ticks every minute once the timestamp is a minute or more old", async () => {
await renderRelativeWithFakeTime();
const before = screen.getByTestId(RELATIVE_TEST_ID).textContent;
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(screen.getByTestId(RELATIVE_TEST_ID).textContent).not.toBe(before);
});
});
describe("Last seen display discovery", () => {
it("registers the sessions and last-seen-display targets", async () => {
const reveal = vi.fn();
authApiMocks.listSessions.mockResolvedValue({ sessions: [SESSION] });
render(
<StateProvider initialState={{ userSettings: makeUserSettings({ revision: 1 }) }}>
<SettingsSaveProvider>
<SettingsTargetProvider revealTarget={reveal}>
<TooltipProvider>
<SecuritySettings />
</TooltipProvider>
</SettingsTargetProvider>
</SettingsSaveProvider>
</StateProvider>,
);
await screen.findByTestId(SELECT_TEST_ID);
emitSettingsTargetRequest("setting-account-sessions");
await waitFor(() => expect(reveal).toHaveBeenCalledTimes(1));
emitSettingsTargetRequest("setting-account-last-seen-display");
await waitFor(() => expect(reveal).toHaveBeenCalledTimes(2));
});
});
@@ -1,12 +1,23 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { CardContent } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Spinner } from "@kandev/ui/spinner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@kandev/ui/drawer";
import { Badge } from "@kandev/ui/badge";
import { IconDevices, IconKey } from "@tabler/icons-react";
import { ApiError } from "@/lib/api/client";
@@ -17,11 +28,26 @@ import {
type AuthSession,
} from "@/lib/api/domains/auth-api";
import { SettingsCard } from "@/components/settings/settings-card";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { useSettingsTargetRegistration } from "@/components/settings/settings-target-provider";
import { ACCOUNT_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/account";
import { formatDateTime } from "@/lib/i18n/formats";
import { formatDateTime, formatRelativeTime } from "@/lib/i18n/formats";
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
import { SettingsErrorText, SettingsFieldLabel } from "@/components/settings/settings-typography";
import { useNow } from "@/hooks/use-now";
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { createQueuedUserSettingsSyncWithResponse } from "@/lib/user-settings-sync";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import { toast } from "@/lib/toast/sonner";
import type { LastSeenDisplay } from "@/lib/types/http";
/** Queued sync that persists the last-seen display preference to the backend. */
const syncLastSeenDisplay = createQueuedUserSettingsSyncWithResponse<LastSeenDisplay>(
(lastSeenDisplay) => ({ last_seen_display: lastSeenDisplay }),
);
/** Renders the change-password form card. */
function ChangePasswordCard() {
const { t } = useTranslation();
const [current, setCurrent] = useState("");
@@ -30,6 +56,7 @@ function ChangePasswordCard() {
const [success, setSuccess] = useState(false);
const [submitting, setSubmitting] = useState(false);
/** Submits the password change and updates success/error state. */
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -108,12 +135,14 @@ function ChangePasswordCard() {
);
}
/** Loads the active sessions list and exposes reload/error state. */
function useSessionsList() {
const { t } = useTranslation();
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Fetches the latest sessions from the API and updates state. */
const reload = useCallback(async () => {
setError(null);
try {
@@ -129,21 +158,248 @@ function useSessionsList() {
void reload();
}, [reload]);
return { sessions, loaded, error, reload };
return { sessions, loaded, error, reload, setError };
}
/** Parses a last-seen timestamp into a Date, or null when invalid. */
function parseLastSeenAt(lastSeenAt: string): Date | null {
const parsed = new Date(lastSeenAt);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
/** Renders a session's last-seen timestamp in the selected display format. */
function LastSeenCell({ lastSeenAt, display }: { lastSeenAt: string; display: LastSeenDisplay }) {
const absolute = useMemo(() => parseLastSeenAt(lastSeenAt), [lastSeenAt]);
if (!absolute) {
// Guard formatDateTime, which throws RangeError on invalid dates.
return <TableCell className="text-xs" data-testid="last-seen-empty" />;
}
if (display === "absolute") {
return <TableCell className="text-xs">{formatDateTime(absolute)}</TableCell>;
}
return <RelativeLastSeenCell lastSeenAt={absolute} />;
}
/** Renders a live-updating relative last-seen time with an absolute-time tooltip. */
function RelativeLastSeenCell({ lastSeenAt }: { lastSeenAt: Date }) {
const { t } = useTranslation();
const usesTouchDrawer = useTouchDrawer();
const [open, setOpen] = useState(false);
// Tick every second while the timestamp is under a minute old (second-scale
// labels like "45 seconds ago" need live updates), then every minute once
// the age crosses a minute. The interval is recomputed on each render, so
// useNow re-creates its timer exactly when the cadence changes.
const ageMs = Math.abs(Date.now() - lastSeenAt.getTime());
const intervalMs = ageMs < 60_000 ? 1_000 : 60_000;
const now = useNow(intervalMs);
const absolute = formatDateTime(lastSeenAt);
const relative = formatRelativeTime(lastSeenAt, now);
if (usesTouchDrawer) {
return (
<TableCell className="text-xs">
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<button
type="button"
aria-label={absolute}
title={absolute}
aria-haspopup="dialog"
aria-expanded={open}
className="min-h-11 cursor-pointer rounded-sm px-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="last-seen-relative"
>
<time dateTime={lastSeenAt.toISOString()}>{relative}</time>
</button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t("account:lastSeen")}</DrawerTitle>
<DrawerDescription data-testid="last-seen-absolute">{absolute}</DrawerDescription>
</DrawerHeader>
</DrawerContent>
</Drawer>
</TableCell>
);
}
return (
<TableCell className="text-xs">
<Tooltip>
<TooltipTrigger asChild>
<time
dateTime={lastSeenAt.toISOString()}
tabIndex={0}
title={absolute}
aria-label={absolute}
className="cursor-default rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="last-seen-relative"
>
{relative}
</time>
</TooltipTrigger>
<TooltipContent>{absolute}</TooltipContent>
</Tooltip>
</TableCell>
);
}
/** Renders the last-seen display format preference selector. */
function LastSeenDisplaySelect({
value,
onChange,
}: {
value: LastSeenDisplay;
onChange: (value: LastSeenDisplay) => void;
}) {
const { t } = useTranslation();
/** Ref callback that registers this element as the last-seen display discovery target. */
const registerTarget = useSettingsTargetRegistration(ACCOUNT_SETTINGS_TARGETS.lastSeenDisplay);
return (
<div ref={registerTarget} className="space-y-2" data-testid="last-seen-display-control">
<Label htmlFor="last-seen-display">{t("account:lastSeenDisplay")}</Label>
<Select value={value} onValueChange={(next) => onChange(next as LastSeenDisplay)}>
<SelectTrigger
id="last-seen-display"
data-testid="last-seen-display-select"
className="min-h-[44px] cursor-pointer"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="absolute" className="min-h-[44px]">
{t("account:absoluteTime")}
</SelectItem>
<SelectItem value="relative" className="min-h-[44px]">
{t("account:relativeTime")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("account:lastSeenDisplayDescription")}</p>
</div>
);
}
/** Renders the active sessions table with revoke actions. */
function SessionsTable({
sessions,
display,
onRevoke,
}: {
sessions: AuthSession[];
display: LastSeenDisplay;
onRevoke: (id: string) => void;
}) {
const { t } = useTranslation();
return (
<Table data-testid="account-sessions-table">
<TableHeader>
<TableRow>
<TableHead>{t("account:device")}</TableHead>
<TableHead>{t("account:ipAddress")}</TableHead>
<TableHead>{t("account:lastSeen")}</TableHead>
<TableHead className="text-right">{t("account:actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => (
<TableRow
key={session.id}
data-testid="account-sessions-row"
data-current-session={session.current ? "true" : "false"}
>
<TableCell className="text-xs">
{session.user_agent}
{session.current && (
<Badge variant="default" className="ml-2 text-[10px]">
{t("account:thisDevice")}
</Badge>
)}
</TableCell>
<TableCell className="text-xs">{session.ip}</TableCell>
<LastSeenCell lastSeenAt={session.last_seen_at} display={display} />
<TableCell className="text-right">
{!session.current && (
<Button
size="sm"
variant="ghost"
className="cursor-pointer text-destructive"
onClick={() => void onRevoke(session.id)}
data-testid="account-sessions-revoke"
>
{t("account:signOut")}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
/** Renders the active-sessions settings card with display preference and revoke. */
function SessionsCard() {
const { t } = useTranslation();
const { sessions, loaded, error, reload } = useSessionsList();
const { sessions, loaded, error, reload, setError } = useSessionsList();
const savedDisplay = useAppStore((state) => state.userSettings.lastSeenDisplay);
const store = useAppStoreApi();
const [draftDisplay, setDraftDisplay] = useState<LastSeenDisplay>(savedDisplay);
const hasLocalDraft = useRef(false);
const isDirty = draftDisplay !== savedDisplay;
useEffect(() => {
if (!hasLocalDraft.current) setDraftDisplay(savedDisplay);
}, [savedDisplay]);
const onDisplayChange = useCallback((next: LastSeenDisplay) => {
hasLocalDraft.current = true;
setDraftDisplay(next);
}, []);
useSettingsSaveContributor({
id: "account-last-seen-display",
order: 30,
revision: draftDisplay,
isDirty,
save: async (revision) => {
const submitted = revision as LastSeenDisplay;
try {
const response = await syncLastSeenDisplay(submitted);
const state = store.getState();
state.setUserSettings(mapUserSettingsResponse(response, state.userSettings));
const confirmed = store.getState().userSettings.lastSeenDisplay;
setDraftDisplay((current) => {
if (current !== submitted) return current;
hasLocalDraft.current = false;
return confirmed;
});
} catch (error) {
toast.error(t("account:failedToSaveLastSeenDisplay"));
throw error;
}
},
discard: () => {
hasLocalDraft.current = false;
setDraftDisplay(savedDisplay);
},
});
/** Revokes a session and reloads the sessions list. */
const onRevoke = async (id: string) => {
await revokeSession(id);
await reload();
try {
await revokeSession(id);
await reload();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("account:failedToRevokeSession"));
}
};
return (
<SettingsCard
discoveryTargetId={ACCOUNT_SETTINGS_TARGETS.sessions}
isDirty={isDirty}
data-testid="account-sessions-card"
>
<SettingsCardHeader
@@ -154,6 +410,10 @@ function SessionsCard() {
}
/>
<CardContent className="space-y-3">
{/* The display preference is independent of session rows, so the
select (and its discovery target) stays reachable during loading
and when the sessions API fails. */}
<LastSeenDisplaySelect value={draftDisplay} onChange={onDisplayChange} />
{error && (
<SettingsErrorText data-testid="account-sessions-error">{error}</SettingsErrorText>
)}
@@ -163,51 +423,14 @@ function SessionsCard() {
</div>
)}
{loaded && sessions.length > 0 && (
<Table data-testid="account-sessions-table">
<TableHeader>
<TableRow>
<TableHead>{t("account:device")}</TableHead>
<TableHead>{t("account:ipAddress")}</TableHead>
<TableHead>{t("account:lastSeen")}</TableHead>
<TableHead className="text-right">{t("account:actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => (
<TableRow key={session.id} data-testid="account-sessions-row">
<TableCell className="text-xs">
{session.user_agent}
{session.current && (
<Badge variant="default" className="ml-2 text-[10px]">
{t("account:thisDevice")}
</Badge>
)}
</TableCell>
<TableCell className="text-xs">{session.ip}</TableCell>
<TableCell className="text-xs">{formatDateTime(session.last_seen_at)}</TableCell>
<TableCell className="text-right">
{!session.current && (
<Button
size="sm"
variant="ghost"
className="cursor-pointer text-destructive"
onClick={() => void onRevoke(session.id)}
data-testid="account-sessions-revoke"
>
{t("account:signOut")}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<SessionsTable sessions={sessions} display={draftDisplay} onRevoke={onRevoke} />
)}
</CardContent>
</SettingsCard>
);
}
/** Renders the account security settings section. */
export function SecuritySettings() {
return (
<div className="space-y-4">
@@ -0,0 +1,112 @@
import { devices, expect } from "@playwright/test";
import path from "node:path";
import { backendFixture as test } from "../../fixtures/backend";
import { login, setupAdmin } from "../../helpers/auth";
/**
* Mobile parity for the relative "Last seen" option: the select is operated
* with touch-native `tap()` (mouse-semantics clicks would not prove touch),
* relative labels render without hover, and the trigger/option meet the 44px
* active-dimension touch target at the Pixel 5 viewport.
*
* Named `mobile-*` so the `mobile-chrome` project routes it away from the
* desktop `auth` project. Manual contexts do not inherit project device
* options, so the Pixel 5 viewport is spread explicitly.
*/
const ADMIN = { email: "admin@demo.dev", password: "adminpass123", displayName: "Ada Admin" };
const SECURITY_PATH = "/settings/account/security";
const CURRENT_SESSION_ROW = '[data-testid="account-sessions-row"][data-current-session="true"]';
test.describe.serial("relative last seen (mobile)", () => {
test.beforeAll(async ({ backend }) => {
await backend.restart({
KANDEV_FEATURES_AUTH: "true",
KANDEV_DATABASE_PATH: path.join(backend.tmpDir, "kandev-mobile-relative-last-seen.db"),
});
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("operates the Last seen select with touch and renders relative labels without hover", async ({
browser,
backend,
}) => {
const ctx = await browser.newContext({
...devices["Pixel 5"],
baseURL: backend.frontendUrl,
});
try {
await setupAdmin(ctx, backend.baseUrl, ADMIN);
await login(ctx, backend.baseUrl, ADMIN);
let original = "absolute";
try {
const originalRes = await ctx.request.get(`${backend.baseUrl}/api/v1/user/settings`);
expect(originalRes.ok(), await originalRes.text()).toBeTruthy();
const originalBody = (await originalRes.json()) as {
settings: { last_seen_display?: string };
};
original = originalBody.settings.last_seen_display ?? "absolute";
// Pin a known absolute baseline so the relative transition below is a
// real persisted change, not a pre-existing value.
const baselineRes = await ctx.request.patch(`${backend.baseUrl}/api/v1/user/settings`, {
data: { last_seen_display: "absolute" },
});
expect(baselineRes.ok(), await baselineRes.text()).toBeTruthy();
const page = await ctx.newPage();
// Manual contexts do not inherit project device options; pin the width.
expect((await page.viewportSize())?.width).toBe(393);
await page.goto(SECURITY_PATH);
await expect(page.getByTestId("last-seen-relative")).toHaveCount(0);
const trigger = page.getByTestId("last-seen-display-select");
await expect(trigger).toBeVisible({ timeout: 15_000 });
// The trigger meets the 44px active-dimension touch target.
const triggerBox = await trigger.boundingBox();
expect(triggerBox).not.toBeNull();
expect(triggerBox!.height).toBeGreaterThanOrEqual(44);
await trigger.tap();
const option = page.getByRole("listbox").getByRole("option", { name: "Relative time" });
await expect(option).toBeVisible();
// The dropdown entrance animation (zoom-in-95 over 100ms) scales the
// whole content, so a one-shot boundingBox() can measure mid-flight
// and read ~42px. Poll until the box settles at the real size before
// asserting the 44px active-dimension touch target.
await expect
.poll(async () => Math.round((await option.boundingBox())?.height ?? 0))
.toBeGreaterThanOrEqual(44);
await option.tap();
await page.getByRole("button", { name: "Save changes" }).tap();
// Relative labels render without hover, with no horizontal overflow.
const relative = page.locator(CURRENT_SESSION_ROW).getByTestId("last-seen-relative");
await expect(relative).toBeVisible({ timeout: 15_000 });
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
);
expect(overflow).toBe(false);
// The absolute stamp stays reachable through an explicit touch drawer.
const absolute = await relative.getAttribute("title");
expect(absolute).toBeTruthy();
await expect(relative).toHaveAttribute("aria-label", absolute!);
await relative.tap();
const absoluteDetails = page.getByTestId("last-seen-absolute");
await expect(absoluteDetails).toBeVisible();
await expect(absoluteDetails).toHaveText(absolute!);
} finally {
const res = await ctx.request.patch(`${backend.baseUrl}/api/v1/user/settings`, {
data: { last_seen_display: original },
});
expect(res.ok(), await res.text()).toBeTruthy();
}
} finally {
await ctx.close();
}
});
});
@@ -0,0 +1,162 @@
import { expect, type BrowserContext } from "@playwright/test";
import path from "node:path";
import { backendFixture as test } from "../../fixtures/backend";
import { login, setupAdmin } from "../../helpers/auth";
import { watchWs } from "../../helpers/causal-waits";
/**
* Relative "Last seen" on the account security page (desktop + cross-tab).
*
* Runs in the `auth` project with the worker backend restarted to require
* auth and use its own database. Serial: auth setup is single-shot per
* database and the worker DB survives restarts, so the file owns its own
* `KANDEV_DATABASE_PATH` (the users-self-actions isolation pattern) and
* restores the baseline backend in afterAll.
*/
const ADMIN = { email: "admin@demo.dev", password: "adminpass123", displayName: "Ada Admin" };
const SECURITY_PATH = "/settings/account/security";
const CURRENT_SESSION_ROW = '[data-testid="account-sessions-row"][data-current-session="true"]';
/** Fetches the user's current last_seen_display setting from the settings API. */
async function readLastSeenDisplay(ctx: BrowserContext, baseUrl: string): Promise<string> {
const res = await ctx.request.get(`${baseUrl}/api/v1/user/settings`);
expect(res.ok(), await res.text()).toBeTruthy();
const body = (await res.json()) as { settings: { last_seen_display?: string } };
return body.settings.last_seen_display ?? "absolute";
}
/** Persists the given last_seen_display value through the settings API. */
async function restoreLastSeenDisplay(
ctx: BrowserContext,
baseUrl: string,
value: string,
): Promise<void> {
const res = await ctx.request.patch(`${baseUrl}/api/v1/user/settings`, {
data: { last_seen_display: value },
});
expect(res.ok(), await res.text()).toBeTruthy();
}
test.describe.serial("relative last seen (desktop)", () => {
test.beforeAll(async ({ browser, backend }) => {
await backend.restart({
KANDEV_FEATURES_AUTH: "true",
KANDEV_DATABASE_PATH: path.join(backend.tmpDir, "kandev-relative-last-seen.db"),
});
const setupContext = await browser.newContext({ baseURL: backend.frontendUrl });
try {
await setupAdmin(setupContext, backend.baseUrl, ADMIN);
} finally {
await setupContext.close();
}
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("switches Last seen to relative time and shows a relative label with an absolute tooltip", async ({
browser,
backend,
}) => {
const ctx = await browser.newContext({ baseURL: backend.frontendUrl });
try {
await login(ctx, backend.baseUrl, ADMIN);
let original = "absolute";
try {
original = await readLastSeenDisplay(ctx, backend.baseUrl);
// Pin a known absolute baseline so selecting Relative time is a real
// transition and PATCH, not a no-op on a pre-existing relative value.
await restoreLastSeenDisplay(ctx, backend.baseUrl, "absolute");
const page = await ctx.newPage();
await page.goto(SECURITY_PATH);
await expect(page.getByTestId("last-seen-relative")).toHaveCount(0);
const select = page.getByTestId("last-seen-display-select");
await expect(select).toBeVisible({ timeout: 15_000 });
await select.click();
await page.getByRole("listbox").getByRole("option", { name: "Relative time" }).click();
await page.getByRole("button", { name: "Save changes" }).click();
const relative = page.locator(CURRENT_SESSION_ROW).getByTestId("last-seen-relative");
await expect(relative).toBeVisible({ timeout: 15_000 });
// Hover reveals the absolute timestamp in a tooltip, matching the
// trigger's accessible name/native title exactly.
const absolute = await relative.getAttribute("title");
expect(absolute).toBeTruthy();
await relative.hover();
const tooltip = page.getByRole("tooltip");
await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveText(absolute!);
// The choice persists to the user-settings API.
await expect
.poll(() => readLastSeenDisplay(ctx, backend.baseUrl), { timeout: 15_000 })
.toBe("relative");
} finally {
await restoreLastSeenDisplay(ctx, backend.baseUrl, original);
}
} finally {
await ctx.close();
}
});
test("syncs a Last seen change to another tab over WebSocket without a reload", async ({
browser,
backend,
}) => {
const ctxA = await browser.newContext({ baseURL: backend.frontendUrl });
const ctxB = await browser.newContext({ baseURL: backend.frontendUrl });
try {
await login(ctxA, backend.baseUrl, ADMIN);
await login(ctxB, backend.baseUrl, ADMIN);
let original = "absolute";
try {
original = await readLastSeenDisplay(ctxA, backend.baseUrl);
// Pin a known absolute baseline so tab A's pre-mutation display is
// established: the relative assertion below can only pass via a real
// WS event, not a stale pre-existing value.
await restoreLastSeenDisplay(ctxA, backend.baseUrl, "absolute");
const pageA = await ctxA.newPage();
// watchWs only observes sockets opened after it is armed, and the
// subscription wait itself must be armed before navigation.
const watcher = watchWs(pageA);
const subscriptionAck = watcher.waitForResponse("user.subscribe");
await pageA.goto(SECURITY_PATH);
await subscriptionAck;
await expect(pageA.getByTestId("last-seen-relative")).toHaveCount(0);
// Arm the WS event wait before tab B mutates; frames are not buffered,
// so a wait armed after the mutation could miss the push entirely.
const settingsUpdated = watcher.waitForEvent("user.settings.updated");
const pageB = await ctxB.newPage();
await pageB.goto(SECURITY_PATH);
await expect(pageB.getByTestId("last-seen-relative")).toHaveCount(0);
await pageB.getByTestId("last-seen-display-select").click();
await pageB.getByRole("listbox").getByRole("option", { name: "Relative time" }).click();
await pageB.getByRole("button", { name: "Save changes" }).click();
await expect(
pageB.locator(CURRENT_SESSION_ROW).getByTestId("last-seen-relative"),
).toBeVisible({
timeout: 15_000,
});
// Tab A observes the change over WebSocket, without a reload.
await settingsUpdated;
await expect(
pageA.locator(CURRENT_SESSION_ROW).getByTestId("last-seen-relative"),
).toBeVisible();
} finally {
await restoreLastSeenDisplay(ctxA, backend.baseUrl, original);
}
} finally {
await ctxA.close();
await ctxB.close();
}
});
});
@@ -44,6 +44,7 @@ import {
useEnsureUserSettings,
} from "./use-ensure-user-settings";
/** Builds a full default user-settings state marked as not loaded. */
function makeUnloadedSettings(): UserSettingsState {
return {
revision: null,
@@ -98,6 +99,7 @@ function makeUnloadedSettings(): UserSettingsState {
terminalFontFamily: null,
terminalFontSize: null,
changesPanelLayout: "tree",
lastSeenDisplay: "absolute",
systemMetricsDisplay: { showInTopbar: false, simplified: false },
appStatusBarEnabled: false,
appStatusBarOrder: { leftItemIds: [], rightItemIds: [] },
@@ -110,6 +112,7 @@ function makeUnloadedSettings(): UserSettingsState {
};
}
/** Builds a mock settings API response wrapping the given task-create last-used payload. */
function userSettingsResponse(taskCreateLastUsed = {}) {
return {
shell_options: [],
@@ -2,14 +2,16 @@ import type { SettingsDiscoveryDefinition } from "../types";
export const ACCOUNT_SETTINGS_HREF = "/settings/account";
export const ACCOUNT_SECURITY_SETTINGS_HREF = `${ACCOUNT_SETTINGS_HREF}/security`;
const ACCOUNT_SECURITY_ID = "account-security";
export const ACCOUNT_SETTINGS_TARGETS = {
password: "setting-account-password",
sessions: "setting-account-sessions",
lastSeenDisplay: "setting-account-last-seen-display",
} as const;
export const ACCOUNT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
{
id: "account-security",
id: ACCOUNT_SECURITY_ID,
kind: "page",
labelKey: "sidebar:profileAndPassword",
groupId: "access",
@@ -30,7 +32,7 @@ export const ACCOUNT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
id: "account-password",
kind: "control",
labelKey: "settings:password",
parentId: "account-security",
parentId: ACCOUNT_SECURITY_ID,
groupId: "access",
href: ACCOUNT_SECURITY_SETTINGS_HREF,
targetId: ACCOUNT_SETTINGS_TARGETS.password,
@@ -41,11 +43,22 @@ export const ACCOUNT_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [
id: "account-sessions",
kind: "section",
labelKey: "settings:activeSessions",
parentId: "account-security",
parentId: ACCOUNT_SECURITY_ID,
groupId: "access",
href: ACCOUNT_SECURITY_SETTINGS_HREF,
targetId: ACCOUNT_SETTINGS_TARGETS.sessions,
order: 812,
requires: "account",
},
{
id: "account-last-seen-display",
kind: "control",
labelKey: "account:lastSeenDisplay",
parentId: ACCOUNT_SECURITY_ID,
groupId: "access",
href: ACCOUNT_SECURITY_SETTINGS_HREF,
targetId: ACCOUNT_SETTINGS_TARGETS.lastSeenDisplay,
order: 813,
requires: "account",
},
];
+47
View File
@@ -1,8 +1,11 @@
import { describe, it, expect } from "vitest";
import {
buildCoreFields,
createDefaultUserSettings,
mapUserSettingsData,
mapUserSettingsResponse,
parseChangesPanelLayout,
parseLastSeenDisplay,
parseLspStatusLocation,
parseStartupPage,
parseSystemMetricsDisplay,
@@ -528,6 +531,50 @@ describe("parseChangesPanelLayout", () => {
});
});
describe("parseLastSeenDisplay", () => {
it('returns "relative" for "relative"', () => {
expect(parseLastSeenDisplay("relative")).toBe("relative");
});
it('returns "absolute" for "absolute"', () => {
expect(parseLastSeenDisplay("absolute")).toBe("absolute");
});
it('returns "absolute" for undefined or unknown values', () => {
expect(parseLastSeenDisplay(undefined)).toBe("absolute");
expect(parseLastSeenDisplay("grid")).toBe("absolute");
expect(parseLastSeenDisplay("")).toBe("absolute");
});
});
describe("last seen display hydration", () => {
it("defaults to absolute", () => {
expect(createDefaultUserSettings().lastSeenDisplay).toBe("absolute");
});
it("maps a stored relative value", () => {
const mapped = mapUserSettingsData(
{ last_seen_display: "relative" },
createDefaultUserSettings(),
);
expect(mapped.lastSeenDisplay).toBe("relative");
});
it("normalizes unknown stored values to absolute", () => {
const mapped = mapUserSettingsData(
{ last_seen_display: "garbage" as "absolute" | "relative" },
createDefaultUserSettings(),
);
expect(mapped.lastSeenDisplay).toBe("absolute");
});
it("preserves the current value when omitted", () => {
const current = { ...createDefaultUserSettings(), lastSeenDisplay: "relative" as const };
const mapped = mapUserSettingsData({}, current);
expect(mapped.lastSeenDisplay).toBe("relative");
});
});
describe("prevent auto-start on open preference", () => {
it("defaults the missing preference to false", () => {
expect(buildCoreFields({}).preventAutoStartAgentOnOpen).toBe(false);
+29
View File
@@ -10,6 +10,7 @@ import { type UserSettingsState } from "@/lib/state/slices/settings/types";
import type { SidebarTaskPrefsApi, UserSettings, UserSettingsResponse } from "@/lib/types/http";
import type {
LspStatusLocation,
LastSeenDisplay,
MCPTaskAgentProfileDefault,
StartupPage,
} from "@/lib/types/http-user-settings";
@@ -18,6 +19,7 @@ export type UserSettingsData = Omit<Partial<UserSettings>, "workspace_id"> & {
workspace_id?: string;
};
/** Builds a fresh UserSettingsState with default values. */
export function createDefaultUserSettings(): UserSettingsState {
return {
revision: null,
@@ -77,6 +79,7 @@ export function createDefaultUserSettings(): UserSettingsState {
terminalFontFamily: null,
terminalFontSize: null,
changesPanelLayout: "tree",
lastSeenDisplay: "absolute",
systemMetricsDisplay: { showInTopbar: false, simplified: false },
appStatusBarEnabled: false,
appStatusBarOrder: { leftItemIds: [], rightItemIds: [] },
@@ -85,28 +88,39 @@ export function createDefaultUserSettings(): UserSettingsState {
};
}
/** Parses the terminal link behavior, defaulting to "new_tab". */
export function parseTerminalLinkBehavior(value: string | undefined): "new_tab" | "browser_panel" {
return value === "browser_panel" ? "browser_panel" : "new_tab";
}
/** Parses the changes panel layout, defaulting to "tree". */
export function parseChangesPanelLayout(value: string | undefined): "flat" | "tree" {
return value === "flat" ? "flat" : "tree";
}
/** Parses the last-seen display format, defaulting to "absolute". */
export function parseLastSeenDisplay(value: string | undefined): LastSeenDisplay {
return value === "relative" ? "relative" : "absolute";
}
/** Parses the MCP task agent profile default, defaulting to "current_task". */
export function parseMCPTaskAgentProfileDefault(
value: string | undefined,
): MCPTaskAgentProfileDefault {
return value === "workspace_default" ? "workspace_default" : "current_task";
}
/** Parses the startup page preference, defaulting to "task_overview". */
export function parseStartupPage(value: string | undefined): StartupPage {
return value === "last_task" ? "last_task" : "task_overview";
}
/** Parses the LSP status location, defaulting to "toolbar". */
export function parseLspStatusLocation(value: string | undefined): LspStatusLocation {
return value === "status_bar" ? "status_bar" : "toolbar";
}
/** Parses the system metrics display fields with defaults. */
export function parseSystemMetricsDisplay(value: UserSettingsData["system_metrics_display"]) {
return {
showInTopbar: value?.show_in_topbar ?? false,
@@ -114,6 +128,7 @@ export function parseSystemMetricsDisplay(value: UserSettingsData["system_metric
};
}
/** Parses the app status bar order fields with empty defaults. */
export function parseAppStatusBarOrder(value: UserSettingsData["app_status_bar_order"]) {
return {
leftItemIds: value?.left_item_ids ?? [],
@@ -121,6 +136,7 @@ export function parseAppStatusBarOrder(value: UserSettingsData["app_status_bar_o
};
}
/** Maps terminal-related API fields onto state, falling back to current values. */
function buildTerminalFields(s: UserSettingsData, current: UserSettingsState) {
return {
terminalLinkBehavior:
@@ -140,6 +156,7 @@ function buildTerminalFields(s: UserSettingsData, current: UserSettingsState) {
};
}
/** Maps the system metrics display field onto state, falling back to current. */
function buildSystemMetricsDisplayFields(
s: UserSettingsData | undefined,
current: UserSettingsState,
@@ -152,6 +169,7 @@ function buildSystemMetricsDisplayFields(
};
}
/** Parses sidebar task preferences with empty-array defaults. */
function parseSidebarTaskPrefs(value: SidebarTaskPrefsApi | undefined) {
return {
pinnedTaskIds: value?.pinned_task_ids ?? [],
@@ -160,6 +178,7 @@ function parseSidebarTaskPrefs(value: SidebarTaskPrefsApi | undefined) {
};
}
/** Returns true when the task-create last-used payload carries any value. */
export function taskCreateLastUsedHasValue(
value: UserSettingsData["task_create_last_used"] | undefined,
) {
@@ -172,6 +191,7 @@ export function taskCreateLastUsedHasValue(
);
}
/** Parses task-create last-used fields, deriving the synced flag from presence. */
function parseTaskCreateLastUsed(value: UserSettingsData["task_create_last_used"] | undefined) {
return {
repositoryId: value?.repository_id || null,
@@ -183,6 +203,7 @@ function parseTaskCreateLastUsed(value: UserSettingsData["task_create_last_used"
};
}
/** Returns current when value is undefined, otherwise maps it. */
function mapDefined<TInput, TOutput>(
value: TInput | undefined,
current: TOutput,
@@ -191,10 +212,12 @@ function mapDefined<TInput, TOutput>(
return value === undefined ? current : map(value);
}
/** Maps an optional string, normalizing empty strings to null. */
function mapNullableString(value: string | undefined, current: string | null) {
return mapDefined(value, current, (defined) => defined || null);
}
/** Maps identity-related API fields onto state, falling back to current values. */
function buildIdentityFields(s: UserSettingsData, current: UserSettingsState) {
return {
workspaceId: mapNullableString(s.workspace_id, current.workspaceId),
@@ -213,6 +236,7 @@ function buildIdentityFields(s: UserSettingsData, current: UserSettingsState) {
};
}
/** Maps behavior-related API fields onto state, falling back to current values. */
function buildBehaviorFields(s: UserSettingsData, current: UserSettingsState) {
return {
enablePreviewOnClick: s.enable_preview_on_click ?? current.enablePreviewOnClick,
@@ -233,6 +257,7 @@ function buildBehaviorFields(s: UserSettingsData, current: UserSettingsState) {
};
}
/** Maps appearance-related API fields onto state, falling back to current values. */
function buildAppearanceFields(s: UserSettingsData, current: UserSettingsState) {
return {
showAnchoredPromptBar: s.show_anchored_prompt_bar ?? current.showAnchoredPromptBar,
@@ -248,9 +273,11 @@ function buildAppearanceFields(s: UserSettingsData, current: UserSettingsState)
s.release_notes_last_seen_version,
current.releaseNotesLastSeenVersion,
),
lastSeenDisplay: mapDefined(s.last_seen_display, current.lastSeenDisplay, parseLastSeenDisplay),
};
}
/** Maps the core user-settings API fields onto state, falling back to current values. */
export function buildCoreFields(
s: UserSettingsData,
current: UserSettingsState = createDefaultUserSettings(),
@@ -312,6 +339,7 @@ export function buildCoreFields(
};
}
/** Maps LSP-related API fields onto state, falling back to current values. */
export function buildLspFields(
s: UserSettingsData | undefined,
current: UserSettingsState = createDefaultUserSettings(),
@@ -327,6 +355,7 @@ export function buildLspFields(
};
}
/** Maps API settings data onto a complete UserSettingsState, marking it loaded. */
export function mapUserSettingsData(
settings: UserSettingsData,
current: UserSettingsState = createDefaultUserSettings(),
@@ -11,6 +11,7 @@ import type {
SavedLayout,
ToolStatus,
LspStatusLocation,
LastSeenDisplay,
MCPTaskAgentProfileDefault,
StartupPage,
} from "@/lib/types/http";
@@ -297,6 +298,7 @@ export type UserSettingsState = {
terminalFontFamily: string | null;
terminalFontSize: number | null;
changesPanelLayout: "flat" | "tree";
lastSeenDisplay: LastSeenDisplay;
systemMetricsDisplay: { showInTopbar: boolean; simplified: boolean };
appStatusBarEnabled: boolean;
appStatusBarOrder: AppStatusBarOrderState;
+3
View File
@@ -3,6 +3,7 @@ import type { WorkspaceId } from "./ids";
export type MCPTaskAgentProfileDefault = "current_task" | "workspace_default";
export type StartupPage = "task_overview" | "last_task";
export type LspStatusLocation = "toolbar" | "status_bar";
export type LastSeenDisplay = "absolute" | "relative";
export type SavedLayout = {
id: string;
@@ -100,6 +101,7 @@ export type UserSettings = {
terminal_font_family?: string;
terminal_font_size?: number;
changes_panel_layout?: "flat" | "tree";
last_seen_display?: LastSeenDisplay;
system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean };
app_status_bar_enabled?: boolean;
app_status_bar_order?: AppStatusBarOrderApi;
@@ -164,6 +166,7 @@ export type UserSettingsUpdatePayload = {
terminal_font_family?: string;
terminal_font_size?: number;
changes_panel_layout?: "flat" | "tree";
last_seen_display?: LastSeenDisplay;
system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean };
app_status_bar_enabled?: boolean;
app_status_bar_order?: AppStatusBarOrderApi;
+1
View File
@@ -28,6 +28,7 @@ export type {
TaskCreateLastUsedApi,
AppStatusBarOrderApi,
LspStatusLocation,
LastSeenDisplay,
MCPTaskAgentProfileDefault,
StartupPage,
UserSettings,
+42
View File
@@ -5,10 +5,12 @@ import { defaultState } from "@/lib/state/default-state";
import type { AppState } from "@/lib/state/store";
import type { BackendMessageMap } from "@/lib/types/backend";
/** Creates a vanilla Zustand store seeded with a structured clone of the default app state. */
function makeStore() {
return createStore<AppState>(() => structuredClone(defaultState) as AppState);
}
/** Builds a `user.settings.updated` notification frame merging the given payload over default values. */
function userSettingsMessage(
payload: Partial<BackendMessageMap["user.settings.updated"]["payload"]>,
): BackendMessageMap["user.settings.updated"] {
@@ -103,6 +105,46 @@ describe("status bar visibility websocket sync", () => {
});
});
describe("last seen display websocket sync", () => {
it("applies a relative value and normalizes unknown values", () => {
const store = makeStore();
expect(store.getState().userSettings.lastSeenDisplay).toBe("absolute");
registerUsersHandlers(store)["user.settings.updated"]?.(
userSettingsMessage({ last_seen_display: "relative" }),
);
expect(store.getState().userSettings.lastSeenDisplay).toBe("relative");
registerUsersHandlers(store)["user.settings.updated"]?.(
userSettingsMessage({
last_seen_display: "unexpected",
} as unknown as Partial<BackendMessageMap["user.settings.updated"]["payload"]>),
);
expect(store.getState().userSettings.lastSeenDisplay).toBe("absolute");
});
it("preserves the current value when omitted", () => {
const store = makeStore();
store.setState((state) => ({
...state,
userSettings: { ...state.userSettings, lastSeenDisplay: "relative" },
}));
registerUsersHandlers(store)["user.settings.updated"]?.(userSettingsMessage({}));
expect(store.getState().userSettings.lastSeenDisplay).toBe("relative");
});
it("ignores older settings revisions", () => {
const store = makeStore();
store.setState((state) => ({
...state,
userSettings: { ...state.userSettings, lastSeenDisplay: "absolute", revision: 2 },
}));
const handler = registerUsersHandlers(store)["user.settings.updated"];
handler?.(userSettingsMessage({ last_seen_display: "relative", revision: 1 }));
expect(store.getState().userSettings.lastSeenDisplay).toBe("absolute");
});
});
describe("user settings websocket handler", () => {
it("updates LSP status location, normalizes unknown values, and preserves omissions", () => {
const store = makeStore();
+6
View File
@@ -1,6 +1,7 @@
{
"actions": "Actions",
"activeSessions": "Active sessions",
"absoluteTime": "Absolute time",
"apiTokens": "API tokens",
"apiTokensBlurb": "Personal access tokens authenticate scripts and CLIs as you. Revoke a token immediately if it may have leaked.",
"cancel": "Cancel",
@@ -17,8 +18,12 @@
"done": "Done",
"failedToLoadSessions": "Failed to load sessions.",
"failedToLoadTokens": "Failed to load tokens.",
"failedToSaveLastSeenDisplay": "Failed to save the last seen display.",
"failedToRevokeSession": "Failed to revoke the session.",
"ipAddress": "IP",
"lastSeen": "Last seen",
"lastSeenDisplay": "Last seen display",
"lastSeenDisplayDescription": "Choose how session activity times are shown.",
"lastUsed": "Last used",
"loadingSessions": "Loading sessions...",
"loadingTokens": "Loading tokens...",
@@ -29,6 +34,7 @@
"noTokensYet": "No tokens yet.",
"password": "Password",
"passwordUpdated": "Password updated.",
"relativeTime": "Relative time",
"revoke": "Revoke",
"saving": "Saving...",
"securityPageDescription": "Change your password and review devices signed in to your account.",
+6
View File
@@ -1,6 +1,7 @@
{
"actions": "Àćţĩōńś",
"activeSessions": "Àćţĩvē śēśśĩōńś",
"absoluteTime": "Àƀśōĺũţē ţĩḿē",
"apiTokens": "ÀƤĨ ţōķēńś",
"apiTokensBlurb": "Ƥēŕśōńàĺ àććēśś ţōķēńś àũţĥēńţĩćàţē śćŕĩƥţś àńď ĆĹĨś àś ŷōũ. Ŕēvōķē à ţōķēń ĩḿḿēďĩàţēĺŷ ĩƒ ĩţ ḿàŷ ĥàvē ĺēàķēď.",
"cancel": "Ćàńćēĺ",
@@ -17,8 +18,12 @@
"done": "Ďōńē",
"failedToLoadSessions": "Ƒàĩĺēď ţō ĺōàď śēśśĩōńś.",
"failedToLoadTokens": "Ƒàĩĺēď ţō ĺōàď ţōķēńś.",
"failedToSaveLastSeenDisplay": "Ƒàĩĺēď ţō śàvē ţĥē ĺàśţ śēēń ďĩśƥĺàŷ.",
"failedToRevokeSession": "Ƒàĩĺēď ţō ŕēvōķē ţĥē śēśśĩōń.",
"ipAddress": "ĨƤ",
"lastSeen": "Ĺàśţ śēēń",
"lastSeenDisplay": "Ĺàśţ śēēń ďĩśƥĺàŷ",
"lastSeenDisplayDescription": "Ćĥōōśē ĥōŵ śēśśĩōń àćţĩvĩţŷ ţĩḿēś àŕē śĥōŵń.",
"lastUsed": "Ĺàśţ ũśēď",
"loadingSessions": "Ĺōàďĩńĝ śēśśĩōńś...",
"loadingTokens": "Ĺōàďĩńĝ ţōķēńś...",
@@ -29,6 +34,7 @@
"noTokensYet": "Ńō ţōķēńś ŷēţ.",
"password": "Ƥàśśŵōŕď",
"passwordUpdated": "Ƥàśśŵōŕď ũƥďàţēď.",
"relativeTime": "Ŕēĺàţĩvē ţĩḿē",
"revoke": "Ŕēvōķē",
"saving": "Śàvĩńĝ...",
"securityPageDescription": "Ćĥàńĝē ŷōũŕ ƥàśśŵōŕď àńď ŕēvĩēŵ ďēvĩćēś śĩĝńēď ĩń ţō ŷōũŕ àććōũńţ.",
+6
View File
@@ -1,6 +1,7 @@
{
"actions": "Ações",
"activeSessions": "Sessões ativas",
"absoluteTime": "Hora absoluta",
"apiTokens": "Tokens de API",
"apiTokensBlurb": "Os tokens de acesso pessoal autenticam scripts e CLI como se fosse o utilizador. Revogue um token de imediato se houver a possibilidade de ter sido divulgado.",
"cancel": "Cancelar",
@@ -17,8 +18,12 @@
"done": "Concluído",
"failedToLoadSessions": "Não foi possível carregar as sessões.",
"failedToLoadTokens": "Não foi possível carregar os tokens.",
"failedToSaveLastSeenDisplay": "Não foi possível guardar a forma de apresentação da última atividade.",
"failedToRevokeSession": "Não foi possível revogar a sessão.",
"ipAddress": "IP",
"lastSeen": "Visto pela última vez",
"lastSeenDisplay": "Apresentação da última atividade",
"lastSeenDisplayDescription": "Escolha como as horas de atividade das sessões são apresentadas.",
"lastUsed": "Última utilização",
"loadingSessions": "A carregar as sessões...",
"loadingTokens": "A carregar os tokens...",
@@ -29,6 +34,7 @@
"noTokensYet": "Ainda não há tokens.",
"password": "Palavra-passe",
"passwordUpdated": "Palavra-passe atualizada.",
"relativeTime": "Hora relativa",
"revoke": "Revogar",
"saving": "A guardar...",
"securityPageDescription": "Altere a sua palavra-passe e reveja os dispositivos com sessão iniciada na sua conta.",
+6
View File
@@ -1,6 +1,7 @@
{
"actions": "操作",
"activeSessions": "活动会话",
"absoluteTime": "绝对时间",
"apiTokens": "API 令牌",
"apiTokensBlurb": "个人访问令牌可让脚本和 CLI 以你的身份认证。若令牌可能已泄露,请立即吊销。",
"cancel": "取消",
@@ -17,8 +18,12 @@
"done": "完成",
"failedToLoadSessions": "加载会话失败。",
"failedToLoadTokens": "加载令牌失败。",
"failedToSaveLastSeenDisplay": "无法保存上次活动的显示方式。",
"failedToRevokeSession": "无法吊销会话。",
"ipAddress": "IP",
"lastSeen": "上次活动",
"lastSeenDisplay": "上次活动显示",
"lastSeenDisplayDescription": "选择会话活动时间的显示方式。",
"lastUsed": "上次使用",
"loadingSessions": "正在加载会话…",
"loadingTokens": "正在加载令牌…",
@@ -29,6 +34,7 @@
"noTokensYet": "暂无令牌。",
"password": "密码",
"passwordUpdated": "密码已更新。",
"relativeTime": "相对时间",
"revoke": "吊销",
"saving": "正在保存…",
"securityPageDescription": "修改密码并查看已登录你账户的设备。",
+6
View File
@@ -1,6 +1,7 @@
{
"actions": "操作",
"activeSessions": "活動工作階段",
"absoluteTime": "絕對時間",
"apiTokens": "API 權杖",
"apiTokensBlurb": "個人存取權杖可讓腳本和 CLI 以你的身份驗證。若權杖可能已泄露,請立即吊銷。",
"cancel": "取消",
@@ -17,8 +18,12 @@
"done": "完成",
"failedToLoadSessions": "載入工作階段失敗。",
"failedToLoadTokens": "載入權杖失敗。",
"failedToSaveLastSeenDisplay": "無法儲存上次活動的顯示方式。",
"failedToRevokeSession": "無法吊銷工作階段。",
"ipAddress": "IP",
"lastSeen": "上次活動",
"lastSeenDisplay": "上次活動顯示",
"lastSeenDisplayDescription": "選擇工作階段活動時間的顯示方式。",
"lastUsed": "上次使用",
"loadingSessions": "正在載入工作階段…",
"loadingTokens": "正在載入權杖…",
@@ -29,6 +34,7 @@
"noTokensYet": "暫無權杖。",
"password": "密碼",
"passwordUpdated": "密碼已更新。",
"relativeTime": "相對時間",
"revoke": "吊銷",
"saving": "正在儲存…",
"securityPageDescription": "修改密碼並查看已登入你帳戶的設備。",
+6
View File
@@ -1,6 +1,7 @@
{
"actions": "操作",
"activeSessions": "活動工作階段",
"absoluteTime": "絕對時間",
"apiTokens": "API 權杖",
"apiTokensBlurb": "個人存取權杖可讓指令碼和 CLI 以你的身分驗證。若權杖可能已洩露,請立即吊銷。",
"cancel": "取消",
@@ -17,8 +18,12 @@
"done": "完成",
"failedToLoadSessions": "載入工作階段失敗。",
"failedToLoadTokens": "載入權杖失敗。",
"failedToSaveLastSeenDisplay": "無法儲存上次活動的顯示方式。",
"failedToRevokeSession": "無法吊銷工作階段。",
"ipAddress": "IP",
"lastSeen": "上次活動",
"lastSeenDisplay": "上次活動顯示",
"lastSeenDisplayDescription": "選擇工作階段活動時間的顯示方式。",
"lastUsed": "上次使用",
"loadingSessions": "正在載入工作階段…",
"loadingTokens": "正在載入權杖…",
@@ -29,6 +34,7 @@
"noTokensYet": "暫無權杖。",
"password": "密碼",
"passwordUpdated": "密碼已更新。",
"relativeTime": "相對時間",
"revoke": "吊銷",
"saving": "正在儲存…",
"securityPageDescription": "修改密碼並檢視已登入你帳戶的裝置。",
+282
View File
@@ -0,0 +1,282 @@
---
spec: docs/specs/relative-last-seen/spec.md
created: 2026-08-16
status: done
---
# Implementation Plan: Relative Last Seen in Account Security
## Overview
Add a persisted per-user enum setting `last_seen_display` (`"absolute"` default | `"relative"`) and
a select on the Active sessions card of `/settings/account/security`. In relative mode the Last seen
column renders a live-updating, locale-aware relative label with the absolute timestamp in a
fine-pointer tooltip or a coarse-pointer touch drawer. The setting follows the established
`changes_panel_layout` pattern end to end: JSON-blob user settings on the backend, boot-payload +
WS-push hydration on the frontend, and the shared manual-save settings flow in the UI.
## Design decisions
- **Shared manual save.** The select edits a local draft. The shared `Save changes` action calls
`useSettingsSaveContributor` and persists the draft. The shared `Reset` action restores the
confirmed value. A failed save keeps the draft dirty so the user can retry.
- **Queued write with revision-aware confirmation.** The contributor save uses
`createQueuedUserSettingsSyncWithResponse`. The draft lives in local component state and is
never written into the store directly. The store's `userSettings.lastSeenDisplay` is the
confirmed baseline, updated by exactly two ingestion paths: a successful PATCH response mapped
with `mapUserSettingsResponse(response, current)` and applied through `setUserSettings` (which
discards older revisions), and a newer `user.settings.updated` WS snapshot through the existing
handler path (`mapUserSettingsData` + handler revision gate, unchanged per task 02). A save
response reconciles the draft with the revision-gated store result, so a newer foreign snapshot
wins. A failed save leaves the submitted draft in place.
- **Revision-gated draft reconciliation; no writer-id correlation or store metadata.** The backend
publishes the settings event before the PATCH response and broadcasts a full snapshot to every
subscriber. A newer local draft stays visible while Save is in flight. The unchanged WebSocket
handler applies own echoes and foreign snapshots to the confirmed store. After a successful
response, the component reconciles the draft with the revision-gated store result. A failed save
keeps the draft dirty for retry. A writer-id scheme is not needed because the shared settings
provider owns Save and Reset, and the store remains the confirmed baseline.
- **Default `"absolute"`** preserves current behavior for users who never opt in.
- **`formatRelativeTime` from `@/lib/i18n/formats`** (locale-aware, accepts `now`) renders the
relative label; the shared `useNow` hook drives the live update only while the cell is in
relative mode (mounted in a dedicated child component so the interval is never created in
absolute mode), with an age-aware cadence: tick every second while the timestamp is under a
minute old (second-scale labels need live updates) and every minute once the age crosses a
minute.
- **Exact time disclosure** uses `@kandev/ui/tooltip` with `formatDateTime(last_seen_at)` on
fine-pointer devices. Coarse-pointer devices use the shipped `useTouchDrawer` and Drawer
pattern to show the same timestamp after a tap.
## Backend
- `apps/backend/internal/user/models/models.go`: add `LastSeenDisplay string` to `UserSettings`
with `json:"last_seen_display"`; add `LastSeenDisplayAbsolute = "absolute"` /
`LastSeenDisplayRelative = "relative"` constants and a `NormalizeLastSeenDisplay` helper
(anything other than `relative` coerces to `absolute`).
- `apps/backend/internal/user/store/sqlite.go`: default `"absolute"` in `defaultUserSettings`; add
the field to BOTH hand-built JSON codec paths (the model struct alone does not persist it):
`marshalUserSettingsPayload` map entry, the `scanUserSettings` payload struct field, and the
scan assignment with `NormalizeLastSeenDisplay` coercion. Guard the PATCH write against the
read-modify-write lost-update race: `UpsertUserSettingsPreservingTaskCreateLastUsed` gains an
`expectedRevision` parameter and its UPDATE gains `AND settings_revision = ?` (SQLite and
Postgres paths), returning a revision-conflict sentinel when zero rows match. The conflict is
distinguished from a missing user explicitly: after a zero-row conditional UPDATE the repo
existence-checks the user row; missing → the existing user-not-found error, present →
`ErrUserSettingsRevisionConflict` (both drivers tested). The service routes ALL full-blob
writers through one bounded read-apply-CAS-retry helper (read → apply patch → upsert with the
read revision → on conflict re-read and re-apply the ORIGINAL patch, ~3 attempts): both
`UpdateUserSettings` and `ClearDefaultEditorID` (service.go:883-900 currently upserts once and
would otherwise either fail to clear on conflict or bypass CAS and overwrite the setting), with
exactly one event publication after the successful final write. The helper carries the IMMUTABLE
`taskCreatePatch` extracted from the original request (service.go:198-202) as a separate input
passed to EVERY CAS attempt, and treats a non-empty task patch as `applied` even when the blob
is otherwise unchanged (a task-create-only PATCH must not be classified no-op and dropped); a
task-create-only PATCH under a forced conflict survives the retry, merges with the winner,
increments the revision once on its own successful retry (the winner already bumped R to R+1, so
the final revision is R+2), and emits the snapshot event (existing public behavior,
service.go:197-206 / service_test.go:1257-1283). Every upsert callsite is
updated: the store interface, both driver implementations, the controller/service test fakes,
and the direct sqlite_test.go calls (~31/96/151/1274/1323/1379, passing the revision from the
preceding read; the existing concurrent test at ~150 asserts conflict-and-retry semantics).
Without this, a concurrent omitted-field PATCH (app-status-bar/review save) that read the
pre-PATCH row can serialize a stale snapshot over `last_seen_display` at a newer revision,
which the frontend then accepts as truth.
- `apps/backend/internal/user/service/service.go`: add `LastSeenDisplay *string` to
`UpdateUserSettingsRequest`; add `applyLastSeenDisplay` (trim, validate against the two values,
reject otherwise) and call it from `applyBasicSettings`; add
`"last_seen_display": models.NormalizeLastSeenDisplay(settings.LastSeenDisplay)` to the
hand-built `publishUserSettingsEvent` snapshot map so the WS `user.settings.updated` payload
carries the field to every other tab. NO writer-id plumbing: the publisher signature stays
`publishUserSettingsEvent(ctx, settings)` (no direct test callsite changes needed:
`app_status_bar_visibility_test.go:44`, `service_test.go:1312, 1333, 1354, 1377, 1399, 1417`),
and pending-selection protection is entirely client-side (pending-write gating).
- `apps/backend/internal/user/dto/dto.go`: add `LastSeenDisplay` to `UserSettingsDTO`
(`json:"last_seen_display"`), map it in `FromUserSettings` via
`models.NormalizeLastSeenDisplay(settings.LastSeenDisplay)` (matching how every other enum-like
setting is normalized at the API boundary, so a model assembled outside the store can never emit
a non-canonical value); add `LastSeenDisplay *string` to the DTO `UpdateUserSettingsRequest`.
- `apps/backend/internal/user/controller/controller.go`: pass `req.LastSeenDisplay` into the
service request. The controller test asserts `req.LastSeenDisplay` reaches the service request
(the REST handler and the WS `wsUpdateUserSettings` handler share this one controller path,
handlers.go:113, so one test covers both transports).
- `apps/backend/internal/backendapp/boot_state_routes.go`: map `lastSeenDisplay` in
`mapUserSettingsState` via a small normalize helper (mirroring `changesPanelLayout(...)`).
## Frontend contracts
- `apps/web/lib/types/http-user-settings.ts`: `LastSeenDisplay` type
(`"absolute" | "relative"`); `last_seen_display?: LastSeenDisplay` on `UserSettings` and
`UserSettingsUpdatePayload`.
- `apps/web/lib/types/http.ts`: add `LastSeenDisplay` to the explicit `http-user-settings`
re-export list (the established import surface; omitting it breaks type resolution for barrel
consumers).
- `apps/web/lib/state/slices/settings/types.ts`: `lastSeenDisplay: LastSeenDisplay` on
`UserSettingsState` (default `"absolute"`). NO client-only metadata field: pending-selection
protection lives in the component (pending-write gating), so no writer-id/lastWriterId state
exists to be wiped by `mapUserSettingsResponse` callers that build from a fresh default state
(use-layout-settings, layout-preset-selector, use-ensure-user-settings, hydration).
- `apps/web/lib/ssr/user-settings.ts`: default `"absolute"` for `lastSeenDisplay` in
`createDefaultUserSettings`; `parseLastSeenDisplay` (only `"relative"` accepted, else
`"absolute"`); map `lastSeenDisplay` in `buildAppearanceFields` via `mapDefined`.
- The WS `user.settings.updated` handler in `apps/web/lib/ws/handlers/users.ts` picks the field up
through `mapUserSettingsData` with its existing revision gate, UNCHANGED otherwise (own echoes
are applied normally, so the store converges even if the component unmounts mid-write); add
`users.test.ts` assertions (valid value applies, unknown value normalizes to `"absolute"`,
omitted field leaves the current value, stale revision ignored), so the cross-tab
producer→handler path is tested on both sides.
- `apps/web/hooks/use-ensure-user-settings.test.ts` (`makeUnloadedSettings`, lines ~47-110) builds
a complete typed `UserSettingsState` and gains `lastSeenDisplay: "absolute"`; audit remaining
typed fixtures for the new required field.
## Security page UI
- `apps/web/components/settings/account/security-settings.tsx`:
- Read `userSettings.lastSeenDisplay` from the store in `SessionsCard`.
- Add a labeled select (Absolute time / Relative time) in the card, above the table, with a
discovery target id `ACCOUNT_SETTINGS_TARGETS.lastSeenDisplay` and self-documenting copy.
- On change: keep the draft in local component state and mark the `SessionsCard` dirty. Register
the field with `useSettingsSaveContributor`. The contributor's `save` sends a queued
`createQueuedUserSettingsSyncWithResponse({ last_seen_display })` write. The contributor's
`discard` restores the confirmed store value. A failed save keeps the draft dirty and shows
error copy so the shared Save action can retry.
- Apply successful responses through `mapUserSettingsResponse` into `setUserSettings` only
(never a raw store write). `setUserSettings` discards older revisions, so a deferred PATCH
response arriving after a newer cross-tab WS event cannot regress the value or revision. After
the response, reconcile the draft with the revision-gated store result. A newer foreign value
therefore wins when it supersedes the submitted response.
- Extract a small `LastSeenCell` component: absolute mode renders `formatDateTime`; relative mode
mounts a ticking `useNow` wrapper and renders a `formatRelativeTime(last_seen_at, now)` label.
Fine-pointer devices use a focusable semantic trigger and a `Tooltip` with the absolute time.
Coarse-pointer devices use an explicit tap trigger and the shipped `useTouchDrawer` Drawer
pattern to show the same absolute time. The cell validates the timestamp once; when unparseable
it renders an empty cell with no disclosure surface (guarding the `formatDateTime` content,
which throws `RangeError` on invalid dates).
- `apps/web/lib/settings-discovery/catalog/account.ts`: add `lastSeenDisplay:
"setting-account-last-seen-display"` target and a `kind: "control"` discovery definition. The
SessionsCard's `SettingsCard` already registers the `sessions` target and accepts exactly one
`discoveryTargetId` (settings-card.tsx:7-30), so the select's wrapper registers the new target
separately with `useSettingsTargetRegistration(ACCOUNT_SETTINGS_TARGETS.lastSeenDisplay)` (from
`settings-target-provider.tsx`) rather than replacing the card's target; add a discovery-target
test asserting both targets register and reveal.
- i18n: new keys in `apps/web/src/locales/en/account.json` (+ pseudo/pt-pt/zh-cn/zh-hk/zh-tw
mirrors). Use plain punctuation (no em dash).
## Tests
- **Backend:** `service` test for `applyLastSeenDisplay` (accept both values, reject invalid, nil
no-op); a `publishUserSettingsEvent` test asserting the published event map carries the
normalized `last_seen_display` (in `service/user_settings_cas_test.go`; the existing publisher
tests at `service_test.go:1312-1417` stay untouched); store tests asserting `defaultUserSettings`,
a JSON round-trip of the new field through `marshalUserSettingsPayload`/`scanUserSettings`
(stored `"relative"` reads back as `"relative"`, stored unknown/empty coerces to `"absolute"`, a
later PATCH updates the stored JSON); a `dto` test asserting `FromUserSettings` emits the
canonical value for an unknown model value; a `controller` mapping test asserting
`req.LastSeenDisplay` reaches the service request (the controller is a manual field-by-field
adapter; an omission compiles and silently no-ops); `boot_state_routes_test.go` mapping test
(relative passes through, unknown coerces to absolute); a barrier-based concurrent PATCH test
proving an unrelated omitted-field write (started from the same initial revision) cannot revert
`last_seen_display` — both fields are present in the final row and the retry loop merges the
loser's patch; a concurrent `ClearDefaultEditorID` + settings PATCH test (the editor is cleared
AND `last_seen_display` survives; TWO events are published, one per successful write with
distinct revisions — the helper contract is exactly one event per successful final write, so two
successful writes emit two events; a one-event outcome is only valid in the no-op scenario); a
CAS-conflict no-op barrier test that FORCES callback re-evaluation (Clear's initial read
completes seeing `old-editor`, its upsert is blocked; the PATCH commits `new-editor` at R+1 and
publishes; Clear's stale upsert then conflicts, the retry fresh-reads the row, the callback
re-evaluates with the immutable `old-editor` and returns `applied=false` → no Clear write/event;
assert read count >= 2, `clearUpsertAttempts == 1` (the forced stale CAS invocation that
conflicts) with `clearSuccessfulUpserts == 0`, `clearEvents == 0`, exactly one PATCH event —
counters named to distinguish attempts from successful commits) PLUS a separate
fresh-read no-op test (Clear starts after the PATCH committed; immediate no-op, no conflict);
repo zero-row tests for both drivers (missing user → user-not-found,
revision mismatch → `ErrUserSettingsRevisionConflict`); direct repo tests migrated to CAS and
MOVED to new focused files (the unique-revision concurrent test becomes one success + one
conflict; the stale-blob merge tests re-read the current revision or assert the conflict)
because `sqlite_test.go`/`service_test.go` are over Revive's 800-line test-file limit and new
code there fails the changed-file lint — new files: `store/sqlite_last_seen_cas_test.go`,
`service/user_settings_cas_test.go`. The concurrent SERVICE tests use a dedicated synchronized
fake (clones settings per read, models revision-conditional writes, records reads/upserts and
events through synchronized state/channels, channel barriers for phase observation) — NOT the
existing `recordingUserRepository`/`recordingEventBus` fakes, which share one mutable
`getSettings` pointer, mutate unprotected fields, and append events unsynchronized
(service_test.go:1741-1801), racing under `go test -race`.
- **Frontend:** `ssr/user-settings.test.ts` covers `parseLastSeenDisplay` and
`mapUserSettingsData`; `users.test.ts` covers valid, unknown, omitted, and stale WebSocket
snapshots. The `SessionsCard` tests cover the absolute default, relative labels, fine-pointer
tooltip and keyboard focus, coarse-pointer drawer disclosure, live ticking, invalid timestamps,
local draft state, shared Save and Reset actions, failed-save retry state, revision-gated PATCH
responses, foreign WebSocket updates, and unmount/remount convergence.
- **E2E (desktop + mobile + cross-tab):** add `apps/web/e2e/tests/auth/relative-last-seen.spec.ts`
(Desktop Chrome, `auth` project) grouped in `test.describe.serial` (single-shot `setupAdmin` +
shared worker backend/DB require serial ordering), restarting the worker backend with auth on and
its OWN `KANDEV_DATABASE_PATH` (auth setup is single-shot and worker DB is preserved across
restart; without a dedicated DB the focused command collides with sibling auth specs), calling
`setupAdmin` in `beforeAll` so focused test runs are independent, logging in, opening
`/settings/account/security`, switching to Relative time, saving through the shared Save action,
and asserting a relative label renders with an absolute tooltip on hover. Restore the original
setting in `finally`, and restart the baseline backend in `afterAll`. The same file adds a
two-context cross-tab scenario that arms `watchWs(pageA)` BEFORE `pageA.goto` (it only sees
sockets opened after it is called) AND arms the subscription wait (`const subscriptionAck =
watcher.waitForResponse("user.subscribe")`) before the same `goto`, then awaits `subscriptionAck`
after navigation before tab B mutates (frames are not buffered and `waitForResponse` only records
request ids once armed, so arming the wait before navigation is required to catch the ACK); the
assertion is causal: tab B changes the setting, tab A observes the update via WS without a
reload. Add `apps/web/e2e/tests/auth/mobile-relative-last-seen.spec.ts` (`mobile-chrome`
project, named `mobile-*` so the project routes it) that spreads `...devices["Pixel 5"]` into a
manual `browser.newContext` (manual contexts do not inherit project device options), asserts the
resulting viewport width, uses its own DB + `setupAdmin`, operates the select with touch-native
`tap()` on the trigger and option, saves through the shared Save action, proves relative labels
render without hover and without horizontal overflow, taps a relative label, and asserts the
absolute stamp in the touch drawer. Restore the setting. The Last seen trigger and options get a
mobile-appropriate touch target
(min-h-11 / 44px at phone widths via responsive classes on this instance, per /mobile-parity —
the shared Select defaults to 28px trigger/items), and the mobile E2E asserts the trigger's and
option's bounding boxes meet 44px in the active dimension.
## Implementation waves
Wave 1:
- [x] [Task 01: Backend last_seen_display setting](task-01-backend-last-seen-setting.md)
Wave 2:
- [x] [Task 02: Frontend settings contract and SSR mapping](task-02-frontend-settings-contract.md)
Wave 3:
- [x] [Task 03: Security page option, relative column, tooltip, live update](task-03-security-page-relative-last-seen.md)
Wave 4:
- [x] [Task 04: E2E coverage (desktop + mobile)](task-04-e2e-relative-last-seen.md)
## Verification
```bash
make -C apps/backend test
cd apps/web && pnpm run typecheck && pnpm run lint:i18n components/settings/account/security-settings.tsx
cd apps/web && pnpm vitest run components/settings/account lib/ssr/user-settings.test.ts
cd apps/web && pnpm e2e:run --project auth tests/auth/relative-last-seen.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome tests/auth/mobile-relative-last-seen.spec.ts
```
## Risks
- The select shares the floating Save and Reset actions with the other `/settings` configuration
controls. Its table preview updates from the local draft, while the durable setting changes only
after Save.
- Live ticking is wasted work if the user never switches to relative mode; the ticking interval is
created only inside the relative-mode cell component, so absolute mode stays inert.
- Tooltip timing/positioning inside a table cell: use the standard Radix tooltip for fine-pointer
devices. Coarse-pointer devices use the shipped Drawer pattern, and mobile E2E taps the label and
asserts the visible absolute stamp.
- The hand-built backend JSON codec paths and the WS event snapshot are easy to miss because the
model struct change alone looks sufficient; task 01 lists each path explicitly and tests the
stored-JSON round trip and the published event map.
- The auth E2E project shares one worker backend whose SQLite DB survives restarts, and auth setup
is single-shot; both new specs must use dedicated `KANDEV_DATABASE_PATH` values or the focused
commands become order-dependent (the `users-self-actions` pattern).
- The PATCH write path is read-modify-write over a full settings blob; without expected-revision
CAS, a concurrent omitted-field PATCH can revert `last_seen_display` at a newer revision that
the frontend accepts. CAS + bounded retry is in task 01; the barrier test pins it.
@@ -0,0 +1,173 @@
---
id: "01-backend-last-seen-setting"
title: "Backend last_seen_display setting"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/relative-last-seen/spec.md"
---
# Task 01: Backend `last_seen_display` setting
## Acceptance
- `UserSettings` model, DTO, store default, service patch, controller mapping, boot-state
mapping, AND the two hand-built store codec paths and the hand-built WS event snapshot all carry
`last_seen_display`; the store default and any unknown value normalize to `"absolute"`.
- The two manual JSON codec paths in `store/sqlite.go` are covered explicitly: the
`marshalUserSettingsPayload` map entry, the `scanUserSettings` payload struct field, and the
scan assignment with `NormalizeLastSeenDisplay` coercion. A model/DTO-only change silently drops
the field on write and falls back to the default on read.
- The PATCH write is expected-revision CAS: `UpsertUserSettingsPreservingTaskCreateLastUsed` gains
`expectedRevision` and its UPDATE gains `AND settings_revision = ?` (SQLite + Postgres paths),
returning a revision-conflict sentinel on zero rows. Zero rows is ambiguous, so the repo
existence-checks the user after a zero-row conditional UPDATE: missing user → the existing
user-not-found error (preserved for direct callers/tests); present → `ErrUserSettingsRevisionConflict`
(both drivers tested, including the RETURNING zero-row case).
- ALL full-blob writers route through one bounded read-apply-CAS-retry helper in the service
(read → apply patch → upsert with the read revision → on conflict re-read and re-apply the
ORIGINAL patch, ~3 attempts): `UpdateUserSettings` AND `ClearDefaultEditorID` (service.go:883-900
currently upserts once; without the shared helper it either fails to clear on conflict or
bypasses CAS and can still overwrite `last_seen_display`). Exactly one event publication after
the successful final write. Every upsert callsite is updated: the store interface, both driver
implementations, the controller/service test fakes, and the direct sqlite_test.go calls
(~31/96/151/1274/1323/1379, passing the revision from the preceding read; the existing
concurrent test at ~150 asserts conflict-and-retry semantics).
- Barrier-based concurrent tests prove it: two PATCHes (one with `last_seen_display: "relative"`,
one unrelated omitted-field) started from the same initial revision — the final row contains
both fields, and the losing write merged via the retry loop rather than reverting
`last_seen_display`; and a concurrent `ClearDefaultEditorID` + settings PATCH (the Clear applies
`default_editor_id` while the PATCH applies `last_seen_display`, different fields) — the final
row contains BOTH effects (editor cleared AND `last_seen_display` set), and TWO events are
published, one per successful write with distinct revisions (each CAS winner publishes its own
successful write; the loser retries and publishes its own — the helper contract is exactly one
event per successful final write, so two successful writes emit two events; a one-event outcome
is only valid in the two-phase no-op scenario above).
- The shared helper's apply/no-op contract is explicit and covers the task-create patch: the
helper signature is `updateUserSettingsCAS(ctx, apply func(*models.UserSettings) (bool, error),
taskCreatePatch *models.TaskCreateLastUsed)`, where `apply` is evaluated against EVERY freshly
read model, captures immutable inputs (the original `editorID`), and returns `applied=false` for
a no-op. The IMMUTABLE `taskCreatePatch` (extracted once from the original request, as
`UpdateUserSettings` does today at service.go:198-202 with `taskCreateLastUsedPatchEmpty`) is
passed to EVERY CAS attempt as the upsert's separate patch argument, so a retry re-applies the
same merge against the fresh row. `applied` is true when the callback mutated the model OR the
task-create patch is non-empty (the json merge is still a write even when the blob is otherwise
unchanged — a task-create-only PATCH must NOT be classified no-op and dropped). The helper
writes and publishes ONLY when applied — a no-op means NO write and NO event.
`ClearDefaultEditorID`'s existing `settings.DefaultEditorID != editorID` early-return
(service.go:883-902) moves inside the callback and is re-evaluated per attempt, so a competing
PATCH that changes `default_editor_id` to another value on a retry read makes the Clear a no-op.
- Service retry test for the task-create path: a task-create-only PATCH (no other field changes)
forced into a revision conflict survives via the retry loop, merges with the concurrent winner's
row, increments the revision exactly once on its own successful retry (the winner already
bumped R to R+1, so the final revision is R+2 — two total increments across two successful
writes), and emits the resulting snapshot event (this pins the existing public PATCH behavior at
service.go:197-206 / service_test.go:1257-1283 under the CAS rewrite).
- Barrier test for the no-op path FORCES a real CAS conflict and callback re-evaluation (the
earlier fresh-read-only ordering could pass even with the editorID check left OUTSIDE the shared
helper, because Clear's first read already saw the new value): initial `DefaultEditorID =
"old-editor"` at revision R. PHASE 1: release Clear's initial `GetUserSettings` (its read
completes seeing `"old-editor"`), then BLOCK its upsert/CAS attempt on a channel. PHASE 2:
release the full PATCH (`default_editor_id: "new-editor"`), wait for its commit + event (row now
R+1). PHASE 3: release Clear's stale upsert — it conflicts (expected R vs current R+1), the
retry loop fresh-reads the row (sees `"new-editor"`), the callback re-evaluates with the
immutable `"old-editor"` and returns `applied=false` → NO Clear upsert and NO Clear event.
Assert: final row keeps `"new-editor"` at R+1; Clear's read count >= 2 (initial + retry
re-read); `clearUpsertAttempts == 1` (the forced stale CAS invocation that conflicts — the
counter records invocations, so a zero total would contradict the required conflict);
`clearSuccessfulUpserts == 0` (the stale attempt never commits); `clearEvents == 0`; exactly one
event total, with the PATCH's revision. Name the fake's counters accordingly (attempts vs
successful commits). KEEP a separate deterministic fresh-read no-op test (Clear starts only
after the PATCH committed; immediate no-op, no conflict) as a distinct scenario — both prove
different halves of the contract.
- The existing direct repository tests are MIGRATED to the CAS contract, not just listed, and the
migrated tests MOVE to new focused files: `TestSQLiteRepositoryAssignsUniqueAtomicSettingsRevisions`
(~120-180) starts two candidates from one read and currently asserts both writes succeed with
revisions [1,2]; it becomes one success + one `ErrUserSettingsRevisionConflict`, with the
fresh-read/reapply retry behavior covered by the service barrier tests (the direct repo layer has
no retry). The stale-blob merge tests — Postgres round trip (staleSettings upsert ~1274), SQLite
stale write after `UpdateTaskCreateLastUsed` (~1323), and the SQLite patch path (~1379) — re-read
the CURRENT revision before the direct upsert where their purpose is the `task_create_last_used`
json merge (the merge assertions must keep passing), or assert `ErrUserSettingsRevisionConflict`
where staleness is the point. MOVING them (deletion from `sqlite_test.go` + migrated copy in the
new file) is mandatory: `sqlite_test.go` (1,466 lines) and `service_test.go` (1,820 lines) are
already over Revive's 800-effective-line limit, which applies to test files
(`apps/backend/AGENTS.md` "Code-quality limits", `.golangci.yml` revive file-length-limit; the
`_test.go` exclusions do not disable revive), so ANY added code there fails the changed-file
lint. New tests land in new files: `apps/backend/internal/user/store/sqlite_last_seen_cas_test.go`
(codec round-trip, CAS conflict/zero-row, migrated repo tests) and
`apps/backend/internal/user/service/user_settings_cas_test.go` (CAS retry, barrier tests,
no-op contract, event-count assertions); the controller mapping test may stay in
`controller_test.go` (under the limit).
- `publishUserSettingsEvent` in `service/service.go` includes the normalized `last_seen_display`,
so the WS `user.settings.updated` payload reaches every other tab (the initiating tab alone
getting the PATCH response is not cross-tab sync).
- NO writer-id plumbing: the publisher signature stays `publishUserSettingsEvent(ctx, settings)`
(no direct test callsite changes; `app_status_bar_visibility_test.go:44` and
`service_test.go:1312, 1333, 1354, 1377, 1399, 1417` keep their current shape). Pending-selection
protection is entirely client-side (pending-write gating in task 03), because the event is a
full snapshot that any user-settings write re-publishes and no writer-id scheme can tell whether
a foreign snapshot changed `last_seen_display` or merely re-asserted the old value.
- Covered by an UpdateUserSettings integration test asserting the PUBLISHED event map (not just a
direct publisher test): a PATCH with `last_seen_display: "relative"` produces the normalized
`last_seen_display` in the published snapshot.
- The controller test asserts `req.LastSeenDisplay` reaches the service request (the manual
adapter can silently drop the field; the REST handler and `wsUpdateUserSettings` share this one
controller path, handlers.go:113).
- A PATCH with `last_seen_display: "relative"` persists and reads back as `"relative"`; a PATCH
with an invalid value is rejected by the service; a PATCH omitting the field leaves it untouched.
- `FromUserSettings` emits the canonical value via `NormalizeLastSeenDisplay` (matching every other
enum-like setting at the API boundary), so a model assembled outside the store cannot leak an
invalid wire value.
- The boot payload includes `lastSeenDisplay` keyed from the DTO.
## Verification
```bash
make -C apps/backend test
```
## Files likely touched
- `apps/backend/internal/user/models/models.go`
- `apps/backend/internal/user/store/sqlite.go` (+ NEW `store/sqlite_last_seen_cas_test.go`:
codec round-trip tests (stored JSON, unknown coercion, later-PATCH update), CAS conflict and
zero-row driver tests, and the MIGRATED repo tests moved out of `sqlite_test.go`)
- `apps/backend/internal/user/service/service.go` (+ NEW `service/user_settings_cas_test.go`:
the shared-CAS-retry tests, barrier tests (PATCH/PATCH, Clear/PATCH, two-phase no-op), the
published event-map integration test, and the new synchronized CAS fakes; the existing
publisher tests at `service_test.go:1312-1417` and `app_status_bar_visibility_test.go:37-52`
are UNCHANGED and stay where they are)
- `apps/backend/internal/user/dto/dto.go` (+ `dto_test.go` regression: unknown model value emits
canonical `"absolute"`)
- `apps/backend/internal/user/controller/controller.go` (+ `controller_test.go`: focused mapping
test asserting `req.LastSeenDisplay` reaches the service request, mirroring the existing enum
mapping tests; the controller is a manual field-by-field adapter, so an omission compiles and
silently no-ops)
- `apps/backend/internal/backendapp/boot_state_routes.go` (+ `boot_state_routes_test.go`)
The concurrent CAS service tests use a DEDICATED synchronized fake (in `user_settings_cas_test.go`),
NOT the existing `recordingUserRepository`/`recordingEventBus` fakes: those return one shared
mutable `getSettings` pointer on every read (service_test.go:1741-1750), mutate/read unprotected
fields (1753-1771), and append to `publishedEvents` unsynchronized (1793-1801), so concurrent
callers race under `go test -race`, one candidate can observe the other's patch before the CAS
decision, and event counts are unsafe. The new fake: clones settings for every read, models
revision-conditional writes, records reads/upserts through synchronized state/channels, and uses a
thread-safe event recorder; channel barriers gate phase-1 commit/event observation before releasing
phase 2 (no timing/polling).
## Dependencies
None.
## Inputs
- Spec "What", "Failure modes" (normalization), "Out of scope" (JSON blob, no schema migration)
- Existing enum precedent: `applyChangesPanelLayout`, `NormalizeLspStatusLocation`,
`changesPanelLayout(...)` boot mapping
## Output contract
Return a compact handoff capsule with acceptance status, exact test command/results, risk tags,
uncertainties, and set this task to `done`.
@@ -0,0 +1,64 @@
---
id: "02-frontend-settings-contract"
title: "Frontend settings contract and SSR mapping"
status: done
wave: 2
depends_on: ["01-backend-last-seen-setting"]
plan: "plan.md"
spec: "../../specs/relative-last-seen/spec.md"
---
# Task 02: Frontend settings contract and SSR mapping
## Acceptance
- `UserSettings` / `UserSettingsUpdatePayload` carry `last_seen_display?: "absolute" | "relative"`;
`LastSeenDisplay` is re-exported from the `apps/web/lib/types/http.ts` barrel (the established
import surface for these types). `UserSettingsState` carries `lastSeenDisplay` (default
`"absolute"`) and NO client-only metadata field: pending-selection protection lives in the
component (pending-write gating), so no writer-id/lastWriterId state exists to be wiped by
`mapUserSettingsResponse` callers that build from a fresh default state (use-layout-settings,
layout-preset-selector, use-ensure-user-settings, hydration).
- `parseLastSeenDisplay` accepts only `"relative"` and coerces everything else (including
`undefined`) to `"absolute"`; `mapUserSettingsData` maps the snake_case field into the store
shape.
- The WS `user.settings.updated` handler (`registerUsersHandlers`) propagates the field through
`mapUserSettingsData` with its existing revision gate and is otherwise UNCHANGED (own echoes are
applied normally, so the store converges even if the component unmounts mid-write): valid value
applies, unknown value normalizes to `"absolute"`, omitted field leaves the current value, stale
revision ignored. Covered in `users.test.ts`, not just by SSR tests.
- `apps/web/hooks/use-ensure-user-settings.test.ts` `makeUnloadedSettings` (lines ~47-110) builds a
complete typed `UserSettingsState` and gains `lastSeenDisplay: "absolute"`; audit remaining typed
fixtures for the new required field.
- SSR unit tests cover default, round-trip, and unknown-value coercion.
## Verification
```bash
cd apps/web && pnpm run typecheck
cd apps/web && pnpm vitest run lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts
```
## Files likely touched
- `apps/web/lib/types/http-user-settings.ts`
- `apps/web/lib/types/http.ts` (barrel re-export)
- `apps/web/lib/state/slices/settings/types.ts` (`lastSeenDisplay`)
- `apps/web/lib/ssr/user-settings.ts` (+ `user-settings.test.ts`)
- `apps/web/lib/ws/handlers/users.ts` (+ `users.test.ts`: `last_seen_display` assertions)
- `apps/web/hooks/use-ensure-user-settings.test.ts` (typed fixture gains `lastSeenDisplay`)
## Dependencies
Task 01 (backend field must exist so the payload type matches the wire contract).
## Inputs
- Spec "What" (per-user persisted setting, WS sync)
- Existing precedent: `changesPanelLayout` through
`http-user-settings.ts` → `types.ts` → `ssr/user-settings.ts` → `ws/handlers/users.ts`
## Output contract
Return a compact handoff capsule with acceptance status, exact test command/results, risk tags,
uncertainties, and set this task to `done`.
@@ -0,0 +1,84 @@
---
id: "03-security-page-relative-last-seen"
title: "Security page option, relative column, tooltip, live update"
status: done
wave: 3
depends_on: ["02-frontend-settings-contract"]
plan: "plan.md"
spec: "../../specs/relative-last-seen/spec.md"
---
# Task 03: Security page option, relative column, tooltip, live update
## Acceptance
- The Active sessions card on `/settings/account/security` shows a labeled **Last seen** select
(Absolute time / Relative time) above the table, with self-documenting copy and a discovery
target. The card's `SettingsCard` keeps its existing `sessions` target (it accepts exactly one
`discoveryTargetId`); the select's wrapper registers `ACCOUNT_SETTINGS_TARGETS.lastSeenDisplay`
separately via `useSettingsTargetRegistration` from `settings-target-provider.tsx`, and a
discovery-target test asserts both the sessions and the last-seen-display targets register and
reveal. The trigger and options get a mobile-appropriate touch target (min-h-11 / 44px at
phone widths via responsive classes on this instance; the shared Select defaults to 28px, below
the /mobile-parity 44px active-dimension guidance).
- Default (`absolute`) renders `formatDateTime(last_seen_at)` exactly as before.
- Relative mode renders a locale-aware `formatRelativeTime(last_seen_at, now)` label inside a
tooltip whose content is the absolute `formatDateTime` on fine-pointer devices. On
coarse-pointer devices, tapping the label opens a Drawer with the same absolute timestamp. The
fine-pointer trigger is focusable (`tabIndex={0}`, semantic element) and exposes the absolute
timestamp as its accessible name and native `title` fallback. The label advances while the page
stays open. An unparseable `last_seen_at` renders an empty cell with no disclosure surface
(guards the `formatDateTime` `RangeError` on invalid dates).
- Changing the select keeps a local draft and marks the card dirty. Register the field with
`useSettingsSaveContributor`. The shared Save action persists the draft through a queued
`createQueuedUserSettingsSyncWithResponse({ last_seen_display })` write. The shared Reset action
restores the confirmed store value. The store's `userSettings.lastSeenDisplay` is the confirmed
baseline, updated by exactly two unchanged ingestion paths: (1) a successful PATCH response
mapped with `mapUserSettingsResponse(response, current)` and applied THROUGH `setUserSettings`
(never a raw store write; `setUserSettings` discards older revisions,
`settings-slice.ts:245-250`); (2) a newer `user.settings.updated` WS snapshot through the
existing handler path (`mapUserSettingsData` inside `store.setState` with the handler's own
revision gate, `users.ts:12-24`), which Task 02 requires to stay UNCHANGED. On failure, keep the
draft dirty and show error copy so the shared Save action can retry.
- While a Save request is in flight, a newer local draft stays visible. The backend event remains a
full snapshot, and the unchanged WebSocket handler still applies own echoes and newer foreign
values to the confirmed store. After a successful response, reconcile the draft with the
revision-gated store result. A failed response keeps the draft dirty for retry. The component
tests cover this behavior, PATCH-response ingestion through `setUserSettings`, WebSocket
ingestion through the unchanged handler, Reset, and unmount/remount convergence.
- Component tests cover absolute default, relative label, tooltip content on hover, Tab focus,
coarse-pointer Drawer disclosure, accessible name/title carrying the absolute timestamp, live
ticking (fake timers), local draft state, shared Save and Reset actions, failed-save retry state,
invalid timestamps, revision-gated PATCH responses, foreign WebSocket snapshots, and
unmount/remount convergence. The tests exercise the PATCH-response ingestion path through
`setUserSettings` and the WebSocket ingestion path through the unchanged handler.
## Verification
```bash
cd apps/web && pnpm run typecheck
cd apps/web && pnpm vitest run components/settings/account
cd apps/web && pnpm run lint:i18n components/settings/account/security-settings.tsx
```
## Files likely touched
- `apps/web/components/settings/account/security-settings.tsx` (+ new `security-settings.test.tsx`)
- `apps/web/lib/settings-discovery/catalog/account.ts`
- `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn,zh-hk,zh-tw}/account.json`
## Dependencies
Task 02 (store field and payload type must exist).
## Inputs
- Spec "What", "Failure modes", "Scenarios"
- Existing precedent: `ChangesPanelLayoutCard` select anatomy, `useNow(30_000)` live labels,
`formatRelativeTime`/`formatDateTime` from `@/lib/i18n/formats`, Radix tooltip usage rules in
`apps/web/AGENTS.md`
## Output contract
Return a compact handoff capsule with acceptance status, exact test command/results, risk tags,
uncertainties, and set this task to `done`.
@@ -0,0 +1,79 @@
---
id: "04-e2e-relative-last-seen"
title: "E2E coverage for relative last seen"
status: done
wave: 4
depends_on: ["03-security-page-relative-last-seen"]
plan: "plan.md"
spec: "../../specs/relative-last-seen/spec.md"
---
# Task 04: E2E coverage for relative last seen (desktop + mobile + cross-tab)
## Acceptance
- **Desktop** (`auth` project, Desktop Chrome): the file groups its tests in
`test.describe.serial` (shared worker backend/DB and backend restart still require serial
ordering), restarts the worker backend with auth on and its OWN `KANDEV_DATABASE_PATH`, creates
the administrator in `beforeAll` so a focused test run is independent, then logs in per test.
Opening `/settings/account/security` shows the Last seen select. Switching to Relative time
updates the local draft. The shared Save action persists the setting. A relative label appears in
the Last seen column, and hovering it reveals a tooltip containing the absolute timestamp.
- **Cross-tab** (same desktop spec, serial): two browser contexts on the same backend, BOTH
authenticated (Playwright contexts have isolated cookie stores and the auth helpers set cookies
per context, so context B must `login` separately — or be created from context A's captured
`storageState` — or it cannot PATCH the setting or receive the user-scoped WS event). Arm
`watchWs(pageA)` BEFORE `pageA.goto` (it only sees sockets opened after it is called), and arm
the subscription wait itself before the same `goto` (`const subscriptionAck =
watcher.waitForResponse("user.subscribe")`), then await `subscriptionAck` after navigation before
tab B mutates: frames are not buffered and `waitForResponse` only records request ids once armed,
so a wait created after `goto` can miss the ACK entirely. Tab B switches the setting, tab A
observes the update via WS without a reload — causal, not timing-based.
- **Mobile** (`mobile-chrome` project, Pixel 5): the spec spreads `...devices["Pixel 5"]` into a
manual `browser.newContext` (manual contexts do not inherit project device options) and asserts
the resulting viewport width; with its own DB + `setupAdmin`, the Last seen select is operated
with touch-native `tap()` on the trigger and the Relative time option, then saved with the shared
Save action. Relative labels render without hover, there is no horizontal document overflow, and
tapping a relative label opens a drawer with the exact timestamp. The Last seen trigger and
options get a mobile-appropriate touch target (min-h-11 / 44px at phone widths via responsive
classes on this instance — the shared Select defaults to a 28px trigger and items — per
/mobile-parity), and the mobile spec asserts the trigger's and option's bounding boxes are >=
44px in the active dimension.
- The original setting is restored in `finally` and the baseline backend is restarted in
`afterAll` in both specs so the worker is reusable.
## Verification
```bash
cd apps/web && pnpm e2e:run --project auth tests/auth/relative-last-seen.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome tests/auth/mobile-relative-last-seen.spec.ts
```
## Files likely touched
- `apps/web/e2e/tests/auth/relative-last-seen.spec.ts` (new; `backendFixture`, `setupAdmin`/`login`
from `e2e/helpers/auth.ts`, own DB path under `backend.tmpDir`, `finally` setting restore via raw
PATCH, `afterAll` baseline restart; two-context cross-tab scenario)
- `apps/web/e2e/tests/auth/mobile-relative-last-seen.spec.ts` (new; MUST be named `mobile-*` so the
`mobile-chrome` project routes it away from the desktop `auth` project; own DB + `setupAdmin`;
`...devices["Pixel 5"]` spread into `browser.newContext` + viewport width assertion; `afterAll`
baseline restart, mirroring `mobile-users-self-actions.spec.ts`)
## Dependencies
Task 03 (UI exists to exercise).
## Inputs
- Spec "Scenarios" (switch, tooltip, persistence, restore, cross-tab) and "Failure modes" (touch
path)
- Existing precedent: `users-self-actions.spec.ts` (own-DB isolation rationale),
`auth-lifecycle.spec.ts` (serial auth project), `auth-screenshots.spec.ts` (security page
visit), `mobile-users-self-actions.spec.ts` (mobile auth project + own DB + Pixel 5 device
spread), `settings-manual-save.spec.ts` (restore settings via raw PATCH), `/mobile-parity`
naming and routing rules
## Output contract
Return a compact handoff capsule with acceptance status, exact E2E command/results, risk tags,
failure artifacts when applicable, uncertainties, and set this task to `done`.
+109
View File
@@ -0,0 +1,109 @@
---
status: draft
created: 2026-08-16
owner: kandev
---
# Relative Last Seen in Account Security
## Why
The **Active sessions** table on `/settings/account/security` renders the "Last seen" column as an
absolute timestamp (`formatDateTime`, e.g. "Aug 15, 2026, 3:42 PM"). Absolute stamps are precise but
hard to scan: a user reviewing devices signed in to their account must mentally subtract to know
whether a session was active moments ago or a month ago. Relative human-readable time ("5 minutes
ago", "yesterday") makes session freshness legible at a glance, which is the primary security
question a user is asking.
Relative time is inherently stale the moment it renders, so the label must live-update, and users
who need the exact moment still need access to it. This feature adds a per-user display option on
the security page that switches the column to relative time with an absolute-time tooltip on fine
pointer devices and a touch drawer on coarse-pointer devices.
## What
- The **Active sessions** card on `/settings/account/security` gains a **Last seen** display option
(a select) offering **Absolute time** and **Relative time**.
- The default remains **Absolute time**; existing behavior is unchanged for users who never touch
the option.
- With **Relative time** selected, the "Last seen" column renders a locale-aware relative label
("now", "5 minutes ago", "yesterday") that re-renders as time passes while the page is open.
- Hovering (or keyboard-focusing) a relative label shows a tooltip with the absolute timestamp on
fine-pointer devices. Tapping the label opens a touch drawer with the absolute timestamp on
coarse-pointer devices.
- The choice is a per-user persisted setting (`last_seen_display`), shared across the account and
synced between tabs via the existing user-settings WebSocket push.
- Changing the select updates a local draft and the table preview. The shared **Save changes**
action persists the draft. The shared **Reset** action discards the draft.
## Permissions
The option is a per-user preference on an account page; it requires only an authenticated session
(the same requirement as the security page itself). No admin or workspace authorization applies.
## Failure modes
- **Settings write fails:** the select keeps the draft and shows the error copy. The page remains
usable, and the user can retry the shared **Save changes** action. The relative/absolute rendering
never depends on a successful write to display.
- **Own-write echo vs unrelated snapshots:** the backend publishes a `user.settings.updated`
event for this tab's own write before the PATCH response returns, and the gateway broadcasts it
to every subscriber, including the initiating tab; the event is a full snapshot that any
user-settings write re-publishes. A pending local selection is therefore never cleared while its
write is in flight (own echoes and unrelated snapshots re-assert the pre-write value or the
write's own confirmation); the selection settles when the write settles, and the page then
always renders server truth, including any change another tab made meanwhile.
- **Unknown persisted value:** the backend coerces any value other than `"relative"` to
`"absolute"` on read (normalization), so a stale or hand-edited blob cannot produce a broken
column.
- **Concurrent unrelated PATCH (lost update):** user-settings updates are read-modify-write over a
full settings blob; a concurrent PATCH that omits `last_seen_display` (for example an
app-status-bar or review-top-bar save) could otherwise serialize a stale snapshot over this
setting, and the frontend would accept it because its revision is newer. The PATCH write must be
expected-revision CAS with bounded retry so an omitted-field write merges against the latest
row and can never revert `last_seen_display`.
- **Unparseable `last_seen_at`:** the relative formatter returns an empty string for invalid input,
but `formatDateTime` (used for the tooltip) throws `RangeError` on an invalid date. The relative
cell must validate the timestamp once and render neither the tooltip nor the absolute label when
it is unparseable, so the cell degrades to an empty cell instead of crashing the render.
- **Tooltip unavailable (touch/coarse pointer):** the absolute time remains reachable through the
relative label's explicit tap action. The tap opens a drawer with the exact timestamp. The
trigger also carries the absolute timestamp as its accessible name, and the fine-pointer path
keeps the native `title` fallback. Mobile E2E proves the tap path at the Pixel 5 viewport.
## Scenarios
- **GIVEN** a user on `/settings/account/security` who has never changed the option, **WHEN** the
Active sessions table renders, **THEN** the Last seen column shows absolute timestamps exactly as
before.
- **GIVEN** the Last seen select, **WHEN** the user picks **Relative time**, **THEN** the column
switches to relative labels immediately and the local draft becomes dirty.
- **GIVEN** a dirty Last seen draft, **WHEN** the user activates **Save changes**, **THEN** the
choice persists to user settings and the draft becomes clean.
- **GIVEN** a dirty Last seen draft, **WHEN** the user activates **Reset**, **THEN** the select and
table return to the confirmed setting without a settings write.
- **GIVEN** Relative time selected and a session last seen 3 minutes ago, **WHEN** the page stays
open for a minute, **THEN** the label advances (for example from "3 minutes ago" to "4 minutes
ago") without a page reload.
- **GIVEN** a relative label on a fine-pointer device, **WHEN** the user hovers or keyboard-focuses
it (the trigger is focusable), **THEN** a tooltip shows the absolute timestamp for that session,
and the absolute timestamp is also exposed as the trigger's accessible name and native title.
- **GIVEN** a coarse-pointer device with no hover, **WHEN** the user taps a relative label, **THEN**
a drawer shows the absolute timestamp, and the select and relative labels remain usable at the
phone viewport.
- **GIVEN** the user selects **Absolute time** again, **WHEN** the table renders, **THEN** absolute
timestamps return and the persisted value flips back.
- **GIVEN** the option changed in another tab, **WHEN** the user-settings push arrives, **THEN**
this page reflects the new value without a reload.
## Out of scope
- Changing how "Last seen" renders anywhere else (task surfaces, agent lists).
- Relative time for the **Created** or **Last used** columns elsewhere in account settings.
- A custom relative formatter: the shared locale-aware `formatRelativeTime` is used, consistent with
the platform i18n spec.
- Backend storage schema changes: `last_seen_display` rides the existing JSON user-settings blob.
## Implementation plan
See [the implementation plan](../../plans/relative-last-seen/plan.md).