Repository: oom-ai/oomstore
Pull request #822 (2021-12-27 05:50:37)

test: update tests
Previously I made `usename / password / database` configurable in oomplay which leads troubles that are hard to solve. In oomplay v0.4.0 I drop these features to make it more robust.


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8a95f488..61e4abf5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,7 +48,7 @@ jobs:
   test:
     runs-on: ubuntu-latest
     env:
-      oomplay-version: '0.3.2'
+      oomplay-version: '0.4.1'
     steps:
     - uses: actions/checkout@v2
     - uses: actions/setup-go@v2
diff --git a/Makefile b/Makefile
index 803f7bbe..0df7a683 100644
--- a/Makefile
+++ b/Makefile
@@ -58,6 +58,7 @@ oomagent: codegen
 
 .PHONY: test
 test: codegen
+	@oomplay init redis postgres mysql dynamodb cassandra
 	@go test -race -coverprofile=coverage.out -covermode=atomic ./...
 
 .PHONY: integration-test
@@ -69,18 +70,6 @@ integration-test: codegen
 lint:
 	@golangci-lint run
 
-.PHONY: start_playground
-start_playground:
-	@playground/start.sh '$(db)'
-
-.PHONY: stop_playground
-stop_playground:
-	@playground/stop.sh '$(db)'
-
-.PHONY: restart_playground
-restart_playground:
-	@playground/restart.sh '$(db)'
-
 .PHONY: clean
 clean:
 	$(MAKE) -C oomcli clean
diff --git a/internal/database/metadata/informer/feature.go b/internal/database/metadata/informer/feature.go
index 9cb4a05e..90efddb6 100644
--- a/internal/database/metadata/informer/feature.go
+++ b/internal/database/metadata/informer/feature.go
@@ -34,11 +34,11 @@ func (c *FeatureCache) List(opt metadata.ListFeatureOpt) types.FeatureList {
 	}
 
 	// filter names
-	if opt.FeatureFullNames != nil {
+	if opt.FeatureNames != nil {
 		var tmp types.FeatureList
-		for _, fullName := range *opt.FeatureFullNames {
+		for _, name := range *opt.FeatureNames {
 			if f := features.Find(func(f *types.Feature) bool {
-				return f.FullName == fullName
+				return f.Name == name
 			}); f != nil {
 				tmp = append(tmp, f)
 			}
diff --git a/internal/database/metadata/mock_metadata/store.go b/internal/database/metadata/mock_metadata/store.go
index 2ac445dd..276d592c 100644
--- a/internal/database/metadata/mock_metadata/store.go
+++ b/internal/database/metadata/mock_metadata/store.go
@@ -172,18 +172,18 @@ func (mr *MockStoreMockRecorder) GetFeature(ctx, id interface{}) *gomock.Call {
 }
 
 // GetFeatureByName mocks base method.
-func (m *MockStore) GetFeatureByName(ctx context.Context, fullName string) (*types.Feature, error) {
+func (m *MockStore) GetFeatureByName(ctx context.Context, name string) (*types.Feature, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetFeatureByName", ctx, fullName)
+	ret := m.ctrl.Call(m, "GetFeatureByName", ctx, name)
 	ret0, _ := ret[0].(*types.Feature)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
 
 // GetFeatureByName indicates an expected call of GetFeatureByName.
-func (mr *MockStoreMockRecorder) GetFeatureByName(ctx, fullName interface{}) *gomock.Call {
+func (mr *MockStoreMockRecorder) GetFeatureByName(ctx, name interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureByName", reflect.TypeOf((*MockStore)(nil).GetFeatureByName), ctx, fullName)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureByName", reflect.TypeOf((*MockStore)(nil).GetFeatureByName), ctx, name)
 }
 
 // GetGroup mocks base method.
@@ -534,18 +534,18 @@ func (mr *MockDBStoreMockRecorder) GetFeature(ctx, id interface{}) *gomock.Call
 }
 
 // GetFeatureByName mocks base method.
-func (m *MockDBStore) GetFeatureByName(ctx context.Context, fullName string) (*types.Feature, error) {
+func (m *MockDBStore) GetFeatureByName(ctx context.Context, name string) (*types.Feature, error) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetFeatureByName", ctx, fullName)
+	ret := m.ctrl.Call(m, "GetFeatureByName", ctx, name)
 	ret0, _ := ret[0].(*types.Feature)
 	ret1, _ := ret[1].(error)
 	return ret0, ret1
 }
 
 // GetFeatureByName indicates an expected call of GetFeatureByName.
-func (mr *MockDBStoreMockRecorder) GetFeatureByName(ctx, fullName interface{}) *gomock.Call {
+func (mr *MockDBStoreMockRecorder) GetFeatureByName(ctx, name interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureByName", reflect.TypeOf((*MockDBStore)(nil).GetFeatureByName), ctx, fullName)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeatureByName", reflect.TypeOf((*MockDBStore)(nil).GetFeatureByName), ctx, name)
 }
 
 // GetGroup mocks base method.
diff --git a/internal/database/metadata/mysql/group.go b/internal/database/metadata/mysql/group.go
index 4d255059..46d083c1 100644
--- a/internal/database/metadata/mysql/group.go
+++ b/internal/database/metadata/mysql/group.go
@@ -6,13 +6,12 @@ import (
 
 	"github.com/go-sql-driver/mysql"
 	"github.com/oom-ai/oomstore/internal/database/metadata"
-	"github.com/oom-ai/oomstore/pkg/errdefs"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
 func createGroup(ctx context.Context, sqlxCtx metadata.SqlxContext, opt metadata.CreateGroupOpt) (int, error) {
-	if opt.Category != types.CategoryBatch && opt.Category != types.CategoryStream {
-		return 0, errdefs.InvalidAttribute(fmt.Errorf("illegal category '%s', should be either 'stream' or 'batch'", opt.Category))
+	if opt.Category != types.BatchFeatureCategory && opt.Category != types.StreamFeatureCategory {
+		return 0, fmt.Errorf("illegal category '%s', should be either 'stream' or 'batch'", opt.Category)
 	}
 
 	query := "INSERT INTO feature_group(name, entity_id, category, description) VALUES(?, ?, ?, ?)"
diff --git a/internal/database/metadata/mysql/schema.go b/internal/database/metadata/mysql/schema.go
index bf764e33..fbd654e3 100644
--- a/internal/database/metadata/mysql/schema.go
+++ b/internal/database/metadata/mysql/schema.go
@@ -11,8 +11,7 @@ var META_TABLE_SCHEMAS = map[string]string{
 			description   	VARCHAR(128)	DEFAULT '',
 			create_time   	TIMESTAMP    	NOT	NULL DEFAULT CURRENT_TIMESTAMP,
 			modify_time   	TIMESTAMP    	NOT	NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-			UNIQUE (group_id, name),
-			UNIQUE (full_name)
+			UNIQUE (name)
 		);
 		`,
 	"feature_group": `
diff --git a/internal/database/metadata/postgres/group.go b/internal/database/metadata/postgres/group.go
index a1e5ccda..27b7c43a 100644
--- a/internal/database/metadata/postgres/group.go
+++ b/internal/database/metadata/postgres/group.go
@@ -7,13 +7,12 @@ import (
 	"github.com/jackc/pgerrcode"
 	"github.com/lib/pq"
 	"github.com/oom-ai/oomstore/internal/database/metadata"
-	"github.com/oom-ai/oomstore/pkg/errdefs"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
 func createGroup(ctx context.Context, sqlxCtx metadata.SqlxContext, opt metadata.CreateGroupOpt) (int, error) {
-	if opt.Category != types.CategoryBatch && opt.Category != types.CategoryStream {
-		return 0, errdefs.InvalidAttribute(fmt.Errorf("illegal category '%s', should be either 'stream' or 'batch'", opt.Category))
+	if opt.Category != types.BatchFeatureCategory && opt.Category != types.StreamFeatureCategory {
+		return 0, fmt.Errorf("illegal category '%s', should be either 'stream' or 'batch'", opt.Category)
 	}
 	var groupID int
 	query := "insert into feature_group(name, entity_id, category, description) values($1, $2, $3, $4) returning id"
diff --git a/internal/database/metadata/postgres/schema.go b/internal/database/metadata/postgres/schema.go
index 0f0afc37..036a0cfb 100644
--- a/internal/database/metadata/postgres/schema.go
+++ b/internal/database/metadata/postgres/schema.go
@@ -29,8 +29,7 @@ var META_TABLE_SCHEMAS = map[string]string{
 			description 	VARCHAR(128) 	DEFAULT '',
 			create_time   	TIMESTAMP    	NOT NULL DEFAULT CURRENT_TIMESTAMP,
 			modify_time   	TIMESTAMP    	NOT NULL DEFAULT CURRENT_TIMESTAMP,
-			UNIQUE (group_id, name),
-			UNIQUE (full_name)
+			UNIQUE (name)
 		);
 		COMMENT ON COLUMN feature.value_type    IS 'data type of feature value';
 		COMMENT ON COLUMN feature.full_name		IS '<group_name>:<feature_name>';
diff --git a/internal/database/metadata/sqlite/group.go b/internal/database/metadata/sqlite/group.go
index 57e6603d..c0afacb6 100644
--- a/internal/database/metadata/sqlite/group.go
+++ b/internal/database/metadata/sqlite/group.go
@@ -6,13 +6,12 @@ import (
 
 	"github.com/mattn/go-sqlite3"
 	"github.com/oom-ai/oomstore/internal/database/metadata"
-	"github.com/oom-ai/oomstore/pkg/errdefs"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
 func createGroup(ctx context.Context, sqlxCtx metadata.SqlxContext, opt metadata.CreateGroupOpt) (int, error) {
-	if opt.Category != types.CategoryBatch && opt.Category != types.CategoryStream {
-		return 0, errdefs.InvalidAttribute(fmt.Errorf("illegal category '%s', should be either 'stream' or 'batch'", opt.Category))
+	if opt.Category != types.BatchFeatureCategory && opt.Category != types.StreamFeatureCategory {
+		return 0, fmt.Errorf("illegal category '%s', should be either 'stream' or 'batch'", opt.Category)
 	}
 
 	query := "INSERT INTO feature_group(name, entity_id, category, description) VALUES(?, ?, ?, ?)"
diff --git a/internal/database/metadata/sqlite/schema.go b/internal/database/metadata/sqlite/schema.go
index bb3c495c..7a45d341 100644
--- a/internal/database/metadata/sqlite/schema.go
+++ b/internal/database/metadata/sqlite/schema.go
@@ -19,8 +19,7 @@ var META_TABLE_SCHEMAS = map[string]string{
 			description   	VARCHAR(128)	DEFAULT '',
 			create_time   	TIMESTAMP    	NOT	NULL DEFAULT CURRENT_TIMESTAMP,
 			modify_time   	TIMESTAMP    	NOT	NULL DEFAULT CURRENT_TIMESTAMP,
-			UNIQUE (group_id, name),
-			UNIQUE (full_name),
+			UNIQUE (name),
 			FOREIGN KEY (group_id) REFERENCES feature_group(id)
 		);
 		`,
diff --git a/internal/database/metadata/sqlutil/feature.go b/internal/database/metadata/sqlutil/feature.go
index 7d8a9e22..895e7a2f 100644
--- a/internal/database/metadata/sqlutil/feature.go
+++ b/internal/database/metadata/sqlutil/feature.go
@@ -55,17 +55,17 @@ func GetFeature(ctx context.Context, sqlxCtx metadata.SqlxContext, id int) (*typ
 	return &feature, nil
 }
 
-func GetFeatureByName(ctx context.Context, sqlxCtx metadata.SqlxContext, fullName string) (*types.Feature, error) {
+func GetFeatureByName(ctx context.Context, sqlxCtx metadata.SqlxContext, name string) (*types.Feature, error) {
 	var (
 		feature types.Feature
 		group   *types.Group
 		err     error
 	)
 
-	query := `SELECT * FROM feature WHERE full_name = ?`
-	if err := sqlxCtx.GetContext(ctx, &feature, sqlxCtx.Rebind(query), fullName); err != nil {
+	query := `SELECT * FROM feature WHERE name = ?`
+	if err := sqlxCtx.GetContext(ctx, &feature, sqlxCtx.Rebind(query), name); err != nil {
 		if err == sql.ErrNoRows {
-			return nil, errdefs.NotFound(fmt.Errorf("feature %s not found", fullName))
+			return nil, errdefs.NotFound(fmt.Errorf("feature %s not found", name))
 		}
 		return nil, err
 	}
@@ -134,11 +134,11 @@ func buildListFeatureCond(opt metadata.ListFeatureOpt) ([]string, []interface{},
 		in["id"] = *opt.FeatureIDs
 	}
 
-	if opt.FeatureFullNames != nil {
-		if len(*opt.FeatureFullNames) == 0 {
+	if opt.FeatureNames != nil {
+		if len(*opt.FeatureNames) == 0 {
 			return []string{"false"}, nil, nil
 		}
-		in["full_name"] = *opt.FeatureFullNames
+		in["name"] = *opt.FeatureNames
 	}
 	return dbutil.BuildConditions(and, in)
 }
diff --git a/internal/database/metadata/store.go b/internal/database/metadata/store.go
index 88747c09..b456b3df 100644
--- a/internal/database/metadata/store.go
+++ b/internal/database/metadata/store.go
@@ -29,7 +29,7 @@ type DBStore interface {
 	CreateFeature(ctx context.Context, opt CreateFeatureOpt) (int, error)
 	UpdateFeature(ctx context.Context, opt UpdateFeatureOpt) error
 	GetFeature(ctx context.Context, id int) (*types.Feature, error)
-	GetFeatureByName(ctx context.Context, fullName string) (*types.Feature, error)
+	GetFeatureByName(ctx context.Context, name string) (*types.Feature, error)
 	ListFeature(ctx context.Context, opt ListFeatureOpt) (types.FeatureList, error)
 
 	// feature group
diff --git a/internal/database/metadata/types.go b/internal/database/metadata/types.go
index 0a2c863a..a33add34 100644
--- a/internal/database/metadata/types.go
+++ b/internal/database/metadata/types.go
@@ -25,7 +25,7 @@ type CreateGroupOpt struct {
 	GroupName   string
 	EntityID    int
 	Description string
-	Category    types.Category
+	Category    string
 }
 
 type CreateRevisionOpt struct {
@@ -60,8 +60,8 @@ type UpdateRevisionOpt struct {
 }
 
 type ListFeatureOpt struct {
-	EntityID         *int
-	GroupID          *int
-	FeatureIDs       *[]int
-	FeatureFullNames *[]string
+	EntityID     *int
+	GroupID      *int
+	FeatureIDs   *[]int
+	FeatureNames *[]string
 }
diff --git a/oomcli/cmd/edit_feature.go b/oomcli/cmd/edit_feature.go
index 154bd7e4..4891c77d 100644
--- a/oomcli/cmd/edit_feature.go
+++ b/oomcli/cmd/edit_feature.go
@@ -29,7 +29,7 @@ var editFeatureCmd = &cobra.Command{
 		if len(args) > 1 {
 			log.Fatalf("argument at most one, got %d", len(args))
 		} else if len(args) == 1 {
-			editFeatureOpt.FeatureFullNames = &[]string{args[0]}
+			editFeatureOpt.FeatureNames = &[]string{args[0]}
 		}
 
 	},
diff --git a/oomcli/cmd/get_meta_feature.go b/oomcli/cmd/get_meta_feature.go
index 2d37c89b..2c15d320 100644
--- a/oomcli/cmd/get_meta_feature.go
+++ b/oomcli/cmd/get_meta_feature.go
@@ -28,7 +28,7 @@ var getMetaFeatureCmd = &cobra.Command{
 			getMetaFeatureOpt.GroupName = nil
 		}
 		if len(args) == 1 {
-			getMetaFeatureOpt.FeatureFullNames = &[]string{args[0]}
+			getMetaFeatureOpt.FeatureNames = &[]string{args[0]}
 		}
 	},
 	Run: func(cmd *cobra.Command, args []string) {
@@ -61,8 +61,8 @@ func queryFeatures(ctx context.Context, oomStore *oomstore.OomStore, opt types.L
 		return nil, fmt.Errorf("failed getting features, error %v\n", err)
 	}
 
-	if opt.FeatureFullNames != nil && len(features) == 0 {
-		return nil, fmt.Errorf("feature '%s' not found", (*opt.FeatureFullNames)[0])
+	if opt.FeatureNames != nil && len(features) == 0 {
+		return nil, fmt.Errorf("feature '%s' not found", (*opt.FeatureNames)[0])
 	}
 
 	return features, nil
diff --git a/oomcli/cmd/register_batch_feature.go b/oomcli/cmd/register_batch_feature.go
index 2e27360b..00a2a5a2 100644
--- a/oomcli/cmd/register_batch_feature.go
+++ b/oomcli/cmd/register_batch_feature.go
@@ -28,7 +28,7 @@ var registerBatchFeatureCmd = &cobra.Command{
 		oomStore := mustOpenOomStore(ctx, oomStoreCfg)
 		defer oomStore.Close()
 
-		if _, err := oomStore.CreateFeature(ctx, registerBatchFeatureOpt); err != nil {
+		if _, err := oomStore.CreateBatchFeature(ctx, registerBatchFeatureOpt); err != nil {
 			log.Fatalf("failed registering new feature: %v\n", err)
 		}
 	},
diff --git a/oomcli/cmd/types.go b/oomcli/cmd/types.go
index b86ee879..e928a6b8 100644
--- a/oomcli/cmd/types.go
+++ b/oomcli/cmd/types.go
@@ -33,7 +33,7 @@ type FlattenFeature struct {
 	Name        string          `oomcli:"NAME"`
 	Group       string          `oomcli:"GROUP"`
 	Entity      string          `oomcli:"ENTITY"`
-	Category    types.Category  `oomcli:"CATEGORY"`
+	Category    string          `oomcli:"CATEGORY"`
 	ValueType   types.ValueType `oomcli:"VALUE-TYPE"`
 	Description string          `oomcli:"DESCRIPTION,truncate"`
 
diff --git a/oomcli/cmd/update_feature.go b/oomcli/cmd/update_feature.go
index b83d3ade..6b32fc3c 100644
--- a/oomcli/cmd/update_feature.go
+++ b/oomcli/cmd/update_feature.go
@@ -15,7 +15,7 @@ var updateFeatureCmd = &cobra.Command{
 	Short: "Update a particular feature",
 	Args:  cobra.ExactArgs(1),
 	PreRun: func(cmd *cobra.Command, args []string) {
-		updateFeatureOpt.FeatureFullName = args[0]
+		updateFeatureOpt.FeatureName = args[0]
 		if !cmd.Flags().Changed("description") {
 			updateFeatureOpt.NewDescription = nil
 		}
@@ -26,7 +26,7 @@ var updateFeatureCmd = &cobra.Command{
 		defer oomStore.Close()
 
 		if err := oomStore.UpdateFeature(ctx, updateFeatureOpt); err != nil {
-			log.Fatalf("failed to update feature %s, err %v\n", updateFeatureOpt.FeatureFullName, err)
+			log.Fatalf("failed to update feature %s, err %v\n", updateFeatureOpt.FeatureName, err)
 		}
 	},
 }
diff --git a/pkg/oomstore/apply.go b/pkg/oomstore/apply.go
index 99017dd2..0420c807 100644
--- a/pkg/oomstore/apply.go
+++ b/pkg/oomstore/apply.go
@@ -31,7 +31,7 @@ func (s *OomStore) Apply(ctx context.Context, opt apply.ApplyOpt) error {
 
 		// apply group
 		for _, group := range stage.NewGroups {
-			group.Category = types.CategoryBatch
+			group.Category = types.BatchFeatureCategory
 			if err := s.applyGroup(c, tx, group); err != nil {
 				return err
 			}
@@ -130,7 +130,6 @@ func (s *OomStore) applyFeature(ctx context.Context, tx metadata.DBStore, newFea
 		}
 		_, err = tx.CreateFeature(ctx, metadata.CreateFeatureOpt{
 			FeatureName: newFeature.Name,
-			FullName:    fmt.Sprintf("%s.%s", group.Name, newFeature.Name),
 			GroupID:     group.ID,
 			Description: newFeature.Description,
 			ValueType:   valueType,
@@ -242,7 +241,7 @@ func buildApplyStage(ctx context.Context, opt apply.ApplyOpt) (*apply.ApplyStage
 
 					for _, group := range entity.BatchFeatures {
 						group.Name = group.Group
-						group.Category = types.CategoryBatch
+						group.Category = types.BatchFeatureCategory
 						stage.NewGroups = append(stage.NewGroups, buildApplyGroup(group, entity.Name))
 
 						for _, feature := range group.Features {
diff --git a/pkg/oomstore/export.go b/pkg/oomstore/export.go
index 3f8807bf..50f3d47c 100644
--- a/pkg/oomstore/export.go
+++ b/pkg/oomstore/export.go
@@ -36,12 +36,8 @@ func (s *OomStore) ChannelExport(ctx context.Context, opt types.ChannelExportOpt
 			GroupName: &revision.Group.Name,
 		})
 	} else {
-		fullNames := make([]string, 0, len(opt.FeatureNames))
-		for _, name := range opt.FeatureNames {
-			fullNames = append(fullNames, fmt.Sprintf("%s.%s", revision.Group.Name, name))
-		}
 		features, err = s.ListFeature(ctx, types.ListFeatureOpt{
-			FeatureFullNames: &fullNames,
+			FeatureNames: &opt.FeatureNames,
 		})
 	}
 	if err != nil {
diff --git a/pkg/oomstore/feature.go b/pkg/oomstore/feature.go
index 780ca650..eb5c0ec7 100644
--- a/pkg/oomstore/feature.go
+++ b/pkg/oomstore/feature.go
@@ -13,15 +13,15 @@ func (s *OomStore) GetFeature(ctx context.Context, id int) (*types.Feature, erro
 	return s.metadata.GetFeature(ctx, id)
 }
 
-// Get metadata of a feature by full name.
-func (s *OomStore) GetFeatureByName(ctx context.Context, fullName string) (*types.Feature, error) {
-	return s.metadata.GetFeatureByName(ctx, fullName)
+// Get metadata of a feature by name.
+func (s *OomStore) GetFeatureByName(ctx context.Context, name string) (*types.Feature, error) {
+	return s.metadata.GetFeatureByName(ctx, name)
 }
 
 // List metadata of features meeting particular criteria.
 func (s *OomStore) ListFeature(ctx context.Context, opt types.ListFeatureOpt) (types.FeatureList, error) {
 	metadataOpt := metadata.ListFeatureOpt{
-		FeatureFullNames: opt.FeatureFullNames,
+		FeatureNames: opt.FeatureNames,
 	}
 	if opt.EntityName != nil {
 		entity, err := s.metadata.GetEntityByName(ctx, *opt.EntityName)
@@ -42,7 +42,7 @@ func (s *OomStore) ListFeature(ctx context.Context, opt types.ListFeatureOpt) (t
 
 // Update metadata of a feature.
 func (s *OomStore) UpdateFeature(ctx context.Context, opt types.UpdateFeatureOpt) error {
-	feature, err := s.metadata.GetFeatureByName(ctx, opt.FeatureFullName)
+	feature, err := s.metadata.GetFeatureByName(ctx, opt.FeatureName)
 	if err != nil {
 		return err
 	}
@@ -52,16 +52,19 @@ func (s *OomStore) UpdateFeature(ctx context.Context, opt types.UpdateFeatureOpt
 	})
 }
 
-// Create metadata of a feature.
-func (s *OomStore) CreateFeature(ctx context.Context, opt types.CreateFeatureOpt) (int, error) {
+// Create metadata of a batch feature.
+func (s *OomStore) CreateBatchFeature(ctx context.Context, opt types.CreateFeatureOpt) (int, error) {
 	group, err := s.metadata.GetGroupByName(ctx, opt.GroupName)
 	if err != nil {
 		return 0, err
 	}
+	if group.Category != types.BatchFeatureCategory {
+		return 0, fmt.Errorf("expected batch feature group, got %s feature group", group.Category)
+	}
 
 	return s.metadata.CreateFeature(ctx, metadata.CreateFeatureOpt{
 		FeatureName: opt.FeatureName,
-		FullName:    fmt.Sprintf("%s.%s", group.Name, opt.FeatureName),
+		FullName:    fmt.Sprintf("%s:%s", group.Name, opt.FeatureName),
 		GroupID:     group.ID,
 		ValueType:   opt.ValueType,
 		Description: opt.Description,
diff --git a/pkg/oomstore/group.go b/pkg/oomstore/group.go
index b4633268..e818c691 100644
--- a/pkg/oomstore/group.go
+++ b/pkg/oomstore/group.go
@@ -13,12 +13,13 @@ func (s *OomStore) CreateGroup(ctx context.Context, opt types.CreateGroupOpt) (i
 	if err != nil {
 		return 0, err
 	}
-
 	return s.metadata.CreateGroup(ctx, metadata.CreateGroupOpt{
 		GroupName:   opt.GroupName,
 		EntityID:    entity.ID,
 		Description: opt.Description,
-		Category:    opt.Category,
+		// Via the oomstore API, we can only create a batch feature group
+		// So we hardcode the category to be batch
+		Category: types.BatchFeatureCategory,
 	})
 }
 
diff --git a/pkg/oomstore/join.go b/pkg/oomstore/join.go
index a578e1ab..25985076 100644
--- a/pkg/oomstore/join.go
+++ b/pkg/oomstore/join.go
@@ -20,14 +20,14 @@ import (
 // Currently, this API only supports batch features.
 func (s *OomStore) ChannelJoin(ctx context.Context, opt types.ChannelJoinOpt) (*types.JoinResult, error) {
 	features, err := s.metadata.ListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureFullNames: &opt.FeatureNames,
+		FeatureNames: &opt.FeatureNames,
 	})
 	if err != nil {
 		return nil, err
 	}
 
 	features = features.Filter(func(f *types.Feature) bool {
-		return f.Group.Category == types.CategoryBatch
+		return f.Group.Category == types.BatchFeatureCategory
 	})
 	if len(features) == 0 {
 		return nil, nil
diff --git a/pkg/oomstore/online_query.go b/pkg/oomstore/online_query.go
index 8050df4a..58ff4a98 100644
--- a/pkg/oomstore/online_query.go
+++ b/pkg/oomstore/online_query.go
@@ -13,7 +13,7 @@ import (
 // Get online features of a particular entity instance.
 func (s *OomStore) OnlineGet(ctx context.Context, opt types.OnlineGetOpt) (*types.FeatureValues, error) {
 	features := s.metadata.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureFullNames: &opt.FeatureNames,
+		FeatureNames: &opt.FeatureNames,
 	}).Filter(func(f *types.Feature) bool {
 		return f.Group.OnlineRevisionID != nil
 	})
@@ -58,7 +58,7 @@ func (s *OomStore) OnlineGet(ctx context.Context, opt types.OnlineGetOpt) (*type
 // Get online features of multiple entity instances.
 func (s *OomStore) OnlineMultiGet(ctx context.Context, opt types.OnlineMultiGetOpt) (map[string]*types.FeatureValues, error) {
 	features := s.metadata.CacheListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureFullNames: &opt.FeatureNames,
+		FeatureNames: &opt.FeatureNames,
 	})
 
 	features = features.Filter(func(f *types.Feature) bool {
diff --git a/pkg/oomstore/types/apply/apply.go b/pkg/oomstore/types/apply/apply.go
index 801efed7..eea35f35 100644
--- a/pkg/oomstore/types/apply/apply.go
+++ b/pkg/oomstore/types/apply/apply.go
@@ -73,13 +73,13 @@ func FromFeatureList(features types.FeatureList) FeatureItems {
 }
 
 type Group struct {
-	Kind        string         `mapstructure:"kind" yaml:"kind,omitempty"`
-	Group       string         `mapstructure:"group" yaml:"group,omitempty"`
-	Name        string         `mapstructure:"name" yaml:"name,omitempty"`
-	EntityName  string         `mapstructure:"entity-name" yaml:"entity-name,omitempty"`
-	Category    types.Category `mapstructure:"category" yaml:"category,omitempty"`
-	Description string         `mapstructure:"description" yaml:"description"`
-	Features    []Feature      `mapstructure:"features" yaml:"features,omitempty"`
+	Kind        string    `mapstructure:"kind" yaml:"kind,omitempty"`
+	Group       string    `mapstructure:"group" yaml:"group,omitempty"`
+	Name        string    `mapstructure:"name" yaml:"name,omitempty"`
+	EntityName  string    `mapstructure:"entity-name" yaml:"entity-name,omitempty"`
+	Category    string    `mapstructure:"category" yaml:"category,omitempty"`
+	Description string    `mapstructure:"description" yaml:"description"`
+	Features    []Feature `mapstructure:"features" yaml:"features,omitempty"`
 }
 
 type GroupItems struct {
diff --git a/pkg/oomstore/types/feature.go b/pkg/oomstore/types/feature.go
index cc2f3c34..e22f44b0 100644
--- a/pkg/oomstore/types/feature.go
+++ b/pkg/oomstore/types/feature.go
@@ -60,13 +60,6 @@ func (l *FeatureList) Names() (names []string) {
 	return
 }
 
-func (l *FeatureList) FullNames() (fullNames []string) {
-	for _, f := range *l {
-		fullNames = append(fullNames, f.FullName)
-	}
-	return
-}
-
 func (l *FeatureList) IDs() (ids []int) {
 	for _, f := range *l {
 		ids = append(ids, f.ID)
diff --git a/pkg/oomstore/types/group.go b/pkg/oomstore/types/group.go
index 8d966a60..1b2a27e3 100644
--- a/pkg/oomstore/types/group.go
+++ b/pkg/oomstore/types/group.go
@@ -5,9 +5,9 @@ import (
 )
 
 type Group struct {
-	ID       int      `db:"id"`
-	Name     string   `db:"name"`
-	Category Category `db:"category"`
+	ID       int    `db:"id"`
+	Name     string `db:"name"`
+	Category string `db:"category"`
 
 	Description string    `db:"description"`
 	CreateTime  time.Time `db:"create_time"`
diff --git a/pkg/oomstore/types/options.go b/pkg/oomstore/types/options.go
index c638d76e..02092cde 100644
--- a/pkg/oomstore/types/options.go
+++ b/pkg/oomstore/types/options.go
@@ -8,14 +8,14 @@ type CreateFeatureOpt struct {
 }
 
 type ListFeatureOpt struct {
-	EntityName       *string
-	GroupName        *string
-	FeatureFullNames *[]string
+	EntityName   *string
+	GroupName    *string
+	FeatureNames *[]string
 }
 
 type UpdateFeatureOpt struct {
-	FeatureFullName string
-	NewDescription  *string
+	FeatureName    string
+	NewDescription *string
 }
 
 type CreateEntityOpt struct {
@@ -27,7 +27,6 @@ type CreateEntityOpt struct {
 type CreateGroupOpt struct {
 	GroupName   string
 	EntityName  string
-	Category    Category
 	Description string
 }
 
diff --git a/pkg/oomstore/types/types.go b/pkg/oomstore/types/types.go
index 49ab7d0a..a4805fb2 100644
--- a/pkg/oomstore/types/types.go
+++ b/pkg/oomstore/types/types.go
@@ -1,17 +1,10 @@
 package types
 
-type Category string
-
 const (
-	CategoryBatch  Category = "batch"
-	CategoryStream Category = "stream"
+	BatchFeatureCategory  = "batch"
+	StreamFeatureCategory = "stream"
 )
 
-// In order for cast.ToString to correctly resolve Category, Category needs to implement the interface String
-func (c Category) String() string {
-	return string(c)
-}
-
 type ExportRecord []interface{}
 
 func (r ExportRecord) EntityKey() string {
diff --git a/playground/restart.sh b/playground/restart.sh
deleted file mode 100755
index d10efa16..00000000
--- a/playground/restart.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-SDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) && cd "$SDIR" || exit 1
-
-./stop.sh  "$@" || true
-./start.sh "$@"
diff --git a/playground/start.sh b/playground/start.sh
deleted file mode 100755
index 3810f732..00000000
--- a/playground/start.sh
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-SDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) && cd "$SDIR" || exit 1
-source ./util.sh
-
-[ "$(docker ps -q -f name="$CONTAINER_NAME")" ] && info <<< "$CONTAINER_NAME already running" && exit
-
-case $DB in
-    postgres)
-        docker run --rm -d --name "$CONTAINER_NAME" \
-            --health-cmd='pg_isready' \
-            -e POSTGRES_PASSWORD=postgres \
-            -e POSTGRES_USER=postgres \
-            -p 5432:5432 \
-            postgres:14.0-alpine 2>&1 | info
-        docker exec "$CONTAINER_NAME" sh -c 'while ! pg_isready; do sleep 1; done' 2>&1 |info
-        info <<< "username: postgres"
-        info <<< "password: postgres"
-        ;;
-    mysql)
-        docker run --rm -d --name "$CONTAINER_NAME" \
-            -e MYSQL_ALLOW_EMPTY_PASSWORD=1 \
-            -p 3306:3306 \
-            mysql:5.7 | info
-        docker exec "$CONTAINER_NAME" sh -c 'while ! mysqladmin ping -h localhost; do sleep 1; done' 2>&1 | info
-        info <<< "username: root"
-        info <<< "password:"
-        ;;
-    *)
-        usage && exit 1
-esac
diff --git a/playground/stop.sh b/playground/stop.sh
deleted file mode 100755
index fda372fa..00000000
--- a/playground/stop.sh
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-SDIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) && cd "$SDIR" || exit 1
-source ./util.sh
-
-[ "$(docker ps -q -f name="$CONTAINER_NAME")" ] || { info <<< "$CONTAINER_NAME not running" && exit; }
-
-info <<< "$(docker stop "$CONTAINER_NAME") stoped"
diff --git a/playground/util.sh b/playground/util.sh
deleted file mode 100644
index 7c5abe8e..00000000
--- a/playground/util.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/usr/bin/env bash
-info() {
-    while IFS='' read -r line || [[ -n "$line" ]]; do
-        printf "%b[info]%b %s\n" '\e[0;32m\033[1m' '\e[0m' "$line" >&2;
-    done
-}
-erro() {
-    while IFS='' read -r line || [[ -n "$line" ]]; do
-        printf "%b[erro]%b %s\n" '\e[0;31m\033[1m' '\e[0m' "$line" >&2;
-    done
-}
-
-usage() { erro <<< "Usage: $(basename "$0") [postgres|mysql]"; }
-
-DB=${1:-postgres}
-CONTAINER_NAME="oomstore_playground_$DB"


Test patch:
diff --git a/internal/database/metadata/mysql/db_test.go b/internal/database/metadata/mysql/db_test.go
index dcc07bf9..fbfe7ce2 100644
--- a/internal/database/metadata/mysql/db_test.go
+++ b/internal/database/metadata/mysql/db_test.go
@@ -24,6 +24,7 @@ func init() {
 func prepareStore(t *testing.T) (context.Context, metadata.Store) {
 	ctx := context.Background()
 	opt := runtime_mysql.GetOpt(DATABASE)
+
 	if err := mysql.CreateDatabase(ctx, *opt); err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/database/metadata/postgres/db_test.go b/internal/database/metadata/postgres/db_test.go
index dc9fa550..2af3c021 100644
--- a/internal/database/metadata/postgres/db_test.go
+++ b/internal/database/metadata/postgres/db_test.go
@@ -24,10 +24,11 @@ func init() {
 func prepareStore(t *testing.T) (context.Context, metadata.Store) {
 	ctx := context.Background()
 
-	opt := runtime_pg.GetOpt(DATABASE)
+	opt := runtime_pg.RootOpt(DATABASE)
 	if err := postgres.CreateDatabase(ctx, *opt); err != nil {
 		t.Fatal(err)
 	}
+
 	store, err := postgres.Open(ctx, opt)
 	if err != nil {
 		t.Fatal(err)
@@ -40,7 +41,7 @@ func TestCreateDatabase(t *testing.T) {
 	t.Cleanup(runtime_pg.DestroyStore(DATABASE))
 
 	ctx := context.Background()
-	opt := runtime_pg.GetOpt(DATABASE)
+	opt := runtime_pg.RootOpt(DATABASE)
 	if err := postgres.CreateDatabase(ctx, *opt); err != nil {
 		t.Fatal(err)
 	}
diff --git a/internal/database/metadata/test_impl/feature.go b/internal/database/metadata/test_impl/feature.go
index 2a9b7c08..77eceb7e 100644
--- a/internal/database/metadata/test_impl/feature.go
+++ b/internal/database/metadata/test_impl/feature.go
@@ -27,7 +27,7 @@ func prepareEntityAndGroup(t *testing.T, ctx context.Context, store metadata.Sto
 		GroupName:   "device_info",
 		EntityID:    entityID,
 		Description: "description",
-		Category:    types.CategoryBatch,
+		Category:    types.BatchFeatureCategory,
 	})
 	require.NoError(t, err)
 	require.NoError(t, store.Refresh())
@@ -146,7 +146,6 @@ func TestGetFeatureByName(t *testing.T, prepareStore PrepareStoreFn, destroyStor
 
 	_, err := store.CreateFeature(ctx, metadata.CreateFeatureOpt{
 		FeatureName: "phone",
-		FullName:    "device_info.phone",
 		GroupID:     groupID,
 		Description: "description",
 		ValueType:   types.String,
@@ -158,12 +157,11 @@ func TestGetFeatureByName(t *testing.T, prepareStore PrepareStoreFn, destroyStor
 	assert.EqualError(t, err, "feature p not found")
 
 	// case 2: correct feature name, return feature `phone`
-	feature, err := store.GetFeatureByName(ctx, "device_info.phone")
+	feature, err := store.GetFeatureByName(ctx, "phone")
 	assert.NoError(t, err)
 	expected := &types.Feature{
 		ID:          1,
 		Name:        "phone",
-		FullName:    "device_info.phone",
 		ValueType:   types.String,
 		Description: "description",
 		GroupID:     1,
@@ -185,7 +183,6 @@ func TestCacheListFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStor
 
 	featureID, err := store.CreateFeature(ctx, metadata.CreateFeatureOpt{
 		FeatureName: "phone",
-		FullName:    "device_info.phone",
 		GroupID:     groupID,
 		Description: "description",
 		ValueType:   types.String,
@@ -229,13 +226,6 @@ func TestCacheListFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStor
 		EntityID: &entityID,
 	})
 	assert.Equal(t, 1, len(features))
-
-	// case 8: list features by FeatureFullNames
-	features, err = store.ListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureFullNames: &[]string{"device_info.phone"},
-	})
-	assert.NoError(t, err)
-	assert.Equal(t, 1, len(features))
 }
 
 func TestListFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStore DestroyStoreFn) {
@@ -252,7 +242,6 @@ func TestListFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStore Des
 
 	featureID, err := store.CreateFeature(ctx, metadata.CreateFeatureOpt{
 		FeatureName: "phone",
-		FullName:    "device_info.phone",
 		GroupID:     groupID,
 		Description: "description",
 		ValueType:   types.String,
@@ -301,13 +290,6 @@ func TestListFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStore Des
 	})
 	assert.NoError(t, err)
 	assert.Equal(t, 1, len(features))
-
-	// case 8: list features by FeatureFullNames
-	features, err = store.ListFeature(ctx, metadata.ListFeatureOpt{
-		FeatureFullNames: &[]string{"device_info.phone"},
-	})
-	assert.NoError(t, err)
-	assert.Equal(t, 1, len(features))
 }
 
 func TestUpdateFeature(t *testing.T, prepareStore PrepareStoreFn, destroyStore DestroyStoreFn) {
diff --git a/internal/database/metadata/test_impl/group.go b/internal/database/metadata/test_impl/group.go
index 493a820d..feb9edb1 100644
--- a/internal/database/metadata/test_impl/group.go
+++ b/internal/database/metadata/test_impl/group.go
@@ -35,7 +35,7 @@ func TestGetGroup(t *testing.T, prepareStore PrepareStoreFn, destroyStore Destro
 		GroupName:   "device_info",
 		EntityID:    entityID,
 		Description: "description",
-		Category:    types.CategoryBatch,
+		Category:    types.BatchFeatureCategory,
 	}
 	id, err := store.CreateGroup(ctx, opt)
 	require.NoError(t, err)
@@ -74,19 +74,19 @@ func TestListGroup(t *testing.T, prepareStore PrepareStoreFn, destroyStore Destr
 		GroupName:   "device_info",
 		EntityID:    deviceEntityID,
 		Description: "description",
-		Category:    types.CategoryBatch,
+		Category:    types.BatchFeatureCategory,
 	}
 	userBaseOpt := metadata.CreateGroupOpt{
 		GroupName:   "user_info",
 		EntityID:    userEntityID,
 		Description: "description",
-		Category:    types.CategoryBatch,
+		Category:    types.BatchFeatureCategory,
 	}
 	userBehaviorOpt := metadata.CreateGroupOpt{
 		GroupName:   "user_profile",
 		EntityID:    userEntityID,
 		Description: "description",
-		Category:    types.CategoryBatch,
+		Category:    types.BatchFeatureCategory,
 	}
 	deviceGroupID, err := store.CreateGroup(ctx, deviceOpt)
 	require.NoError(t, err)
@@ -137,7 +137,7 @@ func TestCreateGroup(t *testing.T, prepareStore PrepareStoreFn, destroyStore Des
 		GroupName:   "device_info",
 		EntityID:    entityID,
 		Description: "description",
-		Category:    types.CategoryBatch,
+		Category:    types.BatchFeatureCategory,
 	}
 
 	// create successfully
@@ -167,7 +167,7 @@ func TestUpdateGroup(t *testing.T, prepareStore PrepareStoreFn, destroyStore Des
 		GroupName:   "device_info",
 		EntityID:    entityID,
 		Description: "description",
-		Category:    types.CategoryBatch,
+		Category:    types.BatchFeatureCategory,
 	}
 	groupID, err := store.CreateGroup(ctx, opt)
 	require.NoError(t, err)
diff --git a/internal/database/metadata/test_impl/revision.go b/internal/database/metadata/test_impl/revision.go
index d88929f2..b4838991 100644
--- a/internal/database/metadata/test_impl/revision.go
+++ b/internal/database/metadata/test_impl/revision.go
@@ -336,7 +336,7 @@ func prepareRevisions(t *testing.T, ctx context.Context, store metadata.Store) (
 		GroupName:   "device_info",
 		EntityID:    entityID,
 		Description: "description",
-		Category:    types.CategoryBatch,
+		Category:    types.BatchFeatureCategory,
 	})
 	require.NoError(t, err)
 	require.NoError(t, store.Refresh())
diff --git a/internal/database/offline/mysql/store_test.go b/internal/database/offline/mysql/store_test.go
index 9a5ecdf0..5f1ea698 100644
--- a/internal/database/offline/mysql/store_test.go
+++ b/internal/database/offline/mysql/store_test.go
@@ -2,7 +2,6 @@ package mysql_test
 
 import (
 	"context"
-	"fmt"
 	"strings"
 	"testing"
 
@@ -20,30 +19,14 @@ func init() {
 }
 
 func prepareStore(t *testing.T) (context.Context, offline.Store) {
-	ctx := context.Background()
-	opt := runtime_mysql.GetOpt(DATABASE)
-	db, err := dbutil.OpenMysqlDB(
-		opt.Host,
-		opt.Port,
-		opt.User,
-		opt.Password,
-		"",
-	)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if _, err := db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE %s", DATABASE)); err != nil {
-		t.Fatal(err)
-	}
-	db.Close()
+	runtime_mysql.CreateDatabase(DATABASE)
 
-	store, err := mysql.Open(opt)
+	store, err := mysql.Open(runtime_mysql.GetOpt(DATABASE))
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	return ctx, store
+	return context.Background(), store
 }
 
 func TestPing(t *testing.T) {
diff --git a/internal/database/offline/postgres/store_test.go b/internal/database/offline/postgres/store_test.go
index 85fbd835..a7364ba0 100644
--- a/internal/database/offline/postgres/store_test.go
+++ b/internal/database/offline/postgres/store_test.go
@@ -2,7 +2,6 @@ package postgres_test
 
 import (
 	"context"
-	"fmt"
 	"strings"
 	"testing"
 
@@ -20,21 +19,14 @@ func init() {
 }
 
 func prepareStore(t *testing.T) (context.Context, offline.Store) {
-	ctx, db := runtime_pg.PrepareDB(t, DATABASE)
-	opt := runtime_pg.GetOpt(DATABASE)
+	runtime_pg.CreateDatabase(DATABASE)
 
-	_, err := db.ExecContext(context.Background(), fmt.Sprintf("CREATE DATABASE %s", opt.Database))
+	store, err := postgres.Open(runtime_pg.GetOpt(DATABASE))
 	if err != nil {
 		t.Fatal(err)
 	}
-	db.Close()
 
-	store, err := postgres.Open(opt)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	return ctx, store
+	return context.Background(), store
 }
 
 func TestPing(t *testing.T) {
diff --git a/internal/database/online/mysql/store_test.go b/internal/database/online/mysql/store_test.go
index 4cb7679f..49fbd65c 100644
--- a/internal/database/online/mysql/store_test.go
+++ b/internal/database/online/mysql/store_test.go
@@ -2,7 +2,6 @@ package mysql_test
 
 import (
 	"context"
-	"fmt"
 	"strings"
 	"testing"
 
@@ -20,20 +19,14 @@ func init() {
 }
 
 func prepareStore(t *testing.T) (context.Context, online.Store) {
-	ctx, db := runtime_mysql.PrepareDB(t, DATABASE)
-	opt := runtime_mysql.GetOpt(DATABASE)
+	runtime_mysql.CreateDatabase(DATABASE)
 
-	if _, err := db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE %s", opt.Database)); err != nil {
-		t.Fatal(err)
-	}
-	db.Close()
-
-	store, err := mysql.Open(opt)
+	store, err := mysql.Open(runtime_mysql.GetOpt(DATABASE))
 	if err != nil {
 		t.Fatal(err)
 	}
 
-	return ctx, store
+	return context.Background(), store
 }
 
 func TestOpen(t *testing.T) {
diff --git a/internal/database/online/postgres/store_test.go b/internal/database/online/postgres/store_test.go
index 8431f956..2c146836 100644
--- a/internal/database/online/postgres/store_test.go
+++ b/internal/database/online/postgres/store_test.go
@@ -2,7 +2,6 @@ package postgres_test
 
 import (
 	"context"
-	"fmt"
 	"strings"
 	"testing"
 
@@ -20,21 +19,14 @@ func init() {
 }
 
 func prepareStore(t *testing.T) (context.Context, online.Store) {
-	ctx, db := runtime_pg.PrepareDB(t, DATABASE)
-	opt := runtime_pg.GetOpt(DATABASE)
+	runtime_pg.CreateDatabase(DATABASE)
 
-	_, err := db.ExecContext(context.Background(), fmt.Sprintf("CREATE DATABASE %s", opt.Database))
+	store, err := postgres.Open(runtime_pg.GetOpt(DATABASE))
 	if err != nil {
 		t.Fatal(err)
 	}
-	db.Close()
 
-	store, err := postgres.Open(opt)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	return ctx, store
+	return context.Background(), store
 }
 
 func TestOpen(t *testing.T) {
diff --git a/internal/database/test/runtime_cassandra/cassandra.go b/internal/database/test/runtime_cassandra/cassandra.go
index 2b65d661..770f19f8 100644
--- a/internal/database/test/runtime_cassandra/cassandra.go
+++ b/internal/database/test/runtime_cassandra/cassandra.go
@@ -30,24 +30,16 @@ func PrepareDB() (context.Context, *gocql.Session) {
 }
 
 func init() {
-	// "dummy" db will not actually be used during testing
-	opt := GetOpt("dummy")
-	if out, err := exec.Command(
-		"oomplay", "init", "cassandra",
-		"--port",
-		"9042",
-		"--keyspace",
-		opt.KeySpace,
-	).CombinedOutput(); err != nil {
+	if out, err := exec.Command("oomplay", "init", "cassandra").CombinedOutput(); err != nil {
 		panic(fmt.Sprintf("oomplay failed with error: %v, output: %v", err, out))
 	}
 }
 
 func GetOpt(keySpace string) *types.CassandraOpt {
 	return &types.CassandraOpt{
-		Hosts:    []string{"127.0.0.1:9042"},
-		User:     "test",
-		Password: "test",
+		Hosts:    []string{"127.0.0.1:29042"},
+		User:     "oomplay",
+		Password: "oomplay",
 		KeySpace: keySpace,
 	}
 }
diff --git a/internal/database/test/runtime_dynamodb/dynamodb.go b/internal/database/test/runtime_dynamodb/dynamodb.go
index ce13184b..650955a1 100644
--- a/internal/database/test/runtime_dynamodb/dynamodb.go
+++ b/internal/database/test/runtime_dynamodb/dynamodb.go
@@ -11,10 +11,7 @@ import (
 )
 
 func init() {
-	if out, err := exec.Command(
-		"oomplay", "init", "dynamodb",
-		"--port", "4566",
-	).CombinedOutput(); err != nil {
+	if out, err := exec.Command("oomplay", "init", "dynamodb").CombinedOutput(); err != nil {
 		panic(fmt.Sprintf("oomplay failed with error: %v, output: %s", err, out))
 	}
 }
@@ -30,7 +27,7 @@ func PrepareDB(t *testing.T) (context.Context, *dynamodb.DB) {
 func GetOpt() *types.DynamoDBOpt {
 	return &types.DynamoDBOpt{
 		Region:          ".",
-		EndpointURL:     "http://localhost:4566",
+		EndpointURL:     "http://localhost:24566",
 		AccessKeyID:     ".",
 		SecretAccessKey: ".",
 		SessionToken:    ".",
diff --git a/internal/database/test/runtime_mysql/mysql.go b/internal/database/test/runtime_mysql/mysql.go
index 196ec9cc..f3ee4ffc 100644
--- a/internal/database/test/runtime_mysql/mysql.go
+++ b/internal/database/test/runtime_mysql/mysql.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"os/exec"
-	"testing"
 
 	"github.com/jmoiron/sqlx"
 
@@ -12,31 +11,24 @@ import (
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
-func PrepareDB(t *testing.T, database string) (context.Context, *sqlx.DB) {
-	db, err := prepareDB(database)
-	if err != nil {
-		t.Fatal(err)
+func init() {
+	if out, err := exec.Command("oomplay", "init", "mysql").CombinedOutput(); err != nil {
+		panic(fmt.Sprintf("oomplay failed with error: %v, output: %s", err, out))
 	}
-	return context.Background(), db
 }
 
-func prepareDB(database string) (*sqlx.DB, error) {
-	opt := GetOpt(database)
-	return dbutil.OpenMysqlDB(
-		opt.Host,
-		opt.Port,
-		opt.User,
-		opt.Password,
-		"",
-	)
+func CreateDatabase(database string) {
+	db := rootDB()
+	defer db.Close()
+
+	if _, err := db.ExecContext(context.Background(), fmt.Sprintf("CREATE DATABASE %s", database)); err != nil {
+		panic(err)
+	}
 }
 
 func DestroyStore(database string) func() {
 	return func() {
-		db, err := prepareDB(database)
-		if err != nil {
-			panic(err)
-		}
+		db := rootDB()
 		defer db.Close()
 
 		if _, err := db.ExecContext(context.Background(),
@@ -47,26 +39,20 @@ func DestroyStore(database string) func() {
 	}
 }
 
-func init() {
-	// "dummy" db will not actually be used during testing
-	opt := GetOpt("dummy")
-	if out, err := exec.Command(
-		"oomplay", "init", "mysql",
-		"--port", opt.Port,
-		"--user", opt.User,
-		"--password", opt.Password,
-		"--database", opt.Database,
-	).CombinedOutput(); err != nil {
-		panic(fmt.Sprintf("oomplay failed with error: %v, output: %s", err, out))
-	}
-}
-
 func GetOpt(database string) *types.MySQLOpt {
 	return &types.MySQLOpt{
 		Host:     "127.0.0.1",
-		Port:     "3306",
-		User:     "test",
-		Password: "test",
+		Port:     "23306",
+		User:     "oomplay",
+		Password: "oomplay",
 		Database: database,
 	}
 }
+
+func rootDB() *sqlx.DB {
+	if db, err := dbutil.OpenMysqlDB("127.0.0.1", "23306", "root", "", ""); err != nil {
+		panic(err)
+	} else {
+		return db
+	}
+}
diff --git a/internal/database/test/runtime_pg/postgres.go b/internal/database/test/runtime_pg/postgres.go
index 02697344..ad46951c 100644
--- a/internal/database/test/runtime_pg/postgres.go
+++ b/internal/database/test/runtime_pg/postgres.go
@@ -4,69 +4,69 @@ import (
 	"context"
 	"fmt"
 	"os/exec"
-	"testing"
 
 	"github.com/jmoiron/sqlx"
 	"github.com/oom-ai/oomstore/internal/database/dbutil"
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
-func PrepareDB(t *testing.T, database string) (context.Context, *sqlx.DB) {
-	db, err := prepareDB(database)
-	if err != nil {
-		t.Fatal(err)
+func init() {
+	if out, err := exec.Command("oomplay", "init", "postgres").CombinedOutput(); err != nil {
+		panic(fmt.Sprintf("oomplay failed with error: %v, output: %s", err, out))
 	}
-	return context.Background(), db
 }
 
-func prepareDB(database string) (*sqlx.DB, error) {
-	opt := GetOpt(database)
-	return dbutil.OpenPostgresDB(
-		opt.Host,
-		opt.Port,
-		opt.User,
-		opt.Password,
-		// Postgres creates a database with the same name of the user.
-		// We need to connect using this database to drop other databases.
-		opt.User,
-	)
+func CreateDatabase(database string) {
+	db := rootDB()
+	defer db.Close()
+
+	if _, err := db.ExecContext(context.Background(), fmt.Sprintf("CREATE DATABASE %s", database)); err != nil {
+		panic(err)
+	}
 }
 
 func DestroyStore(database string) func() {
 	return func() {
-		db, err := prepareDB(database)
-		if err != nil {
-			panic(err)
-		}
+		db := rootDB()
 		defer db.Close()
 
-		if _, err := db.ExecContext(context.Background(),
-			fmt.Sprintf("DROP DATABASE IF EXISTS %s", database)); err != nil {
+		if _, err := db.ExecContext(context.Background(), fmt.Sprintf("DROP DATABASE IF EXISTS %s", database)); err != nil {
 			panic(err)
 		}
 	}
 }
 
-func init() {
-	// "dummy" db will not actually be used during testing
-	opt := GetOpt("dummy")
-	if out, err := exec.Command(
-		"oomplay", "init", "postgres",
-		"--port", opt.Port,
-		"--user", opt.User,
-		"--password", opt.Password,
-		"--database", opt.Database,
-	).CombinedOutput(); err != nil {
-		panic(fmt.Sprintf("oomplay failed with error: %v, output: %s", err, out))
+func GetOpt(database string) *types.PostgresOpt {
+	return &types.PostgresOpt{
+		Host:     "127.0.0.1",
+		Port:     "25432",
+		User:     "oomplay",
+		Password: "oomplay",
+		Database: database,
 	}
 }
 
-func GetOpt(database string) *types.PostgresOpt {
+func RootOpt(database string) *types.PostgresOpt {
 	return &types.PostgresOpt{
 		Host:     "127.0.0.1",
-		Port:     "5432",
-		User:     "test",
-		Password: "test",
+		Port:     "25432",
+		User:     "postgres",
+		Password: "postgres",
 		Database: database,
 	}
 }
+
+func rootDB() *sqlx.DB {
+	opt := RootOpt("postgres")
+	if db, err := dbutil.OpenPostgresDB(
+		opt.Host,
+		opt.Port,
+		opt.User,
+		opt.Password,
+		opt.Database,
+	); err != nil {
+		panic(err)
+	} else {
+		return db
+	}
+}
diff --git a/internal/database/test/runtime_redis/redis.go b/internal/database/test/runtime_redis/redis.go
index 1d7fbe39..55542958 100644
--- a/internal/database/test/runtime_redis/redis.go
+++ b/internal/database/test/runtime_redis/redis.go
@@ -3,28 +3,19 @@ package runtime_redis
 import (
 	"fmt"
 	"os/exec"
-	"strconv"
 
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
 func init() {
-	opt := GetOpt()
-	if out, err := exec.Command(
-		"oomplay", "init", "redis",
-		"--port", opt.Port,
-		"--password", opt.Password,
-		"--database", strconv.Itoa(opt.Database),
-	).CombinedOutput(); err != nil {
+	if out, err := exec.Command("oomplay", "init", "redis").CombinedOutput(); err != nil {
 		panic(fmt.Sprintf("oomplay failed with error: %v, output: %s", err, out))
 	}
 }
 
 func GetOpt() *types.RedisOpt {
 	return &types.RedisOpt{
-		Host:     "localhost",
-		Port:     "6379",
-		Password: "test",
-		Database: 0,
+		Host: "localhost",
+		Port: "26379",
 	}
 }
diff --git a/oomagent/test/config.yaml b/oomagent/test/config.yaml
index d5cc6488..60742000 100644
--- a/oomagent/test/config.yaml
+++ b/oomagent/test/config.yaml
@@ -1,22 +1,21 @@
 online-store:
   redis:
     host: 127.0.0.1
-    port: 6379
-    password: test
+    port: 26379
     database: 0
 
 offline-store:
   postgres:
     host: 127.0.0.1
-    port: 5432
-    user: test
-    password: test
-    database: oomstore_test
+    port: 25432
+    user: oomplay
+    password: oomplay
+    database: oomplay
 
 metadata-store:
   postgres:
     host: 127.0.0.1
-    port: 5432
-    user: test
-    password: test
-    database: oomstore_test
+    port: 25432
+    user: oomplay
+    password: oomplay
+    database: oomplay
diff --git a/oomagent/test/util.sh b/oomagent/test/util.sh
index 400a58f7..9d0fdf03 100644
--- a/oomagent/test/util.sh
+++ b/oomagent/test/util.sh
@@ -84,7 +84,7 @@ import_sample() {
 
 prepare_store() {
     info "initialize feature store"
-    oomplay init -f "$OOMCLI_CONFIG"
+    oomplay init redis postgres
 
     # initialize feature store
     oomcli init
diff --git a/oomcli/test/config.yaml b/oomcli/test/config.yaml
index d5cc6488..60742000 100644
--- a/oomcli/test/config.yaml
+++ b/oomcli/test/config.yaml
@@ -1,22 +1,21 @@
 online-store:
   redis:
     host: 127.0.0.1
-    port: 6379
-    password: test
+    port: 26379
     database: 0
 
 offline-store:
   postgres:
     host: 127.0.0.1
-    port: 5432
-    user: test
-    password: test
-    database: oomstore_test
+    port: 25432
+    user: oomplay
+    password: oomplay
+    database: oomplay
 
 metadata-store:
   postgres:
     host: 127.0.0.1
-    port: 5432
-    user: test
-    password: test
-    database: oomstore_test
+    port: 25432
+    user: oomplay
+    password: oomplay
+    database: oomplay
diff --git a/oomcli/test/test_apply_entity.sh b/oomcli/test/test_apply_entity.sh
index e764f496..912dddad 100755
--- a/oomcli/test/test_apply_entity.sh
+++ b/oomcli/test/test_apply_entity.sh
@@ -55,7 +55,7 @@ ID,NAME,GROUP,ENTITY,CATEGORY,VALUE-TYPE,DESCRIPTION
 4,gender,user,user,batch,int64,description
 '
     feature_actual=$(oomcli get meta feature -o csv)
-    assert_eq "apply_single_complex_entity: check feature" "$(sort <<< "$feature_expected")" "$(sort <<< "$feature_actual")"
+    assert_eq "apply_single_complex_entity(: check feature" "$(sort <<< "$feature_expected")" "$(sort <<< "$feature_actual")"
 }
 
 apply_multiple_files_of_entity() {
diff --git a/oomcli/test/test_get_meta_feature.sh b/oomcli/test/test_get_meta_feature.sh
index 5f215439..4f240ba2 100755
--- a/oomcli/test/test_get_meta_feature.sh
+++ b/oomcli/test/test_get_meta_feature.sh
@@ -31,12 +31,12 @@ expected='
 ID,NAME,GROUP,ENTITY,CATEGORY,VALUE-TYPE,DESCRIPTION,ONLINE-REVISION-ID
 2,model,phone,device,batch,string,model,<NULL>
 '
-actual=$(oomcli get meta feature phone.model -o csv --wide)
+actual=$(oomcli get meta feature model -o csv --wide)
 ignore_time() { cut -d ',' -f 1-8 <<<"$1"; }
 assert_eq "$case" "$(sort <<< "$expected")" "$(ignore_time "$actual" | sort)"
 
 
-case='oomcli get meta feature in yaml: one feature'
+case='oomcli get meta feature: one feature'
 expected='
 kind: Feature
 name: model
@@ -45,7 +45,7 @@ value-type: string
 description: model
 '
 
-actual=$(oomcli get meta feature phone.model -o yaml)
+actual=$(oomcli get meta feature model -o yaml)
 assert_eq "$case" "$expected" "$actual"
 
 case='oomcli get meta feature: multiple features'
diff --git a/oomcli/test/util.sh b/oomcli/test/util.sh
index cdcff82b..8b7bdb3f 100644
--- a/oomcli/test/util.sh
+++ b/oomcli/test/util.sh
@@ -79,7 +79,7 @@ init_store() {
     info "initialize feature store"
 
     # initialize backend database
-    oomplay init -f "$OOMCLI_CONFIG"
+    oomplay init redis postgres
 
     # initialize feature store
     oomcli init
diff --git a/pkg/oomstore/feature_test.go b/pkg/oomstore/feature_test.go
index fe75406a..422e6a44 100644
--- a/pkg/oomstore/feature_test.go
+++ b/pkg/oomstore/feature_test.go
@@ -15,7 +15,7 @@ import (
 	"github.com/oom-ai/oomstore/pkg/oomstore/types"
 )
 
-func TestCreateFeature(t *testing.T) {
+func TestCreateBatchFeature(t *testing.T) {
 	ctrl := gomock.NewController(t)
 	defer ctrl.Finish()
 	ctx := context.Background()
@@ -29,6 +29,7 @@ func TestCreateFeature(t *testing.T) {
 		opt         types.CreateFeatureOpt
 		valueType   types.ValueType
 		group       types.Group
+		expectError bool
 	}{
 		{
 			description: "create batch feature, succeed",
@@ -41,11 +42,12 @@ func TestCreateFeature(t *testing.T) {
 			group: types.Group{
 				ID:       1,
 				Name:     "device_info",
-				Category: types.CategoryBatch,
+				Category: types.BatchFeatureCategory,
 			},
+			expectError: false,
 		},
 		{
-			description: "create stream feature, succeed",
+			description: "create stream feature, fail",
 			opt: types.CreateFeatureOpt{
 				FeatureName: "model",
 				GroupName:   "device_info",
@@ -55,8 +57,9 @@ func TestCreateFeature(t *testing.T) {
 			group: types.Group{
 				ID:       1,
 				Name:     "device_info",
-				Category: types.CategoryStream,
+				Category: types.StreamFeatureCategory,
 			},
+			expectError: true,
 		},
 	}
 
@@ -65,17 +68,22 @@ func TestCreateFeature(t *testing.T) {
 			metadataStore.EXPECT().Refresh().Return(nil).AnyTimes()
 			metadataStore.EXPECT().GetGroupByName(ctx, tc.opt.GroupName).Return(&tc.group, nil)
 
-			metadataOpt := metadata.CreateFeatureOpt{
-				FeatureName: tc.opt.FeatureName,
-				FullName:    fmt.Sprintf("%s.%s", tc.opt.GroupName, tc.opt.FeatureName),
-				GroupID:     tc.group.ID,
-				ValueType:   tc.valueType,
-				Description: tc.opt.Description,
+			if tc.group.Category == types.BatchFeatureCategory {
+				metadataOpt := metadata.CreateFeatureOpt{
+					FeatureName: tc.opt.FeatureName,
+					FullName:    fmt.Sprintf("%s:%s", tc.opt.GroupName, tc.opt.FeatureName),
+					GroupID:     tc.group.ID,
+					ValueType:   tc.valueType,
+					Description: tc.opt.Description,
+				}
+				metadataStore.EXPECT().CreateFeature(ctx, metadataOpt).Return(0, nil)
+			}
+			_, err := store.CreateBatchFeature(ctx, tc.opt)
+			if tc.expectError {
+				assert.Error(t, err, fmt.Errorf("expected batch feature group, got %s feature group", tc.group.Category))
+			} else {
+				assert.NoError(t, err)
 			}
-			metadataStore.EXPECT().CreateFeature(ctx, metadataOpt).Return(0, nil)
-
-			_, err := store.CreateFeature(ctx, tc.opt)
-			assert.NoError(t, err)
 		})
 	}
 }
diff --git a/pkg/oomstore/join_test.go b/pkg/oomstore/join_test.go
index d2194f74..11ae70a2 100644
--- a/pkg/oomstore/join_test.go
+++ b/pkg/oomstore/join_test.go
@@ -111,7 +111,7 @@ func TestChannelJoin(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(tc.description, func(t *testing.T) {
 			metadataStore.EXPECT().Refresh().Return(nil).AnyTimes()
-			metadataStore.EXPECT().ListFeature(gomock.Any(), metadata.ListFeatureOpt{FeatureFullNames: &tc.opt.FeatureNames}).Return(tc.features, nil)
+			metadataStore.EXPECT().ListFeature(gomock.Any(), metadata.ListFeatureOpt{FeatureNames: &tc.opt.FeatureNames}).Return(tc.features, nil)
 			if tc.entity != nil {
 				for _, featureList := range tc.featureMap {
 					metadataStore.EXPECT().ListRevision(gomock.Any(), &featureList[0].GroupID).Return(revisions, nil).AnyTimes()
diff --git a/pkg/oomstore/online_query_test.go b/pkg/oomstore/online_query_test.go
index 512f1bac..3b6a0be9 100644
--- a/pkg/oomstore/online_query_test.go
+++ b/pkg/oomstore/online_query_test.go
@@ -78,7 +78,7 @@ func TestOnlineGet(t *testing.T) {
 
 	for _, tc := range testCases {
 		t.Run(tc.description, func(t *testing.T) {
-			metadataStore.EXPECT().CacheListFeature(gomock.Any(), metadata.ListFeatureOpt{FeatureFullNames: &tc.opt.FeatureNames}).Return(tc.features)
+			metadataStore.EXPECT().CacheListFeature(gomock.Any(), metadata.ListFeatureOpt{FeatureNames: &tc.opt.FeatureNames}).Return(tc.features)
 			if tc.entityName != nil {
 				onlineStore.EXPECT().Get(gomock.Any(), gomock.Any()).Return(dbutil.RowMap{
 					"price": int64(100),
@@ -173,7 +173,7 @@ func TestOnlineMultiGet(t *testing.T) {
 
 	for _, tc := range testCases {
 		t.Run(tc.description, func(t *testing.T) {
-			metadataStore.EXPECT().CacheListFeature(gomock.Any(), metadata.ListFeatureOpt{FeatureFullNames: &tc.opt.FeatureNames}).Return(tc.features)
+			metadataStore.EXPECT().CacheListFeature(gomock.Any(), metadata.ListFeatureOpt{FeatureNames: &tc.opt.FeatureNames}).Return(tc.features)
 			if tc.entityName != nil {
 				onlineStore.EXPECT().MultiGet(gomock.Any(), gomock.Any()).Return(map[string]dbutil.RowMap{
 					"1234": {
@@ -222,7 +222,7 @@ func prepareFeatures(isConsistent bool, isAvailable bool) types.FeatureList {
 			Group: &types.Group{
 				EntityID:         1,
 				OnlineRevisionID: &revision1,
-				Category:         types.CategoryBatch,
+				Category:         types.BatchFeatureCategory,
 				Entity:           entityDevice,
 			},
 		},
@@ -233,7 +233,7 @@ func prepareFeatures(isConsistent bool, isAvailable bool) types.FeatureList {
 			Group: &types.Group{
 				EntityID:         1,
 				OnlineRevisionID: &revision2,
-				Category:         types.CategoryBatch,
+				Category:         types.BatchFeatureCategory,
 				Entity:           entityDevice,
 			},
 		},
@@ -244,7 +244,7 @@ func prepareFeatures(isConsistent bool, isAvailable bool) types.FeatureList {
 			Group: &types.Group{
 				EntityID:         2,
 				OnlineRevisionID: &revision2,
-				Category:         types.CategoryBatch,
+				Category:         types.BatchFeatureCategory,
 				Entity:           entityUser,
 			},
 		},
@@ -252,7 +252,7 @@ func prepareFeatures(isConsistent bool, isAvailable bool) types.FeatureList {
 	if !isAvailable {
 		for i := range features {
 			features[i].Group.OnlineRevisionID = nil
-			features[i].Group.Category = types.CategoryStream
+			features[i].Group.Category = types.StreamFeatureCategory
 		}
 	}