Files
pangolin/server/internal/idgen/idgen_test.go
T
wangjia 25e0204471 merge: maestro/tsk_GXDoc3Cs07Rn [tsk_nI2T8qpcoier] apierr + idgen + CONVENTIONS.md
Apply changes from tsk_GXDoc3Cs07Rn (conflict rescue: original auto-merge
blocked by uncommitted changes on main; worktree was clean, no code conflicts).

Changes applied verbatim from b64c002:
- server/CONVENTIONS.md: new file — mandatory coding conventions
- server/internal/apierr: add New(), StatusFor(), ErrUnauthorized/Forbidden/
  NotFound/Conflict predefined errors, chi Middleware; add apierr_test.go (8 tests)
- server/internal/idgen: implement idgen.go (UUID v7 + Crockford Base32 moved
  from codes); add idgen_test.go (12 tests)
- server/internal/codes/generator.go: refactor to delegate to idgen.*
- server/go.mod: move google/uuid from indirect to direct dependency

All tests pass: go test ./internal/apierr/... ./internal/idgen/... ./internal/codes/...

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:45:46 +08:00

245 lines
7.9 KiB
Go
Raw 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)
}
}