357 lines
10 KiB
Go
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"], "-张三")
|
|
}
|
|
}
|