源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,588 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
|
||||
userprojectbiz "github.com/tutti-os/tutti/services/tuttid/biz/userproject"
|
||||
workspacebiz "github.com/tutti-os/tutti/services/tuttid/biz/workspace"
|
||||
)
|
||||
|
||||
func TestSQLiteStorePutUserProjectRepairsImportedSessionRail(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestSQLiteStore(t)
|
||||
if err := store.Create(ctx, workspacebiz.Summary{ID: "ws-project-rail", Name: "Project rail"}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
projectPath := filepath.Join(t.TempDir(), "project")
|
||||
cwd := filepath.Join(projectPath, "src")
|
||||
if err := os.MkdirAll(cwd, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if _, err := store.ReportSessionState(ctx, agentactivitybiz.SessionStateReport{
|
||||
WorkspaceID: "ws-project-rail",
|
||||
AgentSessionID: "imported-before-registration",
|
||||
Origin: "runtime",
|
||||
Provider: "codex",
|
||||
Cwd: cwd,
|
||||
RuntimeContext: map[string]any{"imported": true},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReportSessionState() error = %v", err)
|
||||
}
|
||||
initial := getTestAgentSessionRailSection(t, store, "ws-project-rail", "imported-before-registration")
|
||||
if initial.Key != agentactivitybiz.RailSectionKeyConversations {
|
||||
t.Fatalf("initial rail = %#v, want conversations", initial)
|
||||
}
|
||||
|
||||
if _, err := store.PutUserProject(ctx, userprojectbiz.Project{
|
||||
ID: "project-rail",
|
||||
Path: projectPath,
|
||||
Label: "project",
|
||||
}); err != nil {
|
||||
t.Fatalf("PutUserProject() error = %v", err)
|
||||
}
|
||||
final := getTestAgentSessionRailSection(t, store, "ws-project-rail", "imported-before-registration")
|
||||
wantPath := agentactivitybiz.NormalizeProjectPath(projectPath)
|
||||
if final.Kind != agentactivitybiz.RailSectionKindProject || final.ProjectPath != wantPath || final.Key != agentactivitybiz.RailSectionKeyForProject(wantPath) {
|
||||
t.Fatalf("final rail = %#v, want project path=%q", final, wantPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreFinalizeProjectRemovalKeepsPinnedSessionAndRehomesTombstones(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestSQLiteStore(t)
|
||||
const workspaceID = "ws-remove-project"
|
||||
if err := store.Create(ctx, workspacebiz.Summary{ID: workspaceID, Name: "Remove project"}); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
projectPath := filepath.Join(t.TempDir(), "project")
|
||||
if err := os.MkdirAll(projectPath, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if _, err := store.PutUserProject(ctx, userprojectbiz.Project{ID: "remove-project", Path: projectPath, Label: "project"}); err != nil {
|
||||
t.Fatalf("PutUserProject() error = %v", err)
|
||||
}
|
||||
for index, sessionID := range []string{"unpinned", "pinned"} {
|
||||
if _, err := store.ReportSessionState(ctx, agentactivitybiz.SessionStateReport{
|
||||
WorkspaceID: workspaceID, AgentSessionID: sessionID, Kind: "root",
|
||||
Origin: "runtime", Provider: "codex", Cwd: projectPath,
|
||||
Title: sessionID, OccurredAtUnixMS: int64(100 + index),
|
||||
}); err != nil {
|
||||
t.Fatalf("ReportSessionState(%s) error = %v", sessionID, err)
|
||||
}
|
||||
}
|
||||
if _, ok, err := store.UpdateSessionPinned(ctx, workspaceID, "pinned", true); err != nil || !ok {
|
||||
t.Fatalf("UpdateSessionPinned() ok=%v error=%v", ok, err)
|
||||
}
|
||||
var pinnedAt, createdAt, updatedAt int64
|
||||
if err := store.writeDB.QueryRowContext(ctx, `
|
||||
SELECT pinned_at_unix_ms, created_at_unix_ms, updated_at_unix_ms
|
||||
FROM workspace_agent_sessions WHERE workspace_id = ? AND agent_session_id = 'pinned'
|
||||
`, workspaceID).Scan(&pinnedAt, &createdAt, &updatedAt); err != nil {
|
||||
t.Fatalf("read pinned session before removal: %v", err)
|
||||
}
|
||||
|
||||
plan, err := store.TryFinalizeUserProjectRemovalByPath(ctx, projectPath)
|
||||
if err != nil {
|
||||
t.Fatalf("TryFinalizeUserProjectRemovalByPath(plan) error = %v", err)
|
||||
}
|
||||
if plan.Finalized || len(plan.SessionIDsByWorkspace[workspaceID]) != 1 || plan.SessionIDsByWorkspace[workspaceID][0] != "unpinned" {
|
||||
t.Fatalf("removal plan = %#v, want only unpinned session", plan)
|
||||
}
|
||||
if _, err := store.DeleteSessionsBatch(ctx, agentactivitybiz.DeleteSessionsBatchInput{
|
||||
WorkspaceID: workspaceID, SessionIDs: []string{"unpinned"},
|
||||
}); err != nil {
|
||||
t.Fatalf("DeleteSessionsBatch() error = %v", err)
|
||||
}
|
||||
finalized, err := store.TryFinalizeUserProjectRemovalByPath(ctx, projectPath)
|
||||
if err != nil || !finalized.Finalized {
|
||||
t.Fatalf("TryFinalizeUserProjectRemovalByPath(finalize) = %#v, error=%v", finalized, err)
|
||||
}
|
||||
projects, err := store.ListUserProjects(ctx)
|
||||
if err != nil || len(projects) != 0 {
|
||||
t.Fatalf("ListUserProjects() = %#v, error=%v, want empty", projects, err)
|
||||
}
|
||||
|
||||
var sectionKind, railPath, sectionKey string
|
||||
var finalPinnedAt, finalCreatedAt, finalUpdatedAt, deletedAt int64
|
||||
if err := store.writeDB.QueryRowContext(ctx, `
|
||||
SELECT rail_section_kind, rail_project_path, rail_section_key,
|
||||
pinned_at_unix_ms, created_at_unix_ms, updated_at_unix_ms, deleted_at_unix_ms
|
||||
FROM workspace_agent_sessions WHERE workspace_id = ? AND agent_session_id = 'pinned'
|
||||
`, workspaceID).Scan(§ionKind, &railPath, §ionKey, &finalPinnedAt, &finalCreatedAt, &finalUpdatedAt, &deletedAt); err != nil {
|
||||
t.Fatalf("read pinned session after removal: %v", err)
|
||||
}
|
||||
if sectionKind != agentactivitybiz.RailSectionKindConversations || railPath != "" || sectionKey != agentactivitybiz.RailSectionKeyConversations || deletedAt != 0 {
|
||||
t.Fatalf("pinned session rail = kind=%q path=%q key=%q deleted=%d", sectionKind, railPath, sectionKey, deletedAt)
|
||||
}
|
||||
if finalPinnedAt != pinnedAt || finalCreatedAt != createdAt || finalUpdatedAt != updatedAt {
|
||||
t.Fatalf("pinned session metadata changed: before=(%d,%d,%d) after=(%d,%d,%d)", pinnedAt, createdAt, updatedAt, finalPinnedAt, finalCreatedAt, finalUpdatedAt)
|
||||
}
|
||||
|
||||
if err := store.writeDB.QueryRowContext(ctx, `
|
||||
SELECT rail_section_key, deleted_at_unix_ms
|
||||
FROM workspace_agent_sessions WHERE workspace_id = ? AND agent_session_id = 'unpinned'
|
||||
`, workspaceID).Scan(§ionKey, &deletedAt); err != nil {
|
||||
t.Fatalf("read deleted unpinned session after removal: %v", err)
|
||||
}
|
||||
if sectionKey != agentactivitybiz.RailSectionKeyConversations || deletedAt == 0 {
|
||||
t.Fatalf("deleted unpinned session key=%q deleted=%d, want Chats tombstone", sectionKey, deletedAt)
|
||||
}
|
||||
|
||||
// A create request may have selected its placement immediately before the
|
||||
// removal transaction committed. The canonical write must reject that
|
||||
// stale project owner instead of recreating an orphan rail section.
|
||||
if _, err := store.ReportSessionState(ctx, agentactivitybiz.SessionStateReport{
|
||||
WorkspaceID: workspaceID, AgentSessionID: "late-session", Kind: "root",
|
||||
Origin: "runtime", Provider: "codex", Cwd: projectPath,
|
||||
RailPlacement: &agentactivitybiz.RailSection{
|
||||
Kind: agentactivitybiz.RailSectionKindProject,
|
||||
ProjectPath: projectPath,
|
||||
Key: agentactivitybiz.RailSectionKeyForProject(projectPath),
|
||||
},
|
||||
OccurredAtUnixMS: 200,
|
||||
}); err != nil {
|
||||
t.Fatalf("ReportSessionState(late stale placement) error = %v", err)
|
||||
}
|
||||
lateRail := getTestAgentSessionRailSection(t, store, workspaceID, "late-session")
|
||||
if lateRail.Kind != agentactivitybiz.RailSectionKindConversations || lateRail.Key != agentactivitybiz.RailSectionKeyConversations {
|
||||
t.Fatalf("late session rail = %#v, want Chats", lateRail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreUserProjectPathIdentityTreatsWindowsVariantsAsOneProject(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("Windows filesystem identity is platform-specific")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
store := openTestSQLiteStore(t)
|
||||
projectPath := agentactivitybiz.NormalizeProjectPath(t.TempDir())
|
||||
variantPath := strings.ToLower(filepath.ToSlash(projectPath))
|
||||
first, err := store.PutUserProject(ctx, userprojectbiz.Project{
|
||||
ID: "windows-project",
|
||||
Path: projectPath,
|
||||
Label: "Windows project",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutUserProject(first) error = %v", err)
|
||||
}
|
||||
second, err := store.PutUserProject(ctx, userprojectbiz.Project{
|
||||
ID: "windows-project-variant",
|
||||
Path: variantPath,
|
||||
Label: "Windows project variant",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutUserProject(variant) error = %v", err)
|
||||
}
|
||||
if second.ID != first.ID || second.Path != first.Path {
|
||||
t.Fatalf("variant project = %#v, want existing project %#v", second, first)
|
||||
}
|
||||
projects, err := store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
if len(projects) != 1 {
|
||||
t.Fatalf("ListUserProjects() len = %d, want 1", len(projects))
|
||||
}
|
||||
if err := store.DeleteUserProjectByPath(ctx, variantPath); err != nil {
|
||||
t.Fatalf("DeleteUserProjectByPath(variant) error = %v", err)
|
||||
}
|
||||
projects, err = store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() after delete error = %v", err)
|
||||
}
|
||||
if len(projects) != 0 {
|
||||
t.Fatalf("ListUserProjects() after delete len = %d, want 0", len(projects))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorePutUserProjectKeepsDurableOrderWhenReused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestSQLiteStore(t)
|
||||
|
||||
first, err := store.PutUserProject(ctx, userprojectbiz.Project{
|
||||
ID: "user_project_first",
|
||||
Path: "/workspace/first",
|
||||
Label: "first",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutUserProject(first) error = %v", err)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
second, err := store.PutUserProject(ctx, userprojectbiz.Project{
|
||||
ID: "user_project_second",
|
||||
Path: "/workspace/second",
|
||||
Label: "second",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutUserProject(second) error = %v", err)
|
||||
}
|
||||
|
||||
projects, err := store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
if len(projects) != 2 {
|
||||
t.Fatalf("ListUserProjects() len = %d, want 2", len(projects))
|
||||
}
|
||||
if projects[0].ID != second.ID || projects[1].ID != first.ID {
|
||||
t.Fatalf("ListUserProjects() order = [%q, %q], want second then first", projects[0].ID, projects[1].ID)
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
usedFirst, err := store.PutUserProject(ctx, userprojectbiz.Project{
|
||||
ID: first.ID,
|
||||
Path: first.Path,
|
||||
Label: first.Label,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutUserProject(first again) error = %v", err)
|
||||
}
|
||||
if usedFirst.CreatedAtUnixMS != first.CreatedAtUnixMS {
|
||||
t.Fatalf("PutUserProject(first again) createdAt = %d, want %d", usedFirst.CreatedAtUnixMS, first.CreatedAtUnixMS)
|
||||
}
|
||||
if usedFirst.LastUsedAtUnixMS <= first.LastUsedAtUnixMS {
|
||||
t.Fatalf("PutUserProject(first again) lastUsedAt = %d, want > %d", usedFirst.LastUsedAtUnixMS, first.LastUsedAtUnixMS)
|
||||
}
|
||||
|
||||
projects, err = store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() after reuse error = %v", err)
|
||||
}
|
||||
if projects[0].ID != second.ID || projects[1].ID != first.ID {
|
||||
t.Fatalf("ListUserProjects() after reuse order = [%q, %q], want second then first", projects[0].ID, projects[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreUserProjectOrderMigrationPreservesLegacyVisualOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbPath := filepath.Join(t.TempDir(), "legacy-user-projects.writeDB")
|
||||
store, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLiteStore() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if _, err := store.writeDB.ExecContext(ctx, `
|
||||
CREATE TABLE tuttid_schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at_unix_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE user_projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
created_at_unix_ms INTEGER NOT NULL,
|
||||
updated_at_unix_ms INTEGER NOT NULL,
|
||||
last_used_at_unix_ms INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO tuttid_schema_migrations (id, applied_at_unix_ms) VALUES ('user_projects_v1', 1);
|
||||
INSERT INTO user_projects (id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms) VALUES
|
||||
('beta', '/workspace/beta', 'Beta', 1, 20, 100),
|
||||
('alpha', '/workspace/alpha', 'Alpha', 1, 10, 100),
|
||||
('gamma', '/workspace/gamma', 'Gamma', 1, 30, 90);
|
||||
`); err != nil {
|
||||
t.Fatalf("seed legacy user projects: %v", err)
|
||||
}
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
t.Fatalf("Migrate() error = %v", err)
|
||||
}
|
||||
projects, err := store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "alpha", "gamma"})
|
||||
for _, project := range projects {
|
||||
if project.PinnedAtUnixMS != 0 {
|
||||
t.Fatalf("project %s pinnedAtUnixMS = %d, want 0", project.ID, project.PinnedAtUnixMS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreUserProjectPinMigrationPreservesDurableOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dbPath := filepath.Join(t.TempDir(), "ordered-user-projects.writeDB")
|
||||
store, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenSQLiteStore() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if _, err := store.writeDB.ExecContext(ctx, `
|
||||
CREATE TABLE tuttid_schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at_unix_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE user_projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
created_at_unix_ms INTEGER NOT NULL,
|
||||
updated_at_unix_ms INTEGER NOT NULL,
|
||||
last_used_at_unix_ms INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO tuttid_schema_migrations (id, applied_at_unix_ms) VALUES
|
||||
('user_projects_v1', 1),
|
||||
('user_projects_v2', 2);
|
||||
INSERT INTO user_projects (id, path, label, created_at_unix_ms, updated_at_unix_ms, last_used_at_unix_ms, sort_order) VALUES
|
||||
('older', '/workspace/older', 'Older', 1, 10, 10, 1),
|
||||
('newer', '/workspace/newer', 'Newer', 1, 20, 20, 0);
|
||||
`); err != nil {
|
||||
t.Fatalf("seed ordered user projects: %v", err)
|
||||
}
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
t.Fatalf("Migrate() error = %v", err)
|
||||
}
|
||||
projects, err := store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"newer", "older"})
|
||||
for _, project := range projects {
|
||||
if project.PinnedAtUnixMS != 0 {
|
||||
t.Fatalf("project %s pinnedAtUnixMS = %d, want 0", project.ID, project.PinnedAtUnixMS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStorePinsAndMovesUserProjectsWithinPartitions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestSQLiteStore(t)
|
||||
for _, project := range []userprojectbiz.Project{
|
||||
{ID: "alpha", Path: "/workspace/alpha", Label: "alpha"},
|
||||
{ID: "beta", Path: "/workspace/beta", Label: "beta"},
|
||||
{ID: "gamma", Path: "/workspace/gamma", Label: "gamma"},
|
||||
} {
|
||||
if _, err := store.PutUserProject(ctx, project); err != nil {
|
||||
t.Fatalf("PutUserProject(%s) error = %v", project.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
projects, changed, err := store.PinUserProject(ctx, "beta", true)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("PinUserProject(beta) changed=%v error=%v", changed, err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "gamma", "alpha"})
|
||||
betaPinnedAt := projects[0].PinnedAtUnixMS
|
||||
betaUpdatedAt := projects[0].UpdatedAtUnixMS
|
||||
betaLastUsedAt := projects[0].LastUsedAtUnixMS
|
||||
if betaPinnedAt <= 0 {
|
||||
t.Fatalf("beta pinnedAtUnixMS = %d, want > 0", betaPinnedAt)
|
||||
}
|
||||
|
||||
projects, changed, err = store.PinUserProject(ctx, "beta", true)
|
||||
if err != nil || changed {
|
||||
t.Fatalf("PinUserProject(beta idempotent) changed=%v error=%v", changed, err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "gamma", "alpha"})
|
||||
if projects[0].PinnedAtUnixMS != betaPinnedAt || projects[0].UpdatedAtUnixMS != betaUpdatedAt || projects[0].LastUsedAtUnixMS != betaLastUsedAt {
|
||||
t.Fatalf("idempotent pin changed beta: %#v", projects[0])
|
||||
}
|
||||
usedBeta, err := store.PutUserProject(ctx, userprojectbiz.Project{ID: "beta", Path: "/workspace/beta", Label: "beta"})
|
||||
if err != nil {
|
||||
t.Fatalf("PutUserProject(beta reused) error = %v", err)
|
||||
}
|
||||
if usedBeta.PinnedAtUnixMS != betaPinnedAt || usedBeta.SortOrder != 0 {
|
||||
t.Fatalf("reused beta = %#v, want pin and order preserved", usedBeta)
|
||||
}
|
||||
|
||||
projects, changed, err = store.PinUserProject(ctx, "alpha", true)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("PinUserProject(alpha) changed=%v error=%v", changed, err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"alpha", "beta", "gamma"})
|
||||
projects, err = store.MoveUserProject(ctx, "alpha", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveUserProject(alpha to pinned end) error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "alpha", "gamma"})
|
||||
|
||||
beforeAlpha := "alpha"
|
||||
if _, err := store.MoveUserProject(ctx, "gamma", &beforeAlpha); !errors.Is(err, ErrUserProjectPartitionMismatch) {
|
||||
t.Fatalf("MoveUserProject(cross partition) error = %v, want ErrUserProjectPartitionMismatch", err)
|
||||
}
|
||||
projects, err = store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "alpha", "gamma"})
|
||||
|
||||
if _, err := store.PutUserProject(ctx, userprojectbiz.Project{ID: "delta", Path: "/workspace/delta", Label: "delta"}); err != nil {
|
||||
t.Fatalf("PutUserProject(delta) error = %v", err)
|
||||
}
|
||||
projects, err = store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "alpha", "delta", "gamma"})
|
||||
projects, err = store.MoveUserProject(ctx, "delta", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveUserProject(delta to unpinned end) error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "alpha", "gamma", "delta"})
|
||||
|
||||
alphaLastUsedAt := projects[1].LastUsedAtUnixMS
|
||||
projects, changed, err = store.PinUserProject(ctx, "alpha", false)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("PinUserProject(alpha false) changed=%v error=%v", changed, err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "alpha", "gamma", "delta"})
|
||||
if projects[1].PinnedAtUnixMS != 0 || projects[1].LastUsedAtUnixMS != alphaLastUsedAt {
|
||||
t.Fatalf("unpin alpha = %#v, want cleared pin and unchanged lastUsed", projects[1])
|
||||
}
|
||||
if err := store.DeleteUserProject(ctx, "alpha"); err != nil {
|
||||
t.Fatalf("DeleteUserProject(alpha) error = %v", err)
|
||||
}
|
||||
readded, err := store.PutUserProject(ctx, userprojectbiz.Project{ID: "alpha", Path: "/workspace/alpha", Label: "alpha"})
|
||||
if err != nil {
|
||||
t.Fatalf("PutUserProject(alpha readded) error = %v", err)
|
||||
}
|
||||
if readded.PinnedAtUnixMS != 0 {
|
||||
t.Fatalf("readded alpha pinnedAtUnixMS = %d, want 0", readded.PinnedAtUnixMS)
|
||||
}
|
||||
projects, err = store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "alpha", "gamma", "delta"})
|
||||
if _, _, err := store.PinUserProject(ctx, "unknown", true); !errors.Is(err, ErrUserProjectNotFound) {
|
||||
t.Fatalf("PinUserProject(unknown) error = %v, want ErrUserProjectNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreMoveAndDeleteUserProjectsRewriteContinuousOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestSQLiteStore(t)
|
||||
for _, project := range []userprojectbiz.Project{
|
||||
{ID: "alpha", Path: "/workspace/alpha", Label: "alpha"},
|
||||
{ID: "beta", Path: "/workspace/beta", Label: "beta"},
|
||||
{ID: "gamma", Path: "/workspace/gamma", Label: "gamma"},
|
||||
} {
|
||||
if _, err := store.PutUserProject(ctx, project); err != nil {
|
||||
t.Fatalf("PutUserProject(%s) error = %v", project.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
beforeAlpha := "alpha"
|
||||
projects, err := store.MoveUserProject(ctx, "beta", &beforeAlpha)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveUserProject() error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"gamma", "beta", "alpha"})
|
||||
|
||||
beforeBeta := "beta"
|
||||
projects, err = store.MoveUserProject(ctx, "beta", &beforeBeta)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveUserProject(self) error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"gamma", "beta", "alpha"})
|
||||
|
||||
projects, err = store.MoveUserProject(ctx, "gamma", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveUserProject(to end) error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "alpha", "gamma"})
|
||||
|
||||
unknownBefore := "unknown"
|
||||
if _, err := store.MoveUserProject(ctx, "beta", &unknownBefore); !errors.Is(err, ErrUserProjectNotFound) {
|
||||
t.Fatalf("MoveUserProject(unknown before) error = %v, want ErrUserProjectNotFound", err)
|
||||
}
|
||||
projects, err = store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() after rejected move error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"beta", "alpha", "gamma"})
|
||||
|
||||
if err := store.DeleteUserProject(ctx, "beta"); err != nil {
|
||||
t.Fatalf("DeleteUserProject() error = %v", err)
|
||||
}
|
||||
projects, err = store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
assertUserProjectOrder(t, projects, []string{"alpha", "gamma"})
|
||||
for index, project := range projects {
|
||||
if project.SortOrder != index {
|
||||
t.Fatalf("project %s sort order = %d, want %d", project.ID, project.SortOrder, index)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := store.MoveUserProject(ctx, "unknown", nil); !errors.Is(err, ErrUserProjectNotFound) {
|
||||
t.Fatalf("MoveUserProject(unknown) error = %v, want ErrProjectNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertUserProjectOrder(t *testing.T, projects []userprojectbiz.Project, want []string) {
|
||||
t.Helper()
|
||||
if len(projects) != len(want) {
|
||||
t.Fatalf("projects length = %d, want %d", len(projects), len(want))
|
||||
}
|
||||
for index, id := range want {
|
||||
if projects[index].ID != id || projects[index].SortOrder != index {
|
||||
t.Fatalf("project[%d] = %#v, want id=%s sortOrder=%d", index, projects[index], id, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreDeleteUserProjectRemovesRecentProject(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestSQLiteStore(t)
|
||||
|
||||
project, err := store.PutUserProject(ctx, userprojectbiz.Project{
|
||||
ID: "user_project_deleted",
|
||||
Path: "/workspace/deleted",
|
||||
Label: "deleted",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutUserProject() error = %v", err)
|
||||
}
|
||||
|
||||
if err := store.DeleteUserProject(ctx, project.ID); err != nil {
|
||||
t.Fatalf("DeleteUserProject() error = %v", err)
|
||||
}
|
||||
projects, err := store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
if len(projects) != 0 {
|
||||
t.Fatalf("ListUserProjects() len = %d, want 0", len(projects))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSQLiteStoreDeleteUserProjectByPathRemovesRowWithMismatchedID guards
|
||||
// against the "remove project" no-op regression: the `path` column is the
|
||||
// table's UNIQUE key (see applyUserProjectsV1), so deleting by path must
|
||||
// still remove the row even if the stored `id` doesn't match whatever a
|
||||
// caller would recompute from the path. Deleting by a recomputed id instead
|
||||
// is exactly the bug this store method exists to avoid.
|
||||
func TestSQLiteStoreDeleteUserProjectByPathRemovesRowWithMismatchedID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := openTestSQLiteStore(t)
|
||||
|
||||
_, err := store.PutUserProject(ctx, userprojectbiz.Project{
|
||||
ID: "user_project_stale-mismatched-id",
|
||||
Path: "/workspace/mismatched",
|
||||
Label: "mismatched",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PutUserProject() error = %v", err)
|
||||
}
|
||||
|
||||
if err := store.DeleteUserProjectByPath(ctx, "/workspace/mismatched"); err != nil {
|
||||
t.Fatalf("DeleteUserProjectByPath() error = %v", err)
|
||||
}
|
||||
projects, err := store.ListUserProjects(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUserProjects() error = %v", err)
|
||||
}
|
||||
if len(projects) != 0 {
|
||||
t.Fatalf("ListUserProjects() len = %d, want 0", len(projects))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user