Skip to content

Commit aaf1936

Browse files
wesmclaude
andauthored
Overhaul test suite: shared fixtures, deterministic timing, and reduced boilerplate (#17)
This PR was the result of running `roborev analyze refactor --per-file '*/*/*_test.go'` and `roborev analyze test-fixtures --per-file '*/*/*_test.go'` and then using `roborev fix` to grind through the results. This is a very effective pattern for systematic refactoring. ## Summary Comprehensive overhaul of the test suite across 54 files (~12,600 lines added, ~12,000 removed). The goal is to make tests more reliable, readable, and maintainable as the codebase grows. No production code behavior changes. ### Shared test infrastructure (`internal/testutil/`) - **`testutil/ptr`** — pointer/date helper functions used across packages - **`testutil/email`** — `MakeRawEmail` and slice assertions for MIME test data - **`testutil/dbtest`** — shared DB test helpers and typed `TestDataBuilder` replacing raw SQL fixtures - **`testutil/storetest`** — `StoreFixture` and `MessageBuilder` for store-layer tests - **`querytest.MockEngine`** — centralized `query.Engine` test double ### Deterministic timing - Introduced a `Clock` interface on `RateLimiter` with a mock clock for tests - Replaced all `time.Sleep`-based synchronization with mock-clock timer polling - Eliminates flaky failures under CI load ### Test pattern improvements - Converted many tests to **table-driven** format (search parser, aggregates, attachments, manifests, MCP error cases, etc.) - Extracted **fluent builders**: `ManifestBuilder`, `GmailErrorBuilder`, `MessageBuilder` (MIME), `TestDataBuilder` (Parquet) - Added **assertion helpers** (`assertLevel`, `assertRow`, `assertSearchCount`, etc.) to reduce repetitive check-and-fail blocks - Split the 5,800-line `model_test.go` into domain-specific test files (selection, search, navigation, view rendering) ### Hardening - Fixed cross-test state leakage via defensive copies of shared slices and factory functions - Added duplicate-key detection in aggregate test helpers - Guarded `fakeT` against `Fatal`/`FailNow`/`Skip`/`Skipf`/`SkipNow` bypassing sentinel recovery - Added missing failure-path tests (nil summaries, DB errors, missing messages) - Fixed nondeterministic map iteration and header ordering in test fixtures ### Developer tooling - Added `.githooks/pre-commit` hook that runs `gofmt` and `golangci-lint` on staged Go files - Enable with `make setup-hooks` — prevents lint failures from reaching CI ## Test plan - [x] `make test` — all tests pass - [x] `make lint` — no new warnings - [ ] CI passes on all platforms 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9a9924c commit aaf1936

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+12693
-11931
lines changed

.githooks/pre-commit

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/bin/sh
2+
# Pre-commit hook: run fmt check and lint on staged Go files.
3+
# Install: git config core.hooksPath .githooks
4+
5+
# Check if any Go files are staged
6+
STAGED_GO=$(git diff --cached --name-only --diff-filter=ACM | grep '\.go$')
7+
if [ -z "$STAGED_GO" ]; then
8+
exit 0
9+
fi
10+
11+
# Check formatting
12+
UNFORMATTED=$(gofmt -l $STAGED_GO 2>/dev/null)
13+
if [ -n "$UNFORMATTED" ]; then
14+
echo "gofmt: these files need formatting:"
15+
echo "$UNFORMATTED"
16+
echo ""
17+
echo "Run: make fmt"
18+
exit 1
19+
fi
20+
21+
# Run linter
22+
echo "Running linter..."
23+
if ! golangci-lint run ./... 2>&1; then
24+
echo ""
25+
echo "Lint failed. Fix errors before committing."
26+
exit 1
27+
fi

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,11 @@ Sync is **read-only** - no modifications to Gmail.
166166

167167
## Code Style & Linting
168168

169-
All code must pass formatting and linting checks before commit.
169+
All code must pass formatting and linting checks before commit. A pre-commit
170+
hook is available to enforce this automatically:
170171

171172
```bash
173+
make setup-hooks # Enable pre-commit hook (fmt + lint)
172174
make test # Run tests
173175
make fmt # Format code (go fmt)
174176
make lint # Run linter (golangci-lint)

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ LDFLAGS := -X github.com/wesm/msgvault/cmd/msgvault/cmd.Version=$(VERSION) \
1212

1313
LDFLAGS_RELEASE := $(LDFLAGS) -s -w
1414

15-
.PHONY: build build-release install clean test test-v fmt lint tidy shootout run-shootout help
15+
.PHONY: build build-release install clean test test-v fmt lint tidy shootout run-shootout setup-hooks help
1616

1717
# Build the binary (debug)
1818
build:
@@ -62,6 +62,11 @@ lint:
6262
@which golangci-lint > /dev/null || (echo "Install golangci-lint: https://golangci-lint.run/usage/install/" && exit 1)
6363
golangci-lint run ./...
6464

65+
# Enable pre-commit hook (fmt + lint)
66+
setup-hooks:
67+
git config core.hooksPath .githooks
68+
@echo "Pre-commit hook enabled (.githooks/pre-commit)"
69+
6570
# Tidy dependencies
6671
tidy:
6772
go mod tidy
@@ -87,6 +92,7 @@ help:
8792
@echo " fmt - Format code"
8893
@echo " lint - Run linter"
8994
@echo " tidy - Tidy go.mod"
95+
@echo " setup-hooks - Enable pre-commit hook (fmt + lint)"
9096
@echo " clean - Remove build artifacts"
9197
@echo ""
9298
@echo " shootout - Build MIME shootout tool"

cmd/msgvault/cmd/repair_encoding_test.go

Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ package cmd
22

33
import (
44
"testing"
5-
"unicode/utf8"
65

6+
"github.com/wesm/msgvault/internal/testutil"
77
"golang.org/x/text/encoding/charmap"
88
"golang.org/x/text/encoding/japanese"
99
"golang.org/x/text/encoding/korean"
@@ -12,6 +12,7 @@ import (
1212
)
1313

1414
func TestDetectAndDecode_Windows1252(t *testing.T) {
15+
enc := testutil.EncodedSamples()
1516
// Windows-1252 specific characters: smart quotes (0x91-0x94), en/em dash (0x96, 0x97)
1617
tests := []struct {
1718
name string
@@ -20,27 +21,27 @@ func TestDetectAndDecode_Windows1252(t *testing.T) {
2021
}{
2122
{
2223
name: "smart single quote (apostrophe)",
23-
input: []byte("Rand\x92s Opponent"), // 0x92 = right single quote U+2019
24+
input: enc.Win1252_SmartQuoteRight,
2425
expected: "Rand\u2019s Opponent",
2526
},
2627
{
2728
name: "en dash",
28-
input: []byte("Limited Time Only \x96 50 Percent"), // 0x96 = en dash U+2013
29+
input: []byte("Limited Time Only \x96 50 Percent"), // different text than fixture
2930
expected: "Limited Time Only \u2013 50 Percent",
3031
},
3132
{
3233
name: "em dash",
33-
input: []byte("Costco Travel\x97Exclusive"), // 0x97 = em dash U+2014
34+
input: []byte("Costco Travel\x97Exclusive"), // different text than fixture
3435
expected: "Costco Travel\u2014Exclusive",
3536
},
3637
{
3738
name: "trademark symbol",
38-
input: []byte("Craftsman\xae Tools"), // 0xAE = ®
39+
input: []byte("Craftsman\xae Tools"),
3940
expected: "Craftsman® Tools",
4041
},
4142
{
4243
name: "registered trademark in Windows-1252",
43-
input: []byte("Windows\xae 7"), // 0xAE = ®
44+
input: []byte("Windows\xae 7"),
4445
expected: "Windows® 7",
4546
},
4647
}
@@ -54,38 +55,36 @@ func TestDetectAndDecode_Windows1252(t *testing.T) {
5455
if result != tt.expected {
5556
t.Errorf("detectAndDecode() = %q, want %q", result, tt.expected)
5657
}
57-
if !utf8.ValidString(result) {
58-
t.Errorf("detectAndDecode() result is not valid UTF-8")
59-
}
58+
testutil.AssertValidUTF8(t, result)
6059
})
6160
}
6261
}
6362

6463
func TestDetectAndDecode_Latin1(t *testing.T) {
65-
// ISO-8859-1 (Latin-1) characters
64+
enc := testutil.EncodedSamples()
6665
tests := []struct {
6766
name string
6867
input []byte
6968
expected string
7069
}{
7170
{
7271
name: "o with acute accent",
73-
input: []byte("Mir\xf3 - Picasso"), // 0xF3 = ó
72+
input: enc.Latin1_OAcute,
7473
expected: "Miró - Picasso",
7574
},
7675
{
7776
name: "c with cedilla",
78-
input: []byte("Gar\xe7on"), // 0xE7 = ç
77+
input: enc.Latin1_CCedilla,
7978
expected: "Garçon",
8079
},
8180
{
8281
name: "u with umlaut",
83-
input: []byte("M\xfcnchen"), // 0xFC = ü
82+
input: enc.Latin1_UUmlaut,
8483
expected: "München",
8584
},
8685
{
8786
name: "n with tilde",
88-
input: []byte("Espa\xf1a"), // 0xF1 = ñ
87+
input: enc.Latin1_NTilde,
8988
expected: "España",
9089
},
9190
}
@@ -99,37 +98,21 @@ func TestDetectAndDecode_Latin1(t *testing.T) {
9998
if result != tt.expected {
10099
t.Errorf("detectAndDecode() = %q, want %q", result, tt.expected)
101100
}
102-
if !utf8.ValidString(result) {
103-
t.Errorf("detectAndDecode() result is not valid UTF-8")
104-
}
101+
testutil.AssertValidUTF8(t, result)
105102
})
106103
}
107104
}
108105

109106
func TestDetectAndDecode_AsianEncodings(t *testing.T) {
110-
// For short Asian text samples, automatic charset detection is ambiguous
111-
// since the same bytes can be valid in multiple encodings.
112-
// The key requirement is that the output is valid UTF-8.
107+
enc := testutil.EncodedSamples()
113108
tests := []struct {
114109
name string
115110
input []byte
116111
}{
117-
{
118-
name: "Shift-JIS Japanese",
119-
input: []byte{0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd}, // "こんにちは"
120-
},
121-
{
122-
name: "GBK Simplified Chinese",
123-
input: []byte{0xc4, 0xe3, 0xba, 0xc3}, // "你好"
124-
},
125-
{
126-
name: "Big5 Traditional Chinese",
127-
input: []byte{0xa9, 0x6f, 0xa6, 0x6e}, // "你好"
128-
},
129-
{
130-
name: "EUC-KR Korean",
131-
input: []byte{0xbe, 0xc8, 0xb3, 0xe7}, // "안녕"
132-
},
112+
{"Shift-JIS Japanese", enc.ShiftJIS_Konnichiwa},
113+
{"GBK Simplified Chinese", enc.GBK_Nihao},
114+
{"Big5 Traditional Chinese", enc.Big5_Nihao},
115+
{"EUC-KR Korean", enc.EUCKR_Annyeong},
133116
}
134117

135118
for _, tt := range tests {
@@ -138,10 +121,7 @@ func TestDetectAndDecode_AsianEncodings(t *testing.T) {
138121
if err != nil {
139122
t.Fatalf("detectAndDecode() error = %v", err)
140123
}
141-
if !utf8.ValidString(result) {
142-
t.Errorf("detectAndDecode() result is not valid UTF-8: %q", result)
143-
}
144-
// Result should not be empty
124+
testutil.AssertValidUTF8(t, result)
145125
if len(result) == 0 {
146126
t.Errorf("detectAndDecode() returned empty string")
147127
}
@@ -209,17 +189,17 @@ func TestSanitizeUTF8(t *testing.T) {
209189
{
210190
name: "invalid byte replaced",
211191
input: "Hello\x80World",
212-
expected: "Hello�World",
192+
expected: "Hello\ufffdWorld",
213193
},
214194
{
215195
name: "multiple invalid bytes",
216196
input: "Test\x80\x81\x82String",
217-
expected: "Test���String",
197+
expected: "Test\ufffd\ufffd\ufffdString",
218198
},
219199
{
220200
name: "truncated UTF-8 sequence",
221201
input: "Hello\xc3", // Incomplete UTF-8 sequence
222-
expected: "Hello",
202+
expected: "Hello\ufffd",
223203
},
224204
}
225205

@@ -229,10 +209,7 @@ func TestSanitizeUTF8(t *testing.T) {
229209
if result != tt.expected {
230210
t.Errorf("sanitizeUTF8(%q) = %q, want %q", tt.input, result, tt.expected)
231211
}
232-
if !utf8.ValidString(result) {
233-
t.Errorf("sanitizeUTF8() result is not valid UTF-8")
234-
}
212+
testutil.AssertValidUTF8(t, result)
235213
})
236214
}
237215
}
238-

0 commit comments

Comments
 (0)