|
| 1 | +(ns eca.features.chat.export-test |
| 2 | + (:require |
| 3 | + [babashka.fs :as fs] |
| 4 | + [clojure.edn :as edn] |
| 5 | + [clojure.string :as string] |
| 6 | + [clojure.test :refer [deftest is testing]] |
| 7 | + [eca.features.chat.export :as sut])) |
| 8 | + |
| 9 | +(def ^:private sample-chat |
| 10 | + {:id "chat-1" |
| 11 | + :title "Fix the Login Bug!" |
| 12 | + :title-custom? true |
| 13 | + :status :idle |
| 14 | + :created-at 1 |
| 15 | + :updated-at 2 |
| 16 | + :login-provider "anthropic" |
| 17 | + :model "anthropic/claude-sonnet-4-6" |
| 18 | + :variant "high" |
| 19 | + :last-api :anthropic |
| 20 | + :trust true |
| 21 | + :prompt-id #uuid "00000000-0000-0000-0000-000000000001" |
| 22 | + :user-prompt-count 2 |
| 23 | + :subagent false |
| 24 | + :usage {:total-input-tokens 100 :total-output-tokens 50} |
| 25 | + :messages [{:role "user" :content "hello there" :content-id "c1" :created-at 1} |
| 26 | + {:role "assistant" :content [{:type :text :text "general kenobi"}] :content-id "c2" :created-at 2} |
| 27 | + {:role "tool_call" :content {:id "t1" :name "read_file" :full-name "eca__read_file" |
| 28 | + :origin :native :arguments {:path "/tmp/x"} :api :anthropic} :content-id "c3" :created-at 3}] |
| 29 | + :task {:next-id 2 :active-summary nil |
| 30 | + :tasks [{:id 1 :subject "do" :description "d" :status :done :priority :high}]} |
| 31 | + ;; live/transient state that must NOT be exported: |
| 32 | + :tool-calls {"t1" {:status :completed :approved?* (promise) :future-cleanup-complete?* (promise) :future (future 1)}} |
| 33 | + :last-status-payload {:x 1} |
| 34 | + :prompt-cache {:provider :stuff}}) |
| 35 | + |
| 36 | +(def ^:private sample-db |
| 37 | + {:chats {"chat-1" sample-chat}}) |
| 38 | + |
| 39 | +(deftest build-export-test |
| 40 | + (let [export (sut/build-export sample-db "chat-1") |
| 41 | + chat (:chat export)] |
| 42 | + (testing "envelope carries version tags" |
| 43 | + (is (contains? export :eca/version)) |
| 44 | + (is (contains? export :eca/export-version))) |
| 45 | + (testing "resumable fields preserved verbatim (no obfuscation)" |
| 46 | + (is (= "chat-1" (:id chat))) |
| 47 | + (is (= "Fix the Login Bug!" (:title chat))) |
| 48 | + (is (= "anthropic/claude-sonnet-4-6" (:model chat))) |
| 49 | + (is (= "high" (:variant chat))) |
| 50 | + (is (= :anthropic (:last-api chat))) |
| 51 | + (is (= true (:trust chat))) |
| 52 | + (is (= 2 (:user-prompt-count chat))) |
| 53 | + (is (= {:total-input-tokens 100 :total-output-tokens 50} (:usage chat))) |
| 54 | + (is (= "do" (get-in chat [:task :tasks 0 :subject])))) |
| 55 | + (testing "message content kept verbatim and in order" |
| 56 | + (is (= 3 (count (:messages chat)))) |
| 57 | + (is (= "hello there" (:content (first (:messages chat))))) |
| 58 | + (is (= ["user" "assistant" "tool_call"] (mapv :role (:messages chat)))) |
| 59 | + (is (= "/tmp/x" (get-in (nth (:messages chat) 2) [:content :arguments :path])))) |
| 60 | + (testing "live/transient state excluded" |
| 61 | + (is (not (contains? chat :tool-calls))) |
| 62 | + (is (not (contains? chat :last-status-payload))) |
| 63 | + (is (not (contains? chat :prompt-cache)))))) |
| 64 | + |
| 65 | +(deftest export->edn-round-trips-test |
| 66 | + (testing "the export serializes and reads back equal (incl. #uuid prompt-id)" |
| 67 | + (let [export (sut/build-export sample-db "chat-1") |
| 68 | + read-back (edn/read-string (sut/export->edn export))] |
| 69 | + (is (= export read-back)) |
| 70 | + (is (= #uuid "00000000-0000-0000-0000-000000000001" (get-in read-back [:chat :prompt-id])))))) |
| 71 | + |
| 72 | +(deftest export-import-round-trip-test |
| 73 | + (testing "export-chat! then import-chat! preserves the chat" |
| 74 | + (let [tmp (str (fs/path (fs/temp-dir) (str "eca-export-test-" (random-uuid) ".edn"))) |
| 75 | + {:keys [path message-count error]} (sut/export-chat! {:db sample-db :chat-id "chat-1" :filepath tmp})] |
| 76 | + (is (nil? error)) |
| 77 | + (is (= 3 message-count)) |
| 78 | + (is (fs/exists? path)) |
| 79 | + (let [{:keys [chat error]} (sut/import-chat! {:filepath path})] |
| 80 | + (is (nil? error)) |
| 81 | + (is (= "chat-1" (:id chat))) |
| 82 | + (is (= "anthropic/claude-sonnet-4-6" (:model chat))) |
| 83 | + (is (= "high" (:variant chat))) |
| 84 | + (is (= {:total-input-tokens 100 :total-output-tokens 50} (:usage chat))) |
| 85 | + (is (= (get-in (sut/build-export sample-db "chat-1") [:chat :messages]) (:messages chat)))) |
| 86 | + (fs/delete-if-exists path)))) |
| 87 | + |
| 88 | +(deftest export-chat!-defaults-and-errors-test |
| 89 | + (testing "blank filepath falls back to a slugified temp path" |
| 90 | + (let [{:keys [path error]} (sut/export-chat! {:db sample-db :chat-id "chat-1" :filepath ""})] |
| 91 | + (is (nil? error)) |
| 92 | + (is (fs/exists? path)) |
| 93 | + (is (string/includes? path "eca-chat-fix-the-login-bug")) |
| 94 | + (fs/delete-if-exists path))) |
| 95 | + (testing "missing chat returns an error" |
| 96 | + (is (:error (sut/export-chat! {:db sample-db :chat-id "nope" :filepath "/tmp/x.edn"}))))) |
| 97 | + |
| 98 | +(deftest export-chat!-directory-target-test |
| 99 | + (testing "an existing directory writes the default filename inside it" |
| 100 | + (let [dir (str (fs/path (fs/temp-dir) (str "eca-export-dir-" (random-uuid))))] |
| 101 | + (fs/create-dirs dir) |
| 102 | + (let [{:keys [path error]} (sut/export-chat! {:db sample-db :chat-id "chat-1" :filepath dir})] |
| 103 | + (is (nil? error)) |
| 104 | + (is (= "eca-chat-fix-the-login-bug.edn" (fs/file-name path))) |
| 105 | + (is (string/starts-with? path dir)) |
| 106 | + (is (fs/exists? path))) |
| 107 | + (fs/delete-tree dir))) |
| 108 | + (testing "a trailing-separator path creates the directory and writes inside it" |
| 109 | + (let [dir (str (fs/path (fs/temp-dir) (str "eca-export-newdir-" (random-uuid)))) |
| 110 | + {:keys [path error]} (sut/export-chat! {:db sample-db :chat-id "chat-1" :filepath (str dir "/")})] |
| 111 | + (is (nil? error)) |
| 112 | + (is (= "eca-chat-fix-the-login-bug.edn" (fs/file-name path))) |
| 113 | + (is (string/starts-with? path dir)) |
| 114 | + (is (fs/exists? path)) |
| 115 | + (fs/delete-tree dir)))) |
| 116 | + |
| 117 | +(deftest import-chat!-errors-test |
| 118 | + (testing "blank filepath errors" |
| 119 | + (is (:error (sut/import-chat! {:filepath ""})))) |
| 120 | + (testing "non-existent file errors" |
| 121 | + (is (:error (sut/import-chat! {:filepath (str (fs/path (fs/temp-dir) (str "nope-" (random-uuid) ".edn")))})))) |
| 122 | + (testing "content that is not an ECA export errors" |
| 123 | + (let [tmp (str (fs/path (fs/temp-dir) (str "eca-bad-" (random-uuid) ".edn")))] |
| 124 | + (spit tmp (pr-str {:not "an export"})) |
| 125 | + (is (:error (sut/import-chat! {:filepath tmp}))) |
| 126 | + (fs/delete-if-exists tmp)))) |
0 commit comments