diff --git a/.gitignore b/.gitignore index f1c181e..d7dc40d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,107 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# PyCharm files +.idea/ + +# C extensions *.so -*.dylib -# Test binary, build with `go test -c` -*.test +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site -# Output of the go coverage tool, specifically when used with LiteIDE -*.out +# mypy +.mypy_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..126e66e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +# Goredis Dockerfile + +FROM golang:latest + +MAINTAINER Alexander Gutyra + +RUN mkdir -p /go/src/GoHomework + +COPY . /go/src/GoHomework + +EXPOSE 9090 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c057aeb --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +# Goredis Makefile + +PROJECT_DIR_NAME = GoHomework + +CLIENT_RUNFILE_PATH = client/runclient.go +SERVER_RUNFILE_PATH = server/runserver.go +CLIENT_TESTFILE_PATH = client/test/client_test.go +SERVER_TESTFILE_PATH = server/test/server_test.go + +make_container: + sudo docker build -t goredis-app . + sudo docker run -d --rm -i --name goredis-app-running goredis-app + +clear_dangling: + sudo docker rmi $$(sudo docker images -f "dangling=true" -q) + +enter_container: + sudo docker exec -it goredis-app-running /bin/bash + +stop_container: + sudo docker stop goredis-app-running + + +check: + go vet $(PROJECT_DIR_NAME)/server + go vet $(PROJECT_DIR_NAME)/client + +build: + go build -o client/runclient $(CLIENT_RUNFILE_PATH) + go build -o server/runserver $(SERVER_RUNFILE_PATH) + +test: + go test $(SERVER_TESTFILE_PATH) + go test $(CLIENT_TESTFILE_PATH) + +.PHONY: runserver runclient +runserver: + ./server/runserver + +runclient: + ./client/runclient diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c016af --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Goredis + +Goredis is a simple implementation of Redis on Golang. + +## Getting started + +### Requirements + +All you need is a Docker app installed on your local computer. + +### Installation + +Clone or download this repository. + +```git clone https://github.com/SamperMan44/GoHomework.git``` + +Build a docker image and run a Docker container by your own or using Makefile. + +```make -f Makefile make_container``` + +Enter the terminal in your Docker container. + +```make -f Makefile enter_container``` + +### Launching the server + +Use Makefile to build and launch server with default params. + +``` +make -f Makefile build +make -f Makefile runserver +``` + +To get help on specified params, run server with argument `--help`. + +``` +~/go$ cd src/GoHomework/server +~/go/src/GoHomework/server$ go run runserver.go --help +``` + +### Launching the client + +The client is launched by analogy with the server. Simply use `runclient` instead of `runserver`. + +### Checking and testing + +Run Makefile commands `check` and `test`. + +## Available features + +To get the list of available commands, run client and type `HELP`. + +``` +localhost:9090> HELP + Goredis is a simple implementation of Redis on Golang. + Available commands: HELP SET GET DEL KEYS (UN)SUBSCRIBE PUBLISH EXIT STOP. + To get more help on them, type any command with no arguments. +localhost:9090> +``` + +Launch `runserver.go` or `runclient.go` with `--help` argument to get usage manual. + +Server modes: `disk` (saves your changes), `memory` (keeps loaded database only in RAM). \ No newline at end of file diff --git a/client/client/argparser.go b/client/client/argparser.go new file mode 100644 index 0000000..8c6feef --- /dev/null +++ b/client/client/argparser.go @@ -0,0 +1,20 @@ +package client + +import "flag" + +func GetCommandLineParams() (string, string, bool, string) { + var host, port, filename string + var dump bool + + flag.StringVar(&host, "host", "localhost", "default connection host") + flag.StringVar(&host, "h", "localhost", "default connection host (shortcut)") + flag.StringVar(&port, "port", "9090", "connection port") + flag.StringVar(&port, "p", "9090", "connection port (shortcut)") + flag.BoolVar(&dump, "dump", false, "dump database into JSON format") + flag.BoolVar(&dump, "d", false, "dump database into JSON format (shortcut)") + flag.StringVar(&filename, "restore", "", "restore the database from the dumped file") + flag.StringVar(&filename, "r", "", "restore the database from the dumper file (shortcut)") + flag.Parse() + + return host, port, dump, filename +} diff --git a/client/client/client.go b/client/client/client.go new file mode 100644 index 0000000..d82b9dc --- /dev/null +++ b/client/client/client.go @@ -0,0 +1,83 @@ +package client + +import ( + "bufio" + "bytes" + "fmt" + "net" + "os" + "strings" + "time" +) + +func HandleServerResponds(conn net.Conn, invitationMessage string, done chan string) { + scanner := bufio.NewScanner(conn) + for { + fmt.Print(invitationMessage + "> ") + ok := scanner.Scan() + if !ok { + done <- "\rServer closed the connection." + return + } + + text := scanner.Text() + if text != "" { + fmt.Println(text) + } else { + fmt.Println() + } + } +} + +func HandleUserRequests(conn net.Conn, done chan string) { + var buff bytes.Buffer + reader := bufio.NewReader(os.Stdin) + for { + for { + b, _ := reader.ReadByte() + + if strings.Compare(string(b), "\t") == 0 { + // NOT IMPLEMENTED + } + + buff.WriteString(string(b)) + if strings.Compare(string(b), "\n") == 0 { + break + } + } + + text := buff.String() + if strings.Compare(text, "EXIT\n") == 0 { + done <- "\rYou have been disconnected." + } + + SendRequest(conn, text) + buff.Reset() + } +} + +func SendRequest(conn net.Conn, text string) { + err := conn.SetWriteDeadline(time.Now().Add(1 * time.Second)) + if err != nil { + fmt.Println("ERROR: Cannot configure your request.") + } + + _, err = conn.Write([]byte(text)) + if err != nil { + fmt.Println("ERROR: Cannot send your request.") + } +} + +// Returns the rest of the command if it exists. Unused due to unimplementation +// of Tab completion feature. +func CompleteCommand(commandPart string) string { + commands := [4]string{"SET", "GET", "PUBLISH", "SUBSCRIBE"} + + for i := range commands { + if strings.HasPrefix(commands[i], commandPart) { + return commands[i][len(commandPart):] + } + } + + return "" +} diff --git a/client/client/data/restore.json b/client/client/data/restore.json new file mode 100644 index 0000000..4f6df31 --- /dev/null +++ b/client/client/data/restore.json @@ -0,0 +1 @@ +{"This file is created to test...":"database restore"} \ No newline at end of file diff --git a/client/runclient b/client/runclient new file mode 100755 index 0000000..2b77694 Binary files /dev/null and b/client/runclient differ diff --git a/client/runclient.go b/client/runclient.go new file mode 100755 index 0000000..309d7e3 --- /dev/null +++ b/client/runclient.go @@ -0,0 +1,41 @@ +/* + Title: Goredis clientside + Description: Goredis is a simple implementation of Redis on Golang + by Gutyra A. +*/ + +package main + +import ( + "GoHomework/client/client" + "fmt" + "net" + "os" +) + +const protocol = "tcp" + +func main() { + host, port, dump, filename := client.GetCommandLineParams() + exitMsgChan := make(chan string) + + conn, err := net.Dial(protocol, host + ":" + port) + if err != nil { + fmt.Println("ERROR: cannot connect to " + host + ":" + port) + os.Exit(1) + } + + go client.HandleServerResponds(conn, host + ":" + port, exitMsgChan) + go client.HandleUserRequests(conn, exitMsgChan) + + if filename != "" { + client.SendRequest(conn, "RESTORE \"" + filename + "\"\n") + } + + if dump { + client.SendRequest(conn, "DUMP\n") + } + + fmt.Println(<-exitMsgChan) + close(exitMsgChan) +} diff --git a/client/test/client_test.go b/client/test/client_test.go new file mode 100644 index 0000000..b14b62a --- /dev/null +++ b/client/test/client_test.go @@ -0,0 +1,19 @@ +package test + +import ( + "GoHomework/client/client" + "strings" + "testing" +) + +func TestClient(t *testing.T) { + commandEnd := client.CompleteCommand("SUB") + if strings.Compare(commandEnd, "SCRIBE") != 0 { + t.Error("[ERR] CompleteCommand failed. Expected SUB -> SUBSCRIBE, got SUB -> " + commandEnd) + } + + commandEnd = client.CompleteCommand("UNKNOWN") + if strings.Compare(commandEnd, "") != 0 { + t.Error("[ERR] CompleteCommand failed. Expected empty, got " + commandEnd) + } +} diff --git a/server/runserver b/server/runserver new file mode 100755 index 0000000..b731f52 Binary files /dev/null and b/server/runserver differ diff --git a/server/runserver.go b/server/runserver.go new file mode 100755 index 0000000..8a2dc57 --- /dev/null +++ b/server/runserver.go @@ -0,0 +1,74 @@ +/* + Title: Goredis serverside + Description: Goredis is a simple implementation of Redis on Golang + by Gutyra A. +*/ + +package main + +import ( + "GoHomework/server/server" + "fmt" + "net" + "os" + "strings" +) + +const ( + defaultProtocol = "tcp" + defaultAddress = "localhost" +) + +func main() { + storage, port, verbose := server.GetCommandLineParams() + err := server.LoadData(server.DataFile) + if err != nil { + os.Exit(1) + } + server.ConfigureLogging() + + ln, err := net.Listen(defaultProtocol, defaultAddress + ":" + port) + if err != nil { + fmt.Println("ERROR: Cannot create listener.") + } + + setStorage(storage) + setVerbose(verbose) + + go acceptConnections(ln) + + <-server.ExitChannel + server.SaveData(server.DataFile) + close(server.ExitChannel) + fmt.Println("Server stopped. Data saved.") + ln.Close() +} + +func setStorage(storage string) { + if strings.Compare(storage, "memory") == 0 { + server.MemoryMode = true + } else { + server.MemoryMode = false + } +} + +func setVerbose(verbose bool) { + if verbose { + server.PrintlnAndLog("Server launched in verbose mode.") + server.Verbose = true + } else { + fmt.Println("Server launched.") + server.Verbose = false + } +} + +func acceptConnections(ln net.Listener) { + for { + conn, err := ln.Accept() + if err != nil { + server.PrintlnAndLog("ERROR: Cannot accept new connection.") + } + server.Channels[conn] = make([]string, 0) + go server.HandleConnection(conn) + } +} diff --git a/server/server/argparser.go b/server/server/argparser.go new file mode 100755 index 0000000..5687cf6 --- /dev/null +++ b/server/server/argparser.go @@ -0,0 +1,20 @@ +package server + +import ( + "flag" +) + +func GetCommandLineParams() (string, string, bool) { + var storage, port string + var verbose bool + + flag.StringVar(&storage, "mode", "disk", "possible storage option") + flag.StringVar(&storage, "m", "disk", "possible storage option (shortcut)") + flag.StringVar(&port, "port", "9090", "server default port") + flag.StringVar(&port, "p", "9090", "server default port (shortcut)") + flag.BoolVar(&verbose, "verbose", false, "verbose mode, full log of the client requests") + flag.BoolVar(&verbose, "v", false, "verbose mode, full log of the client requests (shortcut)") + flag.Parse() + + return storage, port, verbose +} diff --git a/server/server/data/data.json b/server/server/data/data.json new file mode 100755 index 0000000..c4c8209 --- /dev/null +++ b/server/server/data/data.json @@ -0,0 +1 @@ +{"Key1":"Value1","Key2":"Value2","Key3":"Value3","Key4":"Value4"} \ No newline at end of file diff --git a/server/server/data_handler.go b/server/server/data_handler.go new file mode 100755 index 0000000..fb860ba --- /dev/null +++ b/server/server/data_handler.go @@ -0,0 +1,116 @@ +package server + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "regexp" + "strconv" + "strings" +) + +type DataType map[string]string +var MemoryMode bool + +func LoadFromFile(filename string) ([]byte, error) { + data, err := ioutil.ReadFile(filename) + if err != nil { + fmt.Println("ERROR: cannot load data from " + filename) + return data, err + } + return data, nil +} + +func LoadData(filename string) error { + dumped, err := LoadFromFile(filename) + if err != nil { + return err + } + Data = unDumpData(dumped) + return nil +} + +func SaveData(filename string) { + if !MemoryMode { + dumped := dumpData(Data) + err := ioutil.WriteFile(filename, dumped, 0644) + if err != nil { + fmt.Println("ERROR: cannot save data to filename") + } + } +} + +func AddEntry(key string, value string) { + Data[key] = value +} + +func CheckEntry(key string, value string) string { + if v, ok := Data[key]; ok && strings.Compare(v, value) == 0 { + return "OK" + } else { + return "(nil)" + } +} + +func GetEntry(key string) string { + if value, ok := Data[key]; ok { + return value + } + return "(nil)" +} + +func RemoveEntries(keys []string) string { + deleted := 0 + for i := range keys { + _, ok := Data[keys[i]] + if ok { + deleted += 1 + delete(Data, keys[i]) + } + } + response := "(integer) " + strconv.Itoa(deleted) + return response +} + +func ShowAllKeys() string { + response := "" + for k := range Data { + response = "\"" + k + "\" " + response + } + return response +} + +// Finds keys matching regexp pattern. +func FindKeys(pattern string) string { + response := "" + for k := range Data { + matched, err := regexp.MatchString(pattern, k) + if err != nil { + response = "ERROR: wrong pattern (" + pattern + ")." + return response + } + if matched { + response = "\"" + k + "\" " + response + } + } + return response +} + +// JSON -> Data +func unDumpData(dumped []byte) DataType { + var data DataType + err := json.Unmarshal(dumped, &data) + if err != nil { + fmt.Println("ERROR: cannot undump data.") + } + return data +} + +// DATA -> JSON +func dumpData(data DataType) []byte { + dumped, err := json.Marshal(data) + if err != nil { + fmt.Println("ERROR: cannot dump data.") + } + return dumped +} diff --git a/server/server/server.go b/server/server/server.go new file mode 100755 index 0000000..92896a4 --- /dev/null +++ b/server/server/server.go @@ -0,0 +1,217 @@ +package server + +import ( + "bufio" + "fmt" + "log" + "net" + "os" + "regexp" + "strings" +) + +const LogFile = "server/server.log" +const DataFile = "server/server/data/data.json" + +var Channels = make(map[net.Conn][]string) +var Data DataType +var Verbose bool +var ExitChannel = make(chan bool) + +func ConfigureLogging() { + f, err := os.OpenFile(LogFile, os.O_RDWR | os.O_CREATE | os.O_APPEND, 0666) + if err != nil { + log.Fatalf("ERROR: Cannot open file %v", err) + } + log.SetOutput(f) +} + +func HandleConnection(conn net.Conn) { + clientAddress := conn.RemoteAddr().String() + PrintlnAndLog("New connection established (" + clientAddress + ")") + + scanner := bufio.NewScanner(conn) + + for { + ok := scanner.Scan() + if !ok { + break + } + PrintlnAndLog("New message " + scanner.Text() + " from " + clientAddress) + + var response string + if strings.Compare(scanner.Text(), "") != 0 { + response = ApplyCommand(scanner.Text(), conn) + } else { + response = "ERR Empty message" + } + + _, err := conn.Write([]byte(response + "\n")) + if err != nil { + PrintlnAndLog("ERROR: cannot respond the request to " + clientAddress + ".") + } + } + + PrintlnAndLog("Client with address " + clientAddress + " disconnected.") +} + +// Finds command name, divides arguments and executes according function. +func ApplyCommand(cmdString string, conn net.Conn) string { + cmdName, cmdList := getCommandArguments(cmdString) + response := "ERR Unknown command" + + if strings.Compare(cmdName, "STOP") == 0 { + SaveData(DataFile) + response = "You've stopped the server successfully." + ExitChannel <- true + + } else if strings.Compare(cmdName, "HELP") == 0 { + response = " Goredis is a simple implementation of Redis on Golang.\n" + response += "\r Available commands: HELP SET GET DEL KEYS (UN)SUBSCRIBE PUBLISH EXIT STOP.\n" + response += "\r To get more help on them, type any command with no arguments." + + } else if strings.Contains(cmdName, "SET") { + if len(cmdList) > 1 { + response = "OK" + AddEntry(cmdList[0], cmdList[1]) + SaveData(DataFile) + } else { + response = "Usage: SET " + } + + } else if strings.Contains(cmdName, "GET") { + if len(cmdList) == 1 { + response = GetEntry(cmdList[0]) + } else { + response = "Usage: GET " + } + + } else if strings.Contains(cmdName, "DEL") { + if len(cmdList) > 0 { + response = RemoveEntries(cmdList) + SaveData(DataFile) + } else { + response = "Usage: DEL " + } + + } else if strings.Contains(cmdName, "KEYS") { + if len(cmdList) == 0 || strings.Compare("", cmdList[0]) == 0 || strings.Compare("*", cmdList[0]) == 0 { + response = ShowAllKeys() + } else { + response = FindKeys(cmdList[0]) + } + + } else if strings.Contains(cmdName, "UNSUBSCRIBE") { + if len(cmdList) == 1 { + Channels[conn] = removeFromSlice(cmdList[0], Channels[conn]) + response = "OK" + } else { + response = "Usage: UNSUBSCRIBE " + } + + } else if strings.Contains(cmdName, "SUBSCRIBE") { + if len(cmdList) == 1 { + Channels[conn] = append(Channels[conn], cmdList[0]) + response = "OK" + } else { + response = "Usage: SUBSCRIBE " + } + + } else if strings.Contains(cmdName, "PUBLISH") { + if len(cmdList) == 2 { + for k, v := range Channels { + if stringInSlice(cmdList[0], v) && conn != k { + _, err := k.Write([]byte("\rNew message in channel " + cmdList[0] + ": " + cmdList[1] + "\n")) + if err != nil { + fmt.Println("ERROR: Cannot send message in channel.") + } + } + } + response = "\rOK " + } else { + response = "Usage: PUBLISH " + } + + } else if strings.Contains(cmdName, "DUMP") { + jsonData, _ := LoadFromFile(DataFile) + response = fmt.Sprintf("\rDumped database: %s", jsonData) + + } else if strings.Contains(cmdName, "RESTORE") { + err := LoadData(cmdList[0]) + if err != nil { + PrintlnAndLog("Data cannot be restored by client request.") + response = fmt.Sprintf("\rCannot restore data from " + cmdList[0] + ".") + } else { + SaveData(DataFile) + PrintlnAndLog("Data has been restored by client request.") + response = fmt.Sprintf("\rYou've been successfully restored data from " + cmdList[0] + ".") + } + } + + return response +} + +func getCommandArguments(cmdString string) (string, []string) { + cmdString = getRidOfSpaces(cmdString) + pattern := regexp.MustCompile(`^[A-Za-z]+`) + cmdName := pattern.FindString(cmdString) + cmdName = strings.ToUpper(cmdName) + + cmdList := splitArguments(cmdString) + return cmdName, cmdList[1:] +} + +// Separates arguments considering there are quotation marks used for +// complex arguments. +func splitArguments(s string) []string { + var args []string + inQuotes := false + lastSplitted := 0 + for i := 0; i < len(s); i++ { + if s[i] == '"' { + if inQuotes == false { + inQuotes = true + } else { + inQuotes = false + } + } + + if !inQuotes { + if s[i] == ' ' { + arg := strings.Replace(s[lastSplitted:i], "\"", "", -1) + args = append(args, arg) + lastSplitted = i + 1 + } else if i == len(s) - 1 { + arg := strings.Replace(s[lastSplitted:i + 1], "\"", "", -1) + args = append(args, arg) + } + } + } + return args +} + +// Replaces multiple spaces with only one unit. +func getRidOfSpaces(s string) string { + pattern := regexp.MustCompile(`[\s]{2,}`) + s = pattern.ReplaceAllString(s, " ") + return s +} + +// Checks whether there is a string in a []string slice. +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if strings.Compare(a, b) == 0 { + return true + } + } + return false +} + +func removeFromSlice(a string, list []string) []string { + for i, b := range list { + if strings.Compare(a, b) == 0 { + return append(list[:i], list[i+1:]...) + } + } + return list +} diff --git a/server/server/utils.go b/server/server/utils.go new file mode 100755 index 0000000..81344d6 --- /dev/null +++ b/server/server/utils.go @@ -0,0 +1,13 @@ +package server + +import ( + "fmt" + "log" +) + +func PrintlnAndLog(msg string) { + fmt.Println(msg) + if Verbose { + log.Println(msg) + } +} diff --git a/server/test/data/test_data.json b/server/test/data/test_data.json new file mode 100644 index 0000000..ac5af29 --- /dev/null +++ b/server/test/data/test_data.json @@ -0,0 +1 @@ +{"Key":"Value"} \ No newline at end of file diff --git a/server/test/server_test.go b/server/test/server_test.go new file mode 100644 index 0000000..2df5324 --- /dev/null +++ b/server/test/server_test.go @@ -0,0 +1,62 @@ +package test + +import ( + "GoHomework/server/server" + "strings" + "testing" +) + +const testDataFile = "data/test_data.json" + +func TestServer(t *testing.T) { + err := server.LoadData(testDataFile) + if err != nil { + t.Error("[ERR] LoadData failed. Cannot load from " + testDataFile + ".") + } + + value := server.GetEntry("Key") + if strings.Compare(value, "Value") != 0 { + t.Error("[ERR] GetValue failed. Input: key=`Key`.") + } + + value = server.GetEntry("ItDoesNotExist") + if strings.Compare(value, "(nil)") != 0 { + t.Error("[ERR] GetValue failed. Input: key=`ItDoesNotExist`.") + } + + value = server.GetEntry("") + if strings.Compare(value, "(nil)") != 0 { + t.Error("[ERR] GetValue failed. Input: key=``.") + } + + server.AddEntry("NewKey", "NewValue") + removeResult := server.RemoveEntries([]string{"NewKey"}) + if strings.Compare(removeResult, "(integer) 1") != 0 { + t.Error("[ERR] RemoveEntries failed. Input: keys={`NewKey1`}.") + } + + server.AddEntry("NewKey1", "NewValue1") + server.AddEntry("NewKey2", "NewValue2") + removeResult = server.RemoveEntries([]string{"NewKey1", "NewKey2"}) + if strings.Compare(removeResult, "(integer) 2") != 0 { + t.Error("[ERR] RemoveEntries failed. Input: keys={`NewKey1`, `NewKey2`}.") + } + + keys := server.ShowAllKeys() + if strings.Compare(keys, "\"Key\" ") != 0 { + t.Error("[ERR] ShowAllKeys failed.") + } + + server.AddEntry("NewKey1", "NewValue1") + server.AddEntry("NewKey2", "NewValue2") + server.AddEntry("NewKey3", "NewValue3") + keys = server.FindKeys("NewKe*") + if !strings.Contains(keys, "NewKey1") && + !strings.Contains(keys, "NewKey2") && + !strings.Contains(keys, "NewKey3") { + t.Error("[ERR] FindKeys failed. Input: pattern=`NewKey*`.") + } + server.RemoveEntries([]string{"NewKey1", "NewKey2", "NewKey3"}) + + server.SaveData(testDataFile) +}