From cae3c32d514c710044730f1bdc6de367bbddb4a3 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 15:47:56 +0000 Subject: [PATCH] refactor: add AX config validation helpers Co-Authored-By: Virgil --- compact.go | 44 +++++++++++++++++++++++++++++++------------- compact_test.go | 26 ++++++++++++++++++++++++++ scope.go | 20 ++++++++++++++------ scope_test.go | 11 +++++++++++ store.go | 36 ++++++++++++++++++++++++++++++------ store_test.go | 39 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 150 insertions(+), 26 deletions(-) diff --git a/compact.go b/compact.go index e53b169..b229dc5 100644 --- a/compact.go +++ b/compact.go @@ -26,6 +26,31 @@ type CompactOptions struct { Format string } +// Usage example: `normalisedOptions := (store.CompactOptions{Before: time.Now().Add(-30 * 24 * time.Hour)}).Normalised()` +func (compactOptions CompactOptions) Normalised() CompactOptions { + if compactOptions.Output == "" { + compactOptions.Output = defaultArchiveOutputDirectory + } + if compactOptions.Format == "" { + compactOptions.Format = "gzip" + } + return compactOptions +} + +// Usage example: `if err := (store.CompactOptions{Before: time.Now().Add(-30 * 24 * time.Hour), Format: "gzip"}).Validate(); err != nil { return }` +func (compactOptions CompactOptions) Validate() error { + switch compactOptions.Format { + case "", "gzip", "zstd": + return nil + default: + return core.E( + "store.CompactOptions.Validate", + core.Concat(`format must be "gzip" or "zstd"; got `, compactOptions.Format), + nil, + ) + } +} + type compactArchiveEntry struct { journalEntryID int64 journalBucketName string @@ -44,20 +69,13 @@ func (storeInstance *Store) Compact(options CompactOptions) core.Result { return core.Result{Value: core.E("store.Compact", "ensure journal schema", err), OK: false} } - outputDirectory := options.Output - if outputDirectory == "" { - outputDirectory = defaultArchiveOutputDirectory - } - format := options.Format - if format == "" { - format = "gzip" - } - if format != "gzip" && format != "zstd" { - return core.Result{Value: core.E("store.Compact", core.Concat("unsupported archive format: ", format), nil), OK: false} + options = options.Normalised() + if err := options.Validate(); err != nil { + return core.Result{Value: core.E("store.Compact", "validate options", err), OK: false} } filesystem := (&core.Fs{}).NewUnrestricted() - if result := filesystem.EnsureDir(outputDirectory); !result.OK { + if result := filesystem.EnsureDir(options.Output); !result.OK { return core.Result{Value: core.E("store.Compact", "ensure archive directory", result.Value.(error)), OK: false} } @@ -92,7 +110,7 @@ func (storeInstance *Store) Compact(options CompactOptions) core.Result { return core.Result{Value: "", OK: true} } - outputPath := compactOutputPath(outputDirectory, format) + outputPath := compactOutputPath(options.Output, options.Format) archiveFileResult := filesystem.Create(outputPath) if !archiveFileResult.OK { return core.Result{Value: core.E("store.Compact", "create archive file", archiveFileResult.Value.(error)), OK: false} @@ -109,7 +127,7 @@ func (storeInstance *Store) Compact(options CompactOptions) core.Result { } }() - writer, err := archiveWriter(file, format) + writer, err := archiveWriter(file, options.Format) if err != nil { return core.Result{Value: err, OK: false} } diff --git a/compact_test.go b/compact_test.go index d54f805..0551994 100644 --- a/compact_test.go +++ b/compact_test.go @@ -183,3 +183,29 @@ func TestCompact_Compact_Good_DeterministicOrderingForSameTimestamp(t *testing.T require.True(t, unmarshalResult.OK, "archive line unmarshal failed: %v", unmarshalResult.Value) assert.Equal(t, "session-a", secondArchivedRow["measurement"]) } + +func TestCompact_CompactOptions_Good_Normalised(t *testing.T) { + options := (CompactOptions{ + Before: time.Now().Add(-24 * time.Hour), + }).Normalised() + + assert.Equal(t, defaultArchiveOutputDirectory, options.Output) + assert.Equal(t, "gzip", options.Format) +} + +func TestCompact_CompactOptions_Good_Validate(t *testing.T) { + err := (CompactOptions{ + Before: time.Now().Add(-24 * time.Hour), + Format: "zstd", + }).Validate() + require.NoError(t, err) +} + +func TestCompact_CompactOptions_Bad_ValidateUnsupportedFormat(t *testing.T) { + err := (CompactOptions{ + Before: time.Now().Add(-24 * time.Hour), + Format: "zip", + }).Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), `format must be "gzip" or "zstd"`) +} diff --git a/scope.go b/scope.go index 8e4c68e..593cd3b 100644 --- a/scope.go +++ b/scope.go @@ -22,6 +22,18 @@ type QuotaConfig struct { MaxGroups int } +// Usage example: `if err := (store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}).Validate(); err != nil { return }` +func (quotaConfig QuotaConfig) Validate() error { + if quotaConfig.MaxKeys < 0 || quotaConfig.MaxGroups < 0 { + return core.E( + "store.QuotaConfig.Validate", + core.Sprintf("quota values must be zero or positive; got MaxKeys=%d MaxGroups=%d", quotaConfig.MaxKeys, quotaConfig.MaxGroups), + nil, + ) + } + return nil +} + // Usage example: `scopedStore, err := store.NewScopedConfigured(storeInstance, store.ScopedStoreConfig{Namespace: "tenant-a", Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}}); if err != nil { return }; _ = scopedStore.Set("colour", "blue")` // ScopedStore keeps one namespace isolated behind helpers such as Set and // GetFrom so callers do not repeat the `tenant-a:` prefix manually. @@ -61,12 +73,8 @@ func (scopedConfig ScopedStoreConfig) Validate() error { nil, ) } - if scopedConfig.Quota.MaxKeys < 0 || scopedConfig.Quota.MaxGroups < 0 { - return core.E( - "store.ScopedStoreConfig.Validate", - core.Sprintf("quota values must be zero or positive; got MaxKeys=%d MaxGroups=%d", scopedConfig.Quota.MaxKeys, scopedConfig.Quota.MaxGroups), - nil, - ) + if err := scopedConfig.Quota.Validate(); err != nil { + return core.E("store.ScopedStoreConfig.Validate", "quota", err) } return nil } diff --git a/scope_test.go b/scope_test.go index 1b72c88..c54688e 100644 --- a/scope_test.go +++ b/scope_test.go @@ -126,6 +126,17 @@ func TestScope_NewScopedConfigured_Bad_InvalidNamespace(t *testing.T) { assert.Contains(t, err.Error(), "namespace") } +func TestScope_QuotaConfig_Good_Validate(t *testing.T) { + err := (QuotaConfig{MaxKeys: 4, MaxGroups: 2}).Validate() + require.NoError(t, err) +} + +func TestScope_QuotaConfig_Bad_ValidateNegativeValue(t *testing.T) { + err := (QuotaConfig{MaxKeys: -1, MaxGroups: 2}).Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "quota values must be zero or positive") +} + func TestScope_ScopedStoreConfig_Good_Validate(t *testing.T) { err := (ScopedStoreConfig{ Namespace: "tenant-a", diff --git a/store.go b/store.go index 360dfa7..775d5fa 100644 --- a/store.go +++ b/store.go @@ -55,12 +55,10 @@ func (storeConfig StoreConfig) Validate() error { nil, ) } - if storeConfig.Journal != (JournalConfiguration{}) && !storeConfig.Journal.isConfigured() { - return core.E( - "store.StoreConfig.Validate", - "journal configuration must include endpoint URL, organisation, and bucket name", - nil, - ) + if storeConfig.Journal != (JournalConfiguration{}) { + if err := storeConfig.Journal.Validate(); err != nil { + return core.E("store.StoreConfig.Validate", "journal config", err) + } } if storeConfig.PurgeInterval < 0 { return core.E("store.StoreConfig.Validate", "purge interval must be zero or positive", nil) @@ -79,6 +77,32 @@ type JournalConfiguration struct { BucketName string } +// Usage example: `if err := (store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086", Organisation: "core", BucketName: "events"}).Validate(); err != nil { return }` +func (journalConfig JournalConfiguration) Validate() error { + switch { + case journalConfig.EndpointURL == "": + return core.E( + "store.JournalConfiguration.Validate", + `endpoint URL is empty; use values like "http://127.0.0.1:8086"`, + nil, + ) + case journalConfig.Organisation == "": + return core.E( + "store.JournalConfiguration.Validate", + `organisation is empty; use values like "core"`, + nil, + ) + case journalConfig.BucketName == "": + return core.E( + "store.JournalConfiguration.Validate", + `bucket name is empty; use values like "events"`, + nil, + ) + default: + return nil + } +} + func (journalConfig JournalConfiguration) isConfigured() bool { return journalConfig.EndpointURL != "" && journalConfig.Organisation != "" && diff --git a/store_test.go b/store_test.go index a59094d..f74b6fe 100644 --- a/store_test.go +++ b/store_test.go @@ -141,6 +141,42 @@ func TestStore_JournalConfiguration_Good(t *testing.T) { }, config) } +func TestStore_JournalConfiguration_Good_Validate(t *testing.T) { + err := (JournalConfiguration{ + EndpointURL: "http://127.0.0.1:8086", + Organisation: "core", + BucketName: "events", + }).Validate() + require.NoError(t, err) +} + +func TestStore_JournalConfiguration_Bad_ValidateMissingEndpointURL(t *testing.T) { + err := (JournalConfiguration{ + Organisation: "core", + BucketName: "events", + }).Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "endpoint URL is empty") +} + +func TestStore_JournalConfiguration_Bad_ValidateMissingOrganisation(t *testing.T) { + err := (JournalConfiguration{ + EndpointURL: "http://127.0.0.1:8086", + BucketName: "events", + }).Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "organisation is empty") +} + +func TestStore_JournalConfiguration_Bad_ValidateMissingBucketName(t *testing.T) { + err := (JournalConfiguration{ + EndpointURL: "http://127.0.0.1:8086", + Organisation: "core", + }).Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "bucket name is empty") +} + func TestStore_JournalConfigured_Good(t *testing.T) { storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events")) require.NoError(t, err) @@ -165,7 +201,8 @@ func TestStore_NewConfigured_Bad_PartialJournalConfiguration(t *testing.T) { }, }) require.Error(t, err) - assert.Contains(t, err.Error(), "journal configuration must include endpoint URL, organisation, and bucket name") + assert.Contains(t, err.Error(), "journal config") + assert.Contains(t, err.Error(), "bucket name is empty") } func TestStore_StoreConfig_Good_Validate(t *testing.T) {