From c1ee736ca95c2ad45696c83812693e31adc1068d Mon Sep 17 00:00:00 2001 From: Muhammad Zayyad Mukhtar <95658387+El-swaggerito@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:36:38 +0100 Subject: [PATCH] Implemented the capability registry module for the GuildPass SDK. --- src/capabilities/CapabilityRegistry.ts | 251 ++++++++++++ src/capabilities/errors.ts | 58 +++ src/capabilities/index.ts | 4 + src/capabilities/types.ts | 29 ++ src/capabilities/versionUtils.ts | 110 ++++++ src/index.ts | 1 + tests/capabilities.test.ts | 515 +++++++++++++++++++++++++ 7 files changed, 968 insertions(+) create mode 100644 src/capabilities/CapabilityRegistry.ts create mode 100644 src/capabilities/errors.ts create mode 100644 src/capabilities/index.ts create mode 100644 src/capabilities/types.ts create mode 100644 src/capabilities/versionUtils.ts create mode 100644 tests/capabilities.test.ts diff --git a/src/capabilities/CapabilityRegistry.ts b/src/capabilities/CapabilityRegistry.ts new file mode 100644 index 0000000..c2dcba9 --- /dev/null +++ b/src/capabilities/CapabilityRegistry.ts @@ -0,0 +1,251 @@ +import type { + CapabilityDefinition, + CapabilitySnapshot, + RegistryOptions, +} from "./types.js"; +import { + InvalidCapabilityIdentifierError, + InvalidCapabilityVersionError, + DuplicateCapabilityError, +} from "./errors.js"; +import { isValidVersion, compareVersions } from "./versionUtils.js"; + +/** + * Capability identifier validation regex + * Format: lowercase alphanumeric words separated by dots (e.g., "membership.read", "access.check") + */ +const CAPABILITY_ID_REGEX = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/; + +/** + * Runtime capability registry for managing SDK capabilities + * + * Features: + * - Validates capability identifier syntax + * - Rejects duplicate registrations with conflicting versions + * - Provides deterministic capability checks + * - Maintains stable ordering for capability listing + * - Supports immutable snapshot creation + * - Protects against caller input mutation + */ +export class CapabilityRegistry { + private capabilities: Map = new Map(); + private orderedIds: string[] = []; + private options: Required; + + constructor(options: RegistryOptions = {}) { + this.options = { + duplicatePolicy: options.duplicatePolicy ?? 'error', + }; + } + + /** + * Validate a capability identifier + * @param id - Capability identifier to validate + * @throws InvalidCapabilityIdentifierError if identifier is invalid + */ + private validateIdentifier(id: string): void { + if (typeof id !== 'string' || id.length === 0) { + throw new InvalidCapabilityIdentifierError(id, 'Identifier must be a non-empty string'); + } + + if (!CAPABILITY_ID_REGEX.test(id)) { + throw new InvalidCapabilityIdentifierError( + id, + 'Identifier must match format: lowercase alphanumeric words separated by dots (e.g., "membership.read")' + ); + } + } + + /** + * Validate a capability version + * @param version - Version string to validate + * @throws InvalidCapabilityVersionError if version is invalid + */ + private validateVersion(version: string): void { + if (typeof version !== 'string' || version.length === 0) { + throw new InvalidCapabilityVersionError(version, 'Version must be a non-empty string'); + } + + if (!isValidVersion(version)) { + throw new InvalidCapabilityVersionError( + version, + 'Version must be a valid semantic version (e.g., "1.0.0", "2.1.3-alpha")' + ); + } + } + + /** + * Deep clone a capability definition to prevent caller mutation + * @param definition - Definition to clone + * @returns Cloned definition + */ + private cloneDefinition(definition: CapabilityDefinition): CapabilityDefinition { + return { + id: definition.id, + version: definition.version, + }; + } + + /** + * Register a capability definition + * @param definition - Capability definition to register + * @throws InvalidCapabilityIdentifierError if identifier is invalid + * @throws InvalidCapabilityVersionError if version is invalid + * @throws DuplicateCapabilityError if duplicate with conflicting version + */ + register(definition: CapabilityDefinition): void { + // Clone input to prevent caller mutation + const cloned = this.cloneDefinition(definition); + + // Validate identifier + this.validateIdentifier(cloned.id); + + // Validate version + this.validateVersion(cloned.version); + + // Check for duplicates + const existing = this.capabilities.get(cloned.id); + if (existing) { + if (existing.version === cloned.version) { + // Identical duplicate + if (this.options.duplicatePolicy === 'error') { + throw new DuplicateCapabilityError(cloned.id, existing.version, cloned.version); + } + // 'ignore' policy: silently skip + // 'replace' policy: replace with new (same version, no effect) + return; + } else { + // Conflicting duplicate - always reject + throw new DuplicateCapabilityError(cloned.id, existing.version, cloned.version); + } + } + + // Add new capability + this.capabilities.set(cloned.id, cloned); + + // Maintain deterministic ordering: insert in sorted order + const insertIndex = this.orderedIds.findIndex((id) => id > cloned.id); + if (insertIndex === -1) { + this.orderedIds.push(cloned.id); + } else { + this.orderedIds.splice(insertIndex, 0, cloned.id); + } + } + + /** + * Register multiple capability definitions + * @param definitions - Array of capability definitions to register + * @throws Error if any registration fails + */ + registerBatch(definitions: readonly CapabilityDefinition[]): void { + for (const definition of definitions) { + this.register(definition); + } + } + + /** + * Check if a capability is registered + * @param id - Capability identifier to check + * @returns true if capability exists + */ + hasCapability(id: string): boolean { + return this.capabilities.has(id); + } + + /** + * Get the version of a registered capability + * @param id - Capability identifier + * @returns Version string, or undefined if capability not found + */ + getCapabilityVersion(id: string): string | undefined { + const capability = this.capabilities.get(id); + return capability?.version; + } + + /** + * Check if a capability meets a minimum version requirement + * @param id - Capability identifier + * @param minimumVersion - Minimum version required + * @returns true if capability exists and version >= minimumVersion + */ + satisfiesMinimumVersion(id: string, minimumVersion: string): boolean { + const capability = this.capabilities.get(id); + if (!capability) { + return false; + } + + try { + return compareVersions(capability.version, minimumVersion) >= 0; + } catch { + // If version comparison fails, fall back to string comparison + return capability.version >= minimumVersion; + } + } + + /** + * Get all registered capability identifiers in stable order + * @returns Array of capability identifiers + */ + listCapabilities(): readonly string[] { + return [...this.orderedIds]; + } + + /** + * Get all registered capability definitions in stable order + * @returns Array of capability definitions + */ + listDefinitions(): readonly CapabilityDefinition[] { + return this.orderedIds.map((id) => this.cloneDefinition(this.capabilities.get(id)!)); + } + + /** + * Get the total number of registered capabilities + * @returns Count of registered capabilities + */ + size(): number { + return this.capabilities.size; + } + + /** + * Create an immutable snapshot of the current registry state + * @returns Immutable snapshot + */ + createSnapshot(): CapabilitySnapshot { + const capabilities = new Map>(); + + for (const [id, definition] of this.capabilities) { + capabilities.set(id, Object.freeze({ ...definition })); + } + + return { + capabilities: Object.freeze(capabilities), + orderedIds: Object.freeze([...this.orderedIds]), + }; + } + + /** + * Clear all registered capabilities + */ + clear(): void { + this.capabilities.clear(); + this.orderedIds = []; + } + + /** + * Create a new registry instance from a snapshot + * @param snapshot - Snapshot to restore from + * @returns New CapabilityRegistry instance + */ + static fromSnapshot(snapshot: CapabilitySnapshot): CapabilityRegistry { + const registry = new CapabilityRegistry(); + + for (const id of snapshot.orderedIds) { + const definition = snapshot.capabilities.get(id); + if (definition) { + registry.register(definition); + } + } + + return registry; + } +} diff --git a/src/capabilities/errors.ts b/src/capabilities/errors.ts new file mode 100644 index 0000000..100893e --- /dev/null +++ b/src/capabilities/errors.ts @@ -0,0 +1,58 @@ +/** + * Base error for capability-related errors + */ +export class CapabilityError extends Error { + constructor(message: string) { + super(message); + this.name = "CapabilityError"; + Object.setPrototypeOf(this, CapabilityError.prototype); + } +} + +/** + * Error thrown when a capability identifier has invalid syntax + */ +export class InvalidCapabilityIdentifierError extends CapabilityError { + public readonly identifier: string; + + constructor(identifier: string, reason: string) { + super(`Invalid capability identifier "${identifier}": ${reason}`); + this.name = "InvalidCapabilityIdentifierError"; + this.identifier = identifier; + Object.setPrototypeOf(this, InvalidCapabilityIdentifierError.prototype); + } +} + +/** + * Error thrown when a capability version has invalid format + */ +export class InvalidCapabilityVersionError extends CapabilityError { + public readonly version: string; + + constructor(version: string, reason: string) { + super(`Invalid capability version "${version}": ${reason}`); + this.name = "InvalidCapabilityVersionError"; + this.version = version; + Object.setPrototypeOf(this, InvalidCapabilityVersionError.prototype); + } +} + +/** + * Error thrown when attempting to register a duplicate capability with conflicting version + */ +export class DuplicateCapabilityError extends CapabilityError { + public readonly id: string; + public readonly existingVersion: string; + public readonly newVersion: string; + + constructor(id: string, existingVersion: string, newVersion: string) { + super( + `Duplicate capability "${id}" with conflicting versions: existing="${existingVersion}", new="${newVersion}"` + ); + this.name = "DuplicateCapabilityError"; + this.id = id; + this.existingVersion = existingVersion; + this.newVersion = newVersion; + Object.setPrototypeOf(this, DuplicateCapabilityError.prototype); + } +} diff --git a/src/capabilities/index.ts b/src/capabilities/index.ts new file mode 100644 index 0000000..ff4f034 --- /dev/null +++ b/src/capabilities/index.ts @@ -0,0 +1,4 @@ +export * from "./types.js"; +export * from "./errors.js"; +export * from "./versionUtils.js"; +export * from "./CapabilityRegistry.js"; diff --git a/src/capabilities/types.ts b/src/capabilities/types.ts new file mode 100644 index 0000000..407e858 --- /dev/null +++ b/src/capabilities/types.ts @@ -0,0 +1,29 @@ +/** + * Capability definition interface + * Represents a capability with an identifier and version + */ +export interface CapabilityDefinition { + id: string; + version: string; +} + +/** + * Immutable snapshot of the capability registry + */ +export interface CapabilitySnapshot { + readonly capabilities: ReadonlyMap>; + readonly orderedIds: ReadonlyArray; +} + +/** + * Registry configuration options + */ +export interface RegistryOptions { + /** + * Policy for handling duplicate capability definitions + * - 'error': Reject all duplicates (default) + * - 'ignore': Silently ignore duplicate identical definitions + * - 'replace': Replace existing definition with new one + */ + duplicatePolicy?: 'error' | 'ignore' | 'replace'; +} diff --git a/src/capabilities/versionUtils.ts b/src/capabilities/versionUtils.ts new file mode 100644 index 0000000..1e85618 --- /dev/null +++ b/src/capabilities/versionUtils.ts @@ -0,0 +1,110 @@ +/** + * Self-contained semantic version comparison utilities + * Parses and compares version strings in semver format (major.minor.patch) + */ + +/** + * Parsed semantic version components + */ +interface ParsedVersion { + major: number; + minor: number; + patch: number; + prerelease?: string; + build?: string; +} + +/** + * Parse a semantic version string + * @param version - Version string to parse (e.g., "1.2.3", "2.0.0-alpha") + * @returns Parsed version components + * @throws Error if version format is invalid + */ +export function parseVersion(version: string): ParsedVersion { + const trimmed = version.trim(); + + // Basic semver regex: major.minor.patch[-prerelease][+build] + const semverRegex = /^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+([a-zA-Z0-9.-]+))?$/; + const match = trimmed.match(semverRegex); + + if (!match) { + throw new Error(`Invalid semantic version format: "${version}"`); + } + + return { + major: parseInt(match[1], 10), + minor: parseInt(match[2], 10), + patch: parseInt(match[3], 10), + prerelease: match[4], + build: match[5], + }; +} + +/** + * Compare two version strings + * @param versionA - First version string + * @param versionB - Second version string + * @returns -1 if versionA < versionB, 0 if equal, 1 if versionA > versionB + */ +export function compareVersions(versionA: string, versionB: string): number { + const parsedA = parseVersion(versionA); + const parsedB = parseVersion(versionB); + + // Compare major version + if (parsedA.major !== parsedB.major) { + return parsedA.major < parsedB.major ? -1 : 1; + } + + // Compare minor version + if (parsedA.minor !== parsedB.minor) { + return parsedA.minor < parsedB.minor ? -1 : 1; + } + + // Compare patch version + if (parsedA.patch !== parsedB.patch) { + return parsedA.patch < parsedB.patch ? -1 : 1; + } + + // Compare prerelease (presence indicates pre-release, which is less than release) + if (!parsedA.prerelease && parsedB.prerelease) { + return 1; + } + if (parsedA.prerelease && !parsedB.prerelease) { + return -1; + } + + // If both have prerelease, compare lexicographically + if (parsedA.prerelease && parsedB.prerelease) { + const prereleaseCompare = parsedA.prerelease.localeCompare(parsedB.prerelease); + if (prereleaseCompare !== 0) { + return prereleaseCompare < 0 ? -1 : 1; + } + } + + // Versions are equal + return 0; +} + +/** + * Check if version A is greater than or equal to version B + * @param versionA - Version to check + * @param versionB - Minimum version required + * @returns true if versionA >= versionB + */ +export function satisfiesMinimumVersion(versionA: string, versionB: string): boolean { + return compareVersions(versionA, versionB) >= 0; +} + +/** + * Validate if a string is a valid semantic version + * @param version - Version string to validate + * @returns true if valid semver format + */ +export function isValidVersion(version: string): boolean { + try { + parseVersion(version); + return true; + } catch { + return false; + } +} diff --git a/src/index.ts b/src/index.ts index 6c1d241..801c3a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,3 +16,4 @@ export * from "./time/index.js"; export * from "./stellar/index.js"; export * from "./transport/index.js"; export * from "./errors/index.js"; +export * from "./capabilities/index.js"; diff --git a/tests/capabilities.test.ts b/tests/capabilities.test.ts new file mode 100644 index 0000000..8932ca6 --- /dev/null +++ b/tests/capabilities.test.ts @@ -0,0 +1,515 @@ +import { describe, it, expect } from "vitest"; +import { + CapabilityRegistry, + InvalidCapabilityIdentifierError, + InvalidCapabilityVersionError, + DuplicateCapabilityError, + compareVersions, + satisfiesMinimumVersion, + isValidVersion, + parseVersion, +} from "../src/capabilities/index.js"; + +describe("CapabilityRegistry", () => { + describe("Valid capability registration", () => { + it("should register valid capability identifiers", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "membership.read", version: "1.0.0" }); + registry.register({ id: "access.check", version: "2.1.3" }); + registry.register({ id: "stellar.transaction", version: "1.0.0-alpha" }); + + expect(registry.hasCapability("membership.read")).toBe(true); + expect(registry.hasCapability("access.check")).toBe(true); + expect(registry.hasCapability("stellar.transaction")).toBe(true); + }); + + it("should register capabilities with valid semantic versions", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.0.0" }); + registry.register({ id: "test.capability2", version: "2.3.4" }); + registry.register({ id: "test.capability3", version: "0.1.0" }); + registry.register({ id: "test.capability4", version: "1.0.0-alpha" }); + registry.register({ id: "test.capability5", version: "1.0.0-beta.2" }); + + expect(registry.size()).toBe(5); + }); + }); + + describe("Invalid identifier rejection", () => { + it("should reject empty identifier", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "", version: "1.0.0" })).toThrow( + InvalidCapabilityIdentifierError + ); + }); + + it("should reject identifier with uppercase letters", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "Membership.read", version: "1.0.0" })).toThrow( + InvalidCapabilityIdentifierError + ); + }); + + it("should reject identifier with special characters", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "membership_read", version: "1.0.0" })).toThrow( + InvalidCapabilityIdentifierError + ); + }); + + it("should reject identifier starting with number", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "1membership.read", version: "1.0.0" })).toThrow( + InvalidCapabilityIdentifierError + ); + }); + + it("should reject identifier with consecutive dots", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "membership..read", version: "1.0.0" })).toThrow( + InvalidCapabilityIdentifierError + ); + }); + + it("should reject identifier with trailing dot", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "membership.read.", version: "1.0.0" })).toThrow( + InvalidCapabilityIdentifierError + ); + }); + + it("should reject identifier with leading dot", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: ".membership.read", version: "1.0.0" })).toThrow( + InvalidCapabilityIdentifierError + ); + }); + }); + + describe("Invalid version rejection", () => { + it("should reject empty version", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "test.capability", version: "" })).toThrow( + InvalidCapabilityVersionError + ); + }); + + it("should reject non-semver version", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "test.capability", version: "v1.0.0" })).toThrow( + InvalidCapabilityVersionError + ); + }); + + it("should reject version with only major", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "test.capability", version: "1" })).toThrow( + InvalidCapabilityVersionError + ); + }); + + it("should reject version with major.minor only", () => { + const registry = new CapabilityRegistry(); + + expect(() => registry.register({ id: "test.capability", version: "1.0" })).toThrow( + InvalidCapabilityVersionError + ); + }); + }); + + describe("Duplicate policy - error (default)", () => { + it("should reject identical duplicate definitions with error policy", () => { + const registry = new CapabilityRegistry({ duplicatePolicy: "error" }); + + registry.register({ id: "test.capability", version: "1.0.0" }); + + expect(() => registry.register({ id: "test.capability", version: "1.0.0" })).toThrow( + DuplicateCapabilityError + ); + }); + + it("should reject conflicting duplicate definitions", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.0.0" }); + + expect(() => registry.register({ id: "test.capability", version: "2.0.0" })).toThrow( + DuplicateCapabilityError + ); + }); + }); + + describe("Duplicate policy - ignore", () => { + it("should silently ignore identical duplicate definitions with ignore policy", () => { + const registry = new CapabilityRegistry({ duplicatePolicy: "ignore" }); + + registry.register({ id: "test.capability", version: "1.0.0" }); + registry.register({ id: "test.capability", version: "1.0.0" }); + + expect(registry.size()).toBe(1); + }); + + it("should still reject conflicting duplicate definitions with ignore policy", () => { + const registry = new CapabilityRegistry({ duplicatePolicy: "ignore" }); + + registry.register({ id: "test.capability", version: "1.0.0" }); + + expect(() => registry.register({ id: "test.capability", version: "2.0.0" })).toThrow( + DuplicateCapabilityError + ); + }); + }); + + describe("Duplicate policy - replace", () => { + it("should replace identical duplicate definitions with replace policy", () => { + const registry = new CapabilityRegistry({ duplicatePolicy: "replace" }); + + registry.register({ id: "test.capability", version: "1.0.0" }); + registry.register({ id: "test.capability", version: "1.0.0" }); + + expect(registry.size()).toBe(1); + expect(registry.getCapabilityVersion("test.capability")).toBe("1.0.0"); + }); + + it("should still reject conflicting duplicate definitions with replace policy", () => { + const registry = new CapabilityRegistry({ duplicatePolicy: "replace" }); + + registry.register({ id: "test.capability", version: "1.0.0" }); + + expect(() => registry.register({ id: "test.capability", version: "2.0.0" })).toThrow( + DuplicateCapabilityError + ); + }); + }); + + describe("Capability lookup", () => { + it("should return correct version for registered capability", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.2.3" }); + + expect(registry.getCapabilityVersion("test.capability")).toBe("1.2.3"); + }); + + it("should return undefined for unregistered capability", () => { + const registry = new CapabilityRegistry(); + + expect(registry.getCapabilityVersion("nonexistent")).toBeUndefined(); + }); + + it("should deterministically check capability existence", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.0.0" }); + + expect(registry.hasCapability("test.capability")).toBe(true); + expect(registry.hasCapability("test.capability")).toBe(true); + expect(registry.hasCapability("nonexistent")).toBe(false); + expect(registry.hasCapability("nonexistent")).toBe(false); + }); + }); + + describe("Capability listing with stable ordering", () => { + it("should list capabilities in alphabetical order", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "zebra.read", version: "1.0.0" }); + registry.register({ id: "alpha.check", version: "1.0.0" }); + registry.register({ id: "middle.write", version: "1.0.0" }); + + const capabilities = registry.listCapabilities(); + + expect(capabilities).toEqual(["alpha.check", "middle.write", "zebra.read"]); + }); + + it("should maintain stable ordering across multiple calls", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "c.capability", version: "1.0.0" }); + registry.register({ id: "a.capability", version: "1.0.0" }); + registry.register({ id: "b.capability", version: "1.0.0" }); + + const first = registry.listCapabilities(); + const second = registry.listCapabilities(); + const third = registry.listCapabilities(); + + expect(first).toEqual(second); + expect(second).toEqual(third); + }); + + it("should list definitions in alphabetical order", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "zebra.read", version: "1.0.0" }); + registry.register({ id: "alpha.check", version: "1.0.0" }); + registry.register({ id: "middle.write", version: "1.0.0" }); + + const definitions = registry.listDefinitions(); + + expect(definitions[0].id).toBe("alpha.check"); + expect(definitions[1].id).toBe("middle.write"); + expect(definitions[2].id).toBe("zebra.read"); + }); + }); + + describe("Immutability - caller input mutation protection", () => { + it("should not be affected by mutation of original definition", () => { + const registry = new CapabilityRegistry(); + + const definition = { id: "test.capability", version: "1.0.0" }; + registry.register(definition); + + // Mutate original + definition.id = "mutated.id"; + definition.version = "2.0.0"; + + expect(registry.hasCapability("test.capability")).toBe(true); + expect(registry.hasCapability("mutated.id")).toBe(false); + expect(registry.getCapabilityVersion("test.capability")).toBe("1.0.0"); + }); + + it("should not be affected by mutation of returned definitions", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.0.0" }); + + const definitions = registry.listDefinitions(); + definitions[0].id = "mutated.id"; + definitions[0].version = "2.0.0"; + + expect(registry.hasCapability("test.capability")).toBe(true); + expect(registry.getCapabilityVersion("test.capability")).toBe("1.0.0"); + }); + + it("should not be affected by mutation of returned capability list", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.0.0" }); + + const capabilities = registry.listCapabilities(); + const mutableCapabilities = capabilities as string[]; + mutableCapabilities.push("malicious.capability"); + mutableCapabilities[0] = "mutated.id"; + + expect(registry.hasCapability("test.capability")).toBe(true); + expect(registry.hasCapability("malicious.capability")).toBe(false); + expect(registry.hasCapability("mutated.id")).toBe(false); + }); + }); + + describe("Immutable snapshot creation", () => { + it("should create immutable snapshot", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.0.0" }); + + const snapshot = registry.createSnapshot(); + + expect(snapshot.capabilities.size).toBe(1); + expect(snapshot.orderedIds).toEqual(["test.capability"]); + }); + + it("should not be affected by registry changes after snapshot", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.0.0" }); + + const snapshot = registry.createSnapshot(); + + registry.register({ id: "new.capability", version: "2.0.0" }); + + expect(snapshot.capabilities.size).toBe(1); + expect(snapshot.orderedIds.length).toBe(1); + }); + + it("should restore registry from snapshot", () => { + const registry1 = new CapabilityRegistry(); + + registry1.register({ id: "test.capability", version: "1.0.0" }); + registry1.register({ id: "another.capability", version: "2.0.0" }); + + const snapshot = registry1.createSnapshot(); + + const registry2 = CapabilityRegistry.fromSnapshot(snapshot); + + expect(registry2.hasCapability("test.capability")).toBe(true); + expect(registry2.hasCapability("another.capability")).toBe(true); + expect(registry2.getCapabilityVersion("test.capability")).toBe("1.0.0"); + expect(registry2.listCapabilities()).toEqual(["another.capability", "test.capability"]); + }); + }); + + describe("Version checks", () => { + it("should correctly check minimum version satisfaction", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "2.0.0" }); + + expect(registry.satisfiesMinimumVersion("test.capability", "1.0.0")).toBe(true); + expect(registry.satisfiesMinimumVersion("test.capability", "2.0.0")).toBe(true); + expect(registry.satisfiesMinimumVersion("test.capability", "2.1.0")).toBe(false); + expect(registry.satisfiesMinimumVersion("test.capability", "3.0.0")).toBe(false); + }); + + it("should return false for non-existent capability", () => { + const registry = new CapabilityRegistry(); + + expect(registry.satisfiesMinimumVersion("nonexistent", "1.0.0")).toBe(false); + }); + + it("should handle prerelease versions", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.0.0-alpha" }); + + expect(registry.satisfiesMinimumVersion("test.capability", "1.0.0-alpha")).toBe(true); + expect(registry.satisfiesMinimumVersion("test.capability", "0.9.0")).toBe(true); + }); + }); + + describe("Batch registration", () => { + it("should register multiple capabilities in batch", () => { + const registry = new CapabilityRegistry(); + + const definitions = [ + { id: "capability.one", version: "1.0.0" }, + { id: "capability.two", version: "1.0.0" }, + { id: "capability.three", version: "1.0.0" }, + ]; + + registry.registerBatch(definitions); + + expect(registry.size()).toBe(3); + expect(registry.hasCapability("capability.one")).toBe(true); + expect(registry.hasCapability("capability.two")).toBe(true); + expect(registry.hasCapability("capability.three")).toBe(true); + }); + + it("should fail on first error in batch registration", () => { + const registry = new CapabilityRegistry(); + + const definitions = [ + { id: "valid.capability", version: "1.0.0" }, + { id: "INVALID.capability", version: "1.0.0" }, + { id: "another.valid", version: "1.0.0" }, + ]; + + expect(() => registry.registerBatch(definitions)).toThrow(InvalidCapabilityIdentifierError); + + // First capability should still be registered + expect(registry.hasCapability("valid.capability")).toBe(true); + expect(registry.hasCapability("another.valid")).toBe(false); + }); + }); + + describe("Clear functionality", () => { + it("should clear all registered capabilities", () => { + const registry = new CapabilityRegistry(); + + registry.register({ id: "test.capability", version: "1.0.0" }); + registry.register({ id: "another.capability", version: "1.0.0" }); + + expect(registry.size()).toBe(2); + + registry.clear(); + + expect(registry.size()).toBe(0); + expect(registry.hasCapability("test.capability")).toBe(false); + }); + }); +}); + +describe("Version utilities", () => { + describe("parseVersion", () => { + it("should parse valid semantic versions", () => { + const v1 = parseVersion("1.0.0"); + expect(v1.major).toBe(1); + expect(v1.minor).toBe(0); + expect(v1.patch).toBe(0); + + const v2 = parseVersion("2.0.0-alpha"); + expect(v2.major).toBe(2); + expect(v2.minor).toBe(0); + expect(v2.patch).toBe(0); + expect(v2.prerelease).toBe("alpha"); + + const v3 = parseVersion("1.2.3-beta.2+build.123"); + expect(v3.major).toBe(1); + expect(v3.minor).toBe(2); + expect(v3.patch).toBe(3); + expect(v3.prerelease).toBe("beta.2"); + expect(v3.build).toBe("build.123"); + }); + + it("should throw on invalid version format", () => { + expect(() => parseVersion("invalid")).toThrow(); + expect(() => parseVersion("1")).toThrow(); + expect(() => parseVersion("1.0")).toThrow(); + expect(() => parseVersion("v1.0.0")).toThrow(); + }); + }); + + describe("compareVersions", () => { + it("should compare versions correctly", () => { + expect(compareVersions("1.0.0", "1.0.0")).toBe(0); + expect(compareVersions("2.0.0", "1.0.0")).toBe(1); + expect(compareVersions("1.0.0", "2.0.0")).toBe(-1); + expect(compareVersions("1.2.0", "1.1.9")).toBe(1); + expect(compareVersions("1.0.1", "1.0.0")).toBe(1); + }); + + it("should handle prerelease versions", () => { + expect(compareVersions("1.0.0-alpha", "1.0.0")).toBe(-1); + expect(compareVersions("1.0.0", "1.0.0-alpha")).toBe(1); + expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBe(-1); + }); + + it("should compare prerelease lexicographically", () => { + expect(compareVersions("1.0.0-alpha.1", "1.0.0-alpha.2")).toBe(-1); + expect(compareVersions("1.0.0-alpha.2", "1.0.0-alpha.1")).toBe(1); + }); + }); + + describe("satisfiesMinimumVersion", () => { + it("should return true for versions meeting minimum", () => { + expect(satisfiesMinimumVersion("2.0.0", "1.0.0")).toBe(true); + expect(satisfiesMinimumVersion("1.0.0", "1.0.0")).toBe(true); + expect(satisfiesMinimumVersion("1.2.3", "1.2.0")).toBe(true); + }); + + it("should return false for versions below minimum", () => { + expect(satisfiesMinimumVersion("1.0.0", "2.0.0")).toBe(false); + expect(satisfiesMinimumVersion("1.1.9", "1.2.0")).toBe(false); + }); + }); + + describe("isValidVersion", () => { + it("should validate correct semantic versions", () => { + expect(isValidVersion("1.0.0")).toBe(true); + expect(isValidVersion("2.3.4")).toBe(true); + expect(isValidVersion("1.0.0-alpha")).toBe(true); + expect(isValidVersion("1.0.0-beta.2")).toBe(true); + expect(isValidVersion("1.0.0+build")).toBe(true); + }); + + it("should reject invalid versions", () => { + expect(isValidVersion("invalid")).toBe(false); + expect(isValidVersion("1")).toBe(false); + expect(isValidVersion("1.0")).toBe(false); + expect(isValidVersion("v1.0.0")).toBe(false); + expect(isValidVersion("")).toBe(false); + }); + }); +});