Files
dobotai-platform/internal/questiontable/row_test.go
2026-07-23 14:38:50 +08:00

357 lines
10 KiB
Go

package questiontable
import (
"testing"
"time"
)
func TestRowToFieldsExactMapping(t *testing.T) {
shanghai, _ := time.LoadLocation("Asia/Shanghai")
asked := time.Date(2025, 6, 15, 14, 30, 0, 0, shanghai)
r := Row{
Question: "这个怎么修?",
Answer: "请按以下步骤操作。",
AskedTime: asked,
AskerName: "张三",
AskerUnionID: "union-zhang-san",
TriggerPerson: "李四",
Department: "研发部",
Region: "华东",
}
fields := r.ToFields()
// ChineseQuestion (only when Han present).
if fields[FieldChineseQuestion] != "这个怎么修?" {
t.Errorf("ChineseQuestion = %q, want %q", fields[FieldChineseQuestion], "这个怎么修?")
}
// Answer.
if fields[FieldAnswer] != "请按以下步骤操作。" {
t.Errorf("Answer = %q, want %q", fields[FieldAnswer], "请按以下步骤操作。")
}
// AskedTime: Unix milliseconds.
expectedMS := asked.UnixMilli()
if fields[FieldAskedTime] != expectedMS {
t.Errorf("AskedTime = %v (%T), want %v (int64)", fields[FieldAskedTime], fields[FieldAskedTime], expectedMS)
}
// Asker: user array.
userArr, ok := fields[FieldAsker].([]interface{})
if !ok {
t.Fatalf("Asker not a []interface{}, got %T", fields[FieldAsker])
}
if len(userArr) != 1 {
t.Fatalf("Asker len = %d, want 1", len(userArr))
}
userObj, ok := userArr[0].(map[string]interface{})
if !ok {
t.Fatalf("Asker[0] not a map[string]interface{}, got %T", userArr[0])
}
if userObj["name"] != "张三" {
t.Errorf("Asker name = %q, want %q", userObj["name"], "张三")
}
if userObj["unionId"] != "union-zhang-san" {
t.Errorf("Asker unionId = %q, want %q", userObj["unionId"], "union-zhang-san")
}
// OriginalQuestion.
if fields[FieldOriginalQuestion] != "这个怎么修?" {
t.Errorf("OriginalQuestion = %q, want %q", fields[FieldOriginalQuestion], "这个怎么修?")
}
// Language.
if fields[FieldLanguage] != "中文" {
t.Errorf("Language = %q, want %q", fields[FieldLanguage], "中文")
}
// TriggerPerson.
if fields[FieldTriggerPerson] != "李四" {
t.Errorf("TriggerPerson = %q, want %q", fields[FieldTriggerPerson], "李四")
}
// Department.
if fields[FieldDepartment] != "研发部" {
t.Errorf("Department = %q, want %q", fields[FieldDepartment], "研发部")
}
// Region.
if fields[FieldRegion] != "华东" {
t.Errorf("Region = %q, want %q", fields[FieldRegion], "华东")
}
// Omitted: the five select fields must not be present.
omitted := []string{"QMn9UoU", "H3gtTuv", "nFvv8OR", "UMqUPAG", "lnF7gF9"}
for _, fid := range omitted {
if _, exists := fields[fid]; exists {
t.Errorf("select field %q should be omitted but is present", fid)
}
}
}
func TestRowToFieldsDateZeroOmission(t *testing.T) {
r := Row{
Question: "test",
Answer: "answer",
AskerName: "user",
AskerUnionID: "union-1",
// AskedTime is zero.
}
fields := r.ToFields()
if _, exists := fields[FieldAskedTime]; exists {
t.Error("AskedTime field should be omitted when zero")
}
}
func TestRowToFieldsDateInt64UnixMilli(t *testing.T) {
shanghai, _ := time.LoadLocation("Asia/Shanghai")
asked := time.Date(2026, 1, 2, 3, 4, 5, 0, shanghai)
r := Row{
Question: "test",
Answer: "answer",
AskedTime: asked,
AskerName: "user",
AskerUnionID: "union-1",
}
fields := r.ToFields()
got, ok := fields[FieldAskedTime].(int64)
if !ok {
t.Fatalf("AskedTime is %T, want int64", fields[FieldAskedTime])
}
if got != asked.UnixMilli() {
t.Errorf("AskedTime = %d, want %d", got, asked.UnixMilli())
}
}
func TestRowToFieldsUserShape(t *testing.T) {
// With union ID: user array present.
r := Row{
Question: "test",
Answer: "answer",
AskerName: " 张三 ",
AskerUnionID: " union-123 ",
}
fields := r.ToFields()
userArr, ok := fields[FieldAsker].([]interface{})
if !ok {
t.Fatalf("Asker not present or wrong type: %T", fields[FieldAsker])
}
userObj := userArr[0].(map[string]interface{})
if userObj["name"] != "张三" {
t.Errorf("name = %q, want %q (trimmed)", userObj["name"], "张三")
}
if userObj["unionId"] != "union-123" {
t.Errorf("unionId = %q, want %q (trimmed)", userObj["unionId"], "union-123")
}
// Without union ID: Asker field omitted.
r2 := Row{
Question: "test",
Answer: "answer",
AskerName: "张三",
AskerUnionID: "",
}
fields2 := r2.ToFields()
if _, exists := fields2[FieldAsker]; exists {
t.Error("Asker field should be omitted when union ID is blank")
}
// Whitespace-only union ID: omitted.
r3 := Row{
Question: "test",
Answer: "answer",
AskerName: "张三",
AskerUnionID: " ",
}
fields3 := r3.ToFields()
if _, exists := fields3[FieldAsker]; exists {
t.Error("Asker field should be omitted when union ID is whitespace-only")
}
}
func TestRowToFieldsChineseQuestionPolicy(t *testing.T) {
// English question: ChineseQuestion omitted.
r := Row{
Question: "How to fix this?",
Answer: "Follow these steps.",
AskerName: "Alice",
AskerUnionID: "union-alice",
}
fields := r.ToFields()
if _, exists := fields[FieldChineseQuestion]; exists {
t.Error("ChineseQuestion should be omitted for English question")
}
if fields[FieldLanguage] != "英文" {
t.Errorf("Language = %q, want 英文", fields[FieldLanguage])
}
// Chinese question: ChineseQuestion contains the question.
r2 := Row{
Question: "怎么解决?",
Answer: "按步骤操作。",
AskerName: "Bob",
AskerUnionID: "union-bob",
}
fields2 := r2.ToFields()
if fields2[FieldChineseQuestion] != "怎么解决?" {
t.Errorf("ChineseQuestion = %q, want %q", fields2[FieldChineseQuestion], "怎么解决?")
}
if fields2[FieldLanguage] != "中文" {
t.Errorf("Language = %q, want 中文", fields2[FieldLanguage])
}
}
func TestDetectLanguage(t *testing.T) {
tests := []struct {
input string
want string
}{
{"你好", "中文"},
{"Hello", "英文"},
{"Hello世界", "中文"}, // Han takes priority
{"12345", "其他"},
{"", "其他"},
{"こんにちは", "其他"}, // Japanese kana
{"Привет", "其他"}, // Cyrillic
{"Español", "英文"}, // accented Latin via unicode.Latin
{"café", "英文"}, // accented Latin
{"Déjà vu", "英文"}, // accented Latin
}
for _, tt := range tests {
got := detectLanguage(tt.input)
if got != tt.want {
t.Errorf("detectLanguage(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestSanitiseFormulaInjection(t *testing.T) {
tests := []struct {
input string
want string
}{
{"=SUM(A1:A10)", "'=SUM(A1:A10)"},
{"+A1", "'+A1"},
{"-A1", "'-A1"},
{"@SUM", "'@SUM"},
{"normal text", "normal text"},
{"=", "'="}, // = is the first rune, prefixed to '='
{"", ""},
{" leading space", " leading space"},
{"1=2", "1=2"}, // doesn't start with a formula char
}
for _, tt := range tests {
got := sanitise(tt.input)
if got != tt.want {
t.Errorf("sanitise(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestSanitiseDoesNotMutateNonFormula(t *testing.T) {
inputs := []string{"hello", "世界", "123", "=SUM(", " a", "a=", "null"}
for _, in := range inputs {
got := sanitise(in)
// Only "=SUM(" should be prefixed.
switch in {
case "=SUM(":
if got != "'=SUM(" {
t.Errorf("sanitise(%q) = %q, want '=SUM(", in, got)
}
default:
if got != in {
t.Errorf("sanitise(%q) = %q, want %q (no change)", in, got, in)
}
}
}
}
func TestTriggerPersonFallback(t *testing.T) {
// When TriggerPerson is empty, fall back to AskerName.
r := Row{
Question: "test",
Answer: "answer",
AskerName: "张三",
AskerUnionID: "union-1",
TriggerPerson: "",
}
fields := r.ToFields()
if fields[FieldTriggerPerson] != "张三" {
t.Errorf("TriggerPerson = %q, want %q (fallback to asker)", fields[FieldTriggerPerson], "张三")
}
// When TriggerPerson is set, use it.
r2 := Row{
Question: "test",
Answer: "answer",
AskerName: "张三",
AskerUnionID: "union-1",
TriggerPerson: "nickname",
}
fields2 := r2.ToFields()
if fields2[FieldTriggerPerson] != "nickname" {
t.Errorf("TriggerPerson = %q, want %q (use trigger)", fields2[FieldTriggerPerson], "nickname")
}
}
func TestFormulaInjectionOnAllColumns(t *testing.T) {
shanghai, _ := time.LoadLocation("Asia/Shanghai")
asked := time.Date(2025, 1, 1, 0, 0, 0, 0, shanghai)
// Use a question that starts with formula char AND contains Han so
// the Chinese-question field is populated.
r := Row{
Question: "=修复()",
Answer: "+cmd",
AskedTime: asked,
AskerName: "-张三",
AskerUnionID: "union-sanitise",
TriggerPerson: "@trigger",
Department: "=DEPT",
Region: "normal",
}
fields := r.ToFields()
// Chinese question: "=修复()" has Han, so it's Chinese and gets sanitised.
if fields[FieldChineseQuestion] != "'=修复()" {
t.Errorf("ChineseQuestion = %q, want %q", fields[FieldChineseQuestion], "'=修复()")
}
// Answer: "+cmd" → "'+cmd"
if fields[FieldAnswer] != "'+cmd" {
t.Errorf("Answer = %q, want %q", fields[FieldAnswer], "'+cmd")
}
// OriginalQuestion: "=修复()" → "'=修复()"
if fields[FieldOriginalQuestion] != "'=修复()" {
t.Errorf("OriginalQuestion = %q, want %q", fields[FieldOriginalQuestion], "'=修复()")
}
// TriggerPerson: "@trigger" → "'@trigger"
if fields[FieldTriggerPerson] != "'@trigger" {
t.Errorf("TriggerPerson = %q, want %q", fields[FieldTriggerPerson], "'@trigger")
}
// Department: "=DEPT" → "'=DEPT"
if fields[FieldDepartment] != "'=DEPT" {
t.Errorf("Department = %q, want %q", fields[FieldDepartment], "'=DEPT")
}
// Region: "normal" → no change
if fields[FieldRegion] != "normal" {
t.Errorf("Region = %q, want %q", fields[FieldRegion], "normal")
}
// Asker name: "-张三" → "'-张三" (via user object)
userArr := fields[FieldAsker].([]interface{})
userObj := userArr[0].(map[string]interface{})
// Asker name is NOT sanitised per the contract (only text fields are sanitised),
// but the name is trimmed. Let's verify — the task says trim only union ID/name,
// preserve question/answer text. So name is sanitised via TrimSpace, not sanitise().
// Actually, sanitise is only applied to the text fields. Asker name goes through
// strings.TrimSpace but not sanitise. Let's check the behavior.
// The task says: "Trim only union ID/name for the user object if existing
// conventions require; preserve question/answer text." So name is trimmed, not
// sanitised. We should verify the trimmed name.
if userObj["name"] != "-张三" {
t.Errorf("Asker name = %q, want %q", userObj["name"], "-张三")
}
}