package main import ( "context" "errors" "fmt" "path/filepath" "strings" "testing" "time" "github.com/xuri/excelize/v2" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" ) func TestParseManualInputFile_ValidSingleRow(t *testing.T) { filePath := createManualInputWorkbook( t, "manual_inputs", []string{"project_flock_id", "total_cost", "cutover_date", "note"}, [][]string{ {"101", "12345.678", "2026-06-01", "manual seed"}, }, ) location := mustJakartaLocation(t) sheet, rows, issues, err := parseManualInputFile(filePath, "", location) if err != nil { t.Fatalf("expected no error, got %v", err) } if sheet != "manual_inputs" { t.Fatalf("expected selected sheet manual_inputs, got %q", sheet) } if len(issues) != 0 { t.Fatalf("expected no issues, got %+v", issues) } if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } if rows[0].ProjectFlockID != 101 { t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID) } if rows[0].TotalCost != 12345.678 { t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost) } if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" { t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout)) } if rows[0].Note == nil || *rows[0].Note != "manual seed" { t.Fatalf("expected note manual seed, got %+v", rows[0].Note) } } func TestParseManualInputFile_ValidMultiRow(t *testing.T) { filePath := createManualInputWorkbook( t, "manual_inputs", []string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"}, [][]string{ {"101", "1200", "2026-06-01", ""}, {"102", "1300.5", "2026-06-02", "second"}, }, ) location := mustJakartaLocation(t) _, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(issues) != 0 { t.Fatalf("expected no issues, got %+v", issues) } if len(rows) != 2 { t.Fatalf("expected 2 rows, got %d", len(rows)) } if rows[0].Note != nil { t.Fatalf("expected first row note nil, got %+v", rows[0].Note) } if rows[1].Note == nil || *rows[1].Note != "second" { t.Fatalf("expected second row note second, got %+v", rows[1].Note) } } func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) { filePath := createManualInputWorkbook( t, "manual_inputs", []string{"project_flock_id", "totalcost", "cutover_date", "note"}, [][]string{ {"101", "1200", "2026-06-01", ""}, }, ) location := mustJakartaLocation(t) _, rows, issues, err := parseManualInputFile(filePath, "", location) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 0 { t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows)) } if !hasIssue(issues, 0, "total_cost", "required header is missing") { t.Fatalf("expected missing total_cost header issue, got %+v", issues) } } func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) { filePath := createManualInputWorkbook( t, "manual_inputs", []string{"project_flock_id", "total_cost", "cutover_date", "note"}, [][]string{ {"abc", "1200", "2026-06-01", ""}, {"0", "1300", "2026-06-02", ""}, }, ) location := mustJakartaLocation(t) _, rows, issues, err := parseManualInputFile(filePath, "", location) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 0 { t.Fatalf("expected no valid rows, got %d", len(rows)) } if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") { t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues) } if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") { t.Fatalf("expected project_flock_id >0 issue, got %+v", issues) } } func TestParseManualInputFile_InvalidTotalCost(t *testing.T) { filePath := createManualInputWorkbook( t, "manual_inputs", []string{"project_flock_id", "total_cost", "cutover_date", "note"}, [][]string{ {"101", "abc", "2026-06-01", ""}, {"102", "-1", "2026-06-02", ""}, }, ) location := mustJakartaLocation(t) _, rows, issues, err := parseManualInputFile(filePath, "", location) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 0 { t.Fatalf("expected no valid rows, got %d", len(rows)) } if !hasIssue(issues, 2, "total_cost", "must be numeric") { t.Fatalf("expected total_cost numeric issue, got %+v", issues) } if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") { t.Fatalf("expected total_cost >=0 issue, got %+v", issues) } } func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) { filePath := createManualInputWorkbook( t, "manual_inputs", []string{"project_flock_id", "total_cost", "cutover_date", "note"}, [][]string{ {"101", "1200", "06-01-2026", ""}, }, ) location := mustJakartaLocation(t) _, rows, issues, err := parseManualInputFile(filePath, "", location) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 0 { t.Fatalf("expected no valid rows, got %d", len(rows)) } if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") { t.Fatalf("expected cutover_date format issue, got %+v", issues) } } func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) { filePath := createManualInputWorkbook( t, "manual_inputs", []string{"project_flock_id", "total_cost", "cutover_date", "note"}, [][]string{ {"101", "1200", "2026-06-01", ""}, {"101", "1300", "2026-06-02", ""}, }, ) location := mustJakartaLocation(t) _, rows, issues, err := parseManualInputFile(filePath, "", location) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 1 { t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows)) } if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") { t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues) } } func TestParseManualInputFile_NoteValidation(t *testing.T) { longNote := strings.Repeat("a", 1001) filePath := createManualInputWorkbook( t, "manual_inputs", []string{"project_flock_id", "total_cost", "cutover_date", "note"}, [][]string{ {"101", "1200", "2026-06-01", ""}, {"102", "1300", "2026-06-02", longNote}, }, ) location := mustJakartaLocation(t) _, rows, issues, err := parseManualInputFile(filePath, "", location) if err != nil { t.Fatalf("expected no error, got %v", err) } if len(rows) != 1 { t.Fatalf("expected only first row valid, got %d", len(rows)) } if rows[0].Note != nil { t.Fatalf("expected first row note nil, got %+v", rows[0].Note) } if !hasIssue(issues, 3, "note", "at most 1000 characters") { t.Fatalf("expected note length issue, got %+v", issues) } } func TestApplyImportRows_Success(t *testing.T) { location := mustJakartaLocation(t) runner := &fakeTransactionRunner{} rows := []manualInputImportRow{ { RowNumber: 2, ProjectFlockID: 101, TotalCost: 1000, CutoverDate: mustDateInLocation(t, "2026-06-01", location), }, { RowNumber: 3, ProjectFlockID: 102, TotalCost: 2000, CutoverDate: mustDateInLocation(t, "2026-06-02", location), }, } err := applyImportRows(context.Background(), runner, rows) if err != nil { t.Fatalf("expected no error, got %v", err) } if runner.txCalls != 1 { t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) } if len(runner.committedUpserts) != 2 { t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts)) } if len(runner.committedInvalidations) != 2 { t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations)) } if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 { t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations) } } func TestApplyImportRows_RollbackOnError(t *testing.T) { location := mustJakartaLocation(t) runner := &fakeTransactionRunner{ failUpsertOnProjectFlockID: 102, } rows := []manualInputImportRow{ { RowNumber: 2, ProjectFlockID: 101, TotalCost: 1000, CutoverDate: mustDateInLocation(t, "2026-06-01", location), }, { RowNumber: 3, ProjectFlockID: 102, TotalCost: 2000, CutoverDate: mustDateInLocation(t, "2026-06-02", location), }, } err := applyImportRows(context.Background(), runner, rows) if err == nil { t.Fatal("expected error due to upsert failure") } if runner.txCalls != 1 { t.Fatalf("expected 1 transaction call, got %d", runner.txCalls) } if len(runner.committedUpserts) != 0 { t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts)) } if len(runner.committedInvalidations) != 0 { t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations)) } } func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) { location := mustJakartaLocation(t) runner := &fakeTransactionRunner{} rows := []manualInputImportRow{ { RowNumber: 2, ProjectFlockID: 101, TotalCost: 1000, CutoverDate: mustDateInLocation(t, "2026-06-01", location), }, } err := applyIfRequested(context.Background(), false, runner, rows) if err != nil { t.Fatalf("expected no error, got %v", err) } if runner.txCalls != 0 { t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls) } } func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string { t.Helper() f := excelize.NewFile() defaultSheet := f.GetSheetName(f.GetActiveSheetIndex()) if sheetName == "" { sheetName = defaultSheet } else if sheetName != defaultSheet { f.SetSheetName(defaultSheet, sheetName) } for idx, header := range headers { cell, err := excelize.CoordinatesToCellName(idx+1, 1) if err != nil { t.Fatalf("failed resolving header cell: %v", err) } if err := f.SetCellValue(sheetName, cell, header); err != nil { t.Fatalf("failed setting header cell: %v", err) } } for rowIdx, row := range rows { for colIdx, value := range row { cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) if err != nil { t.Fatalf("failed resolving data cell: %v", err) } if err := f.SetCellValue(sheetName, cell, value); err != nil { t.Fatalf("failed setting data cell: %v", err) } } } path := filepath.Join(t.TempDir(), "manual_inputs.xlsx") if err := f.SaveAs(path); err != nil { t.Fatalf("failed saving workbook: %v", err) } if err := f.Close(); err != nil { t.Fatalf("failed closing workbook: %v", err) } return path } func mustJakartaLocation(t *testing.T) *time.Location { t.Helper() location, err := time.LoadLocation("Asia/Jakarta") if err != nil { t.Fatalf("failed loading Asia/Jakarta location: %v", err) } return location } func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time { t.Helper() value, err := time.ParseInLocation(dateLayout, raw, location) if err != nil { t.Fatalf("failed parsing date %q: %v", raw, err) } return value } func hasIssue(issues []validationIssue, row int, field, messageContains string) bool { for _, issue := range issues { if issue.Row != row { continue } if issue.Field != field { continue } if strings.Contains(issue.Message, messageContains) { return true } } return false } type fakeInvalidation struct { fromDate time.Time farmIDs []uint } type fakeManualInputStore struct { failUpsertOnProjectFlockID uint failDeleteOnProjectFlockID uint upserts []entity.FarmDepreciationManualInput invalidations []fakeInvalidation } func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error { if row == nil { return nil } if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID { return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId) } cloned := *row s.upserts = append(s.upserts, cloned) return nil } func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error { if s.failDeleteOnProjectFlockID > 0 { for _, farmID := range farmIDs { if farmID == s.failDeleteOnProjectFlockID { return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID) } } } copiedFarmIDs := append([]uint{}, farmIDs...) s.invalidations = append(s.invalidations, fakeInvalidation{ fromDate: fromDate, farmIDs: copiedFarmIDs, }) return nil } type fakeTransactionRunner struct { txCalls int failUpsertOnProjectFlockID uint failDeleteOnProjectFlockID uint committedUpserts []entity.FarmDepreciationManualInput committedInvalidations []fakeInvalidation } func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error { r.txCalls++ txStore := &fakeManualInputStore{ failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID, failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID, } if err := fn(txStore); err != nil { return err } r.committedUpserts = append(r.committedUpserts, txStore.upserts...) r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...) return nil } var _ txRunner = (*fakeTransactionRunner)(nil) var _ manualInputStore = (*fakeManualInputStore)(nil) func TestBuildMissingFarmIssues(t *testing.T) { location := mustJakartaLocation(t) rows := []manualInputImportRow{ { RowNumber: 2, ProjectFlockID: 101, TotalCost: 1000, CutoverDate: mustDateInLocation(t, "2026-06-01", location), }, { RowNumber: 3, ProjectFlockID: 102, TotalCost: 1000, CutoverDate: mustDateInLocation(t, "2026-06-01", location), }, } issues := buildMissingFarmIssues(rows, map[uint]string{ 101: "Farm A", }) if len(issues) != 1 { t.Fatalf("expected 1 issue, got %+v", issues) } if issues[0].Row != 3 || issues[0].Field != "project_flock_id" { t.Fatalf("unexpected issue: %+v", issues[0]) } } func TestApplyImportRows_PropagatesDeleteError(t *testing.T) { location := mustJakartaLocation(t) runner := &fakeTransactionRunner{ failDeleteOnProjectFlockID: 101, } rows := []manualInputImportRow{ { RowNumber: 2, ProjectFlockID: 101, TotalCost: 1000, CutoverDate: mustDateInLocation(t, "2026-06-01", location), }, } err := applyImportRows(context.Background(), runner, rows) if err == nil { t.Fatal("expected delete failure") } if !strings.Contains(err.Error(), "snapshot invalidation failed") { t.Fatalf("expected snapshot invalidation error message, got %v", err) } } func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) { workbook := excelize.NewFile() defer func() { _ = workbook.Close() }() _, err := resolveSheetName(workbook, "unknown") if err == nil { t.Fatal("expected error when sheet is missing") } } func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) { location := mustJakartaLocation(t) rows := []manualInputImportRow{ { RowNumber: 2, ProjectFlockID: 101, TotalCost: 1000, CutoverDate: mustDateInLocation(t, "2026-06-01", location), }, } runner := &errorTxRunner{err: errors.New("tx failed")} err := applyIfRequested(context.Background(), true, runner, rows) if err == nil { t.Fatal("expected transaction error") } if err.Error() != "tx failed" { t.Fatalf("unexpected error: %v", err) } } type errorTxRunner struct { err error } func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error { return r.err }