diff --git a/weed/s3api/s3lifecycle/scheduler/configload_test.go b/weed/s3api/s3lifecycle/scheduler/configload_test.go
new file mode 100644
index 000000000..5929ea2ba
--- /dev/null
+++ b/weed/s3api/s3lifecycle/scheduler/configload_test.go
@@ -0,0 +1,236 @@
+package scheduler
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
+ "github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle/engine"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Tests for LoadCompileInputs / IsBucketVersioned / AllActivePriorStates.
+// LoadCompileInputs is the bridge between the filer's bucket directory
+// and the engine snapshot the scheduler compiles every refresh —
+// pagination, parse-error surfacing, and the empty/missing-XML skip
+// semantics matter because a missed bucket silently disables lifecycle
+// for that prefix until the next refresh.
+
+const testBucketsRoot = "/buckets"
+
+// minimalLifecycleXML is the smallest XML that ParseCanonical accepts as
+// a non-empty rule set; the actual rule details are exercised by the
+// lifecycle_xml package's own tests.
+const minimalLifecycleXML = `
+
+ r1
+ Enabled
+ logs/
+ 30
+
+`
+
+func bucketEntry(name string, extended map[string][]byte) *filer_pb.Entry {
+ return dirEntry(name, extended)
+}
+
+func TestIsBucketVersioned_MissingExtendedReturnsFalse(t *testing.T) {
+ assert.False(t, IsBucketVersioned(&filer_pb.Entry{}))
+ assert.False(t, IsBucketVersioned(&filer_pb.Entry{Extended: map[string][]byte{}}))
+}
+
+func TestIsBucketVersioned_AcceptsEnabledAndSuspended(t *testing.T) {
+ // Casing and surrounding whitespace are normalized so the worker
+ // tolerates whatever casing the writer chose.
+ for _, raw := range []string{"Enabled", "ENABLED", "enabled", " enabled ", "Suspended", "suspended"} {
+ t.Run(raw, func(t *testing.T) {
+ e := &filer_pb.Entry{Extended: map[string][]byte{s3_constants.ExtVersioningKey: []byte(raw)}}
+ assert.True(t, IsBucketVersioned(e), "value %q must classify as versioned", raw)
+ })
+ }
+}
+
+func TestIsBucketVersioned_RejectsOtherValues(t *testing.T) {
+ for _, raw := range []string{"", "Disabled", "Off", "true", "1", "garbage"} {
+ t.Run(raw, func(t *testing.T) {
+ e := &filer_pb.Entry{Extended: map[string][]byte{s3_constants.ExtVersioningKey: []byte(raw)}}
+ assert.False(t, IsBucketVersioned(e), "value %q must NOT classify as versioned", raw)
+ })
+ }
+}
+
+func TestAllActivePriorStates_EmptyInputs(t *testing.T) {
+ assert.Empty(t, AllActivePriorStates(nil))
+ assert.Empty(t, AllActivePriorStates([]engine.CompileInput{}))
+}
+
+func TestAllActivePriorStates_SeedsAllActionsAsActive(t *testing.T) {
+ // Every (bucket, ruleHash, actionKind) tuple from the inputs must
+ // receive a PriorState with BootstrapComplete=true and
+ // Mode=ModeEventDriven so the scheduler dispatches matched events
+ // immediately rather than waiting for a per-action bootstrap walk.
+ rule := &s3lifecycle.Rule{
+ ID: "r1",
+ Status: s3lifecycle.StatusEnabled,
+ ExpirationDays: 30,
+ }
+ inputs := []engine.CompileInput{{Bucket: "b1", Rules: []*s3lifecycle.Rule{rule}}}
+ prior := AllActivePriorStates(inputs)
+
+ hash := s3lifecycle.RuleHash(rule)
+ kinds := s3lifecycle.RuleActionKinds(rule)
+ require.NotEmpty(t, kinds, "expiration-days rule must yield at least one action kind")
+ for _, kind := range kinds {
+ key := s3lifecycle.ActionKey{Bucket: "b1", RuleHash: hash, ActionKind: kind}
+ state, ok := prior[key]
+ require.True(t, ok, "prior state missing for action kind %v", kind)
+ assert.True(t, state.BootstrapComplete, "bootstrap complete for kind %v", kind)
+ assert.Equal(t, engine.ModeEventDriven, state.Mode, "mode for kind %v", kind)
+ }
+ assert.Len(t, prior, len(kinds), "exactly one prior-state entry per action kind")
+}
+
+func TestAllActivePriorStates_KeysSeparatedByBucket(t *testing.T) {
+ // Two buckets sharing identical rules must still get distinct
+ // PriorState entries, otherwise refresh would conflate their state.
+ rule := &s3lifecycle.Rule{ID: "r1", Status: s3lifecycle.StatusEnabled, ExpirationDays: 30}
+ inputs := []engine.CompileInput{
+ {Bucket: "alpha", Rules: []*s3lifecycle.Rule{rule}},
+ {Bucket: "beta", Rules: []*s3lifecycle.Rule{rule}},
+ }
+ prior := AllActivePriorStates(inputs)
+ hash := s3lifecycle.RuleHash(rule)
+ for _, kind := range s3lifecycle.RuleActionKinds(rule) {
+ assert.Contains(t, prior, s3lifecycle.ActionKey{Bucket: "alpha", RuleHash: hash, ActionKind: kind})
+ assert.Contains(t, prior, s3lifecycle.ActionKey{Bucket: "beta", RuleHash: hash, ActionKind: kind})
+ }
+}
+
+func TestLoadCompileInputs_EmptyBucketDir(t *testing.T) {
+ // No buckets at all -> no inputs, no parse errors, no transport error.
+ client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{testBucketsRoot: {}}}
+ inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot)
+ require.NoError(t, err)
+ assert.Empty(t, inputs)
+ assert.Empty(t, perr)
+}
+
+func TestLoadCompileInputs_FilesAtBucketLevelSkipped(t *testing.T) {
+ // A bare file at the buckets root isn't a bucket; the loader must
+ // skip it without touching the parser.
+ client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{
+ testBucketsRoot: {fileEntry("stray.txt")},
+ }}
+ inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot)
+ require.NoError(t, err)
+ assert.Empty(t, inputs)
+ assert.Empty(t, perr)
+}
+
+func TestLoadCompileInputs_BucketWithoutLifecycleConfigSkipped(t *testing.T) {
+ // Buckets that don't carry the lifecycle XML extended attribute are
+ // silently skipped — operators may have many buckets without rules.
+ client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{
+ testBucketsRoot: {bucketEntry("b1", nil)},
+ }}
+ inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot)
+ require.NoError(t, err)
+ assert.Empty(t, inputs)
+ assert.Empty(t, perr)
+}
+
+func TestLoadCompileInputs_BucketWithEmptyLifecycleXMLSkipped(t *testing.T) {
+ // Empty bytes under the lifecycle key shouldn't reach the parser.
+ client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{
+ testBucketsRoot: {bucketEntry("b1", map[string][]byte{
+ BucketLifecycleConfigurationXMLKey: nil,
+ })},
+ }}
+ inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot)
+ require.NoError(t, err)
+ assert.Empty(t, inputs)
+ assert.Empty(t, perr)
+}
+
+func TestLoadCompileInputs_ValidConfigBecomesInput(t *testing.T) {
+ client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{
+ testBucketsRoot: {bucketEntry("b1", map[string][]byte{
+ BucketLifecycleConfigurationXMLKey: []byte(minimalLifecycleXML),
+ })},
+ }}
+ inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot)
+ require.NoError(t, err)
+ assert.Empty(t, perr)
+ require.Len(t, inputs, 1)
+ assert.Equal(t, "b1", inputs[0].Bucket)
+ assert.False(t, inputs[0].Versioned, "no versioning attr -> not versioned")
+ assert.NotEmpty(t, inputs[0].Rules)
+}
+
+func TestLoadCompileInputs_VersioningAttrPropagates(t *testing.T) {
+ client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{
+ testBucketsRoot: {bucketEntry("b1", map[string][]byte{
+ BucketLifecycleConfigurationXMLKey: []byte(minimalLifecycleXML),
+ s3_constants.ExtVersioningKey: []byte("Enabled"),
+ })},
+ }}
+ inputs, perr, err := LoadCompileInputs(context.Background(), client, testBucketsRoot)
+ require.NoError(t, err)
+ assert.Empty(t, perr)
+ require.Len(t, inputs, 1)
+ assert.True(t, inputs[0].Versioned)
+}
+
+func TestLoadCompileInputs_MalformedXMLBecomesParseError(t *testing.T) {
+ // Malformed XML must not abort the whole walk — surfacing it as a
+ // ParseError lets operators see the bucket-level failure while
+ // other buckets keep loading.
+ client := &fakeFilerClient{tree: map[string][]*filer_pb.Entry{
+ testBucketsRoot: {
+ bucketEntry("bad", map[string][]byte{
+ BucketLifecycleConfigurationXMLKey: []byte("