| 1 | package upgradefixture |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/desktop/internal/workspacestate" |
| 11 | ) |
| 12 | |
| 13 | func TestEncodeLegacyHistoryEscapesJSONContent(t *testing.T) { |
| 14 | want := []legacyMessage{ |
| 15 | {Role: "user", Content: "quote \" slash \\ newline\n中文 %20 #"}, |
| 16 | {Role: "assistant", Content: "second line"}, |
| 17 | } |
| 18 | body, err := encodeLegacyHistory(want...) |
| 19 | if err != nil { |
| 20 | t.Fatal(err) |
| 21 | } |
| 22 | lines := strings.Split(strings.TrimSuffix(string(body), "\n"), "\n") |
| 23 | if len(lines) != len(want) { |
| 24 | t.Fatalf("encoded lines = %d, want %d: %q", len(lines), len(want), body) |
| 25 | } |
| 26 | for i, line := range lines { |
| 27 | var got legacyMessage |
| 28 | if err := json.Unmarshal([]byte(line), &got); err != nil { |
| 29 | t.Fatalf("decode line %d: %v", i, err) |
| 30 | } |
| 31 | if got != want[i] { |
| 32 | t.Fatalf("line %d = %+v, want %+v", i, got, want[i]) |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func TestVerifyLegacyHistoryChecksContentAfterByteRewrite(t *testing.T) { |
| 38 | path := filepath.Join(t.TempDir(), "legacy.jsonl") |
| 39 | body, err := encodeLegacyHistory( |
| 40 | legacyMessage{Role: "user", Content: fixtureQuestion}, |
| 41 | legacyMessage{Role: "assistant", Content: fixtureText}, |
| 42 | ) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | // A normal shutdown may rewrite the active legacy checkpoint. Byte changes |
| 47 | // are acceptable only while the authored conversation stays intact. |
| 48 | rewritten := strings.ReplaceAll(string(body), "\n", " \n") |
| 49 | if rewritten == string(body) { |
| 50 | t.Fatal("fixture did not change legacy bytes") |
| 51 | } |
| 52 | if err := os.WriteFile(path, []byte(rewritten), 0o600); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if err := verifyLegacyHistory(path, fixtureQuestion, fixtureText); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | changed := strings.Replace(rewritten, fixtureText, "different answer", 1) |
| 59 | if err := os.WriteFile(path, []byte(changed), 0o600); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | if err := verifyLegacyHistory(path, fixtureQuestion, fixtureText); err == nil { |
| 63 | t.Fatal("changed authored content passed verification") |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestRunRestoresEnvironmentOnSuccessAndFailure(t *testing.T) { |
| 68 | for _, key := range []string{"REASONIX_HOME", "REASONIX_STATE_HOME", "REASONIX_CACHE_HOME"} { |
| 69 | t.Setenv(key, "untouched-"+key) |
| 70 | } |
| 71 | home := filepath.Join(t.TempDir(), "isolated") |
| 72 | report := filepath.Join(t.TempDir(), "fixture.json") |
| 73 | for _, mode := range []string{"create", "verify", "invalid"} { |
| 74 | err := Run(mode, home, report, "first") |
| 75 | if mode == "create" && err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | if mode != "create" && err == nil { |
| 79 | t.Fatalf("%s must fail before app startup", mode) |
| 80 | } |
| 81 | for _, key := range []string{"REASONIX_HOME", "REASONIX_STATE_HOME", "REASONIX_CACHE_HOME"} { |
| 82 | if got := os.Getenv(key); got != "untouched-"+key { |
| 83 | t.Fatalf("%s leaked %s=%q", mode, key, got) |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestVerifyPreparedImportRequiresOneCommittedLegacyImport(t *testing.T) { |
| 90 | legacy := filepath.Join(t.TempDir(), "sessions", fixtureSessionID+".jsonl") |
| 91 | report := fixtureReport{LegacyPath: legacy} |
| 92 | prepared := func() *workspacestate.State { |
| 93 | return &workspacestate.State{ |
| 94 | Workspaces: map[string]workspacestate.Workspace{workspacestate.GlobalWorkspaceID: {SessionIDs: []string{"s1"}}}, |
| 95 | SourceMappings: map[string]workspacestate.SourceMapping{"k": { |
| 96 | Path: filepath.Join(filepath.Dir(legacy), ".", filepath.Base(legacy)), Format: "legacy", SessionID: "s1", WorkspaceID: workspacestate.GlobalWorkspaceID, |
| 97 | }}, |
| 98 | PendingOperations: map[string]workspacestate.Operation{"import": {Phase: "committed"}}, |
| 99 | } |
| 100 | } |
| 101 | if id, err := verifyPreparedImport(prepared(), report); err != nil || id != "s1" { |
| 102 | t.Fatalf("committed legacy import = %q, %v", id, err) |
| 103 | } |
| 104 | for name, corrupt := range map[string]func(*workspacestate.State){ |
| 105 | "not imported": func(s *workspacestate.State) { s.SourceMappings = nil }, |
| 106 | "open operation": func(s *workspacestate.State) { |
| 107 | s.PendingOperations["import"] = workspacestate.Operation{Phase: "content_ready"} |
| 108 | }, |
| 109 | "canonical format": func(s *workspacestate.State) { |
| 110 | m := s.SourceMappings["k"] |
| 111 | m.Format = "canonical" |
| 112 | s.SourceMappings["k"] = m |
| 113 | }, |
| 114 | "other source": func(s *workspacestate.State) { |
| 115 | m := s.SourceMappings["k"] |
| 116 | m.Path += ".other" |
| 117 | s.SourceMappings["k"] = m |
| 118 | }, |
| 119 | "extra session": func(s *workspacestate.State) { |
| 120 | s.Workspaces[workspacestate.GlobalWorkspaceID] = workspacestate.Workspace{SessionIDs: []string{"s1", "s2"}} |
| 121 | }, |
| 122 | "session elsewhere": func(s *workspacestate.State) { |
| 123 | s.Workspaces[workspacestate.GlobalWorkspaceID] = workspacestate.Workspace{SessionIDs: []string{"s2"}} |
| 124 | }, |
| 125 | } { |
| 126 | state := prepared() |
| 127 | corrupt(state) |
| 128 | if _, err := verifyPreparedImport(state, report); err == nil { |
| 129 | t.Errorf("%s passed prepared verification", name) |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 |