mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-19 07:27:43 +08:00
test: Introduce comprehensive edge case tests for various internal packages including SSE, Claude, Auth, Account, Config, Deepseek, Admin, and Util.
This commit is contained in:
140
internal/sse/consumer_edge_test.go
Normal file
140
internal/sse/consumer_edge_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── CollectStream edge cases ────────────────────────────────────────
|
||||
|
||||
func makeHTTPResponse(body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamEmpty(t *testing.T) {
|
||||
resp := makeHTTPResponse("")
|
||||
result := CollectStream(resp, false, false)
|
||||
if result.Text != "" || result.Thinking != "" {
|
||||
t.Fatalf("expected empty result, got text=%q think=%q", result.Text, result.Thinking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamTextOnly(t *testing.T) {
|
||||
resp := makeHTTPResponse(
|
||||
"data: {\"p\":\"response/content\",\"v\":\"Hello\"}\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\" World\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
result := CollectStream(resp, false, false)
|
||||
if result.Text != "Hello World" {
|
||||
t.Fatalf("expected 'Hello World', got %q", result.Text)
|
||||
}
|
||||
if result.Thinking != "" {
|
||||
t.Fatalf("expected no thinking, got %q", result.Thinking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamThinkingAndText(t *testing.T) {
|
||||
resp := makeHTTPResponse(
|
||||
"data: {\"p\":\"response/thinking_content\",\"v\":\"Thinking...\"}\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\"Answer\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
result := CollectStream(resp, true, true)
|
||||
if result.Thinking != "Thinking..." {
|
||||
t.Fatalf("expected 'Thinking...', got %q", result.Thinking)
|
||||
}
|
||||
if result.Text != "Answer" {
|
||||
t.Fatalf("expected 'Answer', got %q", result.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamOnlyThinking(t *testing.T) {
|
||||
resp := makeHTTPResponse(
|
||||
"data: {\"p\":\"response/thinking_content\",\"v\":\"Only thinking\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
result := CollectStream(resp, true, true)
|
||||
if result.Thinking != "Only thinking" {
|
||||
t.Fatalf("expected 'Only thinking', got %q", result.Thinking)
|
||||
}
|
||||
if result.Text != "" {
|
||||
t.Fatalf("expected empty text, got %q", result.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamSkipsInvalidLines(t *testing.T) {
|
||||
resp := makeHTTPResponse(
|
||||
"event: comment\n" +
|
||||
"data: invalid_json\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\"valid\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
result := CollectStream(resp, false, false)
|
||||
if result.Text != "valid" {
|
||||
t.Fatalf("expected 'valid', got %q", result.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamWithFragments(t *testing.T) {
|
||||
resp := makeHTTPResponse(
|
||||
"data: {\"p\":\"response/fragments\",\"o\":\"APPEND\",\"v\":[{\"type\":\"THINK\",\"content\":\"Think\"}]}\n" +
|
||||
"data: {\"p\":\"response/fragments\",\"o\":\"APPEND\",\"v\":[{\"type\":\"RESPONSE\",\"content\":\"Done\"}]}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
result := CollectStream(resp, true, true)
|
||||
if result.Thinking != "Think" {
|
||||
t.Fatalf("expected 'Think' thinking, got %q", result.Thinking)
|
||||
}
|
||||
if result.Text != "Done" {
|
||||
t.Fatalf("expected 'Done' text, got %q", result.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamWithCitation(t *testing.T) {
|
||||
resp := makeHTTPResponse(
|
||||
"data: {\"p\":\"response/content\",\"v\":\"Hello\"}\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\"[citation:1] cited text\"}\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\" more\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
result := CollectStream(resp, false, false)
|
||||
// CollectStream does NOT filter citations (that's done by the adapters)
|
||||
// So citations are passed through as-is
|
||||
if !strings.Contains(result.Text, "[citation:1]") {
|
||||
t.Fatalf("expected citations to be passed through, got %q", result.Text)
|
||||
}
|
||||
if result.Text != "Hello[citation:1] cited text more" {
|
||||
t.Fatalf("expected full text with citation, got %q", result.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamMultipleThinkingChunks(t *testing.T) {
|
||||
resp := makeHTTPResponse(
|
||||
"data: {\"p\":\"response/thinking_content\",\"v\":\"part1\"}\n" +
|
||||
"data: {\"p\":\"response/thinking_content\",\"v\":\" part2\"}\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\"answer\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
result := CollectStream(resp, true, true)
|
||||
if result.Thinking != "part1 part2" {
|
||||
t.Fatalf("expected 'part1 part2', got %q", result.Thinking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectStreamStatusFinished(t *testing.T) {
|
||||
resp := makeHTTPResponse(
|
||||
"data: {\"p\":\"response/content\",\"v\":\"Hello\"}\n" +
|
||||
"data: {\"p\":\"response/status\",\"v\":\"FINISHED\"}\n",
|
||||
)
|
||||
result := CollectStream(resp, false, false)
|
||||
if result.Text != "Hello" {
|
||||
t.Fatalf("expected 'Hello', got %q", result.Text)
|
||||
}
|
||||
}
|
||||
70
internal/sse/line_edge_test.go
Normal file
70
internal/sse/line_edge_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package sse
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDeepSeekContentLineNotParsed(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte("not a data line"), false, "text")
|
||||
if res.Parsed {
|
||||
t.Fatal("expected not parsed")
|
||||
}
|
||||
if res.NextType != "text" {
|
||||
t.Fatalf("expected nextType preserved, got %q", res.NextType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLinePreservesNextType(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/thinking_content","v":"think"}`), true, "thinking")
|
||||
if !res.Parsed || res.Stop {
|
||||
t.Fatalf("expected parsed non-stop: %#v", res)
|
||||
}
|
||||
if len(res.Parts) != 1 || res.Parts[0].Type != "thinking" {
|
||||
t.Fatalf("unexpected parts: %#v", res.Parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineFragmentSwitchType(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine(
|
||||
[]byte(`data: {"p":"response/fragments","o":"APPEND","v":[{"type":"RESPONSE","content":"hi"}]}`),
|
||||
true, "thinking",
|
||||
)
|
||||
if !res.Parsed || res.Stop {
|
||||
t.Fatalf("expected parsed non-stop: %#v", res)
|
||||
}
|
||||
if res.NextType != "text" {
|
||||
t.Fatalf("expected nextType text after RESPONSE fragment, got %q", res.NextType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineContentFilterMessage(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte(`data: {"code":"content_filter"}`), false, "text")
|
||||
if !res.ContentFilter {
|
||||
t.Fatal("expected content filter flag")
|
||||
}
|
||||
if res.ErrorMessage == "" {
|
||||
t.Fatal("expected error message on content filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineErrorObjectFormat(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte(`data: {"error":{"message":"rate limit","code":429}}`), false, "text")
|
||||
if !res.Parsed || !res.Stop {
|
||||
t.Fatalf("expected parsed stop: %#v", res)
|
||||
}
|
||||
if res.ErrorMessage == "" {
|
||||
t.Fatal("expected non-empty error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineInvalidJSON(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte("data: {broken"), false, "text")
|
||||
if res.Parsed {
|
||||
t.Fatal("expected not parsed for broken JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineEmptyBytes(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte{}, false, "text")
|
||||
if res.Parsed {
|
||||
t.Fatal("expected not parsed for empty bytes")
|
||||
}
|
||||
}
|
||||
631
internal/sse/parser_edge_test.go
Normal file
631
internal/sse/parser_edge_test.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package sse
|
||||
|
||||
import "testing"
|
||||
|
||||
// ─── ParseDeepSeekSSELine edge cases ─────────────────────────────────
|
||||
|
||||
func TestParseDeepSeekSSELineEmptyLine(t *testing.T) {
|
||||
_, _, ok := ParseDeepSeekSSELine([]byte(""))
|
||||
if ok {
|
||||
t.Fatal("expected not parsed for empty line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekSSELineNoDataPrefix(t *testing.T) {
|
||||
_, _, ok := ParseDeepSeekSSELine([]byte("event: message"))
|
||||
if ok {
|
||||
t.Fatal("expected not parsed for non-data line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekSSELineInvalidJSON(t *testing.T) {
|
||||
_, _, ok := ParseDeepSeekSSELine([]byte("data: {invalid json"))
|
||||
if ok {
|
||||
t.Fatal("expected not parsed for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekSSELineWhitespaceOnly(t *testing.T) {
|
||||
_, _, ok := ParseDeepSeekSSELine([]byte(" "))
|
||||
if ok {
|
||||
t.Fatal("expected not parsed for whitespace-only line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekSSELineDataWithExtraSpaces(t *testing.T) {
|
||||
chunk, done, ok := ParseDeepSeekSSELine([]byte(`data: {"v":"hello"} `))
|
||||
if !ok || done {
|
||||
t.Fatalf("expected parsed chunk for spaced data line")
|
||||
}
|
||||
if chunk["v"] != "hello" {
|
||||
t.Fatalf("unexpected chunk: %#v", chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── shouldSkipPath edge cases ───────────────────────────────────────
|
||||
|
||||
func TestShouldSkipPathQuasiStatus(t *testing.T) {
|
||||
if !shouldSkipPath("response/quasi_status") {
|
||||
t.Fatal("expected skip for quasi_status path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipPathElapsedSecs(t *testing.T) {
|
||||
if !shouldSkipPath("response/elapsed_secs") {
|
||||
t.Fatal("expected skip for elapsed_secs path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipPathTokenUsage(t *testing.T) {
|
||||
if !shouldSkipPath("response/token_usage") {
|
||||
t.Fatal("expected skip for token_usage path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipPathPendingFragment(t *testing.T) {
|
||||
if !shouldSkipPath("response/pending_fragment") {
|
||||
t.Fatal("expected skip for pending_fragment path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipPathConversationMode(t *testing.T) {
|
||||
if !shouldSkipPath("response/conversation_mode") {
|
||||
t.Fatal("expected skip for conversation_mode path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipPathSearchStatus(t *testing.T) {
|
||||
if !shouldSkipPath("response/search_status") {
|
||||
t.Fatal("expected skip for search_status path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipPathFragmentStatus(t *testing.T) {
|
||||
if !shouldSkipPath("response/fragments/-1/status") {
|
||||
t.Fatal("expected skip for fragment -1 status")
|
||||
}
|
||||
if !shouldSkipPath("response/fragments/-2/status") {
|
||||
t.Fatal("expected skip for fragment -2 status")
|
||||
}
|
||||
if !shouldSkipPath("response/fragments/-3/status") {
|
||||
t.Fatal("expected skip for fragment -3 status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkipPathRegularContent(t *testing.T) {
|
||||
if shouldSkipPath("response/content") {
|
||||
t.Fatal("expected not skip for content path")
|
||||
}
|
||||
if shouldSkipPath("response/thinking_content") {
|
||||
t.Fatal("expected not skip for thinking_content path")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ParseSSEChunkForContent edge cases ──────────────────────────────
|
||||
|
||||
func TestParseSSEChunkForContentNoVField(t *testing.T) {
|
||||
parts, finished, nextType := ParseSSEChunkForContent(map[string]any{"p": "response/content"}, false, "text")
|
||||
if finished {
|
||||
t.Fatal("expected not finished")
|
||||
}
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts when v is missing, got %#v", parts)
|
||||
}
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected type preserved, got %q", nextType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentSkippedPath(t *testing.T) {
|
||||
parts, finished, nextType := ParseSSEChunkForContent(map[string]any{
|
||||
"p": "response/token_usage",
|
||||
"v": "some data",
|
||||
}, false, "text")
|
||||
if finished || len(parts) > 0 {
|
||||
t.Fatalf("expected skipped path to produce no output")
|
||||
}
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected type preserved for skipped path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentFinishedStatus(t *testing.T) {
|
||||
parts, finished, _ := ParseSSEChunkForContent(map[string]any{
|
||||
"p": "response/status",
|
||||
"v": "FINISHED",
|
||||
}, false, "text")
|
||||
if !finished {
|
||||
t.Fatal("expected finished on status FINISHED")
|
||||
}
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts on finished, got %d", len(parts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentStatusNotFinished(t *testing.T) {
|
||||
parts, finished, _ := ParseSSEChunkForContent(map[string]any{
|
||||
"p": "response/status",
|
||||
"v": "IN_PROGRESS",
|
||||
}, false, "text")
|
||||
if finished {
|
||||
t.Fatal("expected not finished for non-FINISHED status")
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Text != "IN_PROGRESS" {
|
||||
t.Fatalf("expected content for non-FINISHED status, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentEmptyStringV(t *testing.T) {
|
||||
parts, finished, _ := ParseSSEChunkForContent(map[string]any{
|
||||
"p": "response/content",
|
||||
"v": "",
|
||||
}, false, "text")
|
||||
if finished {
|
||||
t.Fatal("expected not finished")
|
||||
}
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts for empty string v, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentFinishedOnEmptyPath(t *testing.T) {
|
||||
parts, finished, _ := ParseSSEChunkForContent(map[string]any{
|
||||
"p": "",
|
||||
"v": "FINISHED",
|
||||
}, false, "text")
|
||||
if !finished {
|
||||
t.Fatal("expected finished on empty path with FINISHED value")
|
||||
}
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts on finished")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentFinishedOnStatusPath(t *testing.T) {
|
||||
_, finished, _ := ParseSSEChunkForContent(map[string]any{
|
||||
"p": "status",
|
||||
"v": "FINISHED",
|
||||
}, false, "text")
|
||||
if !finished {
|
||||
t.Fatal("expected finished on status path with FINISHED value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentThinkingPathEmptyPath(t *testing.T) {
|
||||
parts, _, nextType := ParseSSEChunkForContent(map[string]any{
|
||||
"v": "some thought",
|
||||
}, true, "thinking")
|
||||
if len(parts) != 1 || parts[0].Type != "thinking" {
|
||||
t.Fatalf("expected thinking part on empty path, got %#v", parts)
|
||||
}
|
||||
if nextType != "thinking" {
|
||||
t.Fatalf("expected nextType thinking, got %q", nextType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentThinkingEnabledTextType(t *testing.T) {
|
||||
parts, _, nextType := ParseSSEChunkForContent(map[string]any{
|
||||
"v": "text content",
|
||||
}, true, "text")
|
||||
if len(parts) != 1 || parts[0].Type != "text" {
|
||||
t.Fatalf("expected text part when currentType=text, got %#v", parts)
|
||||
}
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected nextType text, got %q", nextType)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ParseSSEChunkForContent: fragments path with THINK type ─────────
|
||||
|
||||
func TestParseSSEChunkForContentFragmentsAppendThink(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments",
|
||||
"o": "APPEND",
|
||||
"v": []any{
|
||||
map[string]any{
|
||||
"type": "THINK",
|
||||
"content": "深入思考...",
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, true, "text")
|
||||
if finished {
|
||||
t.Fatal("expected not finished")
|
||||
}
|
||||
if nextType != "thinking" {
|
||||
t.Fatalf("expected nextType thinking, got %q", nextType)
|
||||
}
|
||||
if len(parts) != 1 || parts[0].Type != "thinking" || parts[0].Text != "深入思考..." {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentFragmentsAppendEmptyContent(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments",
|
||||
"o": "APPEND",
|
||||
"v": []any{
|
||||
map[string]any{
|
||||
"type": "RESPONSE",
|
||||
"content": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, true, "thinking")
|
||||
if finished {
|
||||
t.Fatal("expected not finished")
|
||||
}
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected nextType text, got %q", nextType)
|
||||
}
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts for empty content, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentFragmentsAppendDefaultType(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments",
|
||||
"o": "APPEND",
|
||||
"v": []any{
|
||||
map[string]any{
|
||||
"type": "UNKNOWN",
|
||||
"content": "some text",
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, _, _ := ParseSSEChunkForContent(chunk, true, "text")
|
||||
if len(parts) != 1 || parts[0].Type != "text" {
|
||||
t.Fatalf("expected text type for unknown fragment type, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentFragmentsAppendNonArray(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments",
|
||||
"o": "APPEND",
|
||||
"v": "not an array",
|
||||
}
|
||||
parts, finished, _ := ParseSSEChunkForContent(chunk, true, "text")
|
||||
if finished {
|
||||
t.Fatal("expected not finished")
|
||||
}
|
||||
// "not an array" should be treated as string value at the end
|
||||
if len(parts) != 1 || parts[0].Text != "not an array" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentFragmentsAppendNonMap(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments",
|
||||
"o": "APPEND",
|
||||
"v": []any{"string item"},
|
||||
}
|
||||
parts, _, _ := ParseSSEChunkForContent(chunk, false, "text")
|
||||
// Non-map items in fragment array are skipped; the []any itself is handled later
|
||||
_ = parts // just checking it doesn't panic
|
||||
}
|
||||
|
||||
// ─── ParseSSEChunkForContent: response path with nested fragment ─────
|
||||
|
||||
func TestParseSSEChunkForContentResponsePathFragmentsAppend(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response",
|
||||
"v": []any{
|
||||
map[string]any{
|
||||
"p": "fragments",
|
||||
"o": "APPEND",
|
||||
"v": []any{
|
||||
map[string]any{
|
||||
"type": "THINKING",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, _, nextType := ParseSSEChunkForContent(chunk, true, "text")
|
||||
if nextType != "thinking" {
|
||||
t.Fatalf("expected nextType thinking from response path fragments, got %q", nextType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentResponsePathResponseFragment(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response",
|
||||
"v": []any{
|
||||
map[string]any{
|
||||
"p": "fragments",
|
||||
"o": "APPEND",
|
||||
"v": []any{
|
||||
map[string]any{
|
||||
"type": "RESPONSE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, _, nextType := ParseSSEChunkForContent(chunk, true, "thinking")
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected nextType text for RESPONSE fragment, got %q", nextType)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ParseSSEChunkForContent: map value with wrapped response ────────
|
||||
|
||||
func TestParseSSEChunkForContentMapValueWithFragments(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"v": map[string]any{
|
||||
"response": map[string]any{
|
||||
"fragments": []any{
|
||||
map[string]any{
|
||||
"type": "THINK",
|
||||
"content": "思考...",
|
||||
},
|
||||
map[string]any{
|
||||
"type": "RESPONSE",
|
||||
"content": "回答...",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, true, "text")
|
||||
if finished {
|
||||
t.Fatal("expected not finished")
|
||||
}
|
||||
if nextType != "text" {
|
||||
t.Fatalf("expected nextType text after RESPONSE, got %q", nextType)
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("expected 2 parts, got %d: %#v", len(parts), parts)
|
||||
}
|
||||
if parts[0].Type != "thinking" || parts[0].Text != "思考..." {
|
||||
t.Fatalf("first part mismatch: %#v", parts[0])
|
||||
}
|
||||
if parts[1].Type != "text" || parts[1].Text != "回答..." {
|
||||
t.Fatalf("second part mismatch: %#v", parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentMapValueDirectFragments(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"v": map[string]any{
|
||||
"fragments": []any{
|
||||
map[string]any{
|
||||
"type": "RESPONSE",
|
||||
"content": "直接回答",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, _, _ := ParseSSEChunkForContent(chunk, false, "text")
|
||||
if len(parts) != 1 || parts[0].Text != "直接回答" || parts[0].Type != "text" {
|
||||
t.Fatalf("unexpected parts for direct fragments: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentMapValueUnknownType(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"v": map[string]any{
|
||||
"fragments": []any{
|
||||
map[string]any{
|
||||
"type": "CUSTOM",
|
||||
"content": "custom content",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, _, _ := ParseSSEChunkForContent(chunk, false, "text")
|
||||
if len(parts) != 1 || parts[0].Type != "text" {
|
||||
t.Fatalf("expected partType fallback for unknown type, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEChunkForContentMapValueEmptyFragmentContent(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"v": map[string]any{
|
||||
"fragments": []any{
|
||||
map[string]any{
|
||||
"type": "RESPONSE",
|
||||
"content": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, _, _ := ParseSSEChunkForContent(chunk, false, "text")
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts for empty fragment content, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ParseSSEChunkForContent: fragments/-1/content path ──────────────
|
||||
|
||||
func TestParseSSEChunkForContentFragmentContentPathInheritsType(t *testing.T) {
|
||||
chunk := map[string]any{
|
||||
"p": "response/fragments/-1/content",
|
||||
"v": "继续思考",
|
||||
}
|
||||
parts, _, _ := ParseSSEChunkForContent(chunk, true, "thinking")
|
||||
if len(parts) != 1 || parts[0].Type != "thinking" {
|
||||
t.Fatalf("expected inherited thinking type, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IsCitation edge cases ───────────────────────────────────────────
|
||||
|
||||
func TestIsCitationWithLeadingWhitespace(t *testing.T) {
|
||||
if !IsCitation(" [citation:2] text") {
|
||||
t.Fatal("expected citation true with leading whitespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCitationEmpty(t *testing.T) {
|
||||
if IsCitation("") {
|
||||
t.Fatal("expected citation false for empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCitationSimilarPrefix(t *testing.T) {
|
||||
if IsCitation("[cite:1] text") {
|
||||
t.Fatal("expected citation false for [cite: prefix")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── extractContentRecursive edge cases ──────────────────────────────
|
||||
|
||||
func TestExtractContentRecursiveFinishedStatus(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "status", "v": "FINISHED"},
|
||||
}
|
||||
parts, finished := extractContentRecursive(items, "text")
|
||||
if !finished {
|
||||
t.Fatal("expected finished on status FINISHED")
|
||||
}
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveSkipsPath(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "token_usage", "v": "data"},
|
||||
}
|
||||
parts, finished := extractContentRecursive(items, "text")
|
||||
if finished {
|
||||
t.Fatal("expected not finished")
|
||||
}
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts for skipped path, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveContentField(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "x", "v": "val", "content": "actual content", "type": "RESPONSE"},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "text")
|
||||
if len(parts) != 1 || parts[0].Text != "actual content" || parts[0].Type != "text" {
|
||||
t.Fatalf("unexpected parts: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveContentFieldThinkType(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "x", "v": "val", "content": "think text", "type": "THINK"},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "text")
|
||||
if len(parts) != 1 || parts[0].Type != "thinking" {
|
||||
t.Fatalf("expected thinking type for THINK content, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveThinkingPath(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "thinking_content", "v": "deep thought"},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "text")
|
||||
if len(parts) != 1 || parts[0].Type != "thinking" || parts[0].Text != "deep thought" {
|
||||
t.Fatalf("unexpected parts for thinking path: %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveContentPath(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "content", "v": "text content"},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "thinking")
|
||||
if len(parts) != 1 || parts[0].Type != "text" {
|
||||
t.Fatalf("expected text type for content path, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveResponsePath(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "response", "v": "text content"},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "thinking")
|
||||
if len(parts) != 1 || parts[0].Type != "text" {
|
||||
t.Fatalf("expected text type for response path, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveFragmentsPath(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "fragments", "v": "fragment text"},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "thinking")
|
||||
if len(parts) != 1 || parts[0].Type != "text" {
|
||||
t.Fatalf("expected text type for fragments path, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveNestedArrayWithTypes(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{
|
||||
"p": "fragments",
|
||||
"v": []any{
|
||||
map[string]any{"content": "thought", "type": "THINKING"},
|
||||
map[string]any{"content": "answer", "type": "RESPONSE"},
|
||||
"raw string",
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "text")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("expected 3 parts, got %d: %#v", len(parts), parts)
|
||||
}
|
||||
if parts[0].Type != "thinking" || parts[0].Text != "thought" {
|
||||
t.Fatalf("first part mismatch: %#v", parts[0])
|
||||
}
|
||||
if parts[1].Type != "text" || parts[1].Text != "answer" {
|
||||
t.Fatalf("second part mismatch: %#v", parts[1])
|
||||
}
|
||||
if parts[2].Type != "text" || parts[2].Text != "raw string" {
|
||||
t.Fatalf("third part mismatch: %#v", parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveEmptyContentSkipped(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{
|
||||
"p": "fragments",
|
||||
"v": []any{
|
||||
map[string]any{"content": "", "type": "RESPONSE"},
|
||||
},
|
||||
},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "text")
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts for empty nested content, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveFinishedString(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "content", "v": "FINISHED"},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "text")
|
||||
// "FINISHED" string value on non-status path should be skipped
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected FINISHED string to be skipped, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveNoVField(t *testing.T) {
|
||||
items := []any{
|
||||
map[string]any{"p": "content"},
|
||||
}
|
||||
parts, _ := extractContentRecursive(items, "text")
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts for missing v field, got %#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentRecursiveNonMapItem(t *testing.T) {
|
||||
items := []any{"just a string", 42}
|
||||
parts, _ := extractContentRecursive(items, "text")
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("expected no parts for non-map items, got %#v", parts)
|
||||
}
|
||||
}
|
||||
177
internal/sse/stream_edge_test.go
Normal file
177
internal/sse/stream_edge_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartParsedLinePumpEmptyBody(t *testing.T) {
|
||||
body := strings.NewReader("")
|
||||
results, done := StartParsedLinePump(context.Background(), body, false, "text")
|
||||
|
||||
collected := make([]LineResult, 0)
|
||||
for r := range results {
|
||||
collected = append(collected, r)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(collected) != 0 {
|
||||
t.Fatalf("expected no results for empty body, got %d", len(collected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartParsedLinePumpMultipleLines(t *testing.T) {
|
||||
body := strings.NewReader(
|
||||
"data: {\"p\":\"response/thinking_content\",\"v\":\"think\"}\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\"text\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
results, done := StartParsedLinePump(context.Background(), body, true, "thinking")
|
||||
|
||||
collected := make([]LineResult, 0)
|
||||
for r := range results {
|
||||
collected = append(collected, r)
|
||||
}
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(collected) < 3 {
|
||||
t.Fatalf("expected at least 3 results, got %d", len(collected))
|
||||
}
|
||||
// First should be thinking
|
||||
if collected[0].Parts[0].Type != "thinking" {
|
||||
t.Fatalf("expected first part thinking, got %q", collected[0].Parts[0].Type)
|
||||
}
|
||||
// Last should be stop
|
||||
last := collected[len(collected)-1]
|
||||
if !last.Stop {
|
||||
t.Fatal("expected last result to be stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartParsedLinePumpTypeTracking(t *testing.T) {
|
||||
body := strings.NewReader(
|
||||
"data: {\"p\":\"response/fragments\",\"o\":\"APPEND\",\"v\":[{\"type\":\"THINK\",\"content\":\"思\"}]}\n" +
|
||||
"data: {\"p\":\"response/fragments/-1/content\",\"v\":\"考\"}\n" +
|
||||
"data: {\"p\":\"response/fragments\",\"o\":\"APPEND\",\"v\":[{\"type\":\"RESPONSE\",\"content\":\"答\"}]}\n" +
|
||||
"data: {\"p\":\"response/fragments/-1/content\",\"v\":\"案\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
results, done := StartParsedLinePump(context.Background(), body, true, "text")
|
||||
|
||||
types := make([]string, 0)
|
||||
for r := range results {
|
||||
for _, p := range r.Parts {
|
||||
types = append(types, p.Type)
|
||||
}
|
||||
}
|
||||
<-done
|
||||
|
||||
// Should have: thinking, thinking, text, text
|
||||
expected := []string{"thinking", "thinking", "text", "text"}
|
||||
if len(types) != len(expected) {
|
||||
t.Fatalf("expected types %v, got %v", expected, types)
|
||||
}
|
||||
for i, want := range expected {
|
||||
if types[i] != want {
|
||||
t.Fatalf("type[%d] mismatch: want %q got %q (all=%v)", i, want, types[i], types)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartParsedLinePumpContextCancellation(t *testing.T) {
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
results, done := StartParsedLinePump(ctx, pr, false, "text")
|
||||
|
||||
// Write one line to allow it to start
|
||||
go func() {
|
||||
_, _ = io.WriteString(pw, "data: {\"p\":\"response/content\",\"v\":\"hello\"}\n")
|
||||
// Don't close yet - wait for context cancel
|
||||
}()
|
||||
|
||||
// Read first result
|
||||
r := <-results
|
||||
if !r.Parsed || len(r.Parts) == 0 {
|
||||
t.Fatalf("expected first parsed result, got %#v", r)
|
||||
}
|
||||
|
||||
// Cancel context - this will cause the pump to exit on next send
|
||||
cancel()
|
||||
// Close the pipe to unblock scanner.Scan()
|
||||
pw.Close()
|
||||
|
||||
// Drain remaining results
|
||||
for range results {
|
||||
}
|
||||
|
||||
err := <-done
|
||||
// Error may be context.Canceled or nil (if pipe closed first)
|
||||
if err != nil && err != context.Canceled {
|
||||
t.Fatalf("expected context.Canceled or nil error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartParsedLinePumpOnlyDONE(t *testing.T) {
|
||||
body := strings.NewReader("data: [DONE]\n")
|
||||
results, done := StartParsedLinePump(context.Background(), body, false, "text")
|
||||
|
||||
collected := make([]LineResult, 0)
|
||||
for r := range results {
|
||||
collected = append(collected, r)
|
||||
}
|
||||
<-done
|
||||
|
||||
if len(collected) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(collected))
|
||||
}
|
||||
if !collected[0].Stop {
|
||||
t.Fatal("expected stop on [DONE]")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartParsedLinePumpNonSSELines(t *testing.T) {
|
||||
body := strings.NewReader(
|
||||
"event: update\n" +
|
||||
": comment line\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\"valid\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
results, done := StartParsedLinePump(context.Background(), body, false, "text")
|
||||
|
||||
var validCount int
|
||||
for r := range results {
|
||||
if r.Parsed && len(r.Parts) > 0 {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
<-done
|
||||
|
||||
if validCount != 1 {
|
||||
t.Fatalf("expected 1 valid result, got %d", validCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartParsedLinePumpThinkingDisabled(t *testing.T) {
|
||||
body := strings.NewReader(
|
||||
"data: {\"p\":\"response/thinking_content\",\"v\":\"thought\"}\n" +
|
||||
"data: {\"p\":\"response/content\",\"v\":\"response\"}\n" +
|
||||
"data: [DONE]\n",
|
||||
)
|
||||
// With thinking disabled, thinking content should still be emitted but marked differently
|
||||
results, done := StartParsedLinePump(context.Background(), body, false, "text")
|
||||
|
||||
var parts []ContentPart
|
||||
for r := range results {
|
||||
parts = append(parts, r.Parts...)
|
||||
}
|
||||
<-done
|
||||
|
||||
if len(parts) < 1 {
|
||||
t.Fatalf("expected at least 1 part, got %d", len(parts))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user