package projectindex import ( "context" "testing" "time" ) // insertLegacyChapterSQL 用调用方给定的原始时间戳写入一行 // project_chapter,代表定宽布局之前持久化的行。 const insertLegacyChapterSQL = `INSERT INTO project_chapter (id, project_id, title, content, created_at, updated_at) VALUES (?, 'proj.1', '旧章节', '旧内容', ?, ?)` func TestFormatTime_定宽_正常(t *testing.T) { t.Parallel() tests := []struct { name string at time.Time want string }{ {"一位小数秒", time.Date(2026, 9, 7, 3, 20, 5, 830000000, time.UTC), "2026-09-07T03:20:05.830000000Z"}, {"纳秒", time.Date(2026, 9, 7, 3, 20, 5, 830978900, time.UTC), "2026-09-07T03:20:05.830978900Z"}, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got := formatTime(tt.at) if got != tt.want { t.Errorf("formatTime = %q, want %q", got, tt.want) } if len(got) != storedTimeLen { t.Errorf("formatTime length = %d, want %d", len(got), storedTimeLen) } }) } } // TestStore_ListChapters_变宽时间戳_排序 是 projectindex 针对变宽 // 时间戳缺陷的回归:RFC3339Nano 会把它们存成 ":05.83" 与 // ":05.8309789",其文本顺序与时间顺序相反。 func TestStore_ListChapters_变宽时间戳_排序(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) sec := time.Date(2026, 9, 7, 3, 20, 5, 0, time.UTC) older := sec.Add(830 * time.Millisecond) newer := older.Add(978900 * time.Nanosecond) // 较新的章节先插入,因此只按 rowid 排序会把它放到 // 最后:只有正确排序的 created_at 文本才会把它放在倒数第二。 if err := s.AddChapter(ctx, "proj.1", Chapter{ Title: "较晚", Content: "x", CreatedAt: newer, UpdatedAt: newer, }); err != nil { t.Fatalf("AddChapter(较晚): %v", err) } if err := s.AddChapter(ctx, "proj.1", Chapter{ Title: "较早", Content: "y", CreatedAt: older, UpdatedAt: older, }); err != nil { t.Fatalf("AddChapter(较早): %v", err) } var raw string if err := s.db.QueryRowContext(ctx, `SELECT created_at FROM project_chapter WHERE title = ?`, "较早").Scan(&raw); err != nil { t.Fatalf("read stored created_at: %v", err) } if len(raw) != storedTimeLen { t.Errorf("stored created_at = %q (%d bytes), want fixed width %d", raw, len(raw), storedTimeLen) } list, err := s.ListChapters(ctx, "proj.1") if err != nil { t.Fatalf("ListChapters: %v", err) } if len(list) != 2 { t.Fatalf("ListChapters = %d chapters, want 2", len(list)) } if list[0].Title != "较早" || list[1].Title != "较晚" { t.Errorf("titles = [%q %q], want oldest first (较早, 较晚)", list[0].Title, list[1].Title) } } // TestStore_ListChapters_同一时间戳_插入序 覆盖并列情形:连续的 // AddChapter 调用可能共享同一时钟刻,因此 rowid ASC 必须让它们保持 // 插入顺序。 func TestStore_ListChapters_同一时间戳_插入序(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) at := time.Date(2026, 9, 7, 3, 20, 5, 830978900, time.UTC) for _, title := range []string{"第一章", "第二章", "第三章"} { if err := s.AddChapter(ctx, "proj.1", Chapter{ Title: title, Content: "内容", CreatedAt: at, UpdatedAt: at, }); err != nil { t.Fatalf("AddChapter(%s): %v", title, err) } } list, err := s.ListChapters(ctx, "proj.1") if err != nil { t.Fatalf("ListChapters: %v", err) } want := []string{"第一章", "第二章", "第三章"} if len(list) != len(want) { t.Fatalf("ListChapters = %d chapters, want %d", len(list), len(want)) } for i, w := range want { if list[i].Title != w { t.Errorf("chapters[%d].Title = %q, want %q (rowid ASC)", i, list[i].Title, w) } } } // TestNew_归一化历史时间戳_正常 把旧的变宽数据写入两张 // 表,重新打开存储触发规范化,并检查修复后的文本 // 以及幂等性与无法解析行的跳过。 func TestNew_归一化历史时间戳_正常(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) const legacy = "2026-09-07T03:20:05.83Z" if _, err := s.db.ExecContext(ctx, `INSERT INTO project (id, title, desc, created_at, updated_at) VALUES ('proj.legacy', 'T', '', ?, ?)`, legacy, legacy); err != nil { t.Fatalf("insert legacy project: %v", err) } if _, err := s.db.ExecContext(ctx, insertLegacyChapterSQL, "ch.legacy", legacy, legacy); err != nil { t.Fatalf("insert legacy chapter: %v", err) } if _, err := s.db.ExecContext(ctx, insertLegacyChapterSQL, "ch.broken", "not-a-time", "not-a-time"); err != nil { t.Fatalf("insert unparseable chapter: %v", err) } // 重新打开会规范化两张表;无法解析的值不得阻塞它。 if _, err := New(ctx, s.db, testLogger()); err != nil { t.Fatalf("New (normalize): %v", err) } const want = "2026-09-07T03:20:05.830000000Z" for _, tc := range []struct{ name, query, id string }{ {"project", `SELECT created_at FROM project WHERE id = ?`, "proj.legacy"}, {"project_chapter", `SELECT created_at FROM project_chapter WHERE id = ?`, "ch.legacy"}, } { var raw string if err := s.db.QueryRowContext(ctx, tc.query, tc.id).Scan(&raw); err != nil { t.Fatalf("read %s created_at: %v", tc.name, err) } if raw != want { t.Errorf("%s created_at = %q, want %q", tc.name, raw, want) } } var broken string if err := s.db.QueryRowContext(ctx, `SELECT created_at FROM project_chapter WHERE id = ?`, "ch.broken").Scan(&broken); err != nil { t.Fatalf("read unparseable created_at: %v", err) } if broken != "not-a-time" { t.Errorf("unparseable created_at = %q, want it left as is", broken) } // 再次打开必须是空操作。 if _, err := New(ctx, s.db, testLogger()); err != nil { t.Fatalf("New (second pass): %v", err) } var again string if err := s.db.QueryRowContext(ctx, `SELECT created_at FROM project WHERE id = ?`, "proj.legacy").Scan(&again); err != nil { t.Fatalf("read created_at after second open: %v", err) } if again != want { t.Errorf("second open changed created_at: %q -> %q", want, again) } got, err := s.GetProject(ctx, "proj.legacy") if err != nil { t.Fatalf("GetProject: %v", err) } wantTime := time.Date(2026, 9, 7, 3, 20, 5, 830000000, time.UTC) if !got.CreatedAt.Equal(wantTime) { t.Errorf("project created_at = %v, want %v", got.CreatedAt, wantTime) } }