package docfill import ( "database/sql" "errors" "os" "path/filepath" "strings" "testing" ) func TestRunResolvesConfigRelativePathsAndSanitizesOutputFileNames(t *testing.T) { dir := t.TempDir() taskDir := filepath.Join(dir, "task") if err := os.MkdirAll(taskDir, 0o755); err != nil { t.Fatalf("make task dir: %v", err) } writeWorkbook(t, filepath.Join(taskDir, "data.xlsx"), "Daily", [][]string{ {"name", "done"}, {"张/三:测试", "已完成"}, }) writeWorkbook(t, filepath.Join(taskDir, "template.xlsx"), "日报", [][]string{ {"{{name}}", "{{done}}"}, }) configPath := filepath.Join(taskDir, "task.json") writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": "./data.xlsx", "sheet": "Daily", }, "template": map[string]any{ "path": "./template.xlsx", }, "output": map[string]any{ "path": "./out/日报_{{name}}.xlsx", }, }) result, err := RunWithOptions(configPath, RunOptions{}) if err != nil { t.Fatalf("RunWithOptions returned error: %v", err) } wantPath := filepath.Join(taskDir, "out", "日报_张_三_测试.xlsx") if len(result.Files) != 1 || result.Files[0] != wantPath { t.Fatalf("result files = %#v, want %q", result.Files, wantPath) } assertWorkbookCell(t, wantPath, "日报", "A1", "张/三:测试") } func TestRunUsesVarsForSQLiteParamsAndTemplateDefaults(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "daily.db") templatePath := filepath.Join(dir, "template.docx") configPath := filepath.Join(dir, "task.json") seedP0SQLite(t, dbPath, ` create table daily (name text, date text, done text); insert into daily values ('张三', '2026-06-18', '完成旧日报'), ('李四', '2026-06-19', '完成新日报'); `) writeMinimalDocx(t, templatePath, "批次:{{batch}}\n日期:{{date}}\n员工:{{name}}\n内容:{{done}}") writeJSON(t, configPath, map[string]any{ "vars": map[string]any{ "date": "2026-06-18", "batch": "默认批次", }, "data": map[string]any{ "type": "sqlite", "path": dbPath, "query": "select name, date, done from daily where date = ?", "params": []any{"{{date}}"}, }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "日报_{{batch}}_{{date}}_{{name}}.docx"), }, }) result, err := RunWithOptions(configPath, RunOptions{ Vars: map[string]string{ "date": "2026-06-19", "batch": "命令行批次", }, }) if err != nil { t.Fatalf("RunWithOptions returned error: %v", err) } if len(result.Files) != 1 { t.Fatalf("expected 1 generated file, got %#v", result.Files) } got := readZipEntry(t, result.Files[0], "word/document.xml") if !strings.Contains(got, "批次:命令行批次") || !strings.Contains(got, "员工:李四") { t.Fatalf("unexpected rendered docx body: %q", got) } if strings.Contains(got, "张三") || strings.Contains(result.Files[0], "默认批次") { t.Fatalf("CLI vars did not override config vars: file=%q body=%q", result.Files[0], got) } } func TestRunSupportsExcelHeaderRow(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "data.xlsx") templatePath := filepath.Join(dir, "template.xlsx") configPath := filepath.Join(dir, "task.json") writeWorkbook(t, dataPath, "Daily", [][]string{ {"日报导出数据"}, {"name", "done"}, {"张三", "完成日报"}, }) writeWorkbook(t, templatePath, "日报", [][]string{ {"员工", "{{name}}"}, {"完成", "{{done}}"}, }) writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "Daily", "header_row": 2, }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"), }, }) if err := Run(configPath); err != nil { t.Fatalf("Run returned error: %v", err) } assertWorkbookCell(t, filepath.Join(dir, "out", "日报_张三.xlsx"), "日报", "B1", "张三") } func TestRunRefusesToOverwriteUnlessConfigured(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "data.xlsx") templatePath := filepath.Join(dir, "template.xlsx") configPath := filepath.Join(dir, "task.json") outputPath := filepath.Join(dir, "out", "日报_张三.xlsx") writeWorkbook(t, dataPath, "Daily", [][]string{ {"name"}, {"张三"}, }) writeWorkbook(t, templatePath, "日报", [][]string{{"{{name}}"}}) if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { t.Fatalf("make output dir: %v", err) } if err := os.WriteFile(outputPath, []byte("existing"), 0o644); err != nil { t.Fatalf("write existing output: %v", err) } writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "Daily", }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": outputPath, }, }) err := Run(configPath) if err == nil || !strings.Contains(err.Error(), "already exists") { t.Fatalf("expected already exists error, got %v", err) } var appErr *AppError if !errors.As(err, &appErr) { t.Fatalf("expected AppError, got %T", err) } if appErr.Code != CodeOutputExists { t.Fatalf("error code = %s, want %s", appErr.Code, CodeOutputExists) } if appErr.Hint == "" || appErr.Context["output"] != outputPath { t.Fatalf("expected hint and output context, got %#v", appErr) } writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "Daily", }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": outputPath, "overwrite": true, }, }) if err := Run(configPath); err != nil { t.Fatalf("Run with overwrite returned error: %v", err) } assertWorkbookCell(t, outputPath, "日报", "A1", "张三") } func TestRunFailsWhenTemplatePlaceholderIsMissing(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "data.xlsx") templatePath := filepath.Join(dir, "template.xlsx") configPath := filepath.Join(dir, "task.json") writeWorkbook(t, dataPath, "Daily", [][]string{ {"name"}, {"张三"}, }) writeWorkbook(t, templatePath, "日报", [][]string{{"{{name}}", "{{missing}}"}}) writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "Daily", }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"), }, }) err := Run(configPath) if err == nil || !strings.Contains(err.Error(), "missing template field") { t.Fatalf("expected missing template field error, got %v", err) } var appErr *AppError if !errors.As(err, &appErr) { t.Fatalf("expected AppError, got %T", err) } if appErr.Code != CodeTemplateFieldMissing { t.Fatalf("error code = %s, want %s", appErr.Code, CodeTemplateFieldMissing) } if appErr.Context["field"] != "missing" || appErr.Context["template"] != templatePath { t.Fatalf("unexpected context: %#v", appErr.Context) } if !strings.Contains(appErr.Hint, "missing") { t.Fatalf("unexpected hint: %q", appErr.Hint) } } func TestRunFailsWhenRenderedTemplateStillHasPlaceholder(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "data.xlsx") templatePath := filepath.Join(dir, "template.xlsx") configPath := filepath.Join(dir, "task.json") writeWorkbook(t, dataPath, "Daily", [][]string{ {"name", "done"}, {"张三", "{{still_pending}}"}, }) writeWorkbook(t, templatePath, "日报", [][]string{{"{{name}}", "{{done}}"}}) writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "Daily", }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "日报_{{name}}.xlsx"), }, }) err := Run(configPath) if err == nil || !strings.Contains(err.Error(), "unresolved placeholder") { t.Fatalf("expected unresolved placeholder error, got %v", err) } } func TestRunFiltersExcelRowsByMultipleEqualsAndVars(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "tickets.xlsx") templatePath := filepath.Join(dir, "ticket-template.xlsx") configPath := filepath.Join(dir, "task.json") writeWorkbook(t, dataPath, "工单", [][]string{ {"日期", "工单编号", "客户名称", "处理结果"}, {"2026-06-18", "GD-001", "A 公司", "已派单"}, {"2026-06-18", "GD-002", "B 公司", "已完成"}, {"2026-06-19", "GD-001", "C 公司", "待复核"}, }) writeWorkbook(t, templatePath, "工单", [][]string{ {"编号", "{{工单编号}}"}, {"客户", "{{客户名称}}"}, {"结果", "{{处理结果}}"}, }) writeJSON(t, configPath, map[string]any{ "vars": map[string]any{ "date": "2026-06-17", }, "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "工单", }, "filter": map[string]any{ "equals": map[string]any{ "日期": "{{date}}", "工单编号": "{{ticket_no}}", }, }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "工单_{{日期}}_{{工单编号}}.xlsx"), }, }) result, err := RunWithOptions(configPath, RunOptions{ Vars: map[string]string{ "date": "2026-06-18", "ticket_no": "GD-002", }, }) if err != nil { t.Fatalf("RunWithOptions returned error: %v", err) } wantPath := filepath.Join(dir, "out", "工单_2026-06-18_GD-002.xlsx") if len(result.Files) != 1 || result.Files[0] != wantPath { t.Fatalf("result files = %#v, want only %q", result.Files, wantPath) } assertWorkbookCell(t, wantPath, "工单", "B2", "B 公司") assertWorkbookCell(t, wantPath, "工单", "B3", "已完成") } func TestRunFilterAllowsManyRowsWhenConfigured(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "tickets.db") templatePath := filepath.Join(dir, "ticket-template.xlsx") configPath := filepath.Join(dir, "task.json") seedP0SQLite(t, dbPath, ` create table tickets ( report_date text, department text, ticket_no text, result text ); insert into tickets values ('2026-06-18', '营销部', 'GD-001', '已派单'), ('2026-06-18', '客服部', 'GD-002', '已完成'), ('2026-06-19', '营销部', 'GD-003', '待复核'); `) writeWorkbook(t, templatePath, "工单", [][]string{ {"编号", "{{ticket_no}}"}, {"部门", "{{department}}"}, {"结果", "{{result}}"}, }) writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "sqlite", "path": dbPath, "query": "select report_date, department, ticket_no, result from tickets order by ticket_no", }, "filter": map[string]any{ "equals": map[string]any{ "report_date": "{{date}}", }, "expect": "many", }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "工单_{{ticket_no}}_{{_row}}.xlsx"), }, }) result, err := RunWithOptions(configPath, RunOptions{Vars: map[string]string{"date": "2026-06-18"}}) if err != nil { t.Fatalf("RunWithOptions returned error: %v", err) } if len(result.Files) != 2 { t.Fatalf("expected 2 filtered files, got %#v", result.Files) } assertWorkbookCell(t, filepath.Join(dir, "out", "工单_GD-001_1.xlsx"), "工单", "B3", "已派单") assertWorkbookCell(t, filepath.Join(dir, "out", "工单_GD-002_2.xlsx"), "工单", "B2", "客服部") } func TestRunFilterFailsWhenNoRowsMatch(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "tickets.xlsx") templatePath := filepath.Join(dir, "template.xlsx") configPath := filepath.Join(dir, "task.json") writeWorkbook(t, dataPath, "工单", [][]string{ {"日期", "工单编号", "处理结果"}, {"2026-06-18", "GD-001", "已派单"}, }) writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}}) writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "工单", }, "filter": map[string]any{ "equals": map[string]any{ "日期": "{{date}}", "工单编号": "{{ticket_no}}", }, "expect": "one", }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"), }, }) err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{ "date": "2026-06-18", "ticket_no": "GD-999", }}) assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_NO_MATCH")) } func TestRunFilterFailsWhenOneRowExpectedButManyMatch(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "tickets.xlsx") templatePath := filepath.Join(dir, "template.xlsx") configPath := filepath.Join(dir, "task.json") writeWorkbook(t, dataPath, "工单", [][]string{ {"日期", "工单编号", "处理结果"}, {"2026-06-18", "GD-001", "已派单"}, {"2026-06-18", "GD-002", "已完成"}, }) writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}}) writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "工单", }, "filter": map[string]any{ "equals": map[string]any{ "日期": "{{date}}", }, "expect": "one", }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"), }, }) err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{"date": "2026-06-18"}}) assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_MULTI_MATCH")) } func TestRunFilterFailsWhenFieldIsMissing(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "tickets.xlsx") templatePath := filepath.Join(dir, "template.xlsx") configPath := filepath.Join(dir, "task.json") writeWorkbook(t, dataPath, "工单", [][]string{ {"日期", "处理结果"}, {"2026-06-18", "已派单"}, }) writeWorkbook(t, templatePath, "工单", [][]string{{"{{处理结果}}"}}) writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "工单", }, "filter": map[string]any{ "equals": map[string]any{ "工单编号": "{{ticket_no}}", }, }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "工单.xlsx"), }, }) err := RunWithOptionsError(configPath, RunOptions{Vars: map[string]string{"ticket_no": "GD-001"}}) assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_FIELD")) } func TestRunFilterFailsWhenVariableIsMissing(t *testing.T) { dir := t.TempDir() dataPath := filepath.Join(dir, "tickets.xlsx") templatePath := filepath.Join(dir, "template.xlsx") configPath := filepath.Join(dir, "task.json") writeWorkbook(t, dataPath, "工单", [][]string{ {"日期", "工单编号", "处理结果"}, {"2026-06-18", "GD-001", "已派单"}, }) writeWorkbook(t, templatePath, "工单", [][]string{{"{{工单编号}}", "{{处理结果}}"}}) writeJSON(t, configPath, map[string]any{ "data": map[string]any{ "type": "excel", "path": dataPath, "sheet": "工单", }, "filter": map[string]any{ "equals": map[string]any{ "工单编号": "{{ticket_no}}", }, }, "template": map[string]any{ "path": templatePath, }, "output": map[string]any{ "path": filepath.Join(dir, "out", "工单_{{工单编号}}.xlsx"), }, }) err := RunWithOptionsError(configPath, RunOptions{}) assertAppErrorCode(t, err, ErrorCode("DATA_FILTER_VALUE")) } func RunWithOptionsError(configPath string, opts RunOptions) error { _, err := RunWithOptions(configPath, opts) return err } func assertAppErrorCode(t *testing.T, err error, want ErrorCode) { t.Helper() if err == nil { t.Fatalf("expected %s error, got nil", want) } var appErr *AppError if !errors.As(err, &appErr) { t.Fatalf("expected AppError, got %T: %v", err, err) } if appErr.Code != want { t.Fatalf("error code = %s, want %s; error=%v", appErr.Code, want, err) } } func seedP0SQLite(t *testing.T, path, statements string) { t.Helper() db, err := sql.Open("sqlite", path) if err != nil { t.Fatalf("open sqlite: %v", err) } defer db.Close() if _, err := db.Exec(statements); err != nil { t.Fatalf("seed sqlite: %v", err) } }