// Copyright 2024 Kelvin Clement Mwinuka // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package echovault import ( "bytes" "errors" "fmt" "github.com/echovault/echovault/internal/constants" "github.com/tidwall/resp" "os" "path" "slices" "strconv" "testing" ) func TestEchoVault_AddCommand(t *testing.T) { type args struct { command CommandOptions } type scenarios struct { name string command []string wantRes int wantErr error } tests := []struct { name string args args scenarios []scenarios wantErr bool }{ { name: "1 Add command without subcommands", wantErr: false, args: args{ command: CommandOptions{ Command: "CommandOne", Module: "test-module", Description: `(CommandOne write-key read-key ) Test command to handle successful addition of a single command without subcommands. The value passed must be an integer.`, Categories: []string{}, Sync: false, KeyExtractionFunc: func(cmd []string) (CommandKeyExtractionFuncResult, error) { if len(cmd) != 4 { return CommandKeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) } return CommandKeyExtractionFuncResult{ WriteKeys: cmd[1:2], ReadKeys: cmd[2:3], }, nil }, HandlerFunc: func(params CommandHandlerFuncParams) ([]byte, error) { if len(params.Command) != 4 { return nil, errors.New(constants.WrongArgsResponse) } value := params.Command[3] i, err := strconv.ParseInt(value, 10, 64) if err != nil { return nil, errors.New("value must be an integer") } return []byte(fmt.Sprintf(":%d\r\n", i)), nil }, }, }, scenarios: []scenarios{ { name: "1 Successfully execute the command and return the expected integer.", command: []string{"CommandOne", "write-key1", "read-key1", "1111"}, wantRes: 1111, wantErr: nil, }, { name: "2 Get error due to command being too long", command: []string{"CommandOne", "write-key1", "read-key1", "1111", "2222"}, wantRes: 0, wantErr: errors.New(constants.WrongArgsResponse), }, { name: "3 Get error due to command being too short", command: []string{"CommandOne", "write-key1", "read-key1"}, wantRes: 0, wantErr: errors.New(constants.WrongArgsResponse), }, { name: "4 Get error due to value not being an integer", command: []string{"CommandOne", "write-key1", "read-key1", "string"}, wantRes: 0, wantErr: errors.New("value must be an integer"), }, }, }, { name: "2 Add command with subcommands", wantErr: false, args: args{ command: CommandOptions{ Command: "CommandTwo", SubCommand: []SubCommandOptions{ { Command: "SubCommandOne", Module: "test-module", Description: `(CommandTwo SubCommandOne write-key read-key ) Test command to handle successful addition of a single command with subcommands. The value passed must be an integer.`, Categories: []string{}, Sync: false, KeyExtractionFunc: func(cmd []string) (CommandKeyExtractionFuncResult, error) { if len(cmd) != 5 { return CommandKeyExtractionFuncResult{}, errors.New(constants.WrongArgsResponse) } return CommandKeyExtractionFuncResult{ WriteKeys: cmd[2:3], ReadKeys: cmd[3:4], }, nil }, HandlerFunc: func(params CommandHandlerFuncParams) ([]byte, error) { if len(params.Command) != 5 { return nil, errors.New(constants.WrongArgsResponse) } value := params.Command[4] i, err := strconv.ParseInt(value, 10, 64) if err != nil { return nil, errors.New("value must be an integer") } return []byte(fmt.Sprintf(":%d\r\n", i)), nil }, }, }, }, }, scenarios: []scenarios{ { name: "1 Successfully execute the command and return the expected integer.", command: []string{"CommandTwo", "SubCommandOne", "write-key1", "read-key1", "1111"}, wantRes: 1111, wantErr: nil, }, { name: "2 Get error due to command being too long", command: []string{"CommandTwo", "SubCommandOne", "write-key1", "read-key1", "1111", "2222"}, wantRes: 0, wantErr: errors.New(constants.WrongArgsResponse), }, { name: "3 Get error due to command being too short", command: []string{"CommandTwo", "SubCommandOne", "write-key1", "read-key1"}, wantRes: 0, wantErr: errors.New(constants.WrongArgsResponse), }, { name: "4 Get error due to value not being an integer", command: []string{"CommandTwo", "SubCommandOne", "write-key1", "read-key1", "string"}, wantRes: 0, wantErr: errors.New("value must be an integer"), }, }, }, } for _, tt := range tests { server := createEchoVault() t.Run(tt.name, func(t *testing.T) { if err := server.AddCommand(tt.args.command); (err != nil) != tt.wantErr { t.Errorf("AddCommand() error = %v, wantErr %v", err, tt.wantErr) } for _, scenario := range tt.scenarios { b, err := server.ExecuteCommand(scenario.command...) if scenario.wantErr != nil { if scenario.wantErr.Error() != err.Error() { t.Errorf("AddCommand() error = %v, wantErr %v", err, scenario.wantErr) } continue } r := resp.NewReader(bytes.NewReader(b)) v, _, _ := r.ReadValue() if v.Integer() != scenario.wantRes { t.Errorf("AddCommand() res = %v, wantRes %v", resp.BytesValue(b).Integer(), scenario.wantRes) } } }) } } func TestEchoVault_ExecuteCommand(t *testing.T) { type args struct { key string presetValue []string command []string } tests := []struct { name string args args wantRes int wantErr error }{ { name: "1 Execute LPUSH command and get expected result", args: args{ key: "key1", presetValue: []string{"1", "2", "3"}, command: []string{"LPUSH", "key1", "4", "5", "6", "7", "8", "9", "10"}, }, wantRes: 10, wantErr: nil, }, { name: "2 Expect error when trying to execute non-existent command", args: args{ key: "key2", presetValue: nil, command: []string{"NON-EXISTENT", "key1", "key2"}, }, wantRes: 0, wantErr: errors.New("command NON-EXISTENT not supported"), }, } for _, tt := range tests { server := createEchoVault() t.Run(tt.name, func(t *testing.T) { if tt.args.presetValue != nil { _, _ = server.LPush(tt.args.key, tt.args.presetValue...) } b, err := server.ExecuteCommand(tt.args.command...) if tt.wantErr != nil { if err.Error() != tt.wantErr.Error() { t.Errorf("ExecuteCommand() error = %v, wantErr %v", err, tt.wantErr) } } r := resp.NewReader(bytes.NewReader(b)) v, _, _ := r.ReadValue() if v.Integer() != tt.wantRes { t.Errorf("ExecuteCommand() response = %d, wantRes %d", v.Integer(), tt.wantRes) } }) } } func TestEchoVault_RemoveCommand(t *testing.T) { type args struct { removeCommand []string executeCommand []string } tests := []struct { name string args args wantErr error }{ { name: "1 Remove command and expect error when the command is called", args: args{ removeCommand: []string{"LPUSH"}, executeCommand: []string{"LPUSH", "key", "item"}, }, wantErr: errors.New("command LPUSH not supported"), }, { name: "2 Remove sub-command and expect error when the subcommand is called", args: args{ removeCommand: []string{"ACL", "CAT"}, executeCommand: []string{"ACL", "CAT"}, }, wantErr: errors.New("command ACL CAT not supported"), }, { name: "3 Remove sub-command and expect successful response from calling another subcommand", args: args{ removeCommand: []string{"ACL", "WHOAMI"}, executeCommand: []string{"ACL", "DELUSER", "user-one"}, }, wantErr: nil, }, } for _, tt := range tests { server := createEchoVault() t.Run(tt.name, func(t *testing.T) { server.RemoveCommand(tt.args.removeCommand...) _, err := server.ExecuteCommand(tt.args.executeCommand...) if tt.wantErr != nil { if err.Error() != tt.wantErr.Error() { t.Errorf("RemoveCommand() error = %v, wantErr %v", err, tt.wantErr) } } }) } } func TestEchoVault_Plugins(t *testing.T) { t.Cleanup(func() { _ = os.RemoveAll("./testdata/modules") }) server := createEchoVault() moduleSet := path.Join(".", "testdata", "modules", "module_set", "module_set.so") moduleGet := path.Join(".", "testdata", "modules", "module_get", "module_get.so") nonExistent := path.Join(".", "testdata", "modules", "non_existent", "module_non_existent.so") // Load module.set module if err := server.LoadModule(moduleSet); err != nil { t.Error(err) } // Execute module.set command and expect "OK" response res, err := server.ExecuteCommand("module.set", "key1", "15") if err != nil { t.Error(err) } rv, _, err := resp.NewReader(bytes.NewReader(res)).ReadValue() if err != nil { t.Error(err) } if rv.String() != "OK" { t.Errorf("expected response \"OK\", got \"%s\"", rv.String()) } // Load module.get module with args if err := server.LoadModule(moduleGet, "10"); err != nil { t.Error(err) } // Execute module.get command and expect an integer with the value 150 res, err = server.ExecuteCommand("module.get", "key1") rv, _, err = resp.NewReader(bytes.NewReader(res)).ReadValue() if err != nil { t.Error(err) } if rv.Integer() != 150 { t.Errorf("expected response 150, got %d", rv.Integer()) } // Return error when trying to load module that does not exist if err := server.LoadModule(nonExistent); err == nil { t.Error("expected error but got nil instead") } else { if err.Error() != fmt.Sprintf("load module: module %s not found", nonExistent) { t.Errorf( "expected error \"%s\", got \"%s\"", fmt.Sprintf("load module: module %s not found", nonExistent), err.Error(), ) } } // Module list should contain module_get and module_set modules modules := server.ListModules() for _, mod := range []string{moduleSet, moduleGet} { if !slices.Contains(modules, mod) { t.Errorf("expected modules list to contain module \"%s\" but did not find it", mod) } } // Unload modules server.UnloadModule(moduleSet) server.UnloadModule(moduleGet) // Make sure the modules are no longer loaded modules = server.ListModules() for _, mod := range []string{moduleSet, moduleGet} { if slices.Contains(modules, mod) { t.Errorf("expected modules list to not contain module \"%s\" but found it", mod) } } }