Files
rands/randsmust/strings_test.go
Jim Myhrberg fe4308607c feat(strings/uuidv7): add UUIDv7 generation (#10)
The UUID v7 format is a time-ordered random UUID. It uses a timestamp
with millisecond precision in the most significant bits, followed by
random data. This provides both uniqueness and chronological ordering,
making it ideal for database primary keys and situations where sorting
by creation time is desired.

References:
- https://uuid7.com/
- https://www.ietf.org/archive/id/draft-peabody-dispatch-new-uuid-format-04.html#name-uuid-version-7
2025-02-28 02:16:32 +00:00

620 lines
14 KiB
Go

package randsmust
import (
"encoding/base64"
"encoding/hex"
"regexp"
"strings"
"testing"
"time"
"github.com/jimeh/rands"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHex(t *testing.T) {
t.Parallel()
allowed := "0123456789abcdef"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Hex(tt.n)
assert.Len(t, got, tt.n*2)
assertAllowedChars(t, allowed, got)
})
}
}
func TestBase64(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
"0123456789+/="
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Base64(tt.n)
b, err := base64.StdEncoding.DecodeString(got)
require.NoError(t, err)
assert.Len(t, b, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestBase64URL(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
"0123456789-_"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Base64URL(tt.n)
b, err := base64.RawURLEncoding.DecodeString(got)
require.NoError(t, err)
assert.Len(t, b, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestAlphanumeric(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Alphanumeric(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestAlphabetic(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Alphabetic(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestNumeric(t *testing.T) {
t.Parallel()
allowed := "0123456789"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Numeric(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestUpper(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Upper(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestUpperNumeric(t *testing.T) {
t.Parallel()
allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := UpperNumeric(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestLower(t *testing.T) {
t.Parallel()
allowed := "abcdefghijklmnopqrstuvwxyz"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := Lower(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
func TestLowerNumeric(t *testing.T) {
t.Parallel()
allowed := "abcdefghijklmnopqrstuvwxyz0123456789"
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := LowerNumeric(tt.n)
assert.Len(t, got, tt.n)
assertAllowedChars(t, allowed, got)
})
}
}
var stringTestCases = []struct {
name string
n int
alphabet string
panicErrIs error
panicStr string
}{
{
name: "greek",
n: 32,
alphabet: "αβγδεζηθικλμνξοπρστυφχψωςΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΩ" +
"άίόύώέϊϋΐΰΆΈΌΏΎΊ",
panicErrIs: rands.ErrNonASCIIAlphabet,
panicStr: "rands: alphabet contains non-ASCII characters",
},
{
name: "chinese",
n: 32,
alphabet: "的一是不了人我在有他这为之大来以个中上们到说国和地也子" +
"时道出而要于就下得可你年生",
panicErrIs: rands.ErrNonASCIIAlphabet,
panicStr: "rands: alphabet contains non-ASCII characters",
},
{
name: "japanese",
n: 32,
alphabet: "一九七二人入八力十下三千上口土夕大女子小山川五天中六円" +
"手文日月木水火犬王正出本右四",
panicErrIs: rands.ErrNonASCIIAlphabet,
panicStr: "rands: alphabet contains non-ASCII characters",
},
{
name: "n=0",
n: 0,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=1",
n: 1,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=2",
n: 2,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=7",
n: 7,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=8",
n: 8,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=16",
n: 16,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=32",
n: 32,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=128",
n: 128,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=1024",
n: 1024,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=409600",
n: 409600,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "n=2394345",
n: 2394345,
alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
},
{
name: "uppercase",
n: 16,
alphabet: "ABCDEFGHJKMNPRSTUVWXYZ",
},
{
name: "lowercase",
n: 16,
alphabet: "abcdefghjkmnprstuvwxyz",
},
}
func TestString(t *testing.T) {
t.Parallel()
for _, tt := range stringTestCases {
t.Run(tt.name, func(t *testing.T) {
var got string
p := recoverPanic(func() {
got = String(tt.n, tt.alphabet)
})
if tt.panicErrIs == nil || tt.panicStr == "" {
assert.Len(t, []rune(got), tt.n)
assertAllowedChars(t, tt.alphabet, got)
}
if tt.panicErrIs != nil {
assert.ErrorIs(t, p.(error), tt.panicErrIs)
}
if tt.panicStr != "" {
assert.EqualError(t, p.(error), tt.panicStr)
}
})
}
}
var unicodeStringTestCases = []struct {
name string
n int
alphabet string
}{
{
name: "n=0",
n: 0,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=1",
n: 1,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=2",
n: 2,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=7",
n: 7,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=8",
n: 8,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=16",
n: 16,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=32",
n: 32,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=128",
n: 128,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=1024",
n: 1024,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=409600",
n: 409600,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "n=2394345",
n: 2394345,
alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" +
"手文日月木水火犬王正出本右",
},
{
name: "latin",
n: 32,
alphabet: "ABCDEFGHJKMNPRSTUVWXYZabcdefghjkmnprstuvwxyz",
},
{
name: "greek",
n: 32,
alphabet: "αβγδεζηθικλμνξοπρστυφχψωςΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΩ" +
"άίόύώέϊϋΐΰΆΈΌΏΎΊ",
},
{
name: "chinese",
n: 32,
alphabet: "的一是不了人我在有他这为之大来以个中上们到说国和地也子" +
"时道出而要于就下得可你年生",
},
{
name: "japanese",
n: 32,
alphabet: "一九七二人入八力十下三千上口土夕大女子小山川五天中六円" +
"手文日月木水火犬王正出本右四",
},
}
func TestUnicodeString(t *testing.T) {
t.Parallel()
for _, tt := range unicodeStringTestCases {
t.Run(tt.name, func(t *testing.T) {
got := UnicodeString(tt.n, []rune(tt.alphabet))
assert.Len(t, []rune(got), tt.n)
assertAllowedChars(t, tt.alphabet, got)
})
}
}
var dnsLabelTestCases = []struct {
name string
n int
panicErrIs error
panicStr string
}{
{
name: "n=-128",
n: -128,
panicErrIs: rands.ErrDNSLabelLength,
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{
name: "n=0",
n: 0,
panicErrIs: rands.ErrDNSLabelLength,
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{name: "n=1", n: 1},
{name: "n=2", n: 2},
{name: "n=3", n: 3},
{name: "n=4", n: 4},
{name: "n=5", n: 5},
{name: "n=6", n: 6},
{name: "n=7", n: 7},
{name: "n=8", n: 8},
{name: "n=16", n: 16},
{name: "n=32", n: 32},
{name: "n=63", n: 63},
{
name: "n=64",
n: 64,
panicErrIs: rands.ErrDNSLabelLength,
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
{
name: "n=128",
n: 128,
panicErrIs: rands.ErrDNSLabelLength,
panicStr: "rands: DNS labels must be between 1 and 63 characters " +
"in length",
},
}
func TestDNSLabel(t *testing.T) {
t.Parallel()
for _, tt := range dnsLabelTestCases {
t.Run(tt.name, func(t *testing.T) {
// generate lots of labels to increase the chances of catching any
// obscure bugs
for i := 0; i < 10000; i++ {
var got string
p := recoverPanic(func() {
got = DNSLabel(tt.n)
})
if tt.panicErrIs == nil || tt.panicStr == "" {
require.Len(t, got, tt.n)
asserDNSLabel(t, got)
}
if tt.panicErrIs != nil {
require.ErrorIs(t, p.(error), tt.panicErrIs)
}
if tt.panicStr != "" {
require.EqualError(t, p.(error), tt.panicStr)
}
}
})
}
}
func TestUUID(t *testing.T) {
t.Parallel()
m := regexp.MustCompile(
`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`,
)
seen := make(map[string]struct{})
for i := 0; i < 10000; i++ {
got := UUID()
require.Regexp(t, m, got)
if _, ok := seen[got]; ok {
require.FailNow(t, "duplicate UUID")
}
seen[got] = struct{}{}
raw := strings.ReplaceAll(got, "-", "")
b := make([]byte, 16)
_, err := hex.Decode(b, []byte(raw))
require.NoError(t, err)
require.Equal(t, 4, int(b[6]>>4), "version is not 4")
require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122")
}
}
func TestUUIDv7(t *testing.T) {
t.Parallel()
m := regexp.MustCompile(
`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`,
)
// Store timestamps to verify they're increasing
var lastTimestampBytes int64
var lastUUID string
seen := make(map[string]struct{})
for i := 0; i < 10000; i++ {
got := UUIDv7()
require.Regexp(t, m, got)
if _, ok := seen[got]; ok {
require.FailNow(t, "duplicate UUID")
}
seen[got] = struct{}{}
raw := strings.ReplaceAll(got, "-", "")
b := make([]byte, 16)
_, err := hex.Decode(b, []byte(raw))
require.NoError(t, err)
// Check version is 7
require.Equal(t, 7, int(b[6]>>4), "version is not 7")
// Check variant is RFC 4122
require.Equal(t, byte(0x80), b[8]&0xc0, "variant is not RFC 4122")
// Extract timestamp bytes
timestampBytes := int64(b[0])<<40 | int64(b[1])<<32 | int64(b[2])<<24 |
int64(b[3])<<16 | int64(b[4])<<8 | int64(b[5])
// Verify timestamp is within 100 milliseconds of current time
tsTime := time.UnixMilli(timestampBytes)
require.WithinDuration(t, time.Now(), tsTime, 100*time.Millisecond,
"timestamp is not within 100 milliseconds of current time")
// After the first UUID, verify that UUIDs are monotonically increasing
if i > 0 && timestampBytes < lastTimestampBytes {
require.FailNow(t, "UUIDs are not monotonically increasing",
"current: %s (ts: %d), previous: %s (ts: %d)",
got, timestampBytes, lastUUID, lastTimestampBytes)
}
lastTimestampBytes = timestampBytes
lastUUID = got
}
}
//
// Helpers
//
var (
dnsLabelHeadRx = regexp.MustCompile(`^[a-z]$`)
dnsLabelBodyRx = regexp.MustCompile(`^[a-z0-9-]+$`)
dnsLabelTailRx = regexp.MustCompile(`^[a-z0-9]$`)
)
func asserDNSLabel(t *testing.T, label string) {
require.LessOrEqualf(t, len(label), 63,
`DNS label "%s" is longer than 63 characters`, label,
)
require.GreaterOrEqualf(t, len(label), 1,
`DNS label "%s" is shorter than 1 character`, label,
)
if len(label) >= 1 {
require.Regexpf(t, dnsLabelHeadRx, string(label[0]),
`DNS label "%s" must start with a-z`, label,
)
}
if len(label) >= 2 {
require.Regexpf(t, dnsLabelTailRx, string(label[len(label)-1]),
`DNS label "%s" must end with a-z0-9`, label,
)
}
if len(label) >= 3 {
require.Regexpf(t, dnsLabelBodyRx, label[1:len(label)-1],
`DNS label "%s" body must only contain a-z0-9-`, label)
}
if len(label) >= 4 {
require.NotEqualf(t, "--", label[2:4],
`DNS label "%s" cannot contain "--" as 3rd and 4th char`, label,
)
}
}
func assertAllowedChars(t *testing.T, allowed string, s string) {
invalid := ""
for _, c := range s {
if !strings.Contains(allowed, string(c)) &&
!strings.Contains(invalid, string(c)) {
invalid += string(c)
}
}
assert.Truef(
t, len(invalid) == 0, "string contains invalid chars: %s", invalid,
)
}