From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 18:14:59 +0000 (-0300) Subject: Move shared test helpers into helper files X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/6b21fcf78372995c6d8130c63f2f8aa2609bf7fe?hp=ce0eacc2fe5090810cf22e022af3ab83006068a4 Move shared test helpers into helper files Each helper that more than one test file uses moves out of the test file that holds it into a file without tests, named for what it holds: the fake Responses endpoint, the fake ChatGPT login service, the fake Bot API, the terminal test client, the daemon start with a bot, and the tree node helpers. A helper that only one test file uses stays in that file. A reader then finds a shared helper from the file name, and a test file holds only the tests and the helpers of its own feature. The code of the helpers does not change. Issue: 947bddb --- diff --git a/chatgpt_fake_helper_test.go b/chatgpt_fake_helper_test.go new file mode 100644 index 0000000..dfcf17d --- /dev/null +++ b/chatgpt_fake_helper_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// chatgptTestAccount is the fake ChatGPT account ID in the fake tokens. +const chatgptTestAccount = "account-test" + +// ChatGPTFake is an httptest login service. The device token endpoint answers +// 403 pending times before it grants the code. Each token answer has a new +// access token, kept in access. +type ChatGPTFake struct { + *httptest.Server + mutex sync.Mutex + pending int + deny bool + polls int + exchange url.Values + refreshes int + refreshed string + newRefresh string + failRefresh bool + access []string +} + +// token returns a new access token that expires after ttl, for a token file +// that a test writes. +func (f *ChatGPTFake) token(ttl time.Duration) string { + f.mutex.Lock() + defer f.mutex.Unlock() + + return f.newToken(ttl) +} + +// newToken returns a new fake JWT with the expiry and the account claim, and +// keeps it in access. The caller holds the mutex. +func (f *ChatGPTFake) newToken(ttl time.Duration) string { + claims, err := json.Marshal(map[string]any{ + "exp": time.Now().Add(ttl).Unix(), + chatgptAuthClaim: map[string]any{ + "chatgpt_account_id": chatgptTestAccount, + }, + "serial": len(f.access), + }) + if err != nil { + panic(err) + } + + encode := base64.RawURLEncoding.EncodeToString + token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake")) + f.access = append(f.access, token) + + return token +} + +func (f *ChatGPTFake) userCode(w http.ResponseWriter, r *http.Request) { + var body map[string]string + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil || body["client_id"] != chatgptClientID { + http.Error(w, "bad client", http.StatusBadRequest) + return + } + + chatgptWriteJSON(w, map[string]any{ + "device_auth_id": "device-1", + "user_code": "CODE-1234", + "interval": "0", + }) +} + +func (f *ChatGPTFake) deviceToken(w http.ResponseWriter, r *http.Request) { + f.mutex.Lock() + defer f.mutex.Unlock() + + var body map[string]string + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" { + http.Error(w, "bad device", http.StatusBadRequest) + return + } + + f.polls++ + if f.deny { + http.Error(w, "denied", http.StatusBadRequest) + return + } + + if f.polls <= f.pending { + http.Error(w, "pending", http.StatusForbidden) + return + } + + chatgptWriteJSON(w, map[string]any{ + "authorization_code": "auth-code", + "code_challenge": "challenge", + "code_verifier": "verifier", + }) +} + +func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) { + f.mutex.Lock() + defer f.mutex.Unlock() + + if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" { + err := r.ParseForm() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + f.exchange = r.PostForm + chatgptWriteJSON(w, map[string]any{ + "access_token": f.newToken(time.Hour), + "refresh_token": "refresh-1", + "id_token": f.newToken(time.Hour), + }) + + return + } + + var body map[string]string + err := json.NewDecoder(r.Body).Decode(&body) + if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != chatgptClientID { + http.Error(w, "bad refresh", http.StatusBadRequest) + return + } + + f.refreshes++ + f.refreshed = body["refresh_token"] + if f.failRefresh { + http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized) + return + } + + answer := map[string]any{ + "access_token": f.newToken(time.Hour), + "id_token": f.newToken(time.Hour), + } + + if f.newRefresh != "" { + answer["refresh_token"] = f.newRefresh + } + + chatgptWriteJSON(w, answer) +} + +func newChatGPTFake(t *testing.T) *ChatGPTFake { + t.Helper() + + f := &ChatGPTFake{} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode) + mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken) + mux.HandleFunc("POST /oauth/token", f.oauthToken) + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Close) + + return f +} + +func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth { + t.Helper() + + return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL) +} + +func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) { + t.Helper() + + err := auth.write(ChatGPTTokenFile{ + AccessToken: access, + RefreshToken: refresh, + IDToken: access, + AccountID: chatgptTestAccount, + }) + require.NoError(t, err) +} + +func chatgptWriteJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(value) + if err != nil { + panic(fmt.Sprint("encode answer: ", err)) + } +} diff --git a/chatgpt_test.go b/chatgpt_test.go index c6db9bd..a644daf 100644 --- a/chatgpt_test.go +++ b/chatgpt_test.go @@ -1,13 +1,9 @@ package main import ( - "encoding/base64" "encoding/json" - "fmt" "io" "net/http" - "net/http/httptest" - "net/url" "os" "path/filepath" "strings" @@ -18,9 +14,6 @@ import ( "github.com/stretchr/testify/require" ) -// chatgptTestAccount is the fake ChatGPT account ID in the fake tokens. -const chatgptTestAccount = "account-test" - func TestLoginWritesTheTokenFile(t *testing.T) { server := newChatGPTFake(t) server.pending = 2 @@ -203,164 +196,6 @@ func TestModelEndpointFollowsTheConfig(t *testing.T) { require.ErrorContains(t, err, "OPENAI_API_KEY") } -// ChatGPTFake is an httptest login service. The device token endpoint answers -// 403 pending times before it grants the code. Each token answer has a new -// access token, kept in access. -type ChatGPTFake struct { - *httptest.Server - mutex sync.Mutex - pending int - deny bool - polls int - exchange url.Values - refreshes int - refreshed string - newRefresh string - failRefresh bool - access []string -} - -// token returns a new access token that expires after ttl, for a token file -// that a test writes. -func (f *ChatGPTFake) token(ttl time.Duration) string { - f.mutex.Lock() - defer f.mutex.Unlock() - - return f.newToken(ttl) -} - -// newToken returns a new fake JWT with the expiry and the account claim, and -// keeps it in access. The caller holds the mutex. -func (f *ChatGPTFake) newToken(ttl time.Duration) string { - claims, err := json.Marshal(map[string]any{ - "exp": time.Now().Add(ttl).Unix(), - chatgptAuthClaim: map[string]any{ - "chatgpt_account_id": chatgptTestAccount, - }, - "serial": len(f.access), - }) - if err != nil { - panic(err) - } - - encode := base64.RawURLEncoding.EncodeToString - token := encode([]byte(`{"alg":"none"}`)) + "." + encode(claims) + "." + encode([]byte("fake")) - f.access = append(f.access, token) - - return token -} - -func (f *ChatGPTFake) userCode(w http.ResponseWriter, r *http.Request) { - var body map[string]string - err := json.NewDecoder(r.Body).Decode(&body) - if err != nil || body["client_id"] != chatgptClientID { - http.Error(w, "bad client", http.StatusBadRequest) - return - } - - chatgptWriteJSON(w, map[string]any{ - "device_auth_id": "device-1", - "user_code": "CODE-1234", - "interval": "0", - }) -} - -func (f *ChatGPTFake) deviceToken(w http.ResponseWriter, r *http.Request) { - f.mutex.Lock() - defer f.mutex.Unlock() - - var body map[string]string - err := json.NewDecoder(r.Body).Decode(&body) - if err != nil || body["device_auth_id"] != "device-1" || body["user_code"] != "CODE-1234" { - http.Error(w, "bad device", http.StatusBadRequest) - return - } - - f.polls++ - if f.deny { - http.Error(w, "denied", http.StatusBadRequest) - return - } - - if f.polls <= f.pending { - http.Error(w, "pending", http.StatusForbidden) - return - } - - chatgptWriteJSON(w, map[string]any{ - "authorization_code": "auth-code", - "code_challenge": "challenge", - "code_verifier": "verifier", - }) -} - -func (f *ChatGPTFake) oauthToken(w http.ResponseWriter, r *http.Request) { - f.mutex.Lock() - defer f.mutex.Unlock() - - if r.Header.Get("Content-Type") == "application/x-www-form-urlencoded" { - err := r.ParseForm() - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - f.exchange = r.PostForm - chatgptWriteJSON(w, map[string]any{ - "access_token": f.newToken(time.Hour), - "refresh_token": "refresh-1", - "id_token": f.newToken(time.Hour), - }) - - return - } - - var body map[string]string - err := json.NewDecoder(r.Body).Decode(&body) - if err != nil || body["grant_type"] != "refresh_token" || body["client_id"] != chatgptClientID { - http.Error(w, "bad refresh", http.StatusBadRequest) - return - } - - f.refreshes++ - f.refreshed = body["refresh_token"] - if f.failRefresh { - http.Error(w, `{"error": "invalid_grant", "token": "`+body["refresh_token"]+`"}`, http.StatusUnauthorized) - return - } - - answer := map[string]any{ - "access_token": f.newToken(time.Hour), - "id_token": f.newToken(time.Hour), - } - - if f.newRefresh != "" { - answer["refresh_token"] = f.newRefresh - } - - chatgptWriteJSON(w, answer) -} - -func newChatGPTFake(t *testing.T) *ChatGPTFake { - t.Helper() - - f := &ChatGPTFake{} - mux := http.NewServeMux() - mux.HandleFunc("POST /api/accounts/deviceauth/usercode", f.userCode) - mux.HandleFunc("POST /api/accounts/deviceauth/token", f.deviceToken) - mux.HandleFunc("POST /oauth/token", f.oauthToken) - f.Server = httptest.NewServer(mux) - t.Cleanup(f.Close) - - return f -} - -func newTestChatGPTAuth(t *testing.T, server *ChatGPTFake) *ChatGPTAuth { - t.Helper() - - return newChatGPTAuth(filepath.Join(t.TempDir(), chatgptTokenFileName), server.URL) -} - func chatgptReadTokenFile(t *testing.T, path string) ChatGPTTokenFile { t.Helper() @@ -372,23 +207,3 @@ func chatgptReadTokenFile(t *testing.T, path string) ChatGPTTokenFile { return tokens } - -func chatgptWriteTestTokens(t *testing.T, auth *ChatGPTAuth, access, refresh string) { - t.Helper() - - err := auth.write(ChatGPTTokenFile{ - AccessToken: access, - RefreshToken: refresh, - IDToken: access, - AccountID: chatgptTestAccount, - }) - require.NoError(t, err) -} - -func chatgptWriteJSON(w http.ResponseWriter, value any) { - w.Header().Set("Content-Type", "application/json") - err := json.NewEncoder(w).Encode(value) - if err != nil { - panic(fmt.Sprint("encode answer: ", err)) - } -} diff --git a/daemon_start_helper_test.go b/daemon_start_helper_test.go new file mode 100644 index 0000000..fd89b34 --- /dev/null +++ b/daemon_start_helper_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// daemonStartTestWithBot is daemonStartTest with bot, which can be nil. +func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string { + t.Helper() + + dir := t.TempDir() + agent, err := openAgent(filepath.Join(dir, daemonAgentDirName)) + require.NoError(t, err) + + agent.config.Workdir = t.TempDir() + clock := newTestClock() + memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer()) + require.NoError(t, err) + + socket := filepath.Join(dir, socketFileName) + listener, err := daemonListenSocket(socket) + require.NoError(t, err) + + client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl")) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error) + go func() { + done <- daemonServe(ctx, listener, bot, func(notify func(Event)) (*TurnLoop, error) { + return newTurnLoop(agent, memory, client, clock.Now, notify) + }) + }() + + t.Cleanup(func() { + cancel() + require.NoError(t, <-done) + require.NoError(t, memory.Close()) + }) + + return socket +} diff --git a/daemon_test.go b/daemon_test.go index 2dfe7f1..4b0ba0a 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -1,7 +1,6 @@ package main import ( - "context" "io" "net/http" "os" @@ -105,38 +104,3 @@ func daemonStartTest(t *testing.T, server *ModelFake) string { return daemonStartTestWithBot(t, server, nil) } - -// daemonStartTestWithBot is daemonStartTest with bot, which can be nil. -func daemonStartTestWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string { - t.Helper() - - dir := t.TempDir() - agent, err := openAgent(filepath.Join(dir, daemonAgentDirName)) - require.NoError(t, err) - - agent.config.Workdir = t.TempDir() - clock := newTestClock() - memory, err := openMemory(t.Context(), agent.dir, clock.Now, newFakeSummarizer()) - require.NoError(t, err) - - socket := filepath.Join(dir, socketFileName) - listener, err := daemonListenSocket(socket) - require.NoError(t, err) - - client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl")) - ctx, cancel := context.WithCancel(t.Context()) - done := make(chan error) - go func() { - done <- daemonServe(ctx, listener, bot, func(notify func(Event)) (*TurnLoop, error) { - return newTurnLoop(agent, memory, client, clock.Now, notify) - }) - }() - - t.Cleanup(func() { - cancel() - require.NoError(t, <-done) - require.NoError(t, memory.Close()) - }) - - return socket -} diff --git a/model_fake_helper_test.go b/model_fake_helper_test.go new file mode 100644 index 0000000..e3fb4c6 --- /dev/null +++ b/model_fake_helper_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// modelTestKey is a fake API key. +const modelTestKey = "sk-test-not-a-real-key" + +// ModelFakeCall is one call to a fake endpoint, numbered from 0. +type ModelFakeCall struct { + number int + header http.Header + raw []byte + body map[string]any +} + +// ModelFake is an httptest Responses endpoint. It keeps each call, and reply +// writes the response. The default reply is one short message. +type ModelFake struct { + *httptest.Server + mutex sync.Mutex + calls []ModelFakeCall + reply func(http.ResponseWriter, ModelFakeCall) +} + +// Call returns call number n. +func (f *ModelFake) Call(n int) ModelFakeCall { + f.mutex.Lock() + defer f.mutex.Unlock() + + return f.calls[n] +} + +// Calls returns the number of calls. +func (f *ModelFake) Calls() int { + f.mutex.Lock() + defer f.mutex.Unlock() + + return len(f.calls) +} + +func (f *ModelFake) serve(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + call := ModelFakeCall{ + header: r.Header.Clone(), + raw: raw, + } + + err = json.Unmarshal(raw, &call.body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + f.mutex.Lock() + call.number = len(f.calls) + f.calls = append(f.calls, call) + reply := f.reply + f.mutex.Unlock() + + reply(w, call) +} + +func newModelFake(t *testing.T) *ModelFake { + t.Helper() + + f := &ModelFake{ + reply: func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5)) + }, + } + + f.Server = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.Close) + + return f +} + +func modelTestRequest() ModelRequest { + return ModelRequest{ + Model: "gpt-test", + Effort: "low", + Input: []any{modelUserMessage("hello")}, + } +} + +// modelWriteEvents writes events as a server-sent event stream. +func modelWriteEvents(w http.ResponseWriter, events ...any) { + w.Header().Set("Content-Type", "text/event-stream") + for _, event := range events { + data, err := json.Marshal(event) + if err != nil { + panic(err) + } + + _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.(map[string]any)["type"], data) + if err != nil { + panic(err) + } + } +} + +func modelMessageItem(text string) map[string]any { + return map[string]any{ + "type": "message", + "role": "assistant", + "content": []any{ + map[string]any{"type": "output_text", "text": text}, + }, + } +} + +func modelCompletedEvent(output []any, input, cached, out int) map[string]any { + if output == nil { + output = []any{} + } + + return map[string]any{ + "type": "response.completed", + "response": map[string]any{ + "output": output, + "usage": map[string]any{ + "input_tokens": input, + "input_tokens_details": map[string]any{"cached_tokens": cached}, + "output_tokens": out, + }, + }, + } +} + +func modelReadUsage(t *testing.T, path string) []ModelUsageRecord { + t.Helper() + + file, err := os.Open(path) + require.NoError(t, err) + + defer func() { + require.NoError(t, file.Close()) + }() + + var records []ModelUsageRecord + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var record ModelUsageRecord + require.NoError(t, json.Unmarshal(scanner.Bytes(), &record)) + records = append(records, record) + } + + require.NoError(t, scanner.Err()) + + return records +} + +func modelReasoningItem(id, encrypted string) map[string]any { + return map[string]any{ + "type": "reasoning", + "id": id, + "encrypted_content": encrypted, + "summary": []any{}, + } +} + +func modelFunctionCallItem(id, callID, name, arguments string) map[string]any { + return map[string]any{ + "type": "function_call", + "id": id, + "call_id": callID, + "name": name, + "arguments": arguments, + } +} diff --git a/model_test.go b/model_test.go index 7807400..84b767e 100644 --- a/model_test.go +++ b/model_test.go @@ -1,24 +1,16 @@ package main import ( - "bufio" - "encoding/json" - "fmt" "io" "net/http" - "net/http/httptest" "os" "path/filepath" - "sync" "testing" "time" "github.com/stretchr/testify/require" ) -// modelTestKey is a fake API key. -const modelTestKey = "sk-test-not-a-real-key" - func TestClientSendsTheFixedRequestFields(t *testing.T) { server := newModelFake(t) client := newTestModelClient(t, server) @@ -261,177 +253,8 @@ func TestClientFailsWhenTheResponseFails(t *testing.T) { } } -// ModelFakeCall is one call to a fake endpoint, numbered from 0. -type ModelFakeCall struct { - number int - header http.Header - raw []byte - body map[string]any -} - -// ModelFake is an httptest Responses endpoint. It keeps each call, and reply -// writes the response. The default reply is one short message. -type ModelFake struct { - *httptest.Server - mutex sync.Mutex - calls []ModelFakeCall - reply func(http.ResponseWriter, ModelFakeCall) -} - -// Call returns call number n. -func (f *ModelFake) Call(n int) ModelFakeCall { - f.mutex.Lock() - defer f.mutex.Unlock() - - return f.calls[n] -} - -// Calls returns the number of calls. -func (f *ModelFake) Calls() int { - f.mutex.Lock() - defer f.mutex.Unlock() - - return len(f.calls) -} - -func (f *ModelFake) serve(w http.ResponseWriter, r *http.Request) { - raw, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - call := ModelFakeCall{ - header: r.Header.Clone(), - raw: raw, - } - - err = json.Unmarshal(raw, &call.body) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - f.mutex.Lock() - call.number = len(f.calls) - f.calls = append(f.calls, call) - reply := f.reply - f.mutex.Unlock() - - reply(w, call) -} - -func newModelFake(t *testing.T) *ModelFake { - t.Helper() - - f := &ModelFake{ - reply: func(w http.ResponseWriter, call ModelFakeCall) { - modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5)) - }, - } - - f.Server = httptest.NewServer(http.HandlerFunc(f.serve)) - t.Cleanup(f.Close) - - return f -} - func newTestModelClient(t *testing.T, server *ModelFake) *ModelClient { t.Helper() return newModelClient(server.URL, ModelAPIKey(modelTestKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) } - -func modelTestRequest() ModelRequest { - return ModelRequest{ - Model: "gpt-test", - Effort: "low", - Input: []any{modelUserMessage("hello")}, - } -} - -// modelWriteEvents writes events as a server-sent event stream. -func modelWriteEvents(w http.ResponseWriter, events ...any) { - w.Header().Set("Content-Type", "text/event-stream") - for _, event := range events { - data, err := json.Marshal(event) - if err != nil { - panic(err) - } - - _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.(map[string]any)["type"], data) - if err != nil { - panic(err) - } - } -} - -func modelMessageItem(text string) map[string]any { - return map[string]any{ - "type": "message", - "role": "assistant", - "content": []any{ - map[string]any{"type": "output_text", "text": text}, - }, - } -} - -func modelCompletedEvent(output []any, input, cached, out int) map[string]any { - if output == nil { - output = []any{} - } - - return map[string]any{ - "type": "response.completed", - "response": map[string]any{ - "output": output, - "usage": map[string]any{ - "input_tokens": input, - "input_tokens_details": map[string]any{"cached_tokens": cached}, - "output_tokens": out, - }, - }, - } -} - -func modelReadUsage(t *testing.T, path string) []ModelUsageRecord { - t.Helper() - - file, err := os.Open(path) - require.NoError(t, err) - - defer func() { - require.NoError(t, file.Close()) - }() - - var records []ModelUsageRecord - scanner := bufio.NewScanner(file) - for scanner.Scan() { - var record ModelUsageRecord - require.NoError(t, json.Unmarshal(scanner.Bytes(), &record)) - records = append(records, record) - } - - require.NoError(t, scanner.Err()) - - return records -} - -func modelReasoningItem(id, encrypted string) map[string]any { - return map[string]any{ - "type": "reasoning", - "id": id, - "encrypted_content": encrypted, - "summary": []any{}, - } -} - -func modelFunctionCallItem(id, callID, name, arguments string) map[string]any { - return map[string]any{ - "type": "function_call", - "id": id, - "call_id": callID, - "name": name, - "arguments": arguments, - } -} diff --git a/telegram_api_test.go b/telegram_api_test.go index bbfd93e..7cdee66 100644 --- a/telegram_api_test.go +++ b/telegram_api_test.go @@ -2,28 +2,15 @@ package main import ( "context" - "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" - "strings" - "sync" "testing" "time" "github.com/stretchr/testify/require" ) -const ( - // telegramTestToken is the bot token of the tests. It has the form of a - // real token. - telegramTestToken = "123456:TEST-secret_token" - - // telegramTestOwner is the user ID of the owner of the test bot. - telegramTestOwner = 4242 -) - func TestGetUpdatesAsksForMessagesFromTheOffset(t *testing.T) { server := newTelegramFake(t) server.Add(TelegramUpdate{ @@ -257,203 +244,3 @@ func TestBotErrorsDoNotHoldTheToken(t *testing.T) { require.ErrorIs(t, err, errTelegramNetwork) require.NotContains(t, err.Error(), telegramTestToken) } - -// TelegramFakeCall is one call of the fake Bot API. OK tells if the call got a -// success answer. -type TelegramFakeCall struct { - method string - params map[string]any - ok bool -} - -// TelegramFake is a fake Bot API server for telegramTestToken. getUpdates -// returns the added updates from the offset on, or waits a short time when -// there are none. The other methods succeed, unless fail gives an error answer -// for a call. An answer with HTTP 429 asks for a wait of one second. When stall -// is true for a call, the call gets no answer until the client gives it up or -// the test ends. Fail and stall run with the mutex held. -type TelegramFake struct { - *httptest.Server - - mutex sync.Mutex - updates []TelegramUpdate - calls []TelegramFakeCall - fail func(call TelegramFakeCall) (int, string) - stall func(call TelegramFakeCall) bool - added chan struct{} - stopped chan struct{} -} - -// Add makes update available to getUpdates. -func (f *TelegramFake) Add(update TelegramUpdate) { - f.mutex.Lock() - f.updates = append(f.updates, update) - f.mutex.Unlock() - - select { - case f.added <- struct{}{}: - default: - } -} - -// Calls returns the calls so far. -func (f *TelegramFake) Calls() []TelegramFakeCall { - f.mutex.Lock() - defer f.mutex.Unlock() - - return append([]TelegramFakeCall(nil), f.calls...) -} - -// Sent returns the texts of the sendMessage calls and the Markdown of the -// sendRichMessage calls that succeeded, in order. -func (f *TelegramFake) Sent() []string { - var texts []string - for _, call := range f.Calls() { - switch { - case !call.ok: - case call.method == "sendMessage": - texts = append(texts, call.params["text"].(string)) - case call.method == "sendRichMessage": - texts = append(texts, call.params["rich_message"].(map[string]any)["markdown"].(string)) - } - } - - return texts -} - -func (f *TelegramFake) serve(w http.ResponseWriter, r *http.Request) { - method, found := strings.CutPrefix(r.URL.Path, "/bot"+telegramTestToken+"/") - if !found { - http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound) - return - } - - var params map[string]any - err := json.NewDecoder(r.Body).Decode(¶ms) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - call := TelegramFakeCall{ - method: method, - params: params, - } - - f.mutex.Lock() - status, description := 0, "" - if f.fail != nil { - status, description = f.fail(call) - } - - stall := f.stall != nil && f.stall(call) - f.mutex.Unlock() - - if stall { - f.record(call) - select { - case <-r.Context().Done(): - case <-f.stopped: - } - - return - } - - if status != 0 { - f.record(call) - w.WriteHeader(status) - wait := 0 - if status == http.StatusTooManyRequests { - wait = 1 - } - - _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait) - - return - } - - call.ok = true - f.record(call) - if method != "getUpdates" { - _, _ = io.WriteString(w, `{"ok":true,"result":true}`) - return - } - - offset := int64(params["offset"].(float64)) - updates := f.from(offset) - if len(updates) == 0 { - select { - case <-f.added: - case <-time.After(50 * time.Millisecond): - case <-r.Context().Done(): - } - - updates = f.from(offset) - } - - _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "result": updates, - }) -} - -// record keeps call. -func (f *TelegramFake) record(call TelegramFakeCall) { - f.mutex.Lock() - defer f.mutex.Unlock() - - f.calls = append(f.calls, call) -} - -// from returns the updates from offset on, and forgets the ones before it, as -// Telegram does. -func (f *TelegramFake) from(offset int64) []TelegramUpdate { - f.mutex.Lock() - defer f.mutex.Unlock() - - var updates []TelegramUpdate - kept := f.updates[:0] - for _, update := range f.updates { - if update.UpdateID >= offset { - updates = append(updates, update) - kept = append(kept, update) - } - } - - f.updates = kept - - return updates -} - -// newTelegramFake starts a fake Bot API server that stops at the end of the -// test. -func newTelegramFake(t *testing.T) *TelegramFake { - t.Helper() - - f := &TelegramFake{ - added: make(chan struct{}, 1), - stopped: make(chan struct{}), - } - - f.Server = httptest.NewServer(http.HandlerFunc(f.serve)) - t.Cleanup(f.Close) - t.Cleanup(func() { - close(f.stopped) - }) - - return f -} - -// telegramOwnerMessage returns a text message of the owner in the private chat. -func telegramOwnerMessage(text string) *TelegramMessage { - return &TelegramMessage{ - Chat: TelegramChat{ - ID: telegramTestOwner, - Type: "private", - }, - From: &TelegramUser{ - ID: telegramTestOwner, - }, - Text: text, - } -} diff --git a/telegram_fake_helper_test.go b/telegram_fake_helper_test.go new file mode 100644 index 0000000..a70a199 --- /dev/null +++ b/telegram_fake_helper_test.go @@ -0,0 +1,222 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +const ( + // telegramTestToken is the bot token of the tests. It has the form of a + // real token. + telegramTestToken = "123456:TEST-secret_token" + + // telegramTestOwner is the user ID of the owner of the test bot. + telegramTestOwner = 4242 +) + +// TelegramFakeCall is one call of the fake Bot API. OK tells if the call got a +// success answer. +type TelegramFakeCall struct { + method string + params map[string]any + ok bool +} + +// TelegramFake is a fake Bot API server for telegramTestToken. getUpdates +// returns the added updates from the offset on, or waits a short time when +// there are none. The other methods succeed, unless fail gives an error answer +// for a call. An answer with HTTP 429 asks for a wait of one second. When stall +// is true for a call, the call gets no answer until the client gives it up or +// the test ends. Fail and stall run with the mutex held. +type TelegramFake struct { + *httptest.Server + + mutex sync.Mutex + updates []TelegramUpdate + calls []TelegramFakeCall + fail func(call TelegramFakeCall) (int, string) + stall func(call TelegramFakeCall) bool + added chan struct{} + stopped chan struct{} +} + +// Add makes update available to getUpdates. +func (f *TelegramFake) Add(update TelegramUpdate) { + f.mutex.Lock() + f.updates = append(f.updates, update) + f.mutex.Unlock() + + select { + case f.added <- struct{}{}: + default: + } +} + +// Calls returns the calls so far. +func (f *TelegramFake) Calls() []TelegramFakeCall { + f.mutex.Lock() + defer f.mutex.Unlock() + + return append([]TelegramFakeCall(nil), f.calls...) +} + +// Sent returns the texts of the sendMessage calls and the Markdown of the +// sendRichMessage calls that succeeded, in order. +func (f *TelegramFake) Sent() []string { + var texts []string + for _, call := range f.Calls() { + switch { + case !call.ok: + case call.method == "sendMessage": + texts = append(texts, call.params["text"].(string)) + case call.method == "sendRichMessage": + texts = append(texts, call.params["rich_message"].(map[string]any)["markdown"].(string)) + } + } + + return texts +} + +func (f *TelegramFake) serve(w http.ResponseWriter, r *http.Request) { + method, found := strings.CutPrefix(r.URL.Path, "/bot"+telegramTestToken+"/") + if !found { + http.Error(w, `{"ok":false,"error_code":404,"description":"Not Found"}`, http.StatusNotFound) + return + } + + var params map[string]any + err := json.NewDecoder(r.Body).Decode(¶ms) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + call := TelegramFakeCall{ + method: method, + params: params, + } + + f.mutex.Lock() + status, description := 0, "" + if f.fail != nil { + status, description = f.fail(call) + } + + stall := f.stall != nil && f.stall(call) + f.mutex.Unlock() + + if stall { + f.record(call) + select { + case <-r.Context().Done(): + case <-f.stopped: + } + + return + } + + if status != 0 { + f.record(call) + w.WriteHeader(status) + wait := 0 + if status == http.StatusTooManyRequests { + wait = 1 + } + + _, _ = fmt.Fprintf(w, `{"ok":false,"error_code":%d,"description":%q,"parameters":{"retry_after":%d}}`, status, description, wait) + + return + } + + call.ok = true + f.record(call) + if method != "getUpdates" { + _, _ = io.WriteString(w, `{"ok":true,"result":true}`) + return + } + + offset := int64(params["offset"].(float64)) + updates := f.from(offset) + if len(updates) == 0 { + select { + case <-f.added: + case <-time.After(50 * time.Millisecond): + case <-r.Context().Done(): + } + + updates = f.from(offset) + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "result": updates, + }) +} + +// record keeps call. +func (f *TelegramFake) record(call TelegramFakeCall) { + f.mutex.Lock() + defer f.mutex.Unlock() + + f.calls = append(f.calls, call) +} + +// from returns the updates from offset on, and forgets the ones before it, as +// Telegram does. +func (f *TelegramFake) from(offset int64) []TelegramUpdate { + f.mutex.Lock() + defer f.mutex.Unlock() + + var updates []TelegramUpdate + kept := f.updates[:0] + for _, update := range f.updates { + if update.UpdateID >= offset { + updates = append(updates, update) + kept = append(kept, update) + } + } + + f.updates = kept + + return updates +} + +// newTelegramFake starts a fake Bot API server that stops at the end of the +// test. +func newTelegramFake(t *testing.T) *TelegramFake { + t.Helper() + + f := &TelegramFake{ + added: make(chan struct{}, 1), + stopped: make(chan struct{}), + } + + f.Server = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.Close) + t.Cleanup(func() { + close(f.stopped) + }) + + return f +} + +// telegramOwnerMessage returns a text message of the owner in the private chat. +func telegramOwnerMessage(text string) *TelegramMessage { + return &TelegramMessage{ + Chat: TelegramChat{ + ID: telegramTestOwner, + Type: "private", + }, + From: &TelegramUser{ + ID: telegramTestOwner, + }, + Text: text, + } +} diff --git a/terminal_client_helper_test.go b/terminal_client_helper_test.go new file mode 100644 index 0000000..077d90c --- /dev/null +++ b/terminal_client_helper_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "io" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TerminalTestClient is a terminal client of a test daemon, with a pipe as its +// input. +type TerminalTestClient struct { + input *io.PipeWriter + done chan error + out *SyncBuffer +} + +// Type writes one line to the input of the client. +func (c *TerminalTestClient) Type(line string) { + _, err := io.WriteString(c.input, line+"\n") + if err != nil { + panic(err) + } +} + +// Output returns what the client showed. +func (c *TerminalTestClient) Output() string { + return c.out.String() +} + +// WaitFor waits until the client showed text. +func (c *TerminalTestClient) WaitFor(t *testing.T, text string) { + t.Helper() + + require.Eventually(t, func() bool { + return strings.Contains(c.Output(), text) + }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output()) +} + +// Close ends the input and waits for the client. +func (c *TerminalTestClient) Close() error { + err := c.input.Close() + if err != nil { + return err + } + + return <-c.done +} + +// terminalStartTestClient connects a client to the daemon at socket. +func terminalStartTestClient(t *testing.T, socket string) *TerminalTestClient { + t.Helper() + + conn, err := net.Dial("unix", socket) + require.NoError(t, err) + + reader, writer := io.Pipe() + client := &TerminalTestClient{ + input: writer, + done: make(chan error, 1), + out: &SyncBuffer{}, + } + + go func() { + client.done <- terminalSession(t.Context(), conn, reader, client.out) + }() + + t.Cleanup(func() { + _ = writer.Close() + }) + + return client +} diff --git a/terminal_test.go b/terminal_test.go index d86f88a..91e43d2 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -6,7 +6,6 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/stretchr/testify/require" ) @@ -72,68 +71,3 @@ func TestEventText(t *testing.T) { }) } } - -// TerminalTestClient is a terminal client of a test daemon, with a pipe as its -// input. -type TerminalTestClient struct { - input *io.PipeWriter - done chan error - out *SyncBuffer -} - -// Type writes one line to the input of the client. -func (c *TerminalTestClient) Type(line string) { - _, err := io.WriteString(c.input, line+"\n") - if err != nil { - panic(err) - } -} - -// Output returns what the client showed. -func (c *TerminalTestClient) Output() string { - return c.out.String() -} - -// WaitFor waits until the client showed text. -func (c *TerminalTestClient) WaitFor(t *testing.T, text string) { - t.Helper() - - require.Eventually(t, func() bool { - return strings.Contains(c.Output(), text) - }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output()) -} - -// Close ends the input and waits for the client. -func (c *TerminalTestClient) Close() error { - err := c.input.Close() - if err != nil { - return err - } - - return <-c.done -} - -// terminalStartTestClient connects a client to the daemon at socket. -func terminalStartTestClient(t *testing.T, socket string) *TerminalTestClient { - t.Helper() - - conn, err := net.Dial("unix", socket) - require.NoError(t, err) - - reader, writer := io.Pipe() - client := &TerminalTestClient{ - input: writer, - done: make(chan error, 1), - out: &SyncBuffer{}, - } - - go func() { - client.done <- terminalSession(t.Context(), conn, reader, client.out) - }() - - t.Cleanup(func() { - _ = writer.Close() - }) - - return client -} diff --git a/tree_node_helper_test.go b/tree_node_helper_test.go new file mode 100644 index 0000000..90ba72c --- /dev/null +++ b/tree_node_helper_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func treeTestNode(level, index int, text string) Node { + return Node{ + Level: level, + Index: index, + Text: text, + Size: len(text), + } +} + +// treeReadNode reads the node (l, i) of tree, which must be built. +func treeReadNode(t *testing.T, tree *Tree, level, index int) Node { + t.Helper() + + node, built, err := tree.Node(level, index) + require.NoError(t, err) + require.True(t, built, "node (%d, %d)", level, index) + + return node +} + +// treeIsBuilt tells if the node (l, i) of tree is built. +func treeIsBuilt(t *testing.T, tree *Tree, level, index int) bool { + t.Helper() + + built, err := tree.Built(level, index) + require.NoError(t, err) + + return built +} diff --git a/tree_test.go b/tree_test.go index 146ab07..46c17c7 100644 --- a/tree_test.go +++ b/tree_test.go @@ -156,33 +156,3 @@ func TestTreeIndexesTheNodesAfterACrash(t *testing.T) { require.Equal(t, "reply: b", treeReadNode(t, tree, 0, 1).Text) require.NoError(t, tree.Add(treeTestNode(1, 0, "user: a\nreply: b"))) } - -func treeTestNode(level, index int, text string) Node { - return Node{ - Level: level, - Index: index, - Text: text, - Size: len(text), - } -} - -// treeReadNode reads the node (l, i) of tree, which must be built. -func treeReadNode(t *testing.T, tree *Tree, level, index int) Node { - t.Helper() - - node, built, err := tree.Node(level, index) - require.NoError(t, err) - require.True(t, built, "node (%d, %d)", level, index) - - return node -} - -// treeIsBuilt tells if the node (l, i) of tree is built. -func treeIsBuilt(t *testing.T, tree *Tree, level, index int) bool { - t.Helper() - - built, err := tree.Built(level, index) - require.NoError(t, err) - - return built -}