Files
pangolin/server/internal/idgen/idgen_test.go
wangjia b64c002a33 feat(tsk_GXDoc3Cs07Rn): apierr + idgen + CONVENTIONS.md
apierr:
- Add New() constructor, StatusFor() HTTP-status mapping
- Add ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict predefined errors
- Add chi-compatible Middleware for panic(*Error) → JSON recovery
- Add apierr_test.go (8 tests; covers New, StatusFor, WriteJSON, Middleware)

idgen:
- Implement idgen.go: New()/NewString() (UUID v7 via google/uuid v1.6.0)
- Implement GenerateCode/CanonicalizeCode/HashCode (Crockford Base32 moved from codes)
- Add idgen_test.go (12 tests; UUID v7 ordering/uniqueness + Crockford format/normalization/check)

codes:
- Refactor generator.go to delegate GenerateCode/Canonicalize/Hash to idgen
- All existing codes generator tests continue to pass unchanged

server:
- Add CONVENTIONS.md covering package structure, error handling, ID generation,
  database conventions, handler templates, auth context, testing, and logging rules
- Move google/uuid from indirect to direct dependency in go.mod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:58:38 +08:00

245 lines
7.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package idgen_test
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/wangjia/pangolin/server/internal/idgen"
)
// ─────────────────────────────────────────────────────────────────────────────
// UUID v7 tests
// ─────────────────────────────────────────────────────────────────────────────
// TestNewReturnsVersion7 verifies that New() returns a UUID with version 7.
func TestNewReturnsVersion7(t *testing.T) {
for i := 0; i < 100; i++ {
id := idgen.New()
if id.Version() != uuid.Version(7) {
t.Fatalf("New(): version = %d, want 7", id.Version())
}
}
}
// TestNewStringFormat verifies that NewString() returns a properly formatted UUID string.
func TestNewStringFormat(t *testing.T) {
s := idgen.NewString()
// Standard UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars)
if len(s) != 36 {
t.Fatalf("NewString() length = %d, want 36", len(s))
}
parts := strings.Split(s, "-")
if len(parts) != 5 {
t.Fatalf("NewString() has %d hyphen-separated parts, want 5", len(parts))
}
expected := []int{8, 4, 4, 4, 12}
for i, p := range parts {
if len(p) != expected[i] {
t.Errorf("part %d: length = %d, want %d", i, len(p), expected[i])
}
}
}
// TestNewUniqueness verifies that 10 000 generated UUIDs are all distinct.
func TestNewUniqueness(t *testing.T) {
const n = 10_000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
s := idgen.NewString()
if _, dup := seen[s]; dup {
t.Fatalf("duplicate UUID at iteration %d: %s", i, s)
}
seen[s] = struct{}{}
}
}
// TestNewTimeOrdered verifies that sequentially generated UUIDs are
// monotonically non-decreasing in their string representation (UUID v7 is
// time-ordered so lexicographic sort ≈ generation order).
func TestNewTimeOrdered(t *testing.T) {
prev := idgen.NewString()
for i := 0; i < 1000; i++ {
next := idgen.NewString()
if next < prev {
t.Fatalf("UUID ordering violation: %s > %s", prev, next)
}
prev = next
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Crockford Base32 activation-code tests
// ─────────────────────────────────────────────────────────────────────────────
// TestGenerateCodeFormat verifies that GenerateCode returns a 16-char code
// composed entirely of the Crockford alphabet (15 data chars + 1 check char).
func TestGenerateCodeFormat(t *testing.T) {
for i := 0; i < 1000; i++ {
code, err := idgen.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode error: %v", err)
}
if len(code) != 16 {
t.Errorf("code %q: length = %d, want 16", code, len(code))
}
// A freshly generated code must canonicalize to itself.
canonical, err := idgen.CanonicalizeCode(code)
if err != nil {
t.Errorf("CanonicalizeCode(%q): %v", code, err)
}
if canonical != code {
t.Errorf("canonical form mismatch: got %q, want %q", canonical, code)
}
}
}
// TestGenerateCodeUniqueness checks that 10 000 generated codes have no
// hash collisions (birthday probability ≈ 10^8 for 75-bit codes).
func TestGenerateCodeUniqueness(t *testing.T) {
const n = 10_000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
code, err := idgen.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
h := idgen.HashCode(code)
if _, dup := seen[h]; dup {
t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h)
}
seen[h] = struct{}{}
}
}
// TestCanonicalizeCodeNormalization verifies I/L→1 and O→0 substitutions.
func TestCanonicalizeCodeNormalization(t *testing.T) {
base, err := idgen.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
// Lower-case input must produce the upper-case canonical form.
lower := strings.ToLower(base)
canonical, err := idgen.CanonicalizeCode(lower)
if err != nil {
t.Errorf("CanonicalizeCode(lower) error: %v", err)
}
if canonical != base {
t.Errorf("CanonicalizeCode(lower) = %q, want %q", canonical, base)
}
// I, L → 1.
idx := strings.IndexByte(base, '1')
if idx >= 0 && idx < 15 {
for _, sub := range []string{"I", "L", "i", "l"} {
variant := base[:idx] + sub + base[idx+1:]
c, err := idgen.CanonicalizeCode(variant)
if err != nil {
t.Errorf("CanonicalizeCode(%q) error: %v", variant, err)
continue
}
if c != base {
t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base)
}
}
}
// O → 0.
idx = strings.IndexByte(base, '0')
if idx >= 0 && idx < 15 {
variant := base[:idx] + "O" + base[idx+1:]
c, err := idgen.CanonicalizeCode(variant)
if err != nil {
t.Errorf("CanonicalizeCode(%q) error: %v", variant, err)
} else if c != base {
t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base)
}
}
}
// TestCheckCharDetectsSingleErrors verifies that mutating any single data
// character in a valid code causes CanonicalizeCode to return an error.
func TestCheckCharDetectsSingleErrors(t *testing.T) {
const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
code, err := idgen.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
for pos := 0; pos < 15; pos++ {
original := rune(code[pos])
for _, replacement := range alpha {
if replacement == original {
continue
}
mutated := code[:pos] + string(replacement) + code[pos+1:]
if _, err := idgen.CanonicalizeCode(mutated); err == nil {
t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q",
pos, original, replacement, code, mutated)
}
}
}
}
// TestHashCodeConsistency verifies that HashCode is deterministic and that
// different codes produce different digests.
func TestHashCodeConsistency(t *testing.T) {
code1, _ := idgen.GenerateCode()
code2, _ := idgen.GenerateCode()
for code1 == code2 {
code2, _ = idgen.GenerateCode()
}
h1a := idgen.HashCode(code1)
h1b := idgen.HashCode(code1)
h2 := idgen.HashCode(code2)
if h1a != h1b {
t.Error("HashCode is not deterministic")
}
if h1a == h2 {
t.Error("different codes produced the same digest")
}
if len(h1a) != 64 {
t.Errorf("HashCode length = %d, want 64 (hex SHA-256)", len(h1a))
}
}
// TestCanonicalizeCodeRejectsInvalidLength tests length validation.
func TestCanonicalizeCodeRejectsInvalidLength(t *testing.T) {
cases := []string{"", "ABCDE", "ABCDEFGH12345678X"}
for _, c := range cases {
if _, err := idgen.CanonicalizeCode(c); err == nil {
t.Errorf("CanonicalizeCode(%q) should fail for length %d", c, len(c))
}
}
}
// TestCanonicalizeCodeRejectsInvalidChars tests that non-Crockford characters
// at data positions (014) are rejected.
func TestCanonicalizeCodeRejectsInvalidChars(t *testing.T) {
base, _ := idgen.GenerateCode()
invalid := "!" + base[1:]
if _, err := idgen.CanonicalizeCode(invalid); err == nil {
t.Errorf("CanonicalizeCode(%q) should fail for invalid character", invalid)
}
}
// TestHyphenStripping verifies that hyphens inserted for display readability
// (e.g. XXXX-XXXX-XXXX-XXXX) are stripped before validation.
func TestHyphenStripping(t *testing.T) {
code, err := idgen.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:]
canonical, err := idgen.CanonicalizeCode(hyphenated)
if err != nil {
t.Errorf("CanonicalizeCode(hyphenated) error: %v", err)
}
if canonical != code {
t.Errorf("CanonicalizeCode(hyphenated) = %q, want %q", canonical, code)
}
}