feat: implement server versioning approach with semantic versioning support (#296)

This implements the versioning approach agreed upon in issue #158:

## Key Changes

### Documentation
- Added comprehensive versioning guide (docs/versioning.md)
- Updated FAQ with versioning guidance and best practices 
- Enhanced schema description with SHOULD recommendations

### Schema Updates
- Added 255-character limit for version strings in schema.json
- Enhanced version field description with semantic versioning guidance

### Implementation 
- New isSemanticVersion() function for proper semver detection
- Enhanced compareSemanticVersions() with prerelease support
- Implemented compareVersions() strategy:
  * Both semver: use semantic comparison
  * Neither semver: use timestamp comparison
  * Mixed: semver versions always take precedence
- Updated publish logic to determine "latest" using new strategy
- Added version length validation (255 char max)

### Versioning Strategy
1. Version MUST be string up to 255 characters
2. SHOULD use semantic versioning for predictable ordering
3. SHOULD align with package versions to reduce confusion 
4. MAY use prerelease labels for registry-specific versions
5. Registry attempts semver parsing, falls back to timestamp ordering
6. Clients SHOULD follow same comparison logic

Resolves #158

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: adam jones <domdomegg@users.noreply.github.com>
This commit is contained in:
adam jones 2025-08-26 14:55:38 +01:00 • committed by GitHub
commit 7b211a46d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 561 additions and 164 deletions

View file

@ -9,11 +9,12 @@ import (
// Common database errors
var (
ErrNotFound = errors.New("record not found")
ErrAlreadyExists = errors.New("record already exists")
ErrInvalidInput = errors.New("invalid input")
ErrDatabase = errors.New("database error")
ErrInvalidVersion = errors.New("invalid version: cannot publish older version after newer version")
ErrNotFound = errors.New("record not found")
ErrAlreadyExists = errors.New("record already exists")
ErrInvalidInput = errors.New("invalid input")
ErrDatabase = errors.New("database error")
ErrInvalidVersion = errors.New("invalid version: cannot publish older version after newer version")
ErrMaxServersReached = errors.New("maximum number of versions for this server reached (10000): please reach out at https://github.com/modelcontextprotocol/registry to explain your use case")
)
// Database defines the interface for database operations with extension wrapper architecture
@ -23,7 +24,10 @@ type Database interface {
// GetByID retrieves a single ServerRecord by its ID
GetByID(ctx context.Context, id string) (*model.ServerRecord, error)
// Publish adds a new server to the database with separated server.json and extensions
Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error)
// The registryMetadata contains metadata determined by the service layer (e.g., is_latest, timestamps)
Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error)
// UpdateLatestFlag updates the is_latest flag for a specific server record
UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error
// ImportSeed imports initial data from a seed file
ImportSeed(ctx context.Context, seedFilePath string) error
// Close closes the database connection

View file

@ -4,12 +4,9 @@ import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/registry/internal/model"
)
@ -44,53 +41,6 @@ func NewMemoryDB(e map[string]*model.ServerDetail) *MemoryDB {
}
}
// compareSemanticVersions compares two semantic version strings
// Returns:
//
// -1 if version1 < version2
// 0 if version1 == version2
// +1 if version1 > version2
func compareSemanticVersions(version1, version2 string) int {
// Simple semantic version comparison
// Assumes format: major.minor.patch
parts1 := strings.Split(version1, ".")
parts2 := strings.Split(version2, ".")
// Pad with zeros if needed
maxLen := max(len(parts2), len(parts1))
for len(parts1) < maxLen {
parts1 = append(parts1, "0")
}
for len(parts2) < maxLen {
parts2 = append(parts2, "0")
}
// Compare each part
for i := 0; i < maxLen; i++ {
num1, err1 := strconv.Atoi(parts1[i])
num2, err2 := strconv.Atoi(parts2[i])
// If parsing fails, fall back to string comparison
if err1 != nil || err2 != nil {
if parts1[i] < parts2[i] {
return -1
} else if parts1[i] > parts2[i] {
return 1
}
continue
}
if num1 < num2 {
return -1
} else if num1 > num2 {
return 1
}
}
return 0
}
// List retrieves ServerRecord entries with optional filtering and pagination
func (db *MemoryDB) List(
@ -202,7 +152,7 @@ func (db *MemoryDB) GetByID(ctx context.Context, id string) (*model.ServerRecord
}
// Publish adds a new server to the database with separated server.json and extensions
func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) {
func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
@ -221,38 +171,11 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail
db.mu.Lock()
defer db.mu.Unlock()
// Check for existing entry with same name and compare versions
var existingRecord *model.ServerRecord
for _, entry := range db.entries {
if entry.RegistryMetadata.IsLatest && entry.ServerJSON.Name == name {
existingRecord = entry
break
}
}
// Version comparison
if existingRecord != nil {
existingVersion := existingRecord.ServerJSON.VersionDetail.Version
if compareSemanticVersions(version, existingVersion) <= 0 {
return nil, fmt.Errorf("version must be greater than existing version %s", existingVersion)
}
}
// Validate repository URL
if serverDetail.Repository.URL == "" {
return nil, ErrInvalidInput
}
// Create new registry metadata
now := time.Now()
registryMetadata := model.RegistryMetadata{
ID: uuid.New().String(),
PublishedAt: now,
UpdatedAt: now,
IsLatest: true,
ReleaseDate: now.Format(time.RFC3339),
}
// Create server record
record := &model.ServerRecord{
ServerJSON: serverDetail,
@ -260,11 +183,6 @@ func (db *MemoryDB) Publish(ctx context.Context, serverDetail model.ServerDetail
PublisherExtensions: publisherExtensions,
}
// Mark existing record as not latest
if existingRecord != nil {
existingRecord.RegistryMetadata.IsLatest = false
}
// Store the record using registry metadata ID
db.entries[registryMetadata.ID] = record
@ -298,6 +216,24 @@ func (db *MemoryDB) ImportSeed(ctx context.Context, seedFilePath string) error {
return nil
}
// UpdateLatestFlag updates the is_latest flag for a specific server record
func (db *MemoryDB) UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error {
if ctx.Err() != nil {
return ctx.Err()
}
db.mu.Lock()
defer db.mu.Unlock()
if entry, exists := db.entries[id]; exists {
entry.RegistryMetadata.IsLatest = isLatest
entry.RegistryMetadata.UpdatedAt = time.Now()
return nil
}
return ErrNotFound
}
// Close closes the database connection
// For an in-memory database, this is a no-op
func (db *MemoryDB) Close() error {

View file

@ -273,7 +273,7 @@ func (db *PostgreSQL) GetByID(ctx context.Context, id string) (*model.ServerReco
}
// Publish adds a new server to the database with separated server.json and extensions
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}) (*model.ServerRecord, error) {
func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, registryMetadata model.RegistryMetadata) (*model.ServerRecord, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
@ -288,23 +288,6 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
}
}()
// Check if there's an existing latest version for this server
var existingVersion string
checkQuery := `
SELECT s.version
FROM servers s
JOIN server_extensions se ON s.id = se.server_id
WHERE s.name = $1 AND se.is_latest = true
`
err = tx.QueryRow(ctx, checkQuery, serverDetail.Name).Scan(&existingVersion)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("failed to check existing version: %w", err)
}
// Validate version ordering
if existingVersion != "" && serverDetail.VersionDetail.Version <= existingVersion {
return nil, ErrInvalidVersion
}
// Prepare JSON data for server table
repositoryJSON, err := json.Marshal(serverDetail.Repository)
@ -327,26 +310,9 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
return nil, fmt.Errorf("failed to marshal publisher extensions: %w", err)
}
// Generate server ID and create registry metadata
serverID := uuid.New().String()
registryID := uuid.New().String()
now := time.Now()
// Use the same ID for both server and server_extensions records (1:1 relationship)
serverID := registryMetadata.ID
// Update existing latest version to not be latest
if existingVersion != "" {
updateQuery := `
UPDATE server_extensions
SET is_latest = false
WHERE server_id IN (
SELECT s.id FROM servers s WHERE s.name = $1 AND server_extensions.server_id = s.id
)
AND is_latest = true
`
_, err = tx.Exec(ctx, updateQuery, serverDetail.Name)
if err != nil {
return nil, fmt.Errorf("failed to update existing latest version: %w", err)
}
}
// Insert new server record
insertServerQuery := `
@ -373,12 +339,12 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err = tx.Exec(ctx, insertExtensionsQuery,
registryID,
registryMetadata.ID,
serverID,
now,
now,
true, // is_latest
now, // release_date
registryMetadata.PublishedAt,
registryMetadata.UpdatedAt,
registryMetadata.IsLatest,
registryMetadata.PublishedAt, // release_date
publisherExtensionsJSON,
)
if err != nil {
@ -392,14 +358,8 @@ func (db *PostgreSQL) Publish(ctx context.Context, serverDetail model.ServerDeta
// Create and return the ServerRecord
record := &model.ServerRecord{
ServerJSON: serverDetail,
RegistryMetadata: model.RegistryMetadata{
ID: registryID,
PublishedAt: now,
UpdatedAt: now,
IsLatest: true,
ReleaseDate: now.Format(time.RFC3339),
},
ServerJSON: serverDetail,
RegistryMetadata: registryMetadata,
PublisherExtensions: publisherExtensions,
}
@ -447,17 +407,15 @@ func (db *PostgreSQL) ImportSeed(ctx context.Context, seedFilePath string) error
// publishWithTransaction handles publishing within an existing transaction, optionally with predefined metadata
func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, serverDetail model.ServerDetail, publisherExtensions map[string]interface{}, existingMetadata *model.RegistryMetadata) error {
var serverID string
var extensionID string
// Use the same ID for both server and server_extensions (1:1 relationship)
var id string
if existingMetadata != nil && existingMetadata.ID != "" {
// Use predefined IDs from seed data
serverID = existingMetadata.ID
extensionID = existingMetadata.ID // In seed data, these are the same
// Use predefined ID from seed data
id = existingMetadata.ID
} else {
// Generate new UUIDs for normal publishing
serverID = uuid.New().String()
extensionID = uuid.New().String()
// This shouldn't happen as service layer should always provide an ID
// But keeping as fallback for safety
id = uuid.New().String()
}
// Marshal packages and remotes to JSONB
@ -497,7 +455,7 @@ func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, ser
var returnedServerID string
err = tx.QueryRow(ctx, serverQuery,
serverID,
id,
serverDetail.Name,
serverDetail.Description,
string(serverDetail.Status),
@ -532,7 +490,7 @@ func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, ser
}
_, err = tx.Exec(ctx, extensionQuery,
extensionID,
id,
returnedServerID,
publishedAt,
releaseDate,
@ -545,6 +503,30 @@ func (db *PostgreSQL) publishWithTransaction(ctx context.Context, tx pgx.Tx, ser
return nil
}
// UpdateLatestFlag updates the is_latest flag for a specific server record
func (db *PostgreSQL) UpdateLatestFlag(ctx context.Context, id string, isLatest bool) error {
if ctx.Err() != nil {
return ctx.Err()
}
query := `
UPDATE server_extensions
SET is_latest = $1, updated_at = $2
WHERE id = $3
`
result, err := db.conn.Exec(ctx, query, isLatest, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to update latest flag: %w", err)
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Close closes the database connection
func (db *PostgreSQL) Close() error {
return db.conn.Close(context.Background())

View file

@ -127,8 +127,18 @@ func (s *fakeRegistryService) Publish(req model.PublishRequest) (*model.ServerRe
// Extract publisher extensions from request
publisherExtensions := model.ExtractPublisherExtensions(req)
// Create registry metadata for fake service (always marks as latest)
now := time.Now()
registryMetadata := model.RegistryMetadata{
ID: uuid.New().String(),
PublishedAt: now,
UpdatedAt: now,
IsLatest: true,
ReleaseDate: now.Format(time.RFC3339),
}
// Publish to database
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions)
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions, registryMetadata)
if err != nil {
return nil, err
}

View file

@ -2,12 +2,16 @@ package service
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/model"
)
const maxServerVersionsPerServer = 10000
// registryServiceImpl implements the RegistryService interface using our Database
type registryServiceImpl struct {
db database.Database
@ -81,11 +85,69 @@ func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerRe
return nil, err
}
// Get the new version's details
newVersion := req.Server.VersionDetail.Version
newName := req.Server.Name
currentTime := time.Now()
// Check for existing versions of this server
existingServers, _, err := s.db.List(ctx, map[string]any{"name": newName}, "", maxServerVersionsPerServer)
if err != nil && !errors.Is(err, database.ErrNotFound) {
return nil, err
}
if len(existingServers) == maxServerVersionsPerServer {
return nil, database.ErrMaxServersReached
}
// Determine if this version should be marked as latest
isLatest := true
var existingLatestID string
// Check all existing versions for duplicates and determine if new version should be latest
for _, server := range existingServers {
existingVersion := server.ServerJSON.VersionDetail.Version
// Early exit: check for duplicate version
if existingVersion == newVersion {
return nil, database.ErrInvalidVersion
}
if server.RegistryMetadata.IsLatest {
existingLatestID = server.RegistryMetadata.ID
existingTime, _ := time.Parse(time.RFC3339, server.RegistryMetadata.ReleaseDate)
// Compare versions using the proper versioning strategy
comparison := CompareVersions(newVersion, existingVersion, currentTime, existingTime)
if comparison <= 0 {
// New version is not greater than existing latest
isLatest = false
}
}
}
// Extract publisher extensions from request
publisherExtensions := model.ExtractPublisherExtensions(req)
// Publish to database
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions)
// Create registry metadata with service-determined values
registryMetadata := model.RegistryMetadata{
ID: uuid.New().String(),
PublishedAt: currentTime,
UpdatedAt: currentTime,
IsLatest: isLatest,
ReleaseDate: currentTime.Format(time.RFC3339),
}
// If this will be the latest version, we need to update the existing latest
if isLatest && existingLatestID != "" {
// Update the existing latest to no longer be latest
if err := s.db.UpdateLatestFlag(ctx, existingLatestID, false); err != nil {
return nil, err
}
}
// Publish to database with the registry metadata
serverRecord, err := s.db.Publish(ctx, req.Server, publisherExtensions, registryMetadata)
if err != nil {
return nil, err
}
@ -93,4 +155,4 @@ func (s *registryServiceImpl) Publish(req model.PublishRequest) (*model.ServerRe
// Convert ServerRecord to ServerResponse format
response := serverRecord.ToServerResponse()
return &response, nil
}
}

View file

@ -0,0 +1,86 @@
package service
import (
"strings"
"time"
"golang.org/x/mod/semver"
)
// IsSemanticVersion checks if a version string follows semantic versioning format
// Uses the official golang.org/x/mod/semver package for validation
// Requires exactly three parts: major.minor.patch (optionally with prerelease/build)
func IsSemanticVersion(version string) bool {
// The semver package requires a "v" prefix, so add it for validation
versionWithV := ensureVPrefix(version)
if !semver.IsValid(versionWithV) {
return false
}
// Additional validation: require exactly three parts (major.minor.patch)
// Strip the v prefix and any prerelease/build metadata for counting parts
// This ensures semver compliance, because the default go module accepts invalid semvers :/
// (See https://pkg.go.dev/golang.org/x/mod/semver)
versionCore := strings.TrimPrefix(versionWithV, "v")
if idx := strings.Index(versionCore, "-"); idx != -1 {
versionCore = versionCore[:idx]
}
if idx := strings.Index(versionCore, "+"); idx != -1 {
versionCore = versionCore[:idx]
}
parts := strings.Split(versionCore, ".")
return len(parts) == 3
}
// ensureVPrefix adds a "v" prefix if not present
func ensureVPrefix(version string) string {
if !strings.HasPrefix(version, "v") {
return "v" + version
}
return version
}
// compareSemanticVersions compares two semantic version strings
// Uses the official golang.org/x/mod/semver package for comparison
// Returns:
//
// -1 if version1 < version2
// 0 if version1 == version2
// +1 if version1 > version2
func compareSemanticVersions(version1 string, version2 string) int {
// The semver package requires a "v" prefix, so add it for comparison
v1 := ensureVPrefix(version1)
v2 := ensureVPrefix(version2)
return semver.Compare(v1, v2)
}
// CompareVersions implements the versioning strategy agreed upon in the discussion:
// 1. If both versions are valid semver, use semantic version comparison
// 2. If neither are valid semver, use publication timestamp (return 0 to indicate equal for sorting)
// 3. If one is semver and one is not, the semver version is always considered higher
func CompareVersions(version1 string, version2 string, timestamp1 time.Time, timestamp2 time.Time) int {
isSemver1 := IsSemanticVersion(version1)
isSemver2 := IsSemanticVersion(version2)
if isSemver1 && isSemver2 {
// Both are semver - use semantic comparison
return compareSemanticVersions(version1, version2)
}
if !isSemver1 && !isSemver2 {
// Neither are semver - use timestamp comparison
if timestamp1.Before(timestamp2) {
return -1
} else if timestamp1.After(timestamp2) {
return 1
}
return 0
}
// One is semver, one is not - semver is always higher
if isSemver1 && !isSemver2 {
return 1
}
return -1
}

View file

@ -0,0 +1,164 @@
package service_test
import (
"testing"
"time"
"github.com/modelcontextprotocol/registry/internal/service"
)
func TestIsSemanticVersion(t *testing.T) {
tests := []struct {
name string
version string
want bool
}{
// Valid semantic versions
{"basic semver", "1.0.0", true},
{"with patch", "1.2.3", true},
{"with zeros", "0.0.0", true},
{"large numbers", "100.200.300", true},
{"with prerelease alpha", "1.0.0-alpha", true},
{"with prerelease beta", "2.1.3-beta", true},
{"with prerelease rc", "3.0.0-rc", true},
{"with prerelease number", "1.0.0-1", true},
{"with prerelease complex", "1.0.0-alpha.1", true},
{"with prerelease dots", "1.0.0-beta.2.3", true},
{"date format", "2021.11.15", true},
{"with hyphen in prerelease", "1.0.0-pre-release", true},
{"with v prefix", "v1.0.0", true},
// Invalid semantic versions
{"empty string", "", false},
{"single number", "1", false},
{"two parts only", "1.0", false},
{"four parts", "1.0.0.0", false},
{"non-numeric major", "a.0.0", false},
{"non-numeric minor", "1.b.0", false},
{"non-numeric patch", "1.0.c", false},
{"empty prerelease", "1.0.0-", false},
{"special chars in prerelease", "1.0.0-alpha@1", false},
{"snapshot", "snapshot", false},
{"latest", "latest", false},
{"with leading zeros", "2021.03.05", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := service.IsSemanticVersion(tt.version); got != tt.want {
t.Errorf("IsSemanticVersion(%q) = %v, want %v", tt.version, got, tt.want)
}
})
}
}
func TestCompareSemanticVersions(t *testing.T) {
tests := []struct {
name string
version1 string
version2 string
want int
}{
// Major version differences
{"major less", "1.0.0", "2.0.0", -1},
{"major greater", "2.0.0", "1.0.0", 1},
{"major equal", "1.0.0", "1.0.0", 0},
// Minor version differences
{"minor less", "1.1.0", "1.2.0", -1},
{"minor greater", "1.2.0", "1.1.0", 1},
{"minor equal", "1.2.0", "1.2.0", 0},
// Patch version differences
{"patch less", "1.0.1", "1.0.2", -1},
{"patch greater", "1.0.2", "1.0.1", 1},
{"patch equal", "1.0.1", "1.0.1", 0},
// Complex comparisons
{"complex less", "1.9.9", "2.0.0", -1},
{"complex greater", "2.0.0", "1.9.9", 1},
{"complex mixed", "1.10.0", "1.2.0", 1},
// Prerelease comparisons
{"prerelease vs stable less", "1.0.0-alpha", "1.0.0", -1},
{"stable vs prerelease greater", "1.0.0", "1.0.0-alpha", 1},
{"prerelease alpha vs beta", "1.0.0-alpha", "1.0.0-beta", -1},
{"prerelease beta vs alpha", "1.0.0-beta", "1.0.0-alpha", 1},
{"prerelease same", "1.0.0-alpha", "1.0.0-alpha", 0},
{"prerelease numeric", "1.0.0-1", "1.0.0-2", -1},
{"prerelease complex", "1.0.0-alpha.1", "1.0.0-alpha.2", -1},
{"prerelease rc vs alpha", "1.0.0-rc", "1.0.0-alpha", 1},
// Semver spec prerelease precedence rules
{"prerelease alpha.10 vs alpha.2", "1.0.0-alpha.10", "1.0.0-alpha.2", 1},
{"prerelease alpha.2 vs alpha.10", "1.0.0-alpha.2", "1.0.0-alpha.10", -1},
{"prerelease beta.100 vs beta.9", "1.0.0-beta.100", "1.0.0-beta.9", 1},
{"numeric vs alphanumeric precedence", "1.0.0-1", "1.0.0-alpha", -1},
{"alphanumeric vs numeric precedence", "1.0.0-alpha", "1.0.0-1", 1},
{"shorter prerelease list precedence", "1.0.0-alpha", "1.0.0-alpha.1", -1},
{"longer prerelease list precedence", "1.0.0-alpha.1", "1.0.0-alpha", 1},
{"mixed numeric and alpha", "1.0.0-alpha.1.beta", "1.0.0-alpha.1.2", 1},
{"complex prerelease ordering", "1.0.0-rc.1", "1.0.0-rc.10", -1},
// Edge cases
{"zero versions", "0.0.0", "0.0.1", -1},
{"large numbers", "100.200.300", "100.200.301", -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
now := time.Now()
if got := service.CompareVersions(tt.version1, tt.version2, now, now); got != tt.want {
t.Errorf("CompareVersions(%q, %q, %v, %v) = %v, want %v", tt.version1, tt.version2, now, now, got, tt.want)
}
})
}
}
func TestCompareVersions(t *testing.T) {
now := time.Now()
earlier := now.Add(-time.Hour)
later := now.Add(time.Hour)
tests := []struct {
name string
version1 string
version2 string
timestamp1 time.Time
timestamp2 time.Time
want int
}{
// Both semantic versions
{"both semver less", "1.0.0", "2.0.0", now, now, -1},
{"both semver greater", "2.0.0", "1.0.0", now, now, 1},
{"both semver equal", "1.0.0", "1.0.0", now, now, 0},
{"both semver ignore timestamps", "1.0.0", "2.0.0", later, earlier, -1},
// Neither semantic versions
{"neither semver earlier", "snapshot", "latest", earlier, later, -1},
{"neither semver later", "snapshot", "latest", later, earlier, 1},
{"neither semver same time", "snapshot", "latest", now, now, 0},
{"neither semver v-prefix", "v2021.03.15", "v2021.03.16", earlier, later, -1},
// Mixed: one semver, one not
{"semver vs non-semver", "1.0.0", "snapshot", now, now, 1},
{"non-semver vs semver", "snapshot", "1.0.0", now, now, -1},
{"semver vs snapshot", "2.0.0", "snapshot", earlier, later, 1},
{"latest vs semver", "latest", "1.0.0", later, earlier, -1},
{"semver prerelease vs non-semver", "1.0.0-alpha", "custom", now, now, 1},
// Edge cases
{"empty vs semver", "", "1.0.0", now, now, -1},
{"semver vs empty", "1.0.0", "", now, now, 1},
{"both empty", "", "", now, now, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := service.CompareVersions(tt.version1, tt.version2, tt.timestamp1, tt.timestamp2); got != tt.want {
t.Errorf("CompareVersions(%q, %q, %v, %v) = %v, want %v",
tt.version1, tt.version2, tt.timestamp1, tt.timestamp2, got, tt.want)
}
})
}
}