├── .circleci ├── config.yml └── coverage.sh ├── .gitignore ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── README.md ├── appengine ├── Makefile ├── _gopath │ └── README.md └── app │ ├── app.go │ └── app.yaml ├── chat ├── action │ ├── message.go │ ├── message_test.go │ ├── query.go │ ├── timestamp.go │ └── timestamp_test.go ├── command_service.go ├── command_service_test.go ├── errors.go ├── errors_test.go ├── event.go ├── event_test.go ├── hub.go ├── hub_test.go ├── json.go ├── json_test.go ├── login_service.go ├── login_service_test.go ├── pubsub.go ├── queried │ └── queried.go ├── query_service.go ├── query_service_test.go ├── queryer.go └── result │ └── result.go ├── domain ├── active_client.go ├── active_client_test.go ├── conn.go ├── context.go ├── event │ ├── active_client.go │ ├── event.go │ ├── event_test.go │ ├── messages.go │ ├── rooms.go │ ├── type_string.go │ └── users.go ├── events.go ├── messages.go ├── messages_test.go ├── repositories.go ├── rooms.go ├── rooms_test.go ├── users.go └── users_test.go ├── infra ├── _sqlite3 │ ├── gentable.go │ ├── messages.go │ ├── repos.go │ ├── rooms.go │ └── users.go ├── config │ ├── config.go │ ├── example │ │ └── config.toml │ ├── gen_example.go │ ├── toml.go │ └── toml_test.go ├── inmemory │ ├── events.go │ ├── events_test.go │ ├── messages.go │ ├── messages_test.go │ ├── repos.go │ ├── rooms.go │ ├── rooms_test.go │ ├── users.go │ └── users_test.go └── pubsub │ ├── pubsub.go │ └── pubsub_test.go ├── internal └── mocks │ ├── mock_command_service.go │ ├── mock_conn.go │ ├── mock_events.go │ ├── mock_login_service.go │ ├── mock_messages.go │ ├── mock_pubsub.go │ ├── mock_query_service.go │ ├── mock_queryer.go │ ├── mock_repos.go │ ├── mock_rooms.go │ └── mock_users.go ├── main ├── auto-login.html ├── index.html ├── main.go └── test.sh ├── server ├── config.go ├── config_test.go ├── error.go ├── helper_test.go ├── login.go ├── login_test.go ├── resolve.go ├── resolve_test.go ├── rest.go ├── rest_test.go ├── server.go └── server_test.go └── ws ├── conn.go ├── conn_test.go ├── doc.go ├── handler.go ├── handler_test.go └── wstest └── wstest.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # CircleCI Go configrations 2 | # 3 | # Check https://circleci.com/docs/2.0/language-go/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # CircleCI Go images available at: https://hub.docker.com/r/circleci/golang/ 10 | - image: circleci/golang:1.9 11 | 12 | working_directory: /go/src/github.com/shirasudon/go-chat 13 | 14 | environment: 15 | TEST_RESULTS: /tmp/test-results 16 | 17 | steps: 18 | - checkout 19 | 20 | - run: 21 | name: go get dependencies 22 | command: | 23 | go get -u github.com/golang/dep/cmd/dep 24 | dep ensure 25 | 26 | - run: 27 | name: go get test tools 28 | command: | 29 | go get github.com/jstemmer/go-junit-report 30 | go get github.com/haya14busa/goverage 31 | 32 | - run: 33 | name: Run static checkers 34 | command: | 35 | go vet ./... 36 | 37 | - run: mkdir -p $TEST_RESULTS 38 | 39 | - run: 40 | name: Run unit tests 41 | command: | 42 | trap "go-junit-report <${TEST_RESULTS}/go-test.out > ${TEST_RESULTS}/go-test-report.xml" EXIT 43 | go test -v -race ./... | tee ${TEST_RESULTS}/go-test.out 44 | bash .circleci/coverage.sh 45 | 46 | - run: 47 | name: Upload coverage profile to codecov.io 48 | command: | 49 | cp ${TEST_RESULTS}/go-test.coverage ./coverage.txt 50 | bash <(curl -s https://codecov.io/bash) 51 | 52 | - run: 53 | name: Build main program 54 | command: go build -o ./main/build_bin ./main 55 | 56 | - store_artifacts: 57 | path: /tmp/test-results 58 | destination: raw-test-output 59 | 60 | - store_test_results: 61 | path: /tmp/test-results 62 | 63 | -------------------------------------------------------------------------------- /.circleci/coverage.sh: -------------------------------------------------------------------------------- 1 | # collect coverage result on the circleci environment. 2 | GOTEST_COVERAGE_PKGS=`go list ./... | grep -v "/main\|/internal\|/wstest\|/appengine"` 3 | goverage -coverprofile=${TEST_RESULTS}/go-test.coverage ${GOTEST_COVERAGE_PKGS} 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | 3 | appengine/_gopath/src 4 | appengine/_gopath/vendor 5 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/BurntSushi/toml" 6 | packages = ["."] 7 | revision = "b26d9c308763d68093482582cea63d69be07a0f0" 8 | version = "v0.3.0" 9 | 10 | [[projects]] 11 | name = "github.com/antonlindstrom/pgstore" 12 | packages = ["."] 13 | revision = "a407030ba6d0efd9a1aad3d0cfc18a9b13d2f2e7" 14 | version = "1.0.0" 15 | 16 | [[projects]] 17 | name = "github.com/boj/redistore" 18 | packages = ["."] 19 | revision = "fc113767cd6b051980f260d6dbe84b2740c46ab0" 20 | version = "v1.2" 21 | 22 | [[projects]] 23 | branch = "master" 24 | name = "github.com/cskr/pubsub" 25 | packages = ["."] 26 | revision = "d0cbfe51c9165ee318238cab8ba70ff068245502" 27 | 28 | [[projects]] 29 | name = "github.com/dgrijalva/jwt-go" 30 | packages = ["."] 31 | revision = "dbeaa9332f19a944acb5736b4456cfcc02140e29" 32 | version = "v3.1.0" 33 | 34 | [[projects]] 35 | name = "github.com/garyburd/redigo" 36 | packages = [ 37 | "internal", 38 | "redis" 39 | ] 40 | revision = "d1ed5c67e5794de818ea85e6b522fda02623a484" 41 | version = "v1.4.0" 42 | 43 | [[projects]] 44 | branch = "master" 45 | name = "github.com/golang/mock" 46 | packages = ["gomock"] 47 | revision = "b3e60bcdc577185fce3cf625fc96b62857ce5574" 48 | 49 | [[projects]] 50 | branch = "master" 51 | name = "github.com/golang/protobuf" 52 | packages = ["proto"] 53 | revision = "1e59b77b52bf8e4b449a57e6f79f21226d571845" 54 | 55 | [[projects]] 56 | name = "github.com/gorilla/context" 57 | packages = ["."] 58 | revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a" 59 | version = "v1.1" 60 | 61 | [[projects]] 62 | name = "github.com/gorilla/securecookie" 63 | packages = ["."] 64 | revision = "667fe4e3466a040b780561fe9b51a83a3753eefc" 65 | version = "v1.1" 66 | 67 | [[projects]] 68 | name = "github.com/gorilla/sessions" 69 | packages = ["."] 70 | revision = "ca9ada44574153444b00d3fd9c8559e4cc95f896" 71 | version = "v1.1" 72 | 73 | [[projects]] 74 | name = "github.com/ipfans/echo-session" 75 | packages = ["."] 76 | revision = "53b07854989517a32290e9ab5b3c3ec6d9e2f13c" 77 | version = "v3.1.1" 78 | 79 | [[projects]] 80 | name = "github.com/labstack/echo" 81 | packages = [ 82 | ".", 83 | "middleware" 84 | ] 85 | revision = "b338075a0fc6e1a0683dbf03d09b4957a289e26f" 86 | version = "3.2.6" 87 | 88 | [[projects]] 89 | name = "github.com/labstack/gommon" 90 | packages = [ 91 | "bytes", 92 | "color", 93 | "log", 94 | "random" 95 | ] 96 | revision = "57409ada9da0f2afad6664c49502f8c50fbd8476" 97 | version = "0.2.3" 98 | 99 | [[projects]] 100 | branch = "master" 101 | name = "github.com/lib/pq" 102 | packages = [ 103 | ".", 104 | "oid" 105 | ] 106 | revision = "83612a56d3dd153a94a629cd64925371c9adad78" 107 | 108 | [[projects]] 109 | name = "github.com/mattn/go-colorable" 110 | packages = ["."] 111 | revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" 112 | version = "v0.0.9" 113 | 114 | [[projects]] 115 | name = "github.com/mattn/go-isatty" 116 | packages = ["."] 117 | revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" 118 | version = "v0.0.3" 119 | 120 | [[projects]] 121 | name = "github.com/pkg/errors" 122 | packages = ["."] 123 | revision = "645ef00459ed84a119197bfb8d8205042c6df63d" 124 | version = "v0.8.0" 125 | 126 | [[projects]] 127 | branch = "master" 128 | name = "github.com/valyala/bytebufferpool" 129 | packages = ["."] 130 | revision = "e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7" 131 | 132 | [[projects]] 133 | branch = "master" 134 | name = "github.com/valyala/fasttemplate" 135 | packages = ["."] 136 | revision = "dcecefd839c4193db0d35b88ec65b4c12d360ab0" 137 | 138 | [[projects]] 139 | branch = "master" 140 | name = "golang.org/x/crypto" 141 | packages = [ 142 | "acme", 143 | "acme/autocert" 144 | ] 145 | revision = "0fcca4842a8d74bfddc2c96a073bd2a4d2a7a2e8" 146 | 147 | [[projects]] 148 | branch = "master" 149 | name = "golang.org/x/net" 150 | packages = [ 151 | "context", 152 | "websocket" 153 | ] 154 | revision = "434ec0c7fe3742c984919a691b2018a6e9694425" 155 | 156 | [[projects]] 157 | branch = "master" 158 | name = "golang.org/x/sys" 159 | packages = ["unix"] 160 | revision = "1792d66dc88e503d3cb2400578221cdf1f7fe26f" 161 | 162 | [[projects]] 163 | name = "google.golang.org/appengine" 164 | packages = [ 165 | ".", 166 | "internal", 167 | "internal/app_identity", 168 | "internal/base", 169 | "internal/datastore", 170 | "internal/log", 171 | "internal/modules", 172 | "internal/remote_api", 173 | "log" 174 | ] 175 | revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" 176 | version = "v1.0.0" 177 | 178 | [solve-meta] 179 | analyzer-name = "dep" 180 | analyzer-version = 1 181 | inputs-digest = "3ee4b711aa62605df2ce2567fbb1929a04837d088897e2d43d4420ca87fd9536" 182 | solver-name = "gps-cdcl" 183 | solver-version = 1 184 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | 22 | 23 | [[constraint]] 24 | name = "github.com/BurntSushi/toml" 25 | version = "0.3.0" 26 | 27 | [[constraint]] 28 | branch = "master" 29 | name = "github.com/cskr/pubsub" 30 | 31 | [[constraint]] 32 | branch = "master" 33 | name = "github.com/golang/mock" 34 | 35 | [[constraint]] 36 | name = "github.com/ipfans/echo-session" 37 | version = "3.1.1" 38 | 39 | [[constraint]] 40 | name = "github.com/labstack/echo" 41 | version = "3.2.6" 42 | 43 | [[constraint]] 44 | branch = "master" 45 | name = "golang.org/x/net" 46 | 47 | [[constraint]] 48 | name = "google.golang.org/appengine" 49 | version = "1.0.0" 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /appengine/Makefile: -------------------------------------------------------------------------------- 1 | CURRENT_DIR = $(shell pwd) 2 | PROJECT_ROOT ?= $(shell dirname $(CURRENT_DIR)) 3 | 4 | CURRENT_GOPATH_ROOT = $(CURRENT_DIR)/_gopath 5 | 6 | PROJECT_LIB_NAMES = server chat domain infra ws 7 | 8 | 9 | .PHONY: help 10 | help: 11 | @echo "test # run local server" 12 | @echo "deploy # deploy the application for GAE" 13 | @echo "vendor # collect dependencies for deploy" 14 | @echo "clean-vendor # clean dependencies for deploy" 15 | 16 | .PHONY: test 17 | test: 18 | dev_appserver.py app/app.yaml 19 | 20 | .PHONY: deploy 21 | deploy: vendor 22 | @# To deploy for GAE, use ./gopath instead of default GOPATH. 23 | GOPATH=$(CURRENT_GOPATH_ROOT)/vendor:$(CURRENT_GOPATH_ROOT) gcloud app deploy $(CURRENT_DIR)/app/app.yaml 24 | 25 | .PHONY: vendor 26 | vendor: $(PROJECT_ROOT)/Gopkg.lock clean-vendor 27 | mkdir -p $(CURRENT_GOPATH_ROOT)/src/github.com/shirasudon/go-chat 28 | mkdir -p $(CURRENT_GOPATH_ROOT)/vendor 29 | ln -sf $(PROJECT_ROOT)/vendor $(CURRENT_GOPATH_ROOT)/vendor/src 30 | @# go-chat sub packages are placed under ./src/ 31 | @for lib in $(PROJECT_LIB_NAMES); do\ 32 | echo "ln -sf $(PROJECT_ROOT)/$$lib $(CURRENT_GOPATH_ROOT)/src/github.com/shirasudon/go-chat/$$lib";\ 33 | ln -sf $(PROJECT_ROOT)/$$lib $(CURRENT_GOPATH_ROOT)/src/github.com/shirasudon/go-chat/$$lib || exit 50;\ 34 | done 35 | 36 | .PHONY: clean-vendor 37 | clean-vendor: 38 | rm -f $(CURRENT_GOPATH_ROOT)/vendor/src 39 | @for lib in $(PROJECT_LIB_NAMES); do\ 40 | echo "rm -f $(CURRENT_GOPATH_ROOT)/src/github.com/shirasudon/go-chat/$$lib";\ 41 | rm -f $(CURRENT_GOPATH_ROOT)/src/github.com/shirasudon/go-chat/$$lib ;\ 42 | done 43 | -------------------------------------------------------------------------------- /appengine/_gopath/README.md: -------------------------------------------------------------------------------- 1 | # gopath 2 | 3 | gopath is used to store vendoring packages through symbolic link. 4 | it is used for deploying GAE. 5 | -------------------------------------------------------------------------------- /appengine/app/app.go: -------------------------------------------------------------------------------- 1 | // +build appengine 2 | 3 | package app 4 | 5 | import ( 6 | "context" 7 | "log" 8 | "net/http" 9 | "os" 10 | 11 | "github.com/shirasudon/go-chat/chat" 12 | "github.com/shirasudon/go-chat/domain" 13 | "github.com/shirasudon/go-chat/infra/config" 14 | "github.com/shirasudon/go-chat/infra/inmemory" 15 | "github.com/shirasudon/go-chat/infra/pubsub" 16 | goserver "github.com/shirasudon/go-chat/server" 17 | 18 | "google.golang.org/appengine" 19 | ) 20 | 21 | type DoneFunc func() 22 | 23 | func createInfra() (domain.Repositories, *chat.Queryers, chat.Pubsub, DoneFunc) { 24 | ps := pubsub.New() 25 | doneFuncs := make([]func(), 0, 4) 26 | doneFuncs = append(doneFuncs, ps.Shutdown) 27 | 28 | repos := inmemory.OpenRepositories(ps) 29 | doneFuncs = append(doneFuncs, func() { _ = repos.Close() }) 30 | 31 | ctx, cancel := context.WithCancel(context.Background()) 32 | doneFuncs = append(doneFuncs, cancel) 33 | go repos.UpdatingService(ctx) 34 | 35 | qs := &chat.Queryers{ 36 | UserQueryer: repos.UserRepository, 37 | RoomQueryer: repos.RoomRepository, 38 | MessageQueryer: repos.MessageRepository, 39 | EventQueryer: repos.EventRepository, 40 | } 41 | 42 | done := func() { 43 | // reverse order to simulate defer statement. 44 | for i := len(doneFuncs); i >= 0; i-- { 45 | doneFuncs[i]() 46 | } 47 | } 48 | 49 | return repos, qs, ps, done 50 | } 51 | 52 | const ( 53 | DefaultConfigFile = "config.toml" 54 | KeyConfigFileENV = "GOCHAT_CONFIG_FILE" 55 | ) 56 | 57 | func loadConfig() *goserver.Config { 58 | // get config path from environment value. 59 | var configPath = DefaultConfigFile 60 | if confPath := os.Getenv(KeyConfigFileENV); len(confPath) > 0 { 61 | configPath = confPath 62 | } 63 | 64 | // set config value to be used. 65 | var defaultConf = goserver.DefaultConfig 66 | if config.FileExists(configPath) { 67 | log.Printf("[Config] Loading file: %s\n", configPath) 68 | 69 | if err := config.LoadFile(&defaultConf, configPath); err != nil { 70 | log.Printf("[Config] Load Error: %v\n", err) 71 | log.Println("[Config] use default insteadly") 72 | return &defaultConf 73 | } 74 | log.Println("[Config] Loading file: OK") 75 | } else { 76 | log.Println("[Config] Use default") 77 | } 78 | return &defaultConf 79 | } 80 | 81 | var ( 82 | gochatServer *goserver.Server 83 | doneFunc func() 84 | ) 85 | 86 | func init() { 87 | var serverDoneFunc func() 88 | repos, qs, ps, infraDoneFunc := createInfra() 89 | gochatServer, serverDoneFunc = goserver.CreateServerFromInfra(repos, qs, ps, loadConfig()) 90 | doneFunc = func() { 91 | serverDoneFunc() 92 | infraDoneFunc() 93 | } 94 | http.Handle("/", gochatServer.Handler()) 95 | } 96 | 97 | func main() { 98 | defer func() { 99 | log.Println("calling main defer") 100 | doneFunc() 101 | }() 102 | appengine.Main() 103 | } 104 | -------------------------------------------------------------------------------- /appengine/app/app.yaml: -------------------------------------------------------------------------------- 1 | runtime: go 2 | api_version: go1 3 | threadsafe: true 4 | 5 | handlers: 6 | - url: /.* 7 | script: _go_app 8 | 9 | instance_class: F1 10 | 11 | automatic_scaling: 12 | min_idle_instances: automatic # the number of the idle instances 13 | max_idle_instances: 1 14 | min_pending_latency: 3000ms # pending time to grow the number of instances 15 | max_pending_latency: automatic 16 | max_concurrent_requests: 80 17 | -------------------------------------------------------------------------------- /chat/action/message_test.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestAnyMessage(t *testing.T) { 11 | var ( 12 | primitives = AnyMessage{ 13 | "number": float64(2), 14 | "uint64": uint64(1), 15 | "string": "test", 16 | "time": time.Now(), 17 | "object": map[string]interface{}{"A": 3, "B": "test2"}, 18 | "array": []interface{}{1, 2, 3}, 19 | "uint64s": []uint64{4, 5, 6}, 20 | } 21 | ) 22 | 23 | b, err := json.Marshal(primitives) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | 28 | var got AnyMessage 29 | err = json.Unmarshal(b, &got) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | for _, tcase := range []struct { 35 | Key string 36 | LHS interface{} 37 | RHS interface{} 38 | }{ 39 | {"string", got.String("string"), primitives["string"]}, 40 | {"no-string", got.String("no-string"), ""}, 41 | 42 | {"uint64", got.UInt64("uint64"), primitives["uint64"]}, 43 | {"no^uint64", got.UInt64("no-uint64"), uint64(0)}, 44 | 45 | {"number", got.Number("number"), primitives["number"]}, 46 | {"no-number", got.Number("no-number"), float64(0)}, 47 | 48 | {"time", got.Time("time"), primitives["time"]}, 49 | {"no-time", got.Time("no-time"), time.Time{}}, 50 | 51 | {"object", got.Object("object"), primitives["object"]}, 52 | {"no-object", got.Time("no-object"), map[string]interface{}{}}, 53 | 54 | {"array", got.Array("array"), primitives["array"]}, 55 | {"no-array", got.Array("no-array"), []interface{}{}}, 56 | 57 | {"uint64s", got.UInt64s("uint64s"), primitives["uint64s"]}, 58 | {"no-uint64s", got.UInt64s("no-uint64s"), []uint64{}}, 59 | } { 60 | lhs, rhs := tcase.LHS, tcase.RHS 61 | if !reflect.DeepEqual(rhs, rhs) { 62 | t.Errorf("different data for key(%v), expect: %v, got: %v", tcase.Key, lhs, rhs) 63 | } 64 | } 65 | } 66 | 67 | // TODO add test for ParseXXX 68 | 69 | func TestParseAddRoomMember(t *testing.T) { 70 | const ( 71 | SenderID = uint64(1) 72 | RoomID = uint64(2) 73 | AddUserID = uint64(3) 74 | ) 75 | origin := AddRoomMember{ 76 | SenderID: SenderID, 77 | RoomID: RoomID, 78 | AddUserID: AddUserID, 79 | } 80 | bs, err := json.Marshal(origin) 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | 85 | var any AnyMessage 86 | if err := json.Unmarshal(bs, &any); err != nil { 87 | t.Fatal(err) 88 | } 89 | 90 | got, err := ParseAddRoomMember(any, ActionAddRoomMember) 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | if got.RoomID != RoomID { 95 | t.Errorf("different room id") 96 | } 97 | if got.AddUserID != AddUserID { 98 | t.Errorf("different add user id") 99 | } 100 | if got.SenderID != SenderID { 101 | t.Errorf("different sender id") 102 | } 103 | } 104 | 105 | func TestParseRemoveRoomMember(t *testing.T) { 106 | const ( 107 | SenderID = uint64(1) 108 | RoomID = uint64(2) 109 | RemoveUserID = uint64(3) 110 | ) 111 | origin := RemoveRoomMember{ 112 | SenderID: SenderID, 113 | RoomID: RoomID, 114 | RemoveUserID: RemoveUserID, 115 | } 116 | bs, err := json.Marshal(origin) 117 | if err != nil { 118 | t.Fatal(err) 119 | } 120 | 121 | var any AnyMessage 122 | if err := json.Unmarshal(bs, &any); err != nil { 123 | t.Fatal(err) 124 | } 125 | 126 | got, err := ParseRemoveRoomMember(any, ActionRemoveRoomMember) 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | if got.RoomID != RoomID { 131 | t.Errorf("different room id") 132 | } 133 | if got.RemoveUserID != RemoveUserID { 134 | t.Errorf("different remove user id") 135 | } 136 | if got.SenderID != SenderID { 137 | t.Errorf("different sender id") 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /chat/action/query.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | // QueryRoomMessages is a query for 4 | // messages in specified room. 5 | type QueryRoomMessages struct { 6 | RoomID uint64 7 | Before Timestamp `json:"before" query:"before"` 8 | Limit int `json:"limit" query:"limit"` 9 | } 10 | 11 | // QueryUnreadRoomMessages is a query for 12 | // unread messages by user in specified room. 13 | type QueryUnreadRoomMessages struct { 14 | RoomID uint64 15 | Limit int `json:"limit" query:"limit"` 16 | } 17 | -------------------------------------------------------------------------------- /chat/action/timestamp.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import "time" 4 | 5 | // SupportedTimeFormat is time representation format to be acceptable for the application. 6 | // all of the time representation format should be in manner of this format. 7 | const SupportedTimeFormat = time.RFC3339 8 | 9 | // Timestamp is Time data which implements some custom encoders and decoders. 10 | type Timestamp time.Time 11 | 12 | // TimestampNow is shorthand for Timestamp(time.Now()). 13 | func TimestampNow() Timestamp { 14 | return Timestamp(time.Now()) 15 | } 16 | 17 | // implements json.Marshaler interface. 18 | func (t Timestamp) MarshalJSON() ([]byte, error) { 19 | return t.Time().MarshalJSON() // The format of default marshaler is RFC3339. 20 | } 21 | 22 | // implements json.TextMarshaler interface. 23 | func (t Timestamp) MarshalText() ([]byte, error) { 24 | return t.Time().MarshalText() // The format of default marshaler is RFC3339. 25 | } 26 | 27 | // implements json.Unmarshaler interface. 28 | func (t *Timestamp) UnmarshalJSON(data []byte) error { 29 | var ts = time.Time{} 30 | err := ts.UnmarshalJSON(data) 31 | *t = Timestamp(ts) 32 | return err 33 | } 34 | 35 | // implements github.com/labstack/echo.BindUnmarshaler interface. 36 | func (t *Timestamp) UnmarshalParam(src string) error { 37 | ts, err := time.Parse(SupportedTimeFormat, src) 38 | *t = Timestamp(ts) 39 | return err 40 | } 41 | 42 | // Time returns its internal representation as time.Time. 43 | func (t Timestamp) Time() time.Time { 44 | return time.Time(t) 45 | } 46 | -------------------------------------------------------------------------------- /chat/action/timestamp_test.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestTimestampMarshalJSON(t *testing.T) { 11 | var tm = TimestampNow() 12 | bs, err := json.Marshal(tm) 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | strip := strings.Trim(string(bs), "\"") 18 | parsed, err := time.Parse(SupportedTimeFormat, strip) 19 | if err != nil { 20 | t.Fatalf("can not parse with SupportedTimeFormat, expect format: %v, got: %v", SupportedTimeFormat, strip) 21 | } 22 | if !parsed.Equal(tm.Time()) { 23 | t.Errorf("different time after parsing from json string") 24 | } 25 | 26 | var newTm Timestamp 27 | if err := json.Unmarshal(bs, &newTm); err != nil { 28 | t.Fatalf("can not Unmarshal: %v", err) 29 | } 30 | if !newTm.Time().Equal(tm.Time()) { 31 | t.Errorf("different time after Unmarshaling from json") 32 | } 33 | } 34 | 35 | func TestTimestampUnmarshalText(t *testing.T) { 36 | var testStrings = make([]string, 0, 2) 37 | var tm = Timestamp(time.Now()) 38 | 39 | // json text 40 | bs, err := json.Marshal(tm) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | testStrings = append(testStrings, strings.Trim(string(bs), "\"")) 45 | 46 | // format text 47 | bs, err = tm.MarshalText() 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | testStrings = append(testStrings, string(bs)) 52 | 53 | for _, text := range testStrings { 54 | var newTm Timestamp 55 | if err := newTm.UnmarshalParam(text); err != nil { 56 | t.Fatalf("can not UnmarshalParam: %v", err) 57 | } 58 | if !newTm.Time().Equal(tm.Time()) { 59 | t.Errorf("different time after UnmarshalParam, expect: %v, got: %v", newTm.Time(), tm.Time()) 60 | } 61 | } 62 | t.Logf("src text are: %#v", testStrings) 63 | } 64 | -------------------------------------------------------------------------------- /chat/errors.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // TODO distinguish errors from infrastructures and domain. 9 | // For example, infrastructure errors contains NotFound, 10 | // TxFailed and Operaion Failed etc. 11 | 12 | // InfraError represents error caused on the 13 | // infrastructure layer. 14 | // It should be not shown directly for the client side. 15 | // 16 | // It implements error interface. 17 | type InfraError struct { 18 | Cause error 19 | } 20 | 21 | // NewInfraError create new InfraError with same syntax as fmt.Errorf(). 22 | func NewInfraError(msgFormat string, args ...interface{}) *InfraError { 23 | return &InfraError{Cause: fmt.Errorf(msgFormat, args...)} 24 | } 25 | 26 | func (err InfraError) Error() string { 27 | return fmt.Sprintf("infra error: %v", err.Cause.Error()) 28 | } 29 | 30 | // ErrInternalError is the proxy for the internal error 31 | // which should not be shown for the client. 32 | var ErrInternalError = errors.New("Internal error") 33 | 34 | // NotFoundError represents error caused on the 35 | // infrastructure layer but it is specified to that 36 | // the request data is not found. 37 | // It can be shown directly for the client side. 38 | // 39 | // It implements error interface. 40 | type NotFoundError InfraError 41 | 42 | // NewInfraError create new InfraError with same syntax as fmt.Errorf(). 43 | func NewNotFoundError(msgFormat string, args ...interface{}) *NotFoundError { 44 | return &NotFoundError{Cause: fmt.Errorf(msgFormat, args...)} 45 | } 46 | 47 | func (err NotFoundError) Error() string { 48 | return fmt.Sprintf("not found error: %v", err.Cause.Error()) 49 | } 50 | 51 | // It returns true when the type of given err is *NotFoundError or NotFoundError, 52 | // otherwise false. 53 | func IsNotFoundError(err error) bool { 54 | switch err.(type) { 55 | case NotFoundError, *NotFoundError: 56 | return true 57 | default: 58 | return false 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /chat/errors_test.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestInfraError(t *testing.T) { 9 | for _, err := range []error{ 10 | NewInfraError("error test"), 11 | NewInfraError("error test %v", "message"), 12 | NewInfraError(""), 13 | } { 14 | if err == nil { 15 | t.Fatal("can not create new infra error") 16 | } 17 | if msg := err.Error(); len(msg) == 0 { 18 | t.Error("new infra error shows no message") 19 | } 20 | } 21 | } 22 | 23 | func TestNotFoundError(t *testing.T) { 24 | for _, err := range []error{ 25 | NewNotFoundError("error test"), 26 | NewNotFoundError("error test %v", "message"), 27 | NewNotFoundError(""), 28 | } { 29 | if err == nil { 30 | t.Fatal("can not create new not found error") 31 | } 32 | if msg := err.Error(); len(msg) == 0 { 33 | t.Error("new not found error shows no message") 34 | } 35 | if _, ok := err.(*NotFoundError); !ok { 36 | t.Errorf("invalid error type, %T", err) 37 | } 38 | if _, ok := err.(*InfraError); ok { 39 | t.Errorf("passing other type assertion") 40 | } 41 | } 42 | } 43 | 44 | func TestIsNotFoundError(t *testing.T) { 45 | for _, tcase := range []struct { 46 | Err error 47 | IsNotFoundError bool 48 | }{ 49 | {NewNotFoundError(""), true}, 50 | {NotFoundError{}, true}, 51 | {NewInfraError(""), false}, 52 | {errors.New(""), false}, 53 | {nil, false}, 54 | } { 55 | if IsNotFoundError(tcase.Err) != tcase.IsNotFoundError { 56 | t.Errorf("%T is detected as NotFoundError", tcase.Err) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /chat/event.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import "github.com/shirasudon/go-chat/domain/event" 4 | 5 | // These events are external new types and are used only this package. 6 | 7 | // Event for User logged in. 8 | type eventUserLoggedIn struct { 9 | event.ExternalEventEmbd 10 | UserID uint64 `json:"user_id"` 11 | } 12 | 13 | func (eventUserLoggedIn) TypeString() string { return "type_user_logged_in" } 14 | 15 | // Event for User logged out. 16 | type eventUserLoggedOut eventUserLoggedIn 17 | 18 | func (eventUserLoggedOut) TypeString() string { return "type_user_logged_out" } 19 | -------------------------------------------------------------------------------- /chat/event_test.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/shirasudon/go-chat/domain/event" 7 | ) 8 | 9 | func TestNewExternalEvents(t *testing.T) { 10 | for _, testcase := range []struct { 11 | Ev event.TypeStringer 12 | Expect string 13 | }{ 14 | {eventUserLoggedIn{}, "type_user_logged_in"}, 15 | {eventUserLoggedOut{}, "type_user_logged_out"}, 16 | } { 17 | if got := testcase.Ev.TypeString(); got != testcase.Expect { 18 | t.Errorf("different type string, expect: %v, got: %v", testcase.Expect, got) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /chat/json.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/shirasudon/go-chat/domain/event" 7 | ) 8 | 9 | const ( 10 | EventNameMessageCreated = "message_created" 11 | EventNameActiveClientActivated = "client_activated" 12 | EventNameActiveClientInactivated = "client_inactivated" 13 | EventNameRoomCreated = "room_created" 14 | EventNameRoomDeleted = "room_deleted" 15 | EventNameRoomAddedMember = "room_added_member" 16 | EventNameRoomRemovedMember = "room_removed_member" 17 | EventNameRoomMessagesReadByUser = "room_messages_read_by_user" 18 | EventNameUnknown = "unknown" 19 | ) 20 | 21 | var eventEncodeNames = map[event.Type]string{ 22 | event.TypeMessageCreated: EventNameMessageCreated, 23 | event.TypeActiveClientActivated: EventNameActiveClientActivated, 24 | event.TypeActiveClientInactivated: EventNameActiveClientInactivated, 25 | event.TypeRoomCreated: EventNameRoomCreated, 26 | event.TypeRoomDeleted: EventNameRoomDeleted, 27 | event.TypeRoomAddedMember: EventNameRoomAddedMember, 28 | event.TypeRoomRemovedMember: EventNameRoomRemovedMember, 29 | event.TypeRoomMessagesReadByUser: EventNameRoomMessagesReadByUser, 30 | } 31 | 32 | // EventJSON is a data-transfer-object 33 | // which represents domain event to sent to the client connection. 34 | // It implement Event interface. 35 | type EventJSON struct { 36 | EventName string `json:"event"` 37 | Data event.Event `json:"data"` 38 | } 39 | 40 | func (EventJSON) Type() event.Type { return event.TypeNone } 41 | func (e EventJSON) Timestamp() time.Time { return e.Data.Timestamp() } 42 | func (e EventJSON) StreamID() event.StreamID { return e.Data.StreamID() } 43 | 44 | func NewEventJSON(ev event.Event) EventJSON { 45 | if ev == nil { 46 | panic("nil Event is not allowed") 47 | } 48 | 49 | eventName, ok := eventEncodeNames[ev.Type()] 50 | if !ok { 51 | eventName = EventNameUnknown 52 | } 53 | return EventJSON{ 54 | EventName: eventName, 55 | Data: ev, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /chat/json_test.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/shirasudon/go-chat/domain/event" 8 | ) 9 | 10 | func TestEventEncodeNames(t *testing.T) { 11 | for _, type_ := range HubHandlingEventTypes { 12 | if _, ok := eventEncodeNames[type_]; !ok { 13 | t.Errorf("%v is not contained in the eventEncodeNames", type_.String()) 14 | } 15 | } 16 | } 17 | 18 | func TestNewEventJSON(t *testing.T) { 19 | for _, ev := range []event.Event{ 20 | event.MessageCreated{}, 21 | event.ActiveClientActivated{}, 22 | event.ActiveClientInactivated{}, 23 | event.RoomCreated{}, 24 | event.RoomDeleted{}, 25 | event.RoomAddedMember{}, 26 | event.RoomMessagesReadByUser{}, 27 | } { 28 | evJSON := NewEventJSON(ev) 29 | if evJSON.EventName == EventNameUnknown { 30 | t.Errorf("event encode name is undefined for %T", ev) 31 | } 32 | // use reflect.DeepEqual because the event may have no comparable fields, slice or map. 33 | if !reflect.DeepEqual(evJSON.Data, ev) { 34 | t.Errorf("EventJSON has different event data for %T", ev) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /chat/login_service.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shirasudon/go-chat/chat/queried" 7 | ) 8 | 9 | //go:generate mockgen -destination=../internal/mocks/mock_login_service.go -package=mocks github.com/shirasudon/go-chat/chat LoginService 10 | 11 | // LoginService is the interface for the login/logut user. 12 | type LoginService interface { 13 | 14 | // Login finds authenticated user profile matched with given user name and password. 15 | // It returns queried user profile and nil when the user is authenticated, or 16 | // returns nil and NotFoundError when the user is not found. 17 | Login(ctx context.Context, username, password string) (*queried.AuthUser, error) 18 | 19 | // Logout logouts User specified userID from the chat service. 20 | Logout(ctx context.Context, userID uint64) 21 | } 22 | 23 | type LoginServiceImpl struct { 24 | users UserQueryer 25 | pubsub Pubsub 26 | } 27 | 28 | func NewLoginServiceImpl(users UserQueryer, pubsub Pubsub) *LoginServiceImpl { 29 | if users == nil || pubsub == nil { 30 | panic("passing nil arguments") 31 | } 32 | return &LoginServiceImpl{ 33 | users: users, 34 | pubsub: pubsub, 35 | } 36 | } 37 | 38 | func (ls *LoginServiceImpl) Login(ctx context.Context, username, password string) (*queried.AuthUser, error) { 39 | auth, err := ls.users.FindByNameAndPassword(ctx, username, password) 40 | if err != nil { 41 | return nil, err 42 | } 43 | ev := eventUserLoggedIn{UserID: auth.ID} 44 | ev.Occurs() 45 | ls.pubsub.Pub(ev) 46 | return auth, nil 47 | } 48 | 49 | func (ls *LoginServiceImpl) Logout(ctx context.Context, userID uint64) { 50 | ev := eventUserLoggedOut{UserID: userID} 51 | ev.Occurs() 52 | ls.pubsub.Pub(ev) 53 | } 54 | -------------------------------------------------------------------------------- /chat/login_service_test.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/golang/mock/gomock" 8 | 9 | "github.com/shirasudon/go-chat/chat/queried" 10 | "github.com/shirasudon/go-chat/internal/mocks" 11 | ) 12 | 13 | func TestLoginServiceImplement(t *testing.T) { 14 | t.Parallel() 15 | // make sure the interface is implemented. 16 | var _ LoginService = &LoginServiceImpl{} 17 | } 18 | 19 | func TestNewLoginServicePanic(t *testing.T) { 20 | t.Parallel() 21 | ctrl := gomock.NewController(t) 22 | defer ctrl.Finish() 23 | 24 | testPanic := func(doFunc func()) { 25 | t.Helper() 26 | defer func() { 27 | if rec := recover(); rec == nil { 28 | t.Errorf("passing nil argument but no panic") 29 | } 30 | }() 31 | doFunc() 32 | } 33 | 34 | ps := mocks.NewMockPubsub(ctrl) 35 | users := mocks.NewMockUserQueryer(ctrl) 36 | testPanic(func() { _ = NewLoginServiceImpl(users, nil) }) 37 | testPanic(func() { _ = NewLoginServiceImpl(nil, ps) }) 38 | } 39 | 40 | func TestLoginServiceLogin(t *testing.T) { 41 | t.Parallel() 42 | ctrl := gomock.NewController(t) 43 | defer ctrl.Finish() 44 | 45 | ps := mocks.NewMockPubsub(ctrl) 46 | ps.EXPECT().Pub(gomock.Any()).Times(1) 47 | 48 | const ( 49 | UserName = "name" 50 | Password = "password" 51 | ) 52 | auth := queried.AuthUser{ID: 1, Name: UserName, Password: Password} 53 | 54 | users := mocks.NewMockUserQueryer(ctrl) 55 | users.EXPECT().FindByNameAndPassword(gomock.Any(), UserName, Password). 56 | Return(&auth, nil).Times(1) 57 | 58 | impl := NewLoginServiceImpl(users, ps) 59 | got, err := impl.Login(context.Background(), UserName, Password) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | if (*got) != auth { 64 | t.Errorf("different AuthUser, expect: %v, got: %v", got, auth) 65 | } 66 | } 67 | 68 | func TestLoginServiceLoginFail(t *testing.T) { 69 | t.Parallel() 70 | ctrl := gomock.NewController(t) 71 | defer ctrl.Finish() 72 | 73 | const ( 74 | UserName = "name" 75 | Password = "password" 76 | ) 77 | 78 | ps := mocks.NewMockPubsub(ctrl) 79 | users := mocks.NewMockUserQueryer(ctrl) 80 | users.EXPECT().FindByNameAndPassword(gomock.Any(), UserName, Password). 81 | Return(nil, NewNotFoundError("error!")).Times(1) 82 | 83 | impl := NewLoginServiceImpl(users, ps) 84 | _, err := impl.Login(context.Background(), UserName, Password) 85 | if err == nil { 86 | t.Errorf("user not found but no error") 87 | } 88 | } 89 | 90 | func TestLoginServiceLogout(t *testing.T) { 91 | t.Parallel() 92 | ctrl := gomock.NewController(t) 93 | defer ctrl.Finish() 94 | 95 | ps := mocks.NewMockPubsub(ctrl) 96 | ps.EXPECT().Pub(gomock.Any()).Times(1) 97 | 98 | users := mocks.NewMockUserQueryer(ctrl) 99 | 100 | impl := NewLoginServiceImpl(users, ps) 101 | const ( 102 | UserID uint64 = 1 103 | ) 104 | impl.Logout(context.Background(), UserID) 105 | } 106 | -------------------------------------------------------------------------------- /chat/pubsub.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "github.com/shirasudon/go-chat/domain/event" 5 | ) 6 | 7 | //go:generate mockgen -destination=../internal/mocks/mock_pubsub.go -package=mocks github.com/shirasudon/go-chat/chat Pubsub 8 | 9 | // interface for the publisher/subcriber pattern. 10 | type Pubsub interface { 11 | Pub(...event.Event) 12 | Sub(...event.Type) chan interface{} 13 | } 14 | -------------------------------------------------------------------------------- /chat/queried/queried.go: -------------------------------------------------------------------------------- 1 | // package queried contains queried results as 2 | // Data-Transfer-Object. 3 | 4 | package queried 5 | 6 | import "time" 7 | 8 | // EmptyRoomInfo is RoomInfo having empty fields rather than nil. 9 | var EmptyRoomInfo = RoomInfo{ 10 | Members: []RoomMemberProfile{}, 11 | } 12 | 13 | // RoomInfo is a detailed room information. 14 | // creator 15 | type RoomInfo struct { 16 | RoomName string `json:"room_name"` 17 | RoomID uint64 `json:"room_id"` 18 | CreatorID uint64 `json:"room_creator_id"` 19 | Members []RoomMemberProfile `json:"room_members"` 20 | MembersSize int `json:"room_members_size"` 21 | } 22 | 23 | // RoomMemberProfile is a user profile with room specific information. 24 | type RoomMemberProfile struct { 25 | UserProfile 26 | 27 | MessageReadAt time.Time `json:"message_read_at"` 28 | } 29 | 30 | // EmptyUserRelation is UserRelation having empty fields rather than nil. 31 | var EmptyUserRelation = UserRelation{ 32 | Friends: []UserProfile{}, 33 | Rooms: []UserRoom{}, 34 | } 35 | 36 | // UserRelation is the abstarct information associated with specified User. 37 | type UserRelation struct { 38 | UserProfile 39 | 40 | Friends []UserProfile `json:"friends"` 41 | Rooms []UserRoom `json:"rooms"` 42 | } 43 | 44 | // AuthUser is a authenticated user information. 45 | type AuthUser struct { 46 | ID uint64 `json:"user_id"` 47 | Name string `json:"user_name"` 48 | Password string `json:"password"` 49 | } 50 | 51 | // UserProfile holds information for user profile. 52 | type UserProfile struct { 53 | UserID uint64 `json:"user_id"` 54 | UserName string `json:"user_name"` 55 | FirstName string `json:"first_name"` 56 | LastName string `json:"last_name"` 57 | } 58 | 59 | // UserRoom holds abstract information for the room. 60 | type UserRoom struct { 61 | RoomID uint64 `json:"room_id"` 62 | RoomName string `json:"room_name"` 63 | } 64 | 65 | // EmptyRoomMessages is RoomMessages having empty fields rather than nil. 66 | var EmptyRoomMessages = RoomMessages{ 67 | Msgs: []Message{}, 68 | } 69 | 70 | // RoomMessages is a message list in specified Room. 71 | type RoomMessages struct { 72 | RoomID uint64 `json:"room_id"` 73 | 74 | Msgs []Message `json:"messages"` 75 | 76 | Cursor struct { 77 | Current time.Time `json:"current"` 78 | Next time.Time `json:"next"` 79 | } `json:"cursor"` 80 | } 81 | 82 | type Message struct { 83 | MessageID uint64 `json:"message_id"` 84 | UserID uint64 `json:"user_id"` 85 | Content string `json:"content"` 86 | CreatedAt time.Time `json:"created_at"` 87 | } 88 | 89 | // EmptyUnreadRoomMessages is UnreadRoomMessages having empty fields rather than nil. 90 | var EmptyUnreadRoomMessages = UnreadRoomMessages{ 91 | Msgs: []Message{}, 92 | } 93 | 94 | // UnreadRoomMessages is a list of unread messages in specified Room. 95 | type UnreadRoomMessages struct { 96 | RoomID uint64 `json:"room_id"` 97 | 98 | Msgs []Message `json:"messages"` 99 | MsgsSize int `json:"messages_size"` 100 | } 101 | -------------------------------------------------------------------------------- /chat/query_service.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/shirasudon/go-chat/chat/action" 10 | "github.com/shirasudon/go-chat/chat/queried" 11 | "github.com/shirasudon/go-chat/domain/event" 12 | ) 13 | 14 | //go:generate mockgen -destination=../internal/mocks/mock_query_service.go -package=mocks github.com/shirasudon/go-chat/chat QueryService 15 | 16 | // QueryService is the interface for the querying the information from backend datastore. 17 | type QueryService interface { 18 | // It finds authenticated user profile matched with given user name and password. 19 | // It returns queried user profile and nil when the user is found in the data-store, or 20 | // returns nil and NotFoundError when the user is not found. 21 | FindUserByNameAndPassword(ctx context.Context, name, password string) (*queried.AuthUser, error) 22 | 23 | // Find the relational information of user specified by userID. 24 | // It returns queried result and nil, or nil and NotFoundError if the information is not found. 25 | FindUserRelation(ctx context.Context, userID uint64) (*queried.UserRelation, error) 26 | 27 | // Find the room information specified by roomID with userID. 28 | // It returns queried result and nil, or nil and NotFoundError if the information is not found. 29 | FindRoomInfo(ctx context.Context, userID, roomID uint64) (*queried.RoomInfo, error) 30 | 31 | // Find the messages belonging to the room specified by QueryRoomMessages with userID. 32 | // It returns queried messages and nil, or nil and InfraError if infrastructure raise some errors. 33 | FindRoomMessages(ctx context.Context, userID uint64, q action.QueryRoomMessages) (*queried.RoomMessages, error) 34 | 35 | // Find the unread messages belonging to the room specified by QueryUnreadRoomMessages with userID. 36 | // It returns queried messages and nil, or nil and InfraError if infrastructure raise some errors. 37 | FindUnreadRoomMessages(ctx context.Context, userID uint64, q action.QueryUnreadRoomMessages) (*queried.UnreadRoomMessages, error) 38 | } 39 | 40 | // TODO cache feature. 41 | 42 | // QueryServiceImpl implements QueryService interface. 43 | type QueryServiceImpl struct { 44 | users UserQueryer 45 | rooms RoomQueryer 46 | msgs MessageQueryer 47 | 48 | events EventQueryer 49 | } 50 | 51 | func NewQueryServiceImpl(qs *Queryers) *QueryServiceImpl { 52 | if qs == nil { 53 | panic("nil Queryers") 54 | } 55 | return &QueryServiceImpl{ 56 | users: qs.UserQueryer, 57 | rooms: qs.RoomQueryer, 58 | msgs: qs.MessageQueryer, 59 | events: qs.EventQueryer, 60 | } 61 | } 62 | 63 | // TODO event permittion in for user id, 64 | func (s *QueryServiceImpl) FindEventsByTimeCursor(ctx context.Context, after time.Time, limit int) ([]event.Event, error) { 65 | evs, err := s.events.FindAllByTimeCursor(ctx, after, limit) 66 | if err != nil && IsNotFoundError(err) { 67 | return []event.Event{}, nil 68 | } 69 | return evs, err 70 | } 71 | 72 | // Find user profile matched with user name and password. 73 | // It returns queried user profile and nil when found in the data-store. 74 | // It returns nil and error when the user is not found. 75 | func (s *QueryServiceImpl) FindUserByNameAndPassword(ctx context.Context, name, password string) (*queried.AuthUser, error) { 76 | user, err := s.users.FindByNameAndPassword(ctx, name, password) 77 | // TODO cache? 78 | return user, err 79 | } 80 | 81 | // Find abstract information associated with the User. 82 | // It returns queried result and error if the information is not found. 83 | func (s *QueryServiceImpl) FindUserRelation(ctx context.Context, userID uint64) (*queried.UserRelation, error) { 84 | relation, err := s.users.FindUserRelation(ctx, userID) 85 | // TODO cache? 86 | return relation, err 87 | } 88 | 89 | // Find detailed room information specified by room ID. 90 | // It also requires userID to query the information which 91 | // can be permmited to the user. 92 | // It returns queried room information and error if not found. 93 | func (s *QueryServiceImpl) FindRoomInfo(ctx context.Context, userID, roomID uint64) (*queried.RoomInfo, error) { 94 | info, err := s.rooms.FindRoomInfo(ctx, userID, roomID) 95 | // TODO cache? 96 | return info, err 97 | } 98 | 99 | const ( 100 | MaxRoomMessagesLimit = 50 101 | ) 102 | 103 | // Find messages from specified room. 104 | // It returns error if infrastructure raise some errors. 105 | func (s *QueryServiceImpl) FindRoomMessages(ctx context.Context, userID uint64, q action.QueryRoomMessages) (*queried.RoomMessages, error) { 106 | // check query paramnter 107 | if q.Limit > MaxRoomMessagesLimit || q.Limit <= 0 { 108 | q.Limit = MaxRoomMessagesLimit 109 | } 110 | 111 | if q.Before.Time().Equal(time.Time{}) { 112 | q.Before = action.TimestampNow() 113 | } 114 | 115 | // TODO create specific queried data, messages associated with room ID and user ID, 116 | // to remove domain logic in the QueryService. 117 | r, err := s.rooms.Find(ctx, q.RoomID) 118 | if err != nil { 119 | return nil, err 120 | } 121 | u, err := s.users.Find(ctx, userID) 122 | if err != nil { 123 | return nil, err 124 | } 125 | if !r.HasMember(u) { 126 | return nil, fmt.Errorf("can not get the messages from room(id=%d) by not a room member user(id=%d)", q.RoomID, userID) 127 | } 128 | 129 | msgs, err := s.msgs.FindRoomMessagesOrderByLatest(ctx, q.RoomID, q.Before.Time(), q.Limit) 130 | if err != nil { 131 | if IsNotFoundError(err) { 132 | // TODO use logger 133 | log.Println("FindRoomMessages(): error:", err) 134 | res := queried.EmptyRoomMessages 135 | res.RoomID = q.RoomID 136 | return &res, nil 137 | } 138 | return nil, err 139 | } 140 | 141 | // TODO move to infrastructure and just return QueryRoomMessages. 142 | 143 | roomMsgs := &queried.RoomMessages{ 144 | RoomID: q.RoomID, 145 | } 146 | roomMsgs.Cursor.Current = q.Before.Time() 147 | if last := len(msgs) - 1; last >= 0 { 148 | roomMsgs.Cursor.Next = msgs[last].CreatedAt 149 | } else { 150 | roomMsgs.Cursor.Next = q.Before.Time() 151 | } 152 | 153 | qMsgs := make([]queried.Message, 0, len(msgs)) 154 | for _, m := range msgs { 155 | qm := queried.Message{ 156 | MessageID: m.ID, 157 | UserID: m.UserID, 158 | Content: m.Content, 159 | CreatedAt: m.CreatedAt, 160 | } 161 | qMsgs = append(qMsgs, qm) 162 | } 163 | roomMsgs.Msgs = qMsgs 164 | 165 | return roomMsgs, nil 166 | } 167 | 168 | // Find unread messages from specified room. 169 | // It returns error if infrastructure raise some errors. 170 | func (s *QueryServiceImpl) FindUnreadRoomMessages(ctx context.Context, userID uint64, q action.QueryUnreadRoomMessages) (*queried.UnreadRoomMessages, error) { 171 | // check query paramnter 172 | if q.Limit > MaxRoomMessagesLimit || q.Limit <= 0 { 173 | q.Limit = MaxRoomMessagesLimit 174 | } 175 | // check existance of user and room. 176 | if _, err := s.users.Find(context.Background(), userID); err != nil { 177 | return nil, err 178 | } 179 | if _, err := s.rooms.Find(context.Background(), q.RoomID); err != nil { 180 | return nil, err 181 | } 182 | 183 | msgs, err := s.msgs.FindUnreadRoomMessages(ctx, userID, q.RoomID, q.Limit) 184 | if err != nil && IsNotFoundError(err) { 185 | // TODO use logger 186 | log.Println("FindUnreadRoomMessages(): error:", err) 187 | // return empty result because room exists but message is not yet. 188 | res := queried.EmptyUnreadRoomMessages 189 | res.RoomID = q.RoomID 190 | return &res, nil 191 | } 192 | // TODO cache 193 | return msgs, err 194 | } 195 | -------------------------------------------------------------------------------- /chat/queryer.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/shirasudon/go-chat/chat/queried" 8 | "github.com/shirasudon/go-chat/domain" 9 | "github.com/shirasudon/go-chat/domain/event" 10 | ) 11 | 12 | //go:generate mockgen -destination=../internal/mocks/mock_queryer.go -package=mocks github.com/shirasudon/go-chat/chat UserQueryer,RoomQueryer,MessageQueryer,EventQueryer 13 | 14 | // Queryers is just data struct which have 15 | // some XXXQueryers. 16 | type Queryers struct { 17 | UserQueryer 18 | RoomQueryer 19 | MessageQueryer 20 | 21 | EventQueryer 22 | } 23 | 24 | // UserQueryer queries users stored in the data-store. 25 | type UserQueryer interface { 26 | // Find a user specified by userID and return it. 27 | // It returns NotFoundError if not found. 28 | Find(ctx context.Context, userID uint64) (domain.User, error) 29 | 30 | // Find a user profile specified by user name and password. 31 | // It returns error if not found. 32 | FindByNameAndPassword(ctx context.Context, name, password string) (*queried.AuthUser, error) 33 | 34 | // Find a user related information with userID. 35 | // It returns queried result or NotFoundError if the information is not found. 36 | FindUserRelation(ctx context.Context, userID uint64) (*queried.UserRelation, error) 37 | } 38 | 39 | // RoomQueryer queries rooms stored in the data-store. 40 | type RoomQueryer interface { 41 | // Find a room specified by roomID and return it. 42 | // It returns NotFoundError if not found. 43 | Find(ctx context.Context, roomID uint64) (domain.Room, error) 44 | 45 | // Find all rooms which user has. 46 | // It returns NotFoundError if not found. 47 | FindAllByUserID(ctx context.Context, userID uint64) ([]domain.Room, error) 48 | 49 | // Find room information with specified userID and roomID. 50 | // It returns NotFoundError if not found. 51 | FindRoomInfo(ctx context.Context, userID, roomID uint64) (*queried.RoomInfo, error) 52 | } 53 | 54 | // MessageQueryer queries messages stored in the data-store. 55 | type MessageQueryer interface { 56 | 57 | // Find all messages from the room specified by room_id. 58 | // The returned messages are, ordered by latest created at, 59 | // all of before specified before time, 60 | // and the number of messages is limted to less than 61 | // specified limit. 62 | // It returns InfraError if infrastructure raise some errors. 63 | // It returns NotFoundError if not found. 64 | FindRoomMessagesOrderByLatest(ctx context.Context, roomID uint64, before time.Time, limit int) ([]domain.Message, error) 65 | 66 | // Find all unread messages from the room specified by room_id. 67 | // The returned messages are, ordered by latest created at, 68 | // It returns NotFoundError if not found. 69 | FindUnreadRoomMessages(ctx context.Context, userID, roomID uint64, limit int) (*queried.UnreadRoomMessages, error) 70 | } 71 | 72 | // EventQueryer queries events stored in the data-store. 73 | type EventQueryer interface { 74 | // Find events from the data-store. 75 | // The returned events are, ordered by older created at 76 | // and all of after specified after time. 77 | // It returns NotFoundError if not found. 78 | FindAllByTimeCursor(ctx context.Context, after time.Time, limit int) ([]event.Event, error) 79 | 80 | // Find events, associated with specified stream ID, from the data-store. 81 | // The returned events are, ordered by older created at 82 | // and all of after specified after time. 83 | // It returns NotFoundError if not found. 84 | FindAllByStreamID(ctx context.Context, streamID event.StreamID, after time.Time, limit int) ([]event.Event, error) 85 | } 86 | -------------------------------------------------------------------------------- /chat/result/result.go: -------------------------------------------------------------------------------- 1 | // package result provides result of the command service. 2 | 3 | package result 4 | 5 | // AddRoomMember is result for the chat.CommandService.AddRoomMember(). 6 | type AddRoomMember struct { 7 | RoomID uint64 8 | UserID uint64 9 | } 10 | 11 | // RemoveRoomMember is result for the chat.CommandService.RemoveRoomMember(). 12 | type RemoveRoomMember AddRoomMember 13 | -------------------------------------------------------------------------------- /domain/conn.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "github.com/shirasudon/go-chat/domain/event" 4 | 5 | //go:generate mockgen -destination=../internal/mocks/mock_conn.go -package=mocks github.com/shirasudon/go-chat/domain Conn 6 | 7 | // Conn is a interface for the end-point connection for 8 | // sending domain event. 9 | type Conn interface { 10 | // It returns user specific id to distinguish which client 11 | // connect to. 12 | UserID() uint64 13 | 14 | // It sends any domain event to client. 15 | Send(ev event.Event) 16 | 17 | // Close close the underlying connection. 18 | // It should not panic when it is called multiple time, returnning error is OK. 19 | Close() error 20 | } 21 | -------------------------------------------------------------------------------- /domain/context.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | const txObjectKey = "_TRANSACTION_" 9 | 10 | // Get Tx object from context. 11 | func GetTx(ctx context.Context) (tx Tx, exists bool) { 12 | tx, exists = ctx.Value(txObjectKey).(Tx) 13 | if exists && tx != nil { 14 | return tx, exists 15 | } 16 | return nil, false 17 | } 18 | 19 | // set transaction to the new clild context and return it. 20 | func SetTx(ctx context.Context, tx Tx) context.Context { 21 | return context.WithValue(ctx, txObjectKey, tx) 22 | } 23 | 24 | // Tx is a interface for the transaction context. 25 | // the transaction must end by calling Commit() or Rollback(). 26 | // 27 | // Because the transaction object depends on the external 28 | // infrastructures such as Sql-like DB, 29 | // it can be used with type assertion to use full features 30 | // for the implementation-specific transaction object. 31 | type Tx interface { 32 | Commit() error 33 | Rollback() error 34 | } 35 | 36 | // TxBeginner can start transaction with context object. 37 | // TxOptions are typically used to specify 38 | // the transaction level. 39 | // A nil TxOptions means to use default transaction level. 40 | type TxBeginner interface { 41 | BeginTx(context.Context, *sql.TxOptions) (Tx, error) 42 | } 43 | 44 | // EmptyTxBeginner implements Tx and TxBeginner interfaces. 45 | // It is used to implement the repository with the no-operating 46 | // transaction, typically used as embedded struct. 47 | type EmptyTxBeginner struct{} 48 | 49 | func (EmptyTxBeginner) Commit() error { return nil } 50 | func (EmptyTxBeginner) Rollback() error { return nil } 51 | func (tx EmptyTxBeginner) BeginTx(context.Context, *sql.TxOptions) (Tx, error) { return tx, nil } 52 | -------------------------------------------------------------------------------- /domain/event/active_client.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | // domain event for the AcitiveClient is activated. 4 | type ActiveClientActivated struct { 5 | EventEmbd 6 | UserID uint64 `json:"user_id"` 7 | UserName string `json:"user_name"` 8 | } 9 | 10 | func (ActiveClientActivated) Type() Type { return TypeActiveClientActivated } 11 | 12 | // domain event for the AcitiveClient is inactivated. 13 | type ActiveClientInactivated struct { 14 | EventEmbd 15 | UserID uint64 `json:"user_id"` 16 | UserName string `json:"user_name"` 17 | } 18 | 19 | func (ActiveClientInactivated) Type() Type { return TypeActiveClientInactivated } 20 | -------------------------------------------------------------------------------- /domain/event/event.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | //go:generate mockgen -destination=../../internal/mocks/mock_events.go -package=mocks github.com/shirasudon/go-chat/domain/event EventRepository 9 | 10 | //go:generate stringer -type Type 11 | 12 | // EventRepository is a event data store which allows only create action. 13 | type EventRepository interface { 14 | // store events to the data-store. 15 | // It returns stored event's IDs and error if any. 16 | Store(ctx context.Context, ev ...Event) ([]uint64, error) 17 | } 18 | 19 | // Event is a domain event which is emitted when 20 | // domain objects, such as User, Room and Message, are 21 | // modified. 22 | type Event interface { 23 | // return its type 24 | Type() Type 25 | 26 | // return its stream ID 27 | StreamID() StreamID 28 | 29 | // return its time stamp. 30 | Timestamp() time.Time 31 | } 32 | 33 | // Type represents event type. 34 | type Type uint 35 | 36 | const ( 37 | TypeNone Type = iota 38 | TypeErrorRaised 39 | TypeUserCreated 40 | TypeUserDeleted 41 | TypeUserAddedFriend 42 | TypeRoomCreated 43 | TypeRoomDeleted 44 | TypeRoomAddedMember 45 | TypeRoomRemovedMember 46 | TypeRoomMessagesReadByUser 47 | TypeMessageCreated 48 | TypeActiveClientActivated 49 | TypeActiveClientInactivated 50 | TypeExternal 51 | ) 52 | 53 | // TypeStringer can return string representation of its event type. 54 | // The new Events defined by external_packages should implement this 55 | // to distinguish them, 56 | // because TypeExternal, which is returned from Type() method of new Event, 57 | // is always same value in different new Event types over several external_packages, 58 | type TypeStringer interface { 59 | TypeString() string 60 | } 61 | 62 | // TypeString returns string representation of Type of given Event. 63 | // It may return the result of TypeString() If Event implements TypeStringer interface. 64 | // It is used for the case defined new external event. 65 | func TypeString(ev Event) string { 66 | if ev, ok := ev.(TypeStringer); ok { 67 | return ev.TypeString() 68 | } 69 | return ev.Type().String() 70 | } 71 | 72 | // StreamID represents identification for what type of domain-entity. 73 | type StreamID uint 74 | 75 | const ( 76 | NoneStream StreamID = iota 77 | UserStream 78 | RoomStream 79 | MessageStream 80 | ) 81 | 82 | // Common embeded fields for Event. 83 | // It implements Event interface. 84 | type EventEmbd struct { 85 | CreatedAt time.Time `json:"created_at"` 86 | } 87 | 88 | // Occurs confirms the event has occured at a point. 89 | func (e *EventEmbd) Occurs() { e.CreatedAt = time.Now() } 90 | 91 | func (EventEmbd) Type() Type { return TypeNone } 92 | func (EventEmbd) StreamID() StreamID { return NoneStream } 93 | func (e EventEmbd) Timestamp() time.Time { return e.CreatedAt } 94 | 95 | // domain event for the error is raised. 96 | type ErrorRaised struct { 97 | EventEmbd 98 | Message string `json:"message"` 99 | } 100 | 101 | func (ErrorRaised) Type() Type { return TypeErrorRaised } 102 | 103 | // ExternalEventEmbd is embeded filelds for the new Event type 104 | // defined by external_packages. 105 | // 106 | // type NewEvent struct { 107 | // ExternalEventEmbd 108 | // // other fields... 109 | // } 110 | // 111 | // // distinguish from other new event types. 112 | // func (NewEvent) TypeString() string { return "type_new_event" } 113 | // 114 | type ExternalEventEmbd struct{ EventEmbd } 115 | 116 | func (ExternalEventEmbd) Type() Type { return TypeExternal } 117 | func (ExternalEventEmbd) TypeString() string { return "new_external_type" } 118 | -------------------------------------------------------------------------------- /domain/event/event_test.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "testing" 4 | 5 | func TestEventEmbd(t *testing.T) { 6 | for _, ev := range []struct { 7 | Name string 8 | Event 9 | ExpectType Type 10 | ExpectStreamID StreamID 11 | }{ 12 | {"EventEmbd", EventEmbd{}, TypeNone, NoneStream}, 13 | {"UserEventEmbd", UserEventEmbd{}, TypeNone, UserStream}, 14 | {"UserCreated", UserCreated{}, TypeUserCreated, UserStream}, 15 | {"UserAddedFriend", UserAddedFriend{}, TypeUserAddedFriend, UserStream}, 16 | {"RoomEventEmbd", RoomEventEmbd{}, TypeNone, RoomStream}, 17 | {"RoomCreated", RoomCreated{}, TypeRoomCreated, RoomStream}, 18 | {"RoomDeleted", RoomDeleted{}, TypeRoomDeleted, RoomStream}, 19 | {"RoomAddedMember", RoomAddedMember{}, TypeRoomAddedMember, RoomStream}, 20 | {"RoomMessagesReadByUser", RoomMessagesReadByUser{}, TypeRoomMessagesReadByUser, RoomStream}, 21 | {"MessageEventEmbd", MessageEventEmbd{}, TypeNone, MessageStream}, 22 | {"MessageCreated", MessageCreated{}, TypeMessageCreated, MessageStream}, 23 | {"ActiveClientActivated", ActiveClientActivated{}, TypeActiveClientActivated, NoneStream}, 24 | {"ActiveClientInactivated", ActiveClientInactivated{}, TypeActiveClientInactivated, NoneStream}, 25 | {"ExternalEventEmbd", ExternalEventEmbd{}, TypeExternal, NoneStream}, 26 | } { 27 | if ev.Type() != ev.ExpectType { 28 | t.Errorf("%v: different event type, got: %v, expect: %v", ev.Name, ev.Type(), ev.ExpectType) 29 | } 30 | if ev.StreamID() != ev.ExpectStreamID { 31 | t.Errorf("%v: different event stream id, got: %v, expect: %v", ev.Name, ev.StreamID(), ev.ExpectStreamID) 32 | } 33 | } 34 | } 35 | 36 | type NewEvent struct{ ExternalEventEmbd } 37 | 38 | func (NewEvent) TypeString() string { return "new_event" } 39 | 40 | func TestTypeString(t *testing.T) { 41 | ev := UserCreated{} 42 | if got, expect := TypeString(ev), ev.Type().String(); got != expect { 43 | t.Errorf("different type string, expect: %v, got: %v", expect, got) 44 | } 45 | 46 | if got, expect := TypeString(NewEvent{}), "new_event"; got != expect { 47 | t.Errorf("different type string, expect: %v, got: %v", expect, got) 48 | } 49 | 50 | if got, expect := TypeString(ExternalEventEmbd{}), "new_external_type"; got != expect { 51 | t.Errorf("different type string, expect: %v, got: %v", expect, got) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /domain/event/messages.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | // ----------------------- 4 | // Message events 5 | // ----------------------- 6 | 7 | // MessageEventEmbd is EventEmbd with message event specific meta-data. 8 | type MessageEventEmbd struct { 9 | EventEmbd 10 | } 11 | 12 | func (MessageEventEmbd) StreamID() StreamID { return MessageStream } 13 | 14 | // Event for the message is created. 15 | type MessageCreated struct { 16 | MessageEventEmbd 17 | MessageID uint64 `json:"message_id"` 18 | RoomID uint64 `json:"room_id"` 19 | CreatedBy uint64 `json:"created_by"` 20 | Content string `json:"content"` 21 | } 22 | 23 | func (MessageCreated) Type() Type { return TypeMessageCreated } 24 | -------------------------------------------------------------------------------- /domain/event/rooms.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | import "time" 4 | 5 | // ----------------------- 6 | // Room events 7 | // ----------------------- 8 | 9 | // RoomEventEmbd is EventEmbd with room event specific meta-data. 10 | type RoomEventEmbd struct { 11 | EventEmbd 12 | } 13 | 14 | func (RoomEventEmbd) StreamID() StreamID { return RoomStream } 15 | 16 | // Event for Room is created. 17 | type RoomCreated struct { 18 | RoomEventEmbd 19 | CreatedBy uint64 `json:"created_by"` 20 | RoomID uint64 `json:"room_id"` 21 | Name string `json:"name"` 22 | IsTalkRoom bool 23 | MemberIDs []uint64 `json:"member_ids"` 24 | } 25 | 26 | func (RoomCreated) Type() Type { return TypeRoomCreated } 27 | 28 | // Event for Room is deleted. 29 | type RoomDeleted struct { 30 | RoomEventEmbd 31 | DeletedBy uint64 `json:"deleted_by"` 32 | RoomID uint64 `json:"room_id"` 33 | Name string `json:"name"` 34 | IsTalkRoom bool 35 | MemberIDs []uint64 `json:"member_ids"` 36 | } 37 | 38 | func (RoomDeleted) Type() Type { return TypeRoomDeleted } 39 | 40 | // Event for Room added new member. 41 | type RoomAddedMember struct { 42 | RoomEventEmbd 43 | RoomID uint64 `json:"room_id"` 44 | AddedUserID uint64 `json:"added_user_id"` 45 | } 46 | 47 | func (RoomAddedMember) Type() Type { return TypeRoomAddedMember } 48 | 49 | // Event for Room removed a member. 50 | type RoomRemovedMember struct { 51 | RoomEventEmbd 52 | RoomID uint64 `json:"room_id"` 53 | RemovedUserID uint64 `json:"removed_user_id"` 54 | } 55 | 56 | func (RoomRemovedMember) Type() Type { return TypeRoomRemovedMember } 57 | 58 | // Event for the room messages are read by the user. 59 | type RoomMessagesReadByUser struct { 60 | RoomEventEmbd 61 | RoomID uint64 `json:"room_id"` 62 | UserID uint64 `json:"user_id"` 63 | ReadAt time.Time `json:"read_at"` 64 | } 65 | 66 | func (RoomMessagesReadByUser) Type() Type { return TypeRoomMessagesReadByUser } 67 | -------------------------------------------------------------------------------- /domain/event/type_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type Type"; DO NOT EDIT. 2 | 3 | package event 4 | 5 | import "strconv" 6 | 7 | const _Type_name = "TypeNoneTypeErrorRaisedTypeUserCreatedTypeUserDeletedTypeUserAddedFriendTypeRoomCreatedTypeRoomDeletedTypeRoomAddedMemberTypeRoomRemovedMemberTypeRoomMessagesReadByUserTypeMessageCreatedTypeActiveClientActivatedTypeActiveClientInactivatedTypeExternal" 8 | 9 | var _Type_index = [...]uint8{0, 8, 23, 38, 53, 72, 87, 102, 121, 142, 168, 186, 211, 238, 250} 10 | 11 | func (i Type) String() string { 12 | if i >= Type(len(_Type_index)-1) { 13 | return "Type(" + strconv.FormatInt(int64(i), 10) + ")" 14 | } 15 | return _Type_name[_Type_index[i]:_Type_index[i+1]] 16 | } 17 | -------------------------------------------------------------------------------- /domain/event/users.go: -------------------------------------------------------------------------------- 1 | package event 2 | 3 | // ----------------------- 4 | // User events 5 | // ----------------------- 6 | 7 | // UserEventEmbd is EventEmbd with user event specific meta-data. 8 | type UserEventEmbd struct { 9 | EventEmbd 10 | } 11 | 12 | func (UserEventEmbd) StreamID() StreamID { return UserStream } 13 | 14 | // Event for User is created. 15 | type UserCreated struct { 16 | UserEventEmbd 17 | Name string `json:"user_name"` 18 | FirstName string `json:"first_name"` 19 | LastName string `json:"last_name"` 20 | FriendIDs []uint64 `json:"friend_ids"` 21 | } 22 | 23 | func (UserCreated) Type() Type { return TypeUserCreated } 24 | 25 | // Event for User is created. 26 | type UserAddedFriend struct { 27 | UserEventEmbd 28 | UserID uint64 `json:"user_id"` 29 | AddedFriendID uint64 `json:"added_friend_id"` 30 | } 31 | 32 | func (UserAddedFriend) Type() Type { return TypeUserAddedFriend } 33 | -------------------------------------------------------------------------------- /domain/events.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "github.com/shirasudon/go-chat/domain/event" 5 | ) 6 | 7 | // EventHolder holds event objects. 8 | // It is used to embed into entity. 9 | type EventHolder struct { 10 | events []event.Event 11 | } 12 | 13 | func NewEventHolder() EventHolder { 14 | return EventHolder{ 15 | events: make([]event.Event, 0, 2), 16 | } 17 | } 18 | 19 | func (holder *EventHolder) Events() []event.Event { 20 | if holder.events == nil { 21 | holder.events = make([]event.Event, 0, 2) 22 | } 23 | newEvents := make([]event.Event, 0, len(holder.events)) 24 | for _, ev := range holder.events { 25 | newEvents = append(newEvents, ev) 26 | } 27 | return newEvents 28 | } 29 | 30 | func (holder *EventHolder) AddEvent(ev event.Event) { 31 | if holder.events == nil { 32 | holder.events = make([]event.Event, 0, 2) 33 | } 34 | holder.events = append(holder.events, ev) 35 | } 36 | -------------------------------------------------------------------------------- /domain/messages.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/shirasudon/go-chat/domain/event" 10 | ) 11 | 12 | //go:generate mockgen -destination=../internal/mocks/mock_messages.go -package=mocks github.com/shirasudon/go-chat/domain MessageRepository 13 | 14 | type MessageRepository interface { 15 | TxBeginner 16 | 17 | Find(ctx context.Context, msgID uint64) (Message, error) 18 | 19 | // Store stores given message to the repository. 20 | // user need not to set ID for message since it is auto set 21 | // when message is newly. 22 | // It returns stored Message ID and error. 23 | Store(ctx context.Context, m Message) (uint64, error) 24 | 25 | // RemoveAllByRoomID removes all messages related with roomID. 26 | RemoveAllByRoomID(ctx context.Context, roomID uint64) error 27 | } 28 | 29 | type Message struct { 30 | EventHolder 31 | 32 | // ID and CreatedAt are auto set. 33 | ID uint64 `db:"id"` 34 | CreatedAt time.Time `db:"created_at"` 35 | 36 | Content string `db:"content"` 37 | UserID uint64 `db:"user_id"` 38 | RoomID uint64 `db:"room_id"` 39 | Deleted bool `db:"deleted"` 40 | } 41 | 42 | // NewRoomMessage creates new message for the specified room. 43 | // The created message is immediately stored into the repository. 44 | // It returns new message holding event message created and error if any. 45 | func NewRoomMessage( 46 | ctx context.Context, 47 | msgs MessageRepository, 48 | u User, 49 | r Room, 50 | content string, 51 | ) (Message, error) { 52 | if u.NotExist() { 53 | return Message{}, errors.New("the user not in the datastore, can not create new message") 54 | } 55 | if r.NotExist() { 56 | return Message{}, errors.New("the room not in the datastore, can not create new message") 57 | } 58 | if !r.HasMember(u) { 59 | return Message{}, fmt.Errorf("user(id=%d) not a member of the room(id=%d), can not create message", u.ID, r.ID) 60 | } 61 | 62 | m := Message{ 63 | EventHolder: NewEventHolder(), 64 | ID: 0, 65 | CreatedAt: time.Now(), 66 | Content: content, 67 | UserID: u.ID, 68 | RoomID: r.ID, 69 | Deleted: false, 70 | } 71 | id, err := msgs.Store(ctx, m) 72 | if err != nil { 73 | return Message{}, err 74 | } 75 | m.ID = id 76 | 77 | ev := event.MessageCreated{ 78 | MessageID: m.ID, 79 | RoomID: m.RoomID, 80 | CreatedBy: u.ID, 81 | Content: content, 82 | } 83 | ev.Occurs() 84 | m.AddEvent(ev) 85 | 86 | return m, nil 87 | } 88 | 89 | func (m *Message) NotExist() bool { 90 | return m == nil || m.ID == 0 91 | } 92 | -------------------------------------------------------------------------------- /domain/messages_test.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | "time" 8 | 9 | "github.com/shirasudon/go-chat/domain/event" 10 | ) 11 | 12 | type MessageRepositoryStub struct{} 13 | 14 | func (m *MessageRepositoryStub) BeginTx(context.Context, *sql.TxOptions) (Tx, error) { 15 | panic("not implemented") 16 | } 17 | 18 | func (m *MessageRepositoryStub) Find(ctx context.Context, msgID uint64) (Message, error) { 19 | panic("not implemented") 20 | } 21 | 22 | func (m *MessageRepositoryStub) FindAllByRoomIDOrderByLatest(ctx context.Context, roomID uint64, n int) ([]Message, error) { 23 | panic("not implemented") 24 | } 25 | 26 | func (m *MessageRepositoryStub) FindPreviousMessagesOrderByLatest(ctx context.Context, offset Message, n int) ([]Message, error) { 27 | panic("not implemented") 28 | } 29 | 30 | func (m *MessageRepositoryStub) Store(ctx context.Context, msg Message) (uint64, error) { 31 | return msg.ID + 1, nil 32 | } 33 | 34 | func (m *MessageRepositoryStub) RemoveAllByRoomID(ctx context.Context, roomID uint64) error { 35 | panic("not implemented") 36 | } 37 | 38 | var msgRepo MessageRepository = &MessageRepositoryStub{} 39 | 40 | func TestMessageCreatedSuccess(t *testing.T) { 41 | var ( 42 | ctx = context.Background() 43 | user = User{ID: 1} 44 | room = Room{ID: 1} 45 | ) 46 | room.AddMember(user) 47 | m, err := NewRoomMessage(ctx, msgRepo, user, room, "content") 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | 52 | // check whether message has valid ID 53 | if m.ID == 0 { 54 | t.Fatalf("message is created but has invalid ID(%d)", m.ID) 55 | } 56 | 57 | // check whether message created event is valid. 58 | events := m.Events() 59 | if len(events) != 1 { 60 | t.Fatalf("Message is created but message has no event for that.") 61 | } 62 | ev, ok := events[0].(event.MessageCreated) 63 | if !ok { 64 | t.Fatalf("Message is created but event is not a MessageCreated, got: %v", events[0]) 65 | } 66 | if got := ev.MessageID; got != m.ID { 67 | t.Errorf("MessageCreated has different messageID, expect: %v, got: %v", m.ID, got) 68 | } 69 | if got := ev.Timestamp(); got == (time.Time{}) { 70 | t.Error("MessageCreated has no timestamp") 71 | } 72 | } 73 | 74 | func TestMessageCreatedFail(t *testing.T) { 75 | var ( 76 | ctx = context.Background() 77 | ) 78 | 79 | for _, testcase := range []struct { 80 | User 81 | Room 82 | }{ 83 | {User{ID: 0}, Room{ID: 1}}, 84 | {User{ID: 1}, Room{ID: 0}}, 85 | {User{ID: 0}, Room{ID: 0}}, 86 | } { 87 | user, room := testcase.User, testcase.Room 88 | room.AddMember(user) 89 | _, err := NewRoomMessage(ctx, msgRepo, user, room, "content") 90 | if err == nil { 91 | t.Errorf("invalid combination of user and room, but no error: user(%d), room(%d)", user.ID, room.ID) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /domain/repositories.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import "github.com/shirasudon/go-chat/domain/event" 4 | 5 | //go:generate mockgen -destination=../internal/mocks/mock_repos.go -package=mocks github.com/shirasudon/go-chat/domain Repositories 6 | 7 | // Repositories holds any XXXRepository. 8 | // you can get each repository from this. 9 | type Repositories interface { 10 | Users() UserRepository 11 | Messages() MessageRepository 12 | Rooms() RoomRepository 13 | 14 | Events() event.EventRepository 15 | } 16 | 17 | // SimpleRepositories implementes Repositories interface. 18 | // It acts just returning its fields when interface 19 | // methods, Users(), Messages() and Rooms(), are called. 20 | type SimpleRepositories struct { 21 | UserRepository UserRepository 22 | MessageRepository MessageRepository 23 | RoomRepository RoomRepository 24 | 25 | EventRepository event.EventRepository 26 | } 27 | 28 | func (s SimpleRepositories) Users() UserRepository { 29 | return s.UserRepository 30 | } 31 | 32 | func (s SimpleRepositories) Messages() MessageRepository { 33 | return s.MessageRepository 34 | } 35 | 36 | func (s SimpleRepositories) Rooms() RoomRepository { 37 | return s.RoomRepository 38 | } 39 | 40 | func (s SimpleRepositories) Events() event.EventRepository { 41 | return s.EventRepository 42 | } 43 | -------------------------------------------------------------------------------- /domain/users.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/shirasudon/go-chat/domain/event" 8 | ) 9 | 10 | //go:generate mockgen -destination=../internal/mocks/mock_users.go -package=mocks github.com/shirasudon/go-chat/domain UserRepository 11 | 12 | type UserRepository interface { 13 | TxBeginner 14 | 15 | // Store specified user to the repository, and return user id 16 | // for stored new user. 17 | Store(context.Context, User) (uint64, error) 18 | 19 | // Find one user by id. 20 | Find(ctx context.Context, id uint64) (User, error) 21 | } 22 | 23 | // set for user id. 24 | type UserIDSet struct { 25 | idMap map[uint64]bool 26 | } 27 | 28 | func NewUserIDSet(ids ...uint64) UserIDSet { 29 | idMap := make(map[uint64]bool, len(ids)) 30 | for _, id := range ids { 31 | idMap[id] = true 32 | } 33 | return UserIDSet{idMap} 34 | } 35 | 36 | func (set *UserIDSet) getIDMap() map[uint64]bool { 37 | if set.idMap == nil { 38 | set.idMap = make(map[uint64]bool, 4) 39 | } 40 | return set.idMap 41 | } 42 | 43 | func (set *UserIDSet) Has(id uint64) bool { 44 | _, ok := set.getIDMap()[id] 45 | return ok 46 | } 47 | 48 | func (set *UserIDSet) Add(id uint64) { 49 | set.getIDMap()[id] = true 50 | } 51 | 52 | func (set *UserIDSet) Remove(id uint64) { 53 | delete(set.getIDMap(), id) 54 | } 55 | 56 | // It returns a deep copy of the ID list. 57 | func (set *UserIDSet) List() []uint64 { 58 | idMap := set.getIDMap() 59 | ids := make([]uint64, 0, len(idMap)) 60 | for id, _ := range idMap { 61 | ids = append(ids, id) 62 | } 63 | return ids 64 | } 65 | 66 | // User entity. Its fields are exported 67 | // due to construct from the datastore. 68 | // In application side, creating/modifying/deleting the user 69 | // should be done by the methods which emits the domain event. 70 | type User struct { 71 | EventHolder 72 | 73 | ID uint64 74 | Name string 75 | FirstName string 76 | LastName string 77 | Password string 78 | 79 | FriendIDs UserIDSet 80 | } 81 | 82 | // TODO validation 83 | 84 | // create new Room entity into the repository. It retruns the new user 85 | // holding event for UserCreated and error if any. 86 | func NewUser( 87 | ctx context.Context, 88 | userRepo UserRepository, 89 | name, firstName, lastName, password string, 90 | friendIDs UserIDSet, 91 | ) (User, error) { 92 | u := User{ 93 | EventHolder: NewEventHolder(), 94 | ID: 0, // 0 means new entity 95 | Name: name, 96 | FirstName: firstName, 97 | LastName: lastName, 98 | Password: password, 99 | FriendIDs: friendIDs, 100 | } 101 | 102 | id, err := userRepo.Store(ctx, u) 103 | if err != nil { 104 | return u, err 105 | } 106 | u.ID = id 107 | 108 | ev := event.UserCreated{ 109 | Name: name, 110 | FirstName: firstName, 111 | LastName: lastName, 112 | FriendIDs: friendIDs.List(), 113 | } 114 | ev.Occurs() 115 | u.AddEvent(ev) 116 | 117 | return u, nil 118 | } 119 | 120 | // return whether user is not in the datastore. 121 | func (u *User) NotExist() bool { return u == nil || u.ID == 0 } 122 | 123 | // It adds the friend to the user. 124 | // It returns the event adding into the user, and error 125 | // when the friend already exist in the user. 126 | func (u *User) AddFriend(friend User) (event.UserAddedFriend, error) { 127 | if u.ID == 0 { 128 | return event.UserAddedFriend{}, fmt.Errorf("newly user can not be added friend") 129 | } 130 | if u.ID == friend.ID { 131 | return event.UserAddedFriend{}, fmt.Errorf("can not add user itself as friend") 132 | } 133 | if u.HasFriend(friend) { 134 | return event.UserAddedFriend{}, fmt.Errorf("friend(id=%d) already exist in the user(id=%d)", friend.ID, u.ID) 135 | } 136 | 137 | u.FriendIDs.Add(friend.ID) 138 | 139 | ev := event.UserAddedFriend{ 140 | UserID: u.ID, 141 | AddedFriendID: friend.ID, 142 | } 143 | ev.Occurs() 144 | u.AddEvent(ev) 145 | return ev, nil 146 | } 147 | 148 | // It returns whether the user has specified friend? 149 | func (u *User) HasFriend(friend User) bool { 150 | return u.FriendIDs.Has(friend.ID) 151 | } 152 | -------------------------------------------------------------------------------- /domain/users_test.go: -------------------------------------------------------------------------------- 1 | package domain 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | "time" 8 | 9 | "github.com/shirasudon/go-chat/domain/event" 10 | ) 11 | 12 | type UserRepositoryStub struct{} 13 | 14 | func (u *UserRepositoryStub) BeginTx(context.Context, *sql.TxOptions) (Tx, error) { 15 | panic("not implemented") 16 | } 17 | 18 | func (uu *UserRepositoryStub) Store(ctx context.Context, u User) (uint64, error) { 19 | return u.ID + 1, nil 20 | } 21 | 22 | func (u *UserRepositoryStub) Find(ctx context.Context, id uint64) (User, error) { 23 | panic("not implemented") 24 | } 25 | 26 | var userRepo = &UserRepositoryStub{} 27 | 28 | func TestUserCreated(t *testing.T) { 29 | ctx := context.Background() 30 | u, err := NewUser(ctx, userRepo, "user", "u-", "ser", "password", NewUserIDSet(1)) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | if u.ID == 0 { 36 | t.Fatalf("user is created but has invalid ID(%d)", u.ID) 37 | } 38 | 39 | // check whether user has one event, 40 | events := u.Events() 41 | if got := len(events); got != 1 { 42 | t.Errorf("user has no event after UserCreated") 43 | } 44 | ev, ok := events[0].(event.UserCreated) 45 | if !ok { 46 | t.Errorf("invalid event state for the user") 47 | } 48 | 49 | // check whether user created event is valid. 50 | if got := ev.Name; got != "user" { 51 | t.Errorf("UserCreated has different user name, expect: %s, got: %s", "user", got) 52 | } 53 | if got := ev.FirstName; got != "u-" { 54 | t.Errorf("UserCreated has different first name, expect: %s, got: %s", "u-", got) 55 | } 56 | if got := ev.LastName; got != "ser" { 57 | t.Errorf("UserCreated has different last name, expect: %s, got: %s", "ser", got) 58 | } 59 | if got := len(ev.FriendIDs); got != 1 { 60 | t.Errorf("UseerCreated has dieffrent friends size, expect: %d, got: %d", 1, got) 61 | } 62 | if got := ev.Timestamp(); got == (time.Time{}) { 63 | t.Error("UserCreated has no timestamp") 64 | } 65 | 66 | } 67 | 68 | func TestUserAddFriendSuccess(t *testing.T) { 69 | ctx := context.Background() 70 | u, _ := NewUser(ctx, userRepo, "user", "u-", "ser", "password", NewUserIDSet()) 71 | u.ID = 1 // it may not be allowed at application side. 72 | friend := User{ID: u.ID + 1} 73 | ev, err := u.AddFriend(friend) 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | if got := ev.UserID; got != u.ID { 78 | t.Errorf("UserAddedFriend has different user id, expect: %d, got: %d", u.ID, got) 79 | } 80 | if got := ev.AddedFriendID; got != friend.ID { 81 | t.Errorf("UserAddedFriend has different friend id, expect: %d, got: %d", friend.ID, got) 82 | } 83 | if got := ev.Timestamp(); got == (time.Time{}) { 84 | t.Error("UserAddedFriend has no timestamp") 85 | } 86 | 87 | if !u.HasFriend(friend) { 88 | t.Errorf("AddFriend could not add friend to the user") 89 | } 90 | 91 | // user has two events: Created, AddedFriend. 92 | if got := len(u.Events()); got != 2 { 93 | t.Errorf("user has no event") 94 | } 95 | if _, ok := u.Events()[1].(event.UserAddedFriend); !ok { 96 | t.Errorf("invalid event is added") 97 | } 98 | } 99 | 100 | func TestUserAddFriendFail(t *testing.T) { 101 | // fail case: Add itself as friend. 102 | ctx := context.Background() 103 | u, _ := NewUser(ctx, userRepo, "user", "u-", "ser", "password", NewUserIDSet()) 104 | u.ID = 1 // it may not be allowed at application side. 105 | _, err := u.AddFriend(u) 106 | if err == nil { 107 | t.Fatal("add itself as friend but no error") 108 | } 109 | 110 | // user has one events: Created. 111 | if got := len(u.Events()); got != 1 { 112 | t.Errorf("user has invalid event state") 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /infra/_sqlite3/gentable.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "github.com/shirasudon/go-chat/entity/sqlite3" 7 | "log" 8 | "os" 9 | ) 10 | 11 | const DBFile = "_test.sqlite3" 12 | 13 | func main() { 14 | if err := os.Remove(DBFile); err != nil { 15 | log.Fatal(err) 16 | } 17 | 18 | repos, err := sqlite3.RepositoryProducer(DBFile) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | defer repos.Close() 23 | 24 | DB := repos.(*sqlite3.Repositories).DB 25 | if _, err := DB.Exec(`CREATE TABLE users ( 26 | "id" INTEGER PRIMARY_KEY AUTOINCREMENT, 27 | "name" VARCHAR(100), 28 | "password" VARCHAR(100), 29 | )`); err != nil { 30 | log.Fatal(err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /infra/_sqlite3/messages.go: -------------------------------------------------------------------------------- 1 | package sqlite3 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shirasudon/go-chat/entity" 7 | ) 8 | 9 | type MessageRepository struct{} 10 | 11 | func (mr MessageRepository) LatestRoomMessages(ctx context.Context, roomID uint64, n int) ([]entity.Message, error) { 12 | panic("not implemented") 13 | } 14 | 15 | func (mr MessageRepository) PreviousRoomMessages(ctx context.Context, offset entity.Message, n int) ([]entity.Message, error) { 16 | panic("not implemented") 17 | } 18 | 19 | func (mr MessageRepository) Add(ctx context.Context, m entity.Message) (uint64, error) { 20 | panic("not implemented") 21 | } 22 | 23 | func (mr MessageRepository) ReadMessage(ctx context.Context, roomID, userID uint64, messageIDs []uint64) error { 24 | panic("not implemented") 25 | } 26 | -------------------------------------------------------------------------------- /infra/_sqlite3/repos.go: -------------------------------------------------------------------------------- 1 | package sqlite3 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | _ "github.com/mattn/go-sqlite3" 6 | "github.com/shirasudon/go-chat/entity" 7 | ) 8 | 9 | func init() { 10 | entity.RepositoryProducer = RepositoryProducer 11 | } 12 | 13 | func RepositoryProducer(dataSourceName string) (entity.Repositories, error) { 14 | db, err := sqlx.Open("sqlite3", dataSourceName) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | uRepo, err := newUserRepository(db) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | rRepo, err := newRoomRepository(db) 25 | if err != nil { 26 | return nil, err 27 | } 28 | uRepo.rooms = rRepo 29 | rRepo.users = uRepo 30 | 31 | return &Repositories{ 32 | DB: db, 33 | UserRepository: uRepo, 34 | MessageRepository: nil, // TODO implement 35 | RoomRepository: rRepo, 36 | }, nil 37 | } 38 | 39 | type Repositories struct { 40 | DB *sqlx.DB 41 | *UserRepository 42 | *MessageRepository 43 | *RoomRepository 44 | } 45 | 46 | func (r Repositories) Users() entity.UserRepository { 47 | return r.UserRepository 48 | } 49 | 50 | func (r Repositories) Messages() entity.MessageRepository { 51 | panic("entity/sqlite: TODO: not implement") 52 | return r.MessageRepository 53 | } 54 | 55 | func (r Repositories) Rooms() entity.RoomRepository { 56 | panic("entity/sqlite: TODO: not implement") 57 | return r.RoomRepository 58 | } 59 | 60 | func (r Repositories) BeginTx(ctx context.Context) (entity.Tx, error) { 61 | return r.DB.BeginTxx(ctx, nil) 62 | } 63 | 64 | func (r Repositories) Close() error { 65 | r.UserRepository.close() 66 | return r.DB.Close() 67 | } 68 | -------------------------------------------------------------------------------- /infra/_sqlite3/rooms.go: -------------------------------------------------------------------------------- 1 | package sqlite3 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/shirasudon/go-chat/entity" 8 | ) 9 | 10 | type RoomRepository struct { 11 | db *sqlx.DB 12 | 13 | users entity.UserRepository 14 | } 15 | 16 | func newRoomRepository(db *sqlx.DB) (*RoomRepository, error) { 17 | return &RoomRepository{ 18 | db: db, 19 | }, nil 20 | } 21 | 22 | func (repo RoomRepository) GetUserRooms(ctx context.Context, userID uint64) ([]entity.Room, error) { 23 | panic("not implemented") 24 | } 25 | 26 | func (repo RoomRepository) Add(ctx context.Context, r entity.Room) (uint64, error) { 27 | panic("not implemented") 28 | } 29 | 30 | func (repo RoomRepository) Remove(ctx context.Context, r entity.Room) error { 31 | panic("not implemented") 32 | } 33 | 34 | func (repo RoomRepository) Find(ctx context.Context, roomID uint64) (entity.Room, error) { 35 | panic("not implemented") 36 | } 37 | 38 | func (repo RoomRepository) RoomHasMember(ctx context.Context, roomID uint64, userID uint64) bool { 39 | panic("not implemented") 40 | } 41 | -------------------------------------------------------------------------------- /infra/_sqlite3/users.go: -------------------------------------------------------------------------------- 1 | package sqlite3 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/jmoiron/sqlx" 8 | "github.com/shirasudon/go-chat/entity" 9 | ) 10 | 11 | // UserRepository manages access to user table in sqlite database. 12 | // It must be singleton object since database conncetion is so. 13 | type UserRepository struct { 14 | db *sqlx.DB 15 | findByID *sqlx.Stmt 16 | findByNameAndPassword *sqlx.Stmt 17 | insertUser *sqlx.Stmt 18 | 19 | findFriendsByUserID *sqlx.Stmt 20 | 21 | rooms entity.RoomRepository 22 | } 23 | 24 | const ( 25 | userQueryFindByID = ` 26 | SELECT * FROM users where id=$1 LIMIT 1` 27 | userQueryFindByNameAndPassword = ` 28 | SELECT * FROM users WHERE name=$1 and password=$2 LIMIT 1` 29 | userNamedQueryInsertByUser = ` 30 | INSERT INTO users (name, password) VALUES (:name, :password)` 31 | 32 | userQueryFindFriendsByUserID = ` 33 | SELECT * FROM users INNER JOIN user_friends ON users.id = user_friends.user_id 34 | WHERE users.id = $1 ORDER BY users.id ASC` 35 | ) 36 | 37 | func newUserRepository(db *sqlx.DB) (*UserRepository, error) { 38 | findByID, err := db.Preparex(userQueryFindByID) 39 | if err != nil { 40 | return nil, err 41 | } 42 | findByNameAndPassword, err := db.Preparex(userQueryFindByNameAndPassword) 43 | if err != nil { 44 | return nil, err 45 | } 46 | insertUser, err := db.Preparex(userNamedQueryInsertByUser) 47 | if err != nil { 48 | return nil, err 49 | } 50 | findFriendsByUserID, err := db.Preparex(userQueryFindFriendsByUserID) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return &UserRepository{ 56 | db: db, 57 | findByID: findByID, 58 | findByNameAndPassword: findByNameAndPassword, 59 | insertUser: insertUser, 60 | findFriendsByUserID: findFriendsByUserID, 61 | }, nil 62 | } 63 | 64 | func (repo *UserRepository) close() { 65 | for _, stmt := range []*sqlx.Stmt{ 66 | repo.findByID, 67 | repo.findByNameAndPassword, 68 | repo.insertUser, 69 | repo.findFriendsByUserID, 70 | } { 71 | stmt.Close() 72 | } 73 | repo.rooms = nil 74 | } 75 | 76 | func (repo *UserRepository) FindByNameAndPassword(name string, password string) (entity.User, error) { 77 | u := entity.User{} 78 | err := repo.findByNameAndPassword.Get(&u, name, password) 79 | return u, err 80 | } 81 | 82 | func (repo *UserRepository) Save(u entity.User) (uint64, error) { 83 | res, err := repo.insertUser.Exec(&u) 84 | if err != nil { 85 | return 0, err 86 | } 87 | id, err := res.LastInsertId() 88 | return uint64(id), err 89 | } 90 | 91 | func (repo *UserRepository) ExistByNameAndPassword(name string, password string) bool { 92 | _, err := repo.FindByNameAndPassword(name, password) 93 | return err == nil 94 | } 95 | 96 | func (repo *UserRepository) Find(id uint64) (entity.User, error) { 97 | u := entity.User{} 98 | err := repo.findByID.Get(&u, id) 99 | return u, err 100 | } 101 | 102 | func (repo *UserRepository) Relation(ctx context.Context, userID uint64) (entity.UserRelation, error) { 103 | var relaion entity.UserRelation 104 | // validate existance of repo.rooms to use it. 105 | if repo.rooms == nil { 106 | return relaion, errors.New("UserRepository does not have RoomRepository, be sure set it") 107 | } 108 | 109 | if err := repo.findFriendsByUserID.SelectContext(ctx, &(relaion.Friends), userID); err != nil { 110 | return relaion, err 111 | } 112 | var err error 113 | relaion.Rooms, err = repo.rooms.GetUserRooms(ctx, userID) 114 | return relaion, err 115 | } 116 | -------------------------------------------------------------------------------- /infra/config/config.go: -------------------------------------------------------------------------------- 1 | // package config provides functions to parse server.Config from 2 | // external file. 3 | 4 | package config 5 | 6 | //go:generate go run gen_example.go 7 | -------------------------------------------------------------------------------- /infra/config/example/config.toml: -------------------------------------------------------------------------------- 1 | HTTP = "localhost:8080" 2 | ChatAPIPrefix = "" 3 | StaticHandlerPrefix = "" 4 | StaticFileDir = "" 5 | EnableServeStaticFile = true 6 | ShowRoutes = true 7 | -------------------------------------------------------------------------------- /infra/config/gen_example.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | package main 4 | 5 | // this file generates example toml file into ./example/ 6 | 7 | import ( 8 | "log" 9 | "os" 10 | 11 | "github.com/BurntSushi/toml" 12 | 13 | "github.com/shirasudon/go-chat/server" 14 | ) 15 | 16 | const WriteFile = "./example/config.toml" 17 | 18 | func main() { 19 | conf := server.DefaultConfig 20 | 21 | fp, err := os.Create(WriteFile) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | defer fp.Close() 26 | 27 | if err := toml.NewEncoder(fp).Encode(conf); err != nil { 28 | log.Fatal(err) 29 | } 30 | log.Println("write default config to", WriteFile) 31 | } 32 | -------------------------------------------------------------------------------- /infra/config/toml.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/BurntSushi/toml" 9 | 10 | "github.com/shirasudon/go-chat/server" 11 | ) 12 | 13 | // FileExists returns whether given file path is exist? 14 | func FileExists(file string) bool { 15 | _, err := os.Stat(file) 16 | return err == nil 17 | } 18 | 19 | // it loads the configuration from file into dest. 20 | // it returns load error if any. 21 | func LoadFile(dest *server.Config, file string) error { 22 | fp, err := os.Open(file) 23 | if err != nil { 24 | return err 25 | } 26 | defer fp.Close() 27 | return LoadByte(dest, fp) 28 | } 29 | 30 | // it loads the configuration from io.Reader into dest. 31 | // it returns load error if any. 32 | func LoadByte(dest *server.Config, r io.Reader) error { 33 | if err := decode(r, dest); err != nil { 34 | return fmt.Errorf("infra/config: %v", err) 35 | } 36 | if err := dest.Validate(); err != nil { 37 | return fmt.Errorf("infra/config: validation erorr: %v", err) 38 | } 39 | return nil 40 | } 41 | 42 | // decode from reader and store it to data. 43 | func decode(r io.Reader, data interface{}) error { 44 | meta, err := toml.DecodeReader(r, data) 45 | if undecoded := meta.Undecoded(); undecoded != nil && len(undecoded) > 0 { 46 | fmt.Fprintln(os.Stderr, "infra/config.Decode:", "undecoded keys exist,", undecoded) 47 | } 48 | return err 49 | } 50 | -------------------------------------------------------------------------------- /infra/config/toml_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/BurntSushi/toml" 9 | 10 | "github.com/shirasudon/go-chat/server" 11 | ) 12 | 13 | const ( 14 | ExampleFile = "./example/config.toml" 15 | NotFoundFile = "path/to/not/found" 16 | ) 17 | 18 | func TestFileExists(t *testing.T) { 19 | t.Parallel() 20 | if !FileExists(ExampleFile) { 21 | t.Error("exist file is not detected") 22 | } 23 | 24 | if FileExists(NotFoundFile) { 25 | t.Error("not exist file is detected") 26 | } 27 | } 28 | 29 | func TestLoadFile(t *testing.T) { 30 | t.Parallel() 31 | conf := server.Config{} 32 | if err := LoadFile(&conf, ExampleFile); err != nil { 33 | t.Fatal(err) 34 | } 35 | if conf != server.DefaultConfig { 36 | t.Errorf("different config value, expect: %#v, got: %#v", server.DefaultConfig, conf) 37 | } 38 | } 39 | 40 | func TestLoadFileNotFound(t *testing.T) { 41 | t.Parallel() 42 | conf := server.Config{} 43 | if err := LoadFile(&conf, NotFoundFile); err == nil { 44 | t.Fatal("not found file is given, but no error") 45 | } 46 | if conf != (server.Config{}) { 47 | t.Errorf("failed to load external config, but unexpected values are set: %#v", conf) 48 | } 49 | } 50 | 51 | func TestLoadByteInvalid(t *testing.T) { 52 | t.Parallel() 53 | conf := server.DefaultConfig 54 | conf.HTTP = "invalid string" 55 | 56 | buf := new(bytes.Buffer) 57 | if _, err := toml.DecodeReader(buf, &conf); err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | dest := server.Config{} 62 | if err := LoadByte(&dest, buf); err == nil { 63 | t.Errorf("invalid config.HTTP is given, but no error") 64 | } 65 | } 66 | 67 | func TestLoadByteOverwrite(t *testing.T) { 68 | t.Parallel() 69 | 70 | // over writes only Static* fields 71 | const ConfigBody = ` 72 | # HTTP = "localhost:8080" 73 | # ChatAPIPrefix = "" 74 | StaticHandlerPrefix = "/static" 75 | StaticFileDir = "public" 76 | # EnableServeStaticFile = true 77 | # ShowRoutes = true` 78 | 79 | buf := strings.NewReader(ConfigBody) 80 | dest := server.DefaultConfig 81 | if err := LoadByte(&dest, buf); err != nil { 82 | t.Fatal(err) 83 | } 84 | 85 | defaultC := server.DefaultConfig 86 | if dest.HTTP != defaultC.HTTP || 87 | dest.ShowRoutes != defaultC.ShowRoutes || 88 | dest.EnableServeStaticFile != defaultC.EnableServeStaticFile || 89 | dest.ChatAPIPrefix != defaultC.ChatAPIPrefix { 90 | t.Errorf("comment-outed values are overwritten, got: %#v", dest) 91 | } 92 | 93 | if dest.StaticFileDir != "public" || dest.StaticHandlerPrefix != "/static" { 94 | t.Errorf("overwritten values are not changed, got: %#v", dest) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /infra/inmemory/events.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/shirasudon/go-chat/chat" 9 | "github.com/shirasudon/go-chat/domain/event" 10 | ) 11 | 12 | type EventRepository struct{} 13 | 14 | var ( 15 | eventStore = make([]event.Event, 0, 16) 16 | eventStoreMu = new(sync.RWMutex) 17 | ) 18 | 19 | func (EventRepository) Store(ctx context.Context, ev ...event.Event) ([]uint64, error) { 20 | if len(ev) == 0 { 21 | return []uint64{}, nil 22 | } 23 | 24 | eventStoreMu.Lock() 25 | 26 | insertIdx := uint64(len(eventStore)) + 1 27 | eventStore = append(eventStore, ev...) 28 | 29 | eventStoreMu.Unlock() 30 | 31 | ids := make([]uint64, 0, len(ev)) 32 | for i, _ := range ev { 33 | ids = append(ids, insertIdx+uint64(i)) 34 | } 35 | return ids, nil 36 | } 37 | 38 | func (EventRepository) FindAllByTimeCursor(ctx context.Context, after time.Time, limit int) ([]event.Event, error) { 39 | ret := make([]event.Event, 0, limit) 40 | if limit == 0 { 41 | return ret, nil 42 | } 43 | 44 | eventStoreMu.RLock() 45 | defer eventStoreMu.RUnlock() 46 | 47 | const NotFound = -99 48 | var startAt int = NotFound 49 | for i, ev := range eventStore { 50 | if ev.Timestamp().After(after) { 51 | startAt = i - 1 52 | break 53 | } 54 | } 55 | 56 | if startAt == NotFound { 57 | return ret, chat.NewNotFoundError("event not exist after %v", after) 58 | } 59 | 60 | // edge case: events[0] is after given time. 61 | if startAt == -1 { 62 | startAt = 0 63 | } 64 | 65 | if startAt+limit > len(eventStore) { 66 | return append(ret, eventStore[startAt:len(eventStore)]...), nil 67 | } else { 68 | return append(ret, eventStore[startAt:startAt+limit]...), nil 69 | } 70 | } 71 | 72 | func (EventRepository) FindAllByStreamID(ctx context.Context, streamID event.StreamID, after time.Time, limit int) ([]event.Event, error) { 73 | ret := make([]event.Event, 0, limit) 74 | if limit == 0 { 75 | return ret, nil 76 | } 77 | 78 | eventStoreMu.RLock() 79 | defer eventStoreMu.RUnlock() 80 | 81 | for _, ev := range eventStore { 82 | if ev.StreamID() != streamID { 83 | continue 84 | } 85 | 86 | ret = append(ret, ev) 87 | if len(ret) == limit { 88 | break 89 | } 90 | } 91 | 92 | return ret, nil 93 | } 94 | -------------------------------------------------------------------------------- /infra/inmemory/events_test.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/shirasudon/go-chat/domain/event" 9 | ) 10 | 11 | var ( 12 | eventRepo = EventRepository{} 13 | ) 14 | 15 | func TestEventStore(t *testing.T) { 16 | // case 1: store single event 17 | ev := event.UserCreated{} 18 | ev.Occurs() 19 | ids, err := eventRepo.Store(context.Background(), ev) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | if len(ids) != 1 { 24 | t.Fatalf("different event id size, expect: %v, got: %v", 1, len(ids)) 25 | } 26 | 27 | if ids[0] != 1 { 28 | t.Errorf("different inserted event id, expect: %v, got: %v", 1, len(ids)) 29 | } 30 | 31 | // case 2: store multiple events 32 | ev2 := event.RoomCreated{} 33 | ev2.Occurs() 34 | ev3 := event.MessageCreated{} 35 | ev3.Occurs() 36 | ids, err = eventRepo.Store(context.Background(), ev2, ev3) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | if len(ids) != 2 { 41 | t.Fatalf("different event id size, expect: %v, got: %v", 2, len(ids)) 42 | } 43 | 44 | if ids[0] != 2 { 45 | t.Errorf("different inserted event id, expect: %v, got: %v", 2, len(ids)) 46 | } 47 | if ids[1] != 3 { 48 | t.Errorf("different inserted event id, expect: %v, got: %v", 3, len(ids)) 49 | } 50 | } 51 | 52 | func TestEventFindAllByTimeCursor(t *testing.T) { 53 | firstEvent := eventStore[0].(event.UserCreated) 54 | 55 | // case 1: find single result 56 | evs, err := eventRepo.FindAllByTimeCursor( 57 | context.Background(), 58 | firstEvent.Timestamp().Add(-1*time.Second), 59 | 1, 60 | ) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | if len(evs) != 1 { 66 | t.Fatalf("different returned event size, expect: %v, got: %v", 1, len(evs)) 67 | } 68 | if got, ok := evs[0].(event.UserCreated); !ok || got.CreatedAt != firstEvent.CreatedAt { 69 | // NOTE: equalty of the event depends on Timestamp. 70 | t.Errorf("unexpected event is returned, expect: %v, got: %v", firstEvent, got) 71 | } 72 | 73 | // case 2: find multiple results 74 | secondEvent := eventStore[1].(event.RoomCreated) 75 | evs, err = eventRepo.FindAllByTimeCursor(context.Background(), firstEvent.Timestamp(), 2) 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | 80 | if len(evs) != 2 { 81 | t.Fatalf("different returned event size, expect: %v, got: %v", 2, len(evs)) 82 | } 83 | 84 | if got, ok := evs[0].(event.UserCreated); !ok || got.CreatedAt != firstEvent.CreatedAt { 85 | t.Errorf("unexpected event is returned, expect: %v, got: %v", firstEvent, got) 86 | } 87 | if got, ok := evs[1].(event.RoomCreated); !ok || got.CreatedAt != secondEvent.CreatedAt { 88 | t.Errorf("unexpected event is returned, expect: %v, got: %v", secondEvent, got) 89 | } 90 | } 91 | 92 | func TestEventFindAllByStreamID(t *testing.T) { 93 | firstEvent := eventStore[0].(event.UserCreated) 94 | 95 | // create three events to obtain at least one event for each stream. 96 | { 97 | uc := event.UserCreated{} 98 | uc.Occurs() 99 | rc := event.RoomCreated{} 100 | rc.Occurs() 101 | mc := event.MessageCreated{} 102 | mc.Occurs() 103 | _, err := eventRepo.Store(context.Background(), uc, rc, mc) 104 | if err != nil { 105 | t.Fatalf("preparation is failed: %v", err) 106 | } 107 | } 108 | 109 | // check the event returns correct stream id. 110 | for _, streamID := range []event.StreamID{ 111 | event.UserStream, 112 | event.RoomStream, 113 | event.MessageStream, 114 | } { 115 | evs, err := eventRepo.FindAllByStreamID( 116 | context.Background(), 117 | streamID, 118 | firstEvent.Timestamp().Add(-1*time.Second), 119 | 1, 120 | ) 121 | if err != nil { 122 | t.Fatal(err) 123 | } 124 | 125 | if len(evs) != 1 { 126 | t.Fatalf("different returned event size, expect: %v, got: %v", 1, len(evs)) 127 | } 128 | if got := evs[0].StreamID(); got != streamID { 129 | t.Errorf("unexpected event streamID, expect: %v, got: %v", streamID, got) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /infra/inmemory/messages.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "sync" 7 | "time" 8 | 9 | "github.com/shirasudon/go-chat/chat" 10 | "github.com/shirasudon/go-chat/chat/queried" 11 | "github.com/shirasudon/go-chat/domain" 12 | "github.com/shirasudon/go-chat/domain/event" 13 | ) 14 | 15 | var ( 16 | messageMapMu *sync.RWMutex = new(sync.RWMutex) 17 | 18 | messageMap = map[uint64]domain.Message{ 19 | 1: { 20 | ID: 1, 21 | Content: "hello!", 22 | CreatedAt: time.Now().Add(-10 * time.Millisecond), 23 | UserID: 2, 24 | RoomID: 2, 25 | }, 26 | } 27 | 28 | messageCounter uint64 = uint64(len(messageMap)) 29 | 30 | // key: user-room ID, value: read time for Room 31 | // It holds user read time for the Room, 32 | // and user permmition to access room messages 33 | userAndRoomIDToReadTime = make(map[userAndRoomID]time.Time, 64) 34 | ) 35 | 36 | func init() { 37 | // TODO remove below, and call methods for the repository insteadly. 38 | for roomID, userIDs := range roomToUsersMap { 39 | for userID, _ := range userIDs { 40 | userAndRoomIDToReadTime[userAndRoomID{userID, roomID}] = time.Time{} 41 | } 42 | } 43 | } 44 | 45 | type userAndRoomID struct { 46 | UserID uint64 47 | RoomID uint64 48 | } 49 | 50 | func errMsgNotFound(msgID uint64) *chat.NotFoundError { 51 | return chat.NewNotFoundError("message (id=%v) is not found") 52 | } 53 | 54 | type MessageRepository struct { 55 | domain.EmptyTxBeginner 56 | pubsub chat.Pubsub 57 | } 58 | 59 | func NewMessageRepository(pubsub chat.Pubsub) *MessageRepository { 60 | return &MessageRepository{ 61 | pubsub: pubsub, 62 | } 63 | } 64 | 65 | // It runs infinite loop for updating query data by domain events. 66 | // if context is canceled, the infinite loop quits. 67 | // It must be called to be updated to latest query data. 68 | func (repo *MessageRepository) UpdatingService(ctx context.Context) { 69 | evCh := repo.pubsub.Sub( 70 | event.TypeRoomCreated, 71 | event.TypeRoomAddedMember, 72 | event.TypeRoomMessagesReadByUser, 73 | ) 74 | for { 75 | select { 76 | case ev, ok := <-evCh: 77 | if !ok { 78 | return 79 | } 80 | if ev, ok := ev.(event.Event); ok { 81 | repo.updateByEvent(ev) 82 | } 83 | case <-ctx.Done(): 84 | return 85 | } 86 | } 87 | } 88 | 89 | func (repo *MessageRepository) updateByEvent(ev event.Event) { 90 | switch ev := ev.(type) { 91 | case event.RoomCreated: 92 | messageMapMu.Lock() 93 | defer messageMapMu.Unlock() 94 | for _, memberID := range ev.MemberIDs { 95 | userAndRoomIDToReadTime[userAndRoomID{memberID, ev.RoomID}] = time.Time{} 96 | } 97 | 98 | case event.RoomDeleted: 99 | messageMapMu.Lock() 100 | defer messageMapMu.Unlock() 101 | for _, memberID := range ev.MemberIDs { 102 | delete(userAndRoomIDToReadTime, userAndRoomID{memberID, ev.RoomID}) 103 | } 104 | 105 | case event.RoomAddedMember: 106 | messageMapMu.Lock() 107 | defer messageMapMu.Unlock() 108 | userAndRoomIDToReadTime[userAndRoomID{ev.AddedUserID, ev.RoomID}] = time.Time{} 109 | 110 | case event.RoomMessagesReadByUser: 111 | messageMapMu.Lock() 112 | defer messageMapMu.Unlock() 113 | userAndRoomIDToReadTime[userAndRoomID{ev.UserID, ev.RoomID}] = ev.ReadAt 114 | } 115 | } 116 | 117 | func (repo *MessageRepository) Find(ctx context.Context, msgID uint64) (domain.Message, error) { 118 | messageMapMu.RLock() 119 | m, ok := messageMap[msgID] 120 | messageMapMu.RUnlock() 121 | if ok { 122 | return m, nil 123 | } 124 | return domain.Message{}, errMsgNotFound(msgID) 125 | } 126 | 127 | func (repo *MessageRepository) FindRoomMessagesOrderByLatest(ctx context.Context, roomID uint64, before time.Time, limit int) ([]domain.Message, error) { 128 | if limit <= 0 { 129 | return []domain.Message{}, nil 130 | } 131 | 132 | messageMapMu.RLock() 133 | 134 | msgs := make([]domain.Message, 0, limit) 135 | for _, m := range messageMap { 136 | if m.RoomID == roomID && m.CreatedAt.Before(before) { 137 | msgs = append(msgs, m) 138 | } 139 | } 140 | messageMapMu.RUnlock() 141 | 142 | sort.Slice(msgs, func(i, j int) bool { return msgs[i].CreatedAt.After(msgs[j].CreatedAt) }) 143 | 144 | if len(msgs) > limit { 145 | msgs = msgs[:limit] 146 | } 147 | return msgs, nil 148 | } 149 | 150 | func (repo *MessageRepository) Store(ctx context.Context, m domain.Message) (uint64, error) { 151 | // TODO create or update 152 | m.EventHolder = domain.NewEventHolder() // event should not be persisted. 153 | 154 | messageMapMu.Lock() 155 | 156 | messageCounter += 1 157 | m.ID = messageCounter 158 | m.CreatedAt = time.Now() 159 | messageMap[m.ID] = m 160 | 161 | messageMapMu.Unlock() 162 | return m.ID, nil 163 | } 164 | 165 | func (repo *MessageRepository) RemoveAllByRoomID(ctx context.Context, roomID uint64) error { 166 | messageMapMu.Lock() 167 | 168 | for id, m := range messageMap { 169 | if m.RoomID == roomID { 170 | delete(messageMap, id) 171 | } 172 | } 173 | messageMapMu.Unlock() 174 | return nil 175 | } 176 | 177 | func (repo *MessageRepository) FindUnreadRoomMessages(ctx context.Context, userID, roomID uint64, limit int) (*queried.UnreadRoomMessages, error) { 178 | messageMapMu.RLock() 179 | defer messageMapMu.RUnlock() 180 | 181 | key := userAndRoomID{userID, roomID} 182 | readTime, ok := userAndRoomIDToReadTime[key] 183 | if !ok { 184 | // missing readTime indicates user not exist in the room 185 | return nil, chat.NewNotFoundError("user (id=%v) has no unread messsages for the room (id=%v)", userID, roomID) 186 | } 187 | 188 | if limit == 0 { 189 | ret := queried.EmptyUnreadRoomMessages 190 | return &ret, nil // return copy to prevent modifying original. 191 | } 192 | 193 | unreadMsgs := make([]queried.Message, 0, limit) 194 | for _, m := range messageMap { 195 | if m.RoomID == roomID && m.CreatedAt.After(readTime) { 196 | qm := queried.Message{ 197 | MessageID: m.ID, 198 | UserID: m.UserID, 199 | Content: m.Content, 200 | CreatedAt: m.CreatedAt, 201 | } 202 | unreadMsgs = append(unreadMsgs, qm) 203 | 204 | if len(unreadMsgs) >= limit { 205 | break 206 | } 207 | } 208 | } 209 | 210 | return &queried.UnreadRoomMessages{ 211 | RoomID: roomID, 212 | Msgs: unreadMsgs, 213 | MsgsSize: len(unreadMsgs), 214 | }, nil 215 | } 216 | -------------------------------------------------------------------------------- /infra/inmemory/messages_test.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/shirasudon/go-chat/domain" 10 | "github.com/shirasudon/go-chat/domain/event" 11 | "github.com/shirasudon/go-chat/infra/pubsub" 12 | ) 13 | 14 | var ( 15 | globalPubsub *pubsub.PubSub 16 | messageRepository *MessageRepository 17 | ) 18 | 19 | func TestMain(m *testing.M) { 20 | globalPubsub = pubsub.New() 21 | defer globalPubsub.Shutdown() 22 | messageRepository = NewMessageRepository(globalPubsub) 23 | 24 | ret := m.Run() 25 | os.Exit(ret) 26 | } 27 | 28 | func TestMessageRepoUpdatingService(t *testing.T) { 29 | t.Parallel() 30 | 31 | // with timeout to quit correctly 32 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) 33 | defer cancel() 34 | 35 | // run service for updating query data 36 | go messageRepository.UpdatingService(ctx) 37 | 38 | ch := globalPubsub.Sub(event.TypeMessageCreated) 39 | 40 | const ( 41 | TargetRoomID = 1 42 | TargetUserID = 1 43 | Content = "none" 44 | ) 45 | 46 | for i := 0; i < 10; i++ { 47 | id, _ := messageRepository.Store(ctx, domain.Message{Content: Content}) 48 | globalPubsub.Pub(event.MessageCreated{MessageID: id, CreatedBy: TargetUserID, RoomID: TargetRoomID}) 49 | select { 50 | case <-ch: 51 | continue 52 | case <-ctx.Done(): 53 | t.Fatal("timeout") 54 | } 55 | } 56 | } 57 | 58 | func TestMessageRepoStore(t *testing.T) { 59 | t.Parallel() 60 | 61 | const Content = "hello" 62 | id, err := messageRepository.Store(context.Background(), domain.Message{Content: Content}) 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | 67 | stored, err := messageRepository.Find(context.Background(), id) 68 | if err != nil { 69 | t.Fatal("message created but not stored") 70 | } 71 | if stored.ID != id { 72 | t.Errorf("different message id in the datastore, expect: %v, got: %v", id, stored.ID) 73 | } 74 | if stored.Content != Content { 75 | t.Errorf("different message content in the datastore, expect: %v, got: %v", Content, stored.Content) 76 | } 77 | if len(stored.Events()) != 0 { 78 | t.Errorf("event should not be persisted") 79 | } 80 | } 81 | 82 | func TestMessageRepoFind(t *testing.T) { 83 | t.Parallel() 84 | 85 | // case1: found message 86 | const Content = "hello1" 87 | id, err := messageRepository.Store(context.Background(), domain.Message{Content: Content}) 88 | if err != nil { 89 | t.Fatal(err) 90 | } 91 | 92 | m, err := messageRepository.Find(context.Background(), id) 93 | if err != nil { 94 | t.Fatalf("can not find any message: %v", err) 95 | } 96 | if m.Content != Content { 97 | t.Errorf("different message content, expect: %v, got: %v", Content, m.Content) 98 | } 99 | 100 | // case2: not found message 101 | const NotFoundID = 99999 102 | if _, err := messageRepository.Find(context.Background(), NotFoundID); err == nil { 103 | t.Fatal("find by not found id but no error") 104 | } 105 | } 106 | 107 | func TestMessageRepoFindRoomMessagesOrderByLatest(t *testing.T) { 108 | t.Parallel() 109 | 110 | // case1: limit 0 111 | ms, err := messageRepository.FindRoomMessagesOrderByLatest( 112 | context.Background(), 113 | 0, 114 | time.Time{}, 115 | 0, 116 | ) 117 | if err != nil { 118 | t.Fatalf("limit 0 but error returned: %v", err) 119 | } 120 | if len(ms) != 0 { 121 | t.Errorf("limit 0 but some message returned: %v", ms) 122 | } 123 | 124 | // case2: find success 125 | const FoundRoomID = 900 126 | beforeCreated := time.Now() 127 | for _, m := range []domain.Message{ 128 | {RoomID: FoundRoomID, Content: "1", CreatedAt: beforeCreated.Add(10 * time.Millisecond)}, 129 | {RoomID: FoundRoomID, Content: "2", CreatedAt: beforeCreated.Add(11 * time.Millisecond)}, 130 | } { 131 | _, err := messageRepository.Store(context.Background(), m) 132 | if err != nil { 133 | t.Fatalf("storing error with %v: %v", m, err) 134 | } 135 | } 136 | afterCreated := time.Now() 137 | 138 | ms, err = messageRepository.FindRoomMessagesOrderByLatest( 139 | context.Background(), 140 | FoundRoomID, 141 | afterCreated, 142 | 10, 143 | ) 144 | if err != nil { 145 | t.Fatal(err) 146 | } 147 | if len(ms) != 2 { 148 | t.Fatalf("different room messages size, expect: %v, got: %v", 2, len(ms)) 149 | } 150 | if ms[0].CreatedAt.Before(ms[1].CreatedAt) { 151 | t.Errorf("different order for the room messages") 152 | } 153 | } 154 | 155 | func TestMessageRepoRemoveAllByRoomID(t *testing.T) { 156 | t.Parallel() 157 | 158 | // case 1: remove targets are found 159 | const FoundRoomID = 900 160 | if err := messageRepository.RemoveAllByRoomID(context.Background(), FoundRoomID); err != nil { 161 | t.Fatal(err) 162 | } 163 | 164 | ms, err := messageRepository.FindRoomMessagesOrderByLatest( 165 | context.Background(), FoundRoomID, time.Now(), 10) 166 | if err != nil { 167 | t.Fatal(err) 168 | } 169 | if len(ms) != 0 { 170 | t.Errorf("removed messages are found") 171 | } 172 | 173 | // case 2: remove targets are not found 174 | const NotFoundRoomID = FoundRoomID + 10 175 | if err := messageRepository.RemoveAllByRoomID(context.Background(), NotFoundRoomID); err != nil { 176 | t.Fatal("remove by not found room id but error occured") 177 | } 178 | } 179 | 180 | func TestMessageRepoFindUnreadRoomMessages(t *testing.T) { 181 | t.Parallel() 182 | 183 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Millisecond) 184 | defer cancel() 185 | 186 | const ( 187 | TargetRoomID = 999 188 | TargetUserID = 1 189 | Content = "hello" 190 | ) 191 | 192 | id, _ := messageRepository.Store(ctx, domain.Message{RoomID: TargetRoomID, UserID: TargetUserID, Content: Content}) 193 | 194 | // allow read messages by TargetUser 195 | ev := event.RoomCreated{CreatedBy: TargetUserID, RoomID: TargetRoomID, MemberIDs: []uint64{TargetUserID}} 196 | messageRepository.updateByEvent(ev) 197 | 198 | unreads, err := messageRepository.FindUnreadRoomMessages(ctx, TargetUserID, TargetRoomID, 1) 199 | if err != nil { 200 | t.Fatal(err) 201 | } 202 | 203 | if unreads.RoomID != TargetRoomID { 204 | t.Errorf("different RoomID, expect: %v, got: %v", TargetRoomID, unreads.RoomID) 205 | } 206 | if unreads.MsgsSize != 1 { 207 | t.Fatalf("different unread messages size, expect: %v, got: %v", 1, unreads.MsgsSize) 208 | } 209 | if unreads.Msgs[0].Content != Content { 210 | t.Errorf("different queried messages content, expect: %v, got: %v", Content, unreads.Msgs[0].Content) 211 | } 212 | 213 | // after read by user, unreadMsgs is empty. 214 | createdMsg, _ := messageRepository.Find(ctx, id) 215 | t.Log(id) 216 | messageRepository.updateByEvent(event.RoomMessagesReadByUser{ 217 | UserID: TargetUserID, RoomID: TargetRoomID, ReadAt: createdMsg.CreatedAt, 218 | }) 219 | 220 | unreads, err = messageRepository.FindUnreadRoomMessages(ctx, TargetUserID, TargetRoomID, 1) 221 | if err != nil { 222 | t.Errorf("expect empty result with no error, but got error: %v", err) 223 | } 224 | if len(unreads.Msgs) != 0 { 225 | t.Errorf("expect empty result, but got result: %#v", unreads.Msgs) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /infra/inmemory/repos.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shirasudon/go-chat/chat" 7 | "github.com/shirasudon/go-chat/domain" 8 | "github.com/shirasudon/go-chat/domain/event" 9 | ) 10 | 11 | func OpenRepositories(pubsub chat.Pubsub) *Repositories { 12 | return &Repositories{ 13 | UserRepository: &UserRepository{}, 14 | MessageRepository: NewMessageRepository(pubsub), 15 | RoomRepository: NewRoomRepository(), 16 | EventRepository: &EventRepository{}, 17 | } 18 | } 19 | 20 | type Repositories struct { 21 | *UserRepository 22 | *MessageRepository 23 | *RoomRepository 24 | *EventRepository 25 | } 26 | 27 | // run UpdatingService to make the query data is latest. 28 | // User should call this with new Repositories instance. 29 | // If context is done, then the services will be stopped. 30 | func (r *Repositories) UpdatingService(ctx context.Context) { 31 | r.MessageRepository.UpdatingService(ctx) 32 | } 33 | 34 | func (r Repositories) Users() domain.UserRepository { 35 | return r.UserRepository 36 | } 37 | 38 | func (r Repositories) Messages() domain.MessageRepository { 39 | return r.MessageRepository 40 | } 41 | 42 | func (r Repositories) Rooms() domain.RoomRepository { 43 | return r.RoomRepository 44 | } 45 | 46 | func (r Repositories) Events() event.EventRepository { 47 | return r.EventRepository 48 | } 49 | 50 | func (r *Repositories) Close() error { 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /infra/inmemory/rooms.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | "sync" 7 | 8 | "github.com/shirasudon/go-chat/chat" 9 | "github.com/shirasudon/go-chat/chat/queried" 10 | "github.com/shirasudon/go-chat/domain" 11 | ) 12 | 13 | type RoomRepository struct { 14 | domain.EmptyTxBeginner 15 | } 16 | 17 | func NewRoomRepository() *RoomRepository { 18 | return &RoomRepository{} 19 | } 20 | 21 | var ( 22 | DummyRoom1 = domain.Room{ID: 1, Name: "title1", MemberIDSet: domain.NewUserIDSet(), MemberReadTimes: domain.NewTimeSet()} 23 | DummyRoom2 = domain.Room{ID: 2, Name: "title2", MemberIDSet: domain.NewUserIDSet(2, 3), MemberReadTimes: domain.NewTimeSet(2, 3)} 24 | DummyRoom3 = domain.Room{ID: 3, Name: "title3", MemberIDSet: domain.NewUserIDSet(2), MemberReadTimes: domain.NewTimeSet(2)} 25 | 26 | roomMapMu *sync.RWMutex = new(sync.RWMutex) 27 | 28 | // under mu 29 | roomMap = map[uint64]*domain.Room{ 30 | 1: &DummyRoom1, 31 | 2: &DummyRoom2, 32 | 3: &DummyRoom3, 33 | } 34 | 35 | // Many-to-many mapping for Room-to-User. 36 | roomToUsersMap = map[uint64]map[uint64]bool{ 37 | // room id = 2 has, 38 | 2: { 39 | // user id = 2 and id = 3. 40 | 2: true, 41 | 3: true, 42 | }, 43 | 44 | // room id = 3 has, 45 | 3: { 46 | // user id = 2. 47 | 2: true, 48 | }, 49 | } 50 | ) 51 | 52 | func errRoomNotFound(roomID uint64) *chat.NotFoundError { 53 | return chat.NewNotFoundError("room (id=%v) is not found", roomID) 54 | } 55 | 56 | var roomCounter uint64 = uint64(len(roomMap)) 57 | 58 | func (repo *RoomRepository) FindAllByUserID(ctx context.Context, userID uint64) ([]domain.Room, error) { 59 | rooms := make([]domain.Room, 0, 4) 60 | 61 | roomMapMu.RLock() 62 | 63 | for roomID, userIDs := range roomToUsersMap { 64 | if userIDs[userID] { 65 | rooms = append(rooms, *roomMap[roomID]) 66 | } 67 | } 68 | 69 | roomMapMu.RUnlock() 70 | sort.Slice(rooms, func(i, j int) bool { return rooms[i].ID < rooms[j].ID }) 71 | return rooms, nil 72 | } 73 | 74 | func (repo *RoomRepository) Store(ctx context.Context, r domain.Room) (uint64, error) { 75 | r.EventHolder = domain.NewEventHolder() // event should not be persisted. 76 | if r.NotExist() { 77 | return repo.Create(ctx, r) 78 | } else { 79 | return repo.Update(ctx, r) 80 | } 81 | } 82 | 83 | func (repo *RoomRepository) Create(ctx context.Context, r domain.Room) (uint64, error) { 84 | roomMapMu.Lock() 85 | 86 | roomCounter += 1 87 | r.ID = roomCounter 88 | roomMap[r.ID] = &r 89 | 90 | memberIDs := r.MemberIDs() 91 | userIDs := make(map[uint64]bool, len(memberIDs)) 92 | for _, memberID := range memberIDs { 93 | userIDs[memberID] = true 94 | } 95 | roomToUsersMap[r.ID] = userIDs 96 | 97 | roomMapMu.Unlock() 98 | 99 | return r.ID, nil 100 | } 101 | 102 | func (repo *RoomRepository) Update(ctx context.Context, r domain.Room) (uint64, error) { 103 | roomMapMu.Lock() 104 | if _, ok := roomMap[r.ID]; !ok { 105 | roomMapMu.Unlock() 106 | return 0, chat.NewInfraError("room(id=%d) is not in the datastore", r.ID) 107 | } 108 | 109 | // update room 110 | roomMap[r.ID] = &r 111 | 112 | userIDs := roomToUsersMap[r.ID] 113 | if userIDs == nil { 114 | userIDs = make(map[uint64]bool) 115 | roomToUsersMap[r.ID] = userIDs 116 | } 117 | 118 | roomMapMu.Unlock() 119 | 120 | userMapMu.Lock() 121 | // prepare user existance to off. 122 | for uid, _ := range userIDs { 123 | userIDs[uid] = false 124 | } 125 | // set user existance to on. 126 | for _, memberID := range r.MemberIDs() { 127 | userIDs[memberID] = true 128 | } 129 | // remove users deleteted from the room. 130 | for uid, exist := range userIDs { 131 | if !exist { 132 | delete(userIDs, uid) 133 | } 134 | } 135 | userMapMu.Unlock() 136 | 137 | return r.ID, nil 138 | } 139 | 140 | func (repo *RoomRepository) Remove(ctx context.Context, r domain.Room) error { 141 | roomMapMu.Lock() 142 | delete(roomMap, r.ID) 143 | delete(roomToUsersMap, r.ID) 144 | roomMapMu.Unlock() 145 | return nil 146 | } 147 | 148 | func (repo *RoomRepository) Find(ctx context.Context, roomID uint64) (domain.Room, error) { 149 | roomMapMu.RLock() 150 | defer roomMapMu.RUnlock() 151 | 152 | if room, ok := roomMap[roomID]; ok { 153 | return *room, nil 154 | } 155 | return domain.Room{}, errRoomNotFound(roomID) 156 | } 157 | 158 | func (repo *RoomRepository) FindRoomInfo(ctx context.Context, userID, roomID uint64) (*queried.RoomInfo, error) { 159 | roomMapMu.RLock() 160 | r, ok := roomMap[roomID] 161 | if !ok { 162 | roomMapMu.RUnlock() 163 | return nil, errRoomNotFound(roomID) 164 | } 165 | roomMapMu.RUnlock() 166 | 167 | members := make([]queried.RoomMemberProfile, 0, 2) 168 | 169 | userMapMu.RLock() 170 | // check whether user exist in the room 171 | u, ok := userMap[userID] 172 | if !ok || !r.HasMember(u) { 173 | userMapMu.RUnlock() 174 | return nil, chat.NewNotFoundError("user (id=%v) is not a member of the room (id=%v)", userID, roomID) 175 | } 176 | 177 | // create member profiles 178 | for _, id := range r.MemberIDs() { 179 | u, ok := userMap[id] 180 | if !ok { 181 | continue 182 | } 183 | // it should succeed to get time with room.MemberIDs. 184 | readAt, _ := r.MemberReadTimes.Get(id) 185 | 186 | members = append(members, queried.RoomMemberProfile{ 187 | UserProfile: createUserProfile(&u), 188 | MessageReadAt: readAt, 189 | }) 190 | } 191 | userMapMu.RUnlock() 192 | 193 | return &queried.RoomInfo{ 194 | RoomName: r.Name, 195 | RoomID: r.ID, 196 | CreatorID: r.OwnerID, 197 | Members: members, 198 | MembersSize: len(members), 199 | }, nil 200 | } 201 | -------------------------------------------------------------------------------- /infra/inmemory/rooms_test.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/shirasudon/go-chat/domain" 9 | "github.com/shirasudon/go-chat/domain/event" 10 | ) 11 | 12 | func TestRoomVars(t *testing.T) { 13 | t.Parallel() 14 | 15 | testUserID := uint64(2) 16 | repo := RoomRepository{} 17 | rooms, err := repo.FindAllByUserID(context.Background(), testUserID) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | if len(rooms) == 0 { 22 | t.Fatalf("user(id=%d) has no rooms", testUserID) 23 | } 24 | 25 | memberIDs := rooms[0].MemberIDs() 26 | if got := len(memberIDs); got != 2 { 27 | t.Errorf( 28 | "different member size for the room(%#v), expect %d, got %d", 29 | rooms[0], 2, got, 30 | ) 31 | } 32 | } 33 | 34 | func TestFindRoomInfo(t *testing.T) { 35 | t.Parallel() 36 | 37 | repo := &RoomRepository{} 38 | 39 | // case success 40 | const ( 41 | TestUserID = uint64(2) 42 | TestRoomID = uint64(2) 43 | 44 | NotExistUserID = uint64(99) 45 | NotExistRoomID = uint64(99) 46 | ) 47 | 48 | var ( 49 | TimeNow = time.Now() 50 | ) 51 | 52 | // setup read time for test user. 53 | { 54 | roomMapMu.Lock() 55 | readTimes := &roomMap[TestRoomID].MemberReadTimes 56 | prevTime, _ := readTimes.Get(TestUserID) 57 | readTimes.Set(TestUserID, TimeNow) 58 | roomMapMu.Unlock() 59 | 60 | defer func() { 61 | roomMapMu.Lock() 62 | readTimes.Set(TestUserID, prevTime) 63 | roomMapMu.Unlock() 64 | }() 65 | } 66 | 67 | info, err := repo.FindRoomInfo(context.Background(), TestUserID, TestRoomID) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | room, _ := repo.Find(context.Background(), 2) 72 | 73 | if expect, got := room.ID, info.RoomID; expect != got { 74 | t.Errorf("different room id, expect: %v, got: %v", expect, got) 75 | } 76 | if expect, got := room.Name, info.RoomName; expect != got { 77 | t.Errorf("different room name, expect: %v, got: %v", expect, got) 78 | } 79 | if expect, got := len(info.Members), info.MembersSize; expect != got { 80 | t.Errorf("different number of members, expect: %v, got: %v", expect, got) 81 | } 82 | 83 | var ( 84 | memberExists = false 85 | memberReadNow = false 86 | ) 87 | for _, m := range info.Members { 88 | if m.UserID == TestUserID { 89 | memberExists = true 90 | if m.MessageReadAt.Equal(TimeNow) { 91 | memberReadNow = true 92 | } 93 | } 94 | } 95 | if !memberExists { 96 | t.Errorf("query parameter is not found in the result, user id %v", TestUserID) 97 | } 98 | if !memberReadNow { 99 | t.Errorf("room member (id=%v) has no read time", TestUserID) 100 | } 101 | 102 | // case fail 103 | if _, err := repo.FindRoomInfo(context.Background(), NotExistUserID, TestRoomID); err == nil { 104 | t.Fatalf("query no exist user ID (%v) but no error", NotExistUserID) 105 | } 106 | if _, err := repo.FindRoomInfo(context.Background(), TestUserID, NotExistRoomID); err == nil { 107 | t.Fatalf("query no exist room ID (%v) but no error", NotExistRoomID) 108 | } 109 | } 110 | 111 | func TestRoomStore(t *testing.T) { 112 | t.Parallel() 113 | 114 | repo := &RoomRepository{} 115 | 116 | const ( 117 | FirstName = "room1" 118 | SecondName = "room2" 119 | ) 120 | 121 | var ( 122 | newR = domain.Room{Name: FirstName} 123 | err error 124 | ) 125 | newR.AddEvent(event.RoomCreated{}) 126 | // create 127 | newR.ID, err = repo.Store(context.Background(), newR) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | 132 | storedR, err := repo.Find(context.Background(), newR.ID) 133 | if err != nil { 134 | t.Fatal("nothing room after Store: create") 135 | } 136 | if len(storedR.Events()) != 0 { 137 | t.Fatal("event should never be persited") 138 | } 139 | if storedR.Name != FirstName { 140 | t.Errorf("different stored room name, expect: %v, got: %v", FirstName, storedR.Name) 141 | } 142 | 143 | newR.Name = SecondName 144 | newR.AddEvent(event.RoomCreated{}) 145 | // update 146 | newR.ID, err = repo.Store(context.Background(), newR) 147 | if err != nil { 148 | t.Fatal(err) 149 | } 150 | 151 | storedR, err = repo.Find(context.Background(), newR.ID) 152 | if err != nil { 153 | t.Fatal("nothing room after Store: update") 154 | } 155 | if len(storedR.Events()) != 0 { 156 | t.Fatal("event should never be persited") 157 | } 158 | if storedR.Name != SecondName { 159 | t.Errorf("different stored room name, expect: %v, got: %v", SecondName, storedR.Name) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /infra/inmemory/users.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/shirasudon/go-chat/chat" 8 | "github.com/shirasudon/go-chat/chat/queried" 9 | "github.com/shirasudon/go-chat/domain" 10 | ) 11 | 12 | type UserRepository struct { 13 | domain.EmptyTxBeginner 14 | } 15 | 16 | var ( 17 | DummyUser = domain.User{ 18 | ID: 0, 19 | Name: "user", 20 | FirstName: "u-", 21 | LastName: "ser", 22 | Password: "password", 23 | } 24 | DummyUser2 = domain.User{ 25 | ID: 2, 26 | Name: "user2", 27 | FirstName: "u-", 28 | LastName: "ser", 29 | Password: "password", 30 | FriendIDs: domain.NewUserIDSet(3), 31 | } 32 | DummyUser3 = domain.User{ 33 | ID: 3, 34 | Name: "user3", 35 | FirstName: "u-", 36 | LastName: "ser", 37 | Password: "password", 38 | } 39 | 40 | userMapMu *sync.RWMutex = new(sync.RWMutex) 41 | 42 | userMap = map[uint64]domain.User{ 43 | 0: DummyUser, 44 | 2: DummyUser2, 45 | 3: DummyUser3, 46 | } 47 | 48 | userNameUniqueMap = map[string]bool{ 49 | DummyUser.Name: true, 50 | DummyUser2.Name: true, 51 | DummyUser3.Name: true, 52 | } 53 | 54 | userToUsersMap = map[uint64]map[uint64]bool{ 55 | // user(id=2) relates with the user(id=3). 56 | 2: {3: true}, 57 | } 58 | ) 59 | 60 | func errUserNotFound(userID uint64) *chat.NotFoundError { 61 | return chat.NewNotFoundError("user (id=%v) is not found", userID) 62 | } 63 | 64 | var userCounter uint64 = uint64(len(userMap)) 65 | 66 | func (repo UserRepository) Store(ctx context.Context, u domain.User) (uint64, error) { 67 | u.EventHolder = domain.NewEventHolder() // event should not be persisted. 68 | if u.NotExist() { 69 | return repo.Create(ctx, u) 70 | } else { 71 | return repo.Update(ctx, u) 72 | } 73 | } 74 | 75 | func (repo *UserRepository) Create(ctx context.Context, u domain.User) (uint64, error) { 76 | userMapMu.Lock() 77 | defer userMapMu.Unlock() 78 | 79 | if exist := userNameUniqueMap[u.Name]; exist { 80 | return 0, chat.NewInfraError("user name(%v) already exist and not allowed", u.Name) 81 | } 82 | userNameUniqueMap[u.Name] = true 83 | 84 | userCounter += 1 85 | u.ID = roomCounter 86 | userMap[u.ID] = u 87 | 88 | friendIDs := u.FriendIDs.List() 89 | userIDs := make(map[uint64]bool, len(friendIDs)) 90 | for _, friendID := range friendIDs { 91 | userIDs[friendID] = true 92 | } 93 | userToUsersMap[u.ID] = userIDs 94 | 95 | return u.ID, nil 96 | } 97 | 98 | func (repo *UserRepository) Update(ctx context.Context, u domain.User) (uint64, error) { 99 | userMapMu.Lock() 100 | defer userMapMu.Unlock() 101 | 102 | if _, ok := userMap[u.ID]; !ok { 103 | return 0, chat.NewInfraError("user(id=%d) is not in the datastore", u.ID) 104 | } 105 | 106 | // update user 107 | userMap[u.ID] = u 108 | 109 | userIDs := userToUsersMap[u.ID] 110 | if userIDs == nil { 111 | userIDs = make(map[uint64]bool) 112 | userToUsersMap[u.ID] = userIDs 113 | } 114 | 115 | // prepare user existance to off. 116 | for uid, _ := range userIDs { 117 | userIDs[uid] = false 118 | } 119 | // set user existance to on. 120 | for _, friendID := range u.FriendIDs.List() { 121 | userIDs[friendID] = true 122 | } 123 | // remove users deleteted from the friends. 124 | for uid, exist := range userIDs { 125 | if !exist { 126 | delete(userIDs, uid) 127 | } 128 | } 129 | 130 | return u.ID, nil 131 | } 132 | 133 | func (repo UserRepository) Find(ctx context.Context, id uint64) (domain.User, error) { 134 | userMapMu.RLock() 135 | defer userMapMu.RUnlock() 136 | 137 | u, ok := userMap[id] 138 | if ok { 139 | return u, nil 140 | } 141 | return DummyUser, errUserNotFound(id) 142 | } 143 | 144 | func (repo UserRepository) FindByNameAndPassword(ctx context.Context, name, password string) (*queried.AuthUser, error) { 145 | userMapMu.RLock() 146 | defer userMapMu.RUnlock() 147 | 148 | for _, u := range userMap { 149 | if name == u.Name && password == u.Password { 150 | return &queried.AuthUser{ 151 | ID: u.ID, 152 | Name: u.Name, 153 | Password: u.Password, 154 | }, nil 155 | } 156 | } 157 | return nil, chat.NewNotFoundError("user name (%v) and password are not matched", name) 158 | } 159 | 160 | func createUserProfile(u *domain.User) queried.UserProfile { 161 | return queried.UserProfile{ 162 | UserID: u.ID, 163 | UserName: u.Name, 164 | FirstName: u.FirstName, 165 | LastName: u.LastName, 166 | } 167 | } 168 | 169 | func (repo UserRepository) FindUserRelation(ctx context.Context, userID uint64) (*queried.UserRelation, error) { 170 | // TODO: run constructing service by using event, 171 | // then just return already constructed value. 172 | userMapMu.RLock() 173 | 174 | user, ok := userMap[userID] 175 | if !ok { 176 | userMapMu.RUnlock() 177 | return nil, errUserNotFound(userID) 178 | } 179 | 180 | friends := make([]queried.UserProfile, 0, 4) 181 | for _, id := range user.FriendIDs.List() { 182 | if friend, ok := userMap[id]; ok { 183 | friends = append(friends, createUserProfile(&friend)) 184 | } 185 | } 186 | 187 | userMapMu.RUnlock() 188 | 189 | roomMapMu.RLock() 190 | 191 | rooms := make([]queried.UserRoom, 0, 4) 192 | for rID, userIDs := range roomToUsersMap { 193 | if _, ok := userIDs[userID]; ok { 194 | r := roomMap[rID] 195 | rooms = append(rooms, queried.UserRoom{ 196 | RoomID: rID, 197 | RoomName: r.Name, 198 | }) 199 | } 200 | } 201 | 202 | roomMapMu.RUnlock() 203 | 204 | return &queried.UserRelation{ 205 | UserProfile: createUserProfile(&user), 206 | Friends: friends, 207 | Rooms: rooms, 208 | }, nil 209 | } 210 | -------------------------------------------------------------------------------- /infra/inmemory/users_test.go: -------------------------------------------------------------------------------- 1 | package inmemory 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/shirasudon/go-chat/domain" 8 | "github.com/shirasudon/go-chat/domain/event" 9 | ) 10 | 11 | var ( 12 | userRepository = &UserRepository{} 13 | ) 14 | 15 | func TestUsersStore(t *testing.T) { 16 | // case1: success 17 | newUser := domain.User{Name: "stored-user"} 18 | id, err := userRepository.Store(context.Background(), newUser) 19 | if err != nil { 20 | t.Fatal(err) 21 | } 22 | if id == 0 { 23 | t.Errorf("created id is invalid (0)") 24 | } 25 | 26 | // case2: duplicated name error 27 | _, err = userRepository.Store(context.Background(), newUser) 28 | if err == nil { 29 | t.Errorf("store duplicated name user, but no error") 30 | } 31 | } 32 | 33 | func TestUsersFind(t *testing.T) { 34 | // case1: found 35 | newUser := domain.User{Name: "find-user"} 36 | id, err := userRepository.Store(context.Background(), newUser) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | 41 | res, err := userRepository.Find(context.Background(), id) 42 | if err != nil { 43 | t.Fatalf("can not find user: %v", err) 44 | } 45 | if res.Name != newUser.Name { 46 | t.Errorf("different user name, expect: %v, got: %v", newUser.Name, res.Name) 47 | } 48 | 49 | // case2: not found 50 | const NotFoundUserID = 999999 51 | _, err = userRepository.Find(context.Background(), NotFoundUserID) 52 | if err == nil { 53 | t.Errorf("given not found user id, but no error") 54 | } 55 | } 56 | 57 | func TestUsersFindUserRelation(t *testing.T) { 58 | repo := userRepository 59 | 60 | // case success 61 | const TestUserID = uint64(2) 62 | 63 | user, err := repo.Find(context.Background(), TestUserID) 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | 68 | relation, err := repo.FindUserRelation(context.Background(), TestUserID) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | 73 | if expect, got := TestUserID, relation.UserID; expect != got { 74 | t.Errorf("different user id, expect: %v, got: %v", expect, got) 75 | } 76 | if expect, got := user.Name, relation.UserName; expect != got { 77 | t.Errorf("different user name, expect: %v, got: %v", expect, got) 78 | } 79 | if expect, got := user.FirstName, relation.FirstName; expect != got { 80 | t.Errorf("different user first name, expect: %v, got: %v", expect, got) 81 | } 82 | if expect, got := user.LastName, relation.LastName; expect != got { 83 | t.Errorf("different user last name, expect: %v, got: %v", expect, got) 84 | } 85 | 86 | if expect, got := 1, len(relation.Friends); expect != got { 87 | t.Errorf("different number of friends, expect: %v, got: %v", expect, got) 88 | } 89 | if expect, got := 2, len(relation.Rooms); expect != got { 90 | t.Errorf("different number of rooms, expect: %v, got: %v", expect, got) 91 | } 92 | 93 | // case fail 94 | const NotExistUserID = uint64(99) 95 | if _, err := repo.FindUserRelation(context.Background(), NotExistUserID); err == nil { 96 | t.Fatalf("query no exist user ID (%v) but no error", NotExistUserID) 97 | } 98 | } 99 | 100 | func TestUsersFindByNameAndPassword(t *testing.T) { 101 | newUser := domain.User{ 102 | Name: "new user", 103 | Password: "password", 104 | } 105 | 106 | id, err := userRepository.Store(context.Background(), newUser) 107 | if err != nil { 108 | t.Fatal(err) 109 | } 110 | 111 | // case1: found 112 | res, err := userRepository.FindByNameAndPassword(context.Background(), newUser.Name, newUser.Password) 113 | if err != nil { 114 | t.Fatalf("can not find user with name and password: %v", err) 115 | } 116 | 117 | if res.ID != id { 118 | t.Errorf("different user id, expect: %v, got: %v", id, res.ID) 119 | } 120 | if res.Name != newUser.Name { 121 | t.Errorf("different user name, expect: %v, got: %v", newUser.Name, res.Name) 122 | } 123 | if res.Password != newUser.Password { 124 | t.Errorf("different user password, expect: %v, got: %v", newUser.Password, res.Password) 125 | } 126 | 127 | // case2: not found 128 | if _, err := userRepository.FindByNameAndPassword(context.Background(), "not found", newUser.Password); err == nil { 129 | t.Errorf("not found user name and password specified but non erorr") 130 | } 131 | } 132 | 133 | func TestUserStore(t *testing.T) { 134 | repo := userRepository 135 | 136 | const ( 137 | FirstName = "room1" 138 | SecondName = "room2" 139 | ) 140 | 141 | var ( 142 | newU = domain.User{Name: FirstName} 143 | err error 144 | ) 145 | newU.AddEvent(event.UserCreated{}) 146 | // create 147 | newU.ID, err = repo.Store(context.Background(), newU) 148 | if err != nil { 149 | t.Fatal(err) 150 | } 151 | 152 | storedU, err := repo.Find(context.Background(), newU.ID) 153 | if err != nil { 154 | t.Fatal("nothing user after Store: create") 155 | } 156 | if len(storedU.Events()) != 0 { 157 | t.Fatal("event should never be persisted") 158 | } 159 | if storedU.Name != FirstName { 160 | t.Errorf("different stored user name, expect: %v, got: %v", FirstName, storedU.Name) 161 | } 162 | 163 | newU.Name = SecondName 164 | newU.AddEvent(event.UserCreated{}) 165 | // update 166 | newU.ID, err = repo.Store(context.Background(), newU) 167 | if err != nil { 168 | t.Fatal(err) 169 | } 170 | 171 | storedU, err = repo.Find(context.Background(), newU.ID) 172 | if err != nil { 173 | t.Fatal("nothing user after Store: update") 174 | } 175 | if len(storedU.Events()) != 0 { 176 | t.Fatal("event should never be persisted") 177 | } 178 | if storedU.Name != SecondName { 179 | t.Errorf("different stored user name, expect: %v, got: %v", SecondName, storedU.Name) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /infra/pubsub/pubsub.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "github.com/cskr/pubsub" 5 | 6 | "github.com/shirasudon/go-chat/domain/event" 7 | ) 8 | 9 | // DefaultCapacity for the Pubsub 10 | const DefaultCapacity = 1 11 | 12 | type PubSub struct { 13 | pubsub *pubsub.PubSub 14 | } 15 | 16 | // New Pubsub with capacity size. If capacity 17 | // is not given, use default insteadly. 18 | func New(capacity ...int) *PubSub { 19 | if len(capacity) == 0 { 20 | capacity = []int{DefaultCapacity} 21 | } 22 | return &PubSub{pubsub: pubsub.New(capacity[0])} 23 | } 24 | 25 | // subscribes specified EventType and return message channel. 26 | func (ps *PubSub) Sub(typ ...event.Type) chan interface{} { 27 | tags := make([]string, 0, len(typ)) 28 | for _, tp := range typ { 29 | tags = append(tags, tp.String()) 30 | } 31 | return ps.pubsub.Sub(tags...) 32 | } 33 | 34 | // unsubscribes specified channel which is gotten by previous Sub(). 35 | func (ps *PubSub) Unsub(ch chan interface{}) { 36 | ps.pubsub.Unsub(ch) 37 | } 38 | 39 | // publish Event to corresponding subscribers. 40 | func (ps *PubSub) Pub(events ...event.Event) { 41 | for _, ev := range events { 42 | ps.pubsub.Pub(ev, ev.Type().String()) 43 | } 44 | } 45 | 46 | func (ps *PubSub) Shutdown() { 47 | ps.pubsub.Shutdown() 48 | } 49 | -------------------------------------------------------------------------------- /infra/pubsub/pubsub_test.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/shirasudon/go-chat/domain/event" 8 | ) 9 | 10 | func TestPubsub(t *testing.T) { 11 | pubsub := New(10) 12 | defer pubsub.Shutdown() 13 | ch := pubsub.Sub(event.TypeRoomDeleted) 14 | 15 | const DeletedRoomID = 1 16 | pubsub.Pub(event.RoomDeleted{RoomID: DeletedRoomID}) 17 | 18 | timeout := time.After(1 * time.Millisecond) 19 | select { 20 | case ev := <-ch: 21 | if _, ok := ev.(event.RoomDeleted); !ok { 22 | t.Errorf("different subsclibing event, got: %#v", ev) 23 | } 24 | case <-timeout: 25 | t.Error("can not subsclib the RoomDeletedEvent") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/mocks/mock_command_service.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/chat (interfaces: CommandService) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | action "github.com/shirasudon/go-chat/chat/action" 11 | result "github.com/shirasudon/go-chat/chat/result" 12 | reflect "reflect" 13 | ) 14 | 15 | // MockCommandService is a mock of CommandService interface 16 | type MockCommandService struct { 17 | ctrl *gomock.Controller 18 | recorder *MockCommandServiceMockRecorder 19 | } 20 | 21 | // MockCommandServiceMockRecorder is the mock recorder for MockCommandService 22 | type MockCommandServiceMockRecorder struct { 23 | mock *MockCommandService 24 | } 25 | 26 | // NewMockCommandService creates a new mock instance 27 | func NewMockCommandService(ctrl *gomock.Controller) *MockCommandService { 28 | mock := &MockCommandService{ctrl: ctrl} 29 | mock.recorder = &MockCommandServiceMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use 34 | func (m *MockCommandService) EXPECT() *MockCommandServiceMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // AddRoomMember mocks base method 39 | func (m *MockCommandService) AddRoomMember(arg0 context.Context, arg1 action.AddRoomMember) (*result.AddRoomMember, error) { 40 | ret := m.ctrl.Call(m, "AddRoomMember", arg0, arg1) 41 | ret0, _ := ret[0].(*result.AddRoomMember) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // AddRoomMember indicates an expected call of AddRoomMember 47 | func (mr *MockCommandServiceMockRecorder) AddRoomMember(arg0, arg1 interface{}) *gomock.Call { 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddRoomMember", reflect.TypeOf((*MockCommandService)(nil).AddRoomMember), arg0, arg1) 49 | } 50 | 51 | // CreateRoom mocks base method 52 | func (m *MockCommandService) CreateRoom(arg0 context.Context, arg1 action.CreateRoom) (uint64, error) { 53 | ret := m.ctrl.Call(m, "CreateRoom", arg0, arg1) 54 | ret0, _ := ret[0].(uint64) 55 | ret1, _ := ret[1].(error) 56 | return ret0, ret1 57 | } 58 | 59 | // CreateRoom indicates an expected call of CreateRoom 60 | func (mr *MockCommandServiceMockRecorder) CreateRoom(arg0, arg1 interface{}) *gomock.Call { 61 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRoom", reflect.TypeOf((*MockCommandService)(nil).CreateRoom), arg0, arg1) 62 | } 63 | 64 | // DeleteRoom mocks base method 65 | func (m *MockCommandService) DeleteRoom(arg0 context.Context, arg1 action.DeleteRoom) (uint64, error) { 66 | ret := m.ctrl.Call(m, "DeleteRoom", arg0, arg1) 67 | ret0, _ := ret[0].(uint64) 68 | ret1, _ := ret[1].(error) 69 | return ret0, ret1 70 | } 71 | 72 | // DeleteRoom indicates an expected call of DeleteRoom 73 | func (mr *MockCommandServiceMockRecorder) DeleteRoom(arg0, arg1 interface{}) *gomock.Call { 74 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRoom", reflect.TypeOf((*MockCommandService)(nil).DeleteRoom), arg0, arg1) 75 | } 76 | 77 | // PostRoomMessage mocks base method 78 | func (m *MockCommandService) PostRoomMessage(arg0 context.Context, arg1 action.ChatMessage) (uint64, error) { 79 | ret := m.ctrl.Call(m, "PostRoomMessage", arg0, arg1) 80 | ret0, _ := ret[0].(uint64) 81 | ret1, _ := ret[1].(error) 82 | return ret0, ret1 83 | } 84 | 85 | // PostRoomMessage indicates an expected call of PostRoomMessage 86 | func (mr *MockCommandServiceMockRecorder) PostRoomMessage(arg0, arg1 interface{}) *gomock.Call { 87 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostRoomMessage", reflect.TypeOf((*MockCommandService)(nil).PostRoomMessage), arg0, arg1) 88 | } 89 | 90 | // ReadRoomMessages mocks base method 91 | func (m *MockCommandService) ReadRoomMessages(arg0 context.Context, arg1 action.ReadMessages) (uint64, error) { 92 | ret := m.ctrl.Call(m, "ReadRoomMessages", arg0, arg1) 93 | ret0, _ := ret[0].(uint64) 94 | ret1, _ := ret[1].(error) 95 | return ret0, ret1 96 | } 97 | 98 | // ReadRoomMessages indicates an expected call of ReadRoomMessages 99 | func (mr *MockCommandServiceMockRecorder) ReadRoomMessages(arg0, arg1 interface{}) *gomock.Call { 100 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadRoomMessages", reflect.TypeOf((*MockCommandService)(nil).ReadRoomMessages), arg0, arg1) 101 | } 102 | 103 | // RemoveRoomMember mocks base method 104 | func (m *MockCommandService) RemoveRoomMember(arg0 context.Context, arg1 action.RemoveRoomMember) (*result.RemoveRoomMember, error) { 105 | ret := m.ctrl.Call(m, "RemoveRoomMember", arg0, arg1) 106 | ret0, _ := ret[0].(*result.RemoveRoomMember) 107 | ret1, _ := ret[1].(error) 108 | return ret0, ret1 109 | } 110 | 111 | // RemoveRoomMember indicates an expected call of RemoveRoomMember 112 | func (mr *MockCommandServiceMockRecorder) RemoveRoomMember(arg0, arg1 interface{}) *gomock.Call { 113 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveRoomMember", reflect.TypeOf((*MockCommandService)(nil).RemoveRoomMember), arg0, arg1) 114 | } 115 | -------------------------------------------------------------------------------- /internal/mocks/mock_conn.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/domain (interfaces: Conn) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | event "github.com/shirasudon/go-chat/domain/event" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockConn is a mock of Conn interface 14 | type MockConn struct { 15 | ctrl *gomock.Controller 16 | recorder *MockConnMockRecorder 17 | } 18 | 19 | // MockConnMockRecorder is the mock recorder for MockConn 20 | type MockConnMockRecorder struct { 21 | mock *MockConn 22 | } 23 | 24 | // NewMockConn creates a new mock instance 25 | func NewMockConn(ctrl *gomock.Controller) *MockConn { 26 | mock := &MockConn{ctrl: ctrl} 27 | mock.recorder = &MockConnMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockConn) EXPECT() *MockConnMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Close mocks base method 37 | func (m *MockConn) Close() error { 38 | ret := m.ctrl.Call(m, "Close") 39 | ret0, _ := ret[0].(error) 40 | return ret0 41 | } 42 | 43 | // Close indicates an expected call of Close 44 | func (mr *MockConnMockRecorder) Close() *gomock.Call { 45 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockConn)(nil).Close)) 46 | } 47 | 48 | // Send mocks base method 49 | func (m *MockConn) Send(arg0 event.Event) { 50 | m.ctrl.Call(m, "Send", arg0) 51 | } 52 | 53 | // Send indicates an expected call of Send 54 | func (mr *MockConnMockRecorder) Send(arg0 interface{}) *gomock.Call { 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockConn)(nil).Send), arg0) 56 | } 57 | 58 | // UserID mocks base method 59 | func (m *MockConn) UserID() uint64 { 60 | ret := m.ctrl.Call(m, "UserID") 61 | ret0, _ := ret[0].(uint64) 62 | return ret0 63 | } 64 | 65 | // UserID indicates an expected call of UserID 66 | func (mr *MockConnMockRecorder) UserID() *gomock.Call { 67 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserID", reflect.TypeOf((*MockConn)(nil).UserID)) 68 | } 69 | -------------------------------------------------------------------------------- /internal/mocks/mock_events.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/domain/event (interfaces: EventRepository) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | event "github.com/shirasudon/go-chat/domain/event" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockEventRepository is a mock of EventRepository interface 15 | type MockEventRepository struct { 16 | ctrl *gomock.Controller 17 | recorder *MockEventRepositoryMockRecorder 18 | } 19 | 20 | // MockEventRepositoryMockRecorder is the mock recorder for MockEventRepository 21 | type MockEventRepositoryMockRecorder struct { 22 | mock *MockEventRepository 23 | } 24 | 25 | // NewMockEventRepository creates a new mock instance 26 | func NewMockEventRepository(ctrl *gomock.Controller) *MockEventRepository { 27 | mock := &MockEventRepository{ctrl: ctrl} 28 | mock.recorder = &MockEventRepositoryMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockEventRepository) EXPECT() *MockEventRepositoryMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Store mocks base method 38 | func (m *MockEventRepository) Store(arg0 context.Context, arg1 ...event.Event) ([]uint64, error) { 39 | varargs := []interface{}{arg0} 40 | for _, a := range arg1 { 41 | varargs = append(varargs, a) 42 | } 43 | ret := m.ctrl.Call(m, "Store", varargs...) 44 | ret0, _ := ret[0].([]uint64) 45 | ret1, _ := ret[1].(error) 46 | return ret0, ret1 47 | } 48 | 49 | // Store indicates an expected call of Store 50 | func (mr *MockEventRepositoryMockRecorder) Store(arg0 interface{}, arg1 ...interface{}) *gomock.Call { 51 | varargs := append([]interface{}{arg0}, arg1...) 52 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Store", reflect.TypeOf((*MockEventRepository)(nil).Store), varargs...) 53 | } 54 | -------------------------------------------------------------------------------- /internal/mocks/mock_login_service.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/chat (interfaces: LoginService) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | queried "github.com/shirasudon/go-chat/chat/queried" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockLoginService is a mock of LoginService interface 15 | type MockLoginService struct { 16 | ctrl *gomock.Controller 17 | recorder *MockLoginServiceMockRecorder 18 | } 19 | 20 | // MockLoginServiceMockRecorder is the mock recorder for MockLoginService 21 | type MockLoginServiceMockRecorder struct { 22 | mock *MockLoginService 23 | } 24 | 25 | // NewMockLoginService creates a new mock instance 26 | func NewMockLoginService(ctrl *gomock.Controller) *MockLoginService { 27 | mock := &MockLoginService{ctrl: ctrl} 28 | mock.recorder = &MockLoginServiceMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockLoginService) EXPECT() *MockLoginServiceMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Login mocks base method 38 | func (m *MockLoginService) Login(arg0 context.Context, arg1, arg2 string) (*queried.AuthUser, error) { 39 | ret := m.ctrl.Call(m, "Login", arg0, arg1, arg2) 40 | ret0, _ := ret[0].(*queried.AuthUser) 41 | ret1, _ := ret[1].(error) 42 | return ret0, ret1 43 | } 44 | 45 | // Login indicates an expected call of Login 46 | func (mr *MockLoginServiceMockRecorder) Login(arg0, arg1, arg2 interface{}) *gomock.Call { 47 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Login", reflect.TypeOf((*MockLoginService)(nil).Login), arg0, arg1, arg2) 48 | } 49 | 50 | // Logout mocks base method 51 | func (m *MockLoginService) Logout(arg0 context.Context, arg1 uint64) { 52 | m.ctrl.Call(m, "Logout", arg0, arg1) 53 | } 54 | 55 | // Logout indicates an expected call of Logout 56 | func (mr *MockLoginServiceMockRecorder) Logout(arg0, arg1 interface{}) *gomock.Call { 57 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Logout", reflect.TypeOf((*MockLoginService)(nil).Logout), arg0, arg1) 58 | } 59 | -------------------------------------------------------------------------------- /internal/mocks/mock_messages.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/domain (interfaces: MessageRepository) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | sql "database/sql" 10 | gomock "github.com/golang/mock/gomock" 11 | domain "github.com/shirasudon/go-chat/domain" 12 | reflect "reflect" 13 | ) 14 | 15 | // MockMessageRepository is a mock of MessageRepository interface 16 | type MockMessageRepository struct { 17 | ctrl *gomock.Controller 18 | recorder *MockMessageRepositoryMockRecorder 19 | } 20 | 21 | // MockMessageRepositoryMockRecorder is the mock recorder for MockMessageRepository 22 | type MockMessageRepositoryMockRecorder struct { 23 | mock *MockMessageRepository 24 | } 25 | 26 | // NewMockMessageRepository creates a new mock instance 27 | func NewMockMessageRepository(ctrl *gomock.Controller) *MockMessageRepository { 28 | mock := &MockMessageRepository{ctrl: ctrl} 29 | mock.recorder = &MockMessageRepositoryMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use 34 | func (m *MockMessageRepository) EXPECT() *MockMessageRepositoryMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // BeginTx mocks base method 39 | func (m *MockMessageRepository) BeginTx(arg0 context.Context, arg1 *sql.TxOptions) (domain.Tx, error) { 40 | ret := m.ctrl.Call(m, "BeginTx", arg0, arg1) 41 | ret0, _ := ret[0].(domain.Tx) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // BeginTx indicates an expected call of BeginTx 47 | func (mr *MockMessageRepositoryMockRecorder) BeginTx(arg0, arg1 interface{}) *gomock.Call { 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginTx", reflect.TypeOf((*MockMessageRepository)(nil).BeginTx), arg0, arg1) 49 | } 50 | 51 | // Find mocks base method 52 | func (m *MockMessageRepository) Find(arg0 context.Context, arg1 uint64) (domain.Message, error) { 53 | ret := m.ctrl.Call(m, "Find", arg0, arg1) 54 | ret0, _ := ret[0].(domain.Message) 55 | ret1, _ := ret[1].(error) 56 | return ret0, ret1 57 | } 58 | 59 | // Find indicates an expected call of Find 60 | func (mr *MockMessageRepositoryMockRecorder) Find(arg0, arg1 interface{}) *gomock.Call { 61 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockMessageRepository)(nil).Find), arg0, arg1) 62 | } 63 | 64 | // RemoveAllByRoomID mocks base method 65 | func (m *MockMessageRepository) RemoveAllByRoomID(arg0 context.Context, arg1 uint64) error { 66 | ret := m.ctrl.Call(m, "RemoveAllByRoomID", arg0, arg1) 67 | ret0, _ := ret[0].(error) 68 | return ret0 69 | } 70 | 71 | // RemoveAllByRoomID indicates an expected call of RemoveAllByRoomID 72 | func (mr *MockMessageRepositoryMockRecorder) RemoveAllByRoomID(arg0, arg1 interface{}) *gomock.Call { 73 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveAllByRoomID", reflect.TypeOf((*MockMessageRepository)(nil).RemoveAllByRoomID), arg0, arg1) 74 | } 75 | 76 | // Store mocks base method 77 | func (m *MockMessageRepository) Store(arg0 context.Context, arg1 domain.Message) (uint64, error) { 78 | ret := m.ctrl.Call(m, "Store", arg0, arg1) 79 | ret0, _ := ret[0].(uint64) 80 | ret1, _ := ret[1].(error) 81 | return ret0, ret1 82 | } 83 | 84 | // Store indicates an expected call of Store 85 | func (mr *MockMessageRepositoryMockRecorder) Store(arg0, arg1 interface{}) *gomock.Call { 86 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Store", reflect.TypeOf((*MockMessageRepository)(nil).Store), arg0, arg1) 87 | } 88 | -------------------------------------------------------------------------------- /internal/mocks/mock_pubsub.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/chat (interfaces: Pubsub) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | event "github.com/shirasudon/go-chat/domain/event" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockPubsub is a mock of Pubsub interface 14 | type MockPubsub struct { 15 | ctrl *gomock.Controller 16 | recorder *MockPubsubMockRecorder 17 | } 18 | 19 | // MockPubsubMockRecorder is the mock recorder for MockPubsub 20 | type MockPubsubMockRecorder struct { 21 | mock *MockPubsub 22 | } 23 | 24 | // NewMockPubsub creates a new mock instance 25 | func NewMockPubsub(ctrl *gomock.Controller) *MockPubsub { 26 | mock := &MockPubsub{ctrl: ctrl} 27 | mock.recorder = &MockPubsubMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockPubsub) EXPECT() *MockPubsubMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Pub mocks base method 37 | func (m *MockPubsub) Pub(arg0 ...event.Event) { 38 | varargs := []interface{}{} 39 | for _, a := range arg0 { 40 | varargs = append(varargs, a) 41 | } 42 | m.ctrl.Call(m, "Pub", varargs...) 43 | } 44 | 45 | // Pub indicates an expected call of Pub 46 | func (mr *MockPubsubMockRecorder) Pub(arg0 ...interface{}) *gomock.Call { 47 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pub", reflect.TypeOf((*MockPubsub)(nil).Pub), arg0...) 48 | } 49 | 50 | // Sub mocks base method 51 | func (m *MockPubsub) Sub(arg0 ...event.Type) chan interface{} { 52 | varargs := []interface{}{} 53 | for _, a := range arg0 { 54 | varargs = append(varargs, a) 55 | } 56 | ret := m.ctrl.Call(m, "Sub", varargs...) 57 | ret0, _ := ret[0].(chan interface{}) 58 | return ret0 59 | } 60 | 61 | // Sub indicates an expected call of Sub 62 | func (mr *MockPubsubMockRecorder) Sub(arg0 ...interface{}) *gomock.Call { 63 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sub", reflect.TypeOf((*MockPubsub)(nil).Sub), arg0...) 64 | } 65 | -------------------------------------------------------------------------------- /internal/mocks/mock_query_service.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/chat (interfaces: QueryService) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | action "github.com/shirasudon/go-chat/chat/action" 11 | queried "github.com/shirasudon/go-chat/chat/queried" 12 | reflect "reflect" 13 | ) 14 | 15 | // MockQueryService is a mock of QueryService interface 16 | type MockQueryService struct { 17 | ctrl *gomock.Controller 18 | recorder *MockQueryServiceMockRecorder 19 | } 20 | 21 | // MockQueryServiceMockRecorder is the mock recorder for MockQueryService 22 | type MockQueryServiceMockRecorder struct { 23 | mock *MockQueryService 24 | } 25 | 26 | // NewMockQueryService creates a new mock instance 27 | func NewMockQueryService(ctrl *gomock.Controller) *MockQueryService { 28 | mock := &MockQueryService{ctrl: ctrl} 29 | mock.recorder = &MockQueryServiceMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use 34 | func (m *MockQueryService) EXPECT() *MockQueryServiceMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // FindRoomInfo mocks base method 39 | func (m *MockQueryService) FindRoomInfo(arg0 context.Context, arg1, arg2 uint64) (*queried.RoomInfo, error) { 40 | ret := m.ctrl.Call(m, "FindRoomInfo", arg0, arg1, arg2) 41 | ret0, _ := ret[0].(*queried.RoomInfo) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // FindRoomInfo indicates an expected call of FindRoomInfo 47 | func (mr *MockQueryServiceMockRecorder) FindRoomInfo(arg0, arg1, arg2 interface{}) *gomock.Call { 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindRoomInfo", reflect.TypeOf((*MockQueryService)(nil).FindRoomInfo), arg0, arg1, arg2) 49 | } 50 | 51 | // FindRoomMessages mocks base method 52 | func (m *MockQueryService) FindRoomMessages(arg0 context.Context, arg1 uint64, arg2 action.QueryRoomMessages) (*queried.RoomMessages, error) { 53 | ret := m.ctrl.Call(m, "FindRoomMessages", arg0, arg1, arg2) 54 | ret0, _ := ret[0].(*queried.RoomMessages) 55 | ret1, _ := ret[1].(error) 56 | return ret0, ret1 57 | } 58 | 59 | // FindRoomMessages indicates an expected call of FindRoomMessages 60 | func (mr *MockQueryServiceMockRecorder) FindRoomMessages(arg0, arg1, arg2 interface{}) *gomock.Call { 61 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindRoomMessages", reflect.TypeOf((*MockQueryService)(nil).FindRoomMessages), arg0, arg1, arg2) 62 | } 63 | 64 | // FindUnreadRoomMessages mocks base method 65 | func (m *MockQueryService) FindUnreadRoomMessages(arg0 context.Context, arg1 uint64, arg2 action.QueryUnreadRoomMessages) (*queried.UnreadRoomMessages, error) { 66 | ret := m.ctrl.Call(m, "FindUnreadRoomMessages", arg0, arg1, arg2) 67 | ret0, _ := ret[0].(*queried.UnreadRoomMessages) 68 | ret1, _ := ret[1].(error) 69 | return ret0, ret1 70 | } 71 | 72 | // FindUnreadRoomMessages indicates an expected call of FindUnreadRoomMessages 73 | func (mr *MockQueryServiceMockRecorder) FindUnreadRoomMessages(arg0, arg1, arg2 interface{}) *gomock.Call { 74 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUnreadRoomMessages", reflect.TypeOf((*MockQueryService)(nil).FindUnreadRoomMessages), arg0, arg1, arg2) 75 | } 76 | 77 | // FindUserByNameAndPassword mocks base method 78 | func (m *MockQueryService) FindUserByNameAndPassword(arg0 context.Context, arg1, arg2 string) (*queried.AuthUser, error) { 79 | ret := m.ctrl.Call(m, "FindUserByNameAndPassword", arg0, arg1, arg2) 80 | ret0, _ := ret[0].(*queried.AuthUser) 81 | ret1, _ := ret[1].(error) 82 | return ret0, ret1 83 | } 84 | 85 | // FindUserByNameAndPassword indicates an expected call of FindUserByNameAndPassword 86 | func (mr *MockQueryServiceMockRecorder) FindUserByNameAndPassword(arg0, arg1, arg2 interface{}) *gomock.Call { 87 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserByNameAndPassword", reflect.TypeOf((*MockQueryService)(nil).FindUserByNameAndPassword), arg0, arg1, arg2) 88 | } 89 | 90 | // FindUserRelation mocks base method 91 | func (m *MockQueryService) FindUserRelation(arg0 context.Context, arg1 uint64) (*queried.UserRelation, error) { 92 | ret := m.ctrl.Call(m, "FindUserRelation", arg0, arg1) 93 | ret0, _ := ret[0].(*queried.UserRelation) 94 | ret1, _ := ret[1].(error) 95 | return ret0, ret1 96 | } 97 | 98 | // FindUserRelation indicates an expected call of FindUserRelation 99 | func (mr *MockQueryServiceMockRecorder) FindUserRelation(arg0, arg1 interface{}) *gomock.Call { 100 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserRelation", reflect.TypeOf((*MockQueryService)(nil).FindUserRelation), arg0, arg1) 101 | } 102 | -------------------------------------------------------------------------------- /internal/mocks/mock_repos.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/domain (interfaces: Repositories) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | domain "github.com/shirasudon/go-chat/domain" 10 | event "github.com/shirasudon/go-chat/domain/event" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockRepositories is a mock of Repositories interface 15 | type MockRepositories struct { 16 | ctrl *gomock.Controller 17 | recorder *MockRepositoriesMockRecorder 18 | } 19 | 20 | // MockRepositoriesMockRecorder is the mock recorder for MockRepositories 21 | type MockRepositoriesMockRecorder struct { 22 | mock *MockRepositories 23 | } 24 | 25 | // NewMockRepositories creates a new mock instance 26 | func NewMockRepositories(ctrl *gomock.Controller) *MockRepositories { 27 | mock := &MockRepositories{ctrl: ctrl} 28 | mock.recorder = &MockRepositoriesMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockRepositories) EXPECT() *MockRepositoriesMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Events mocks base method 38 | func (m *MockRepositories) Events() event.EventRepository { 39 | ret := m.ctrl.Call(m, "Events") 40 | ret0, _ := ret[0].(event.EventRepository) 41 | return ret0 42 | } 43 | 44 | // Events indicates an expected call of Events 45 | func (mr *MockRepositoriesMockRecorder) Events() *gomock.Call { 46 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Events", reflect.TypeOf((*MockRepositories)(nil).Events)) 47 | } 48 | 49 | // Messages mocks base method 50 | func (m *MockRepositories) Messages() domain.MessageRepository { 51 | ret := m.ctrl.Call(m, "Messages") 52 | ret0, _ := ret[0].(domain.MessageRepository) 53 | return ret0 54 | } 55 | 56 | // Messages indicates an expected call of Messages 57 | func (mr *MockRepositoriesMockRecorder) Messages() *gomock.Call { 58 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Messages", reflect.TypeOf((*MockRepositories)(nil).Messages)) 59 | } 60 | 61 | // Rooms mocks base method 62 | func (m *MockRepositories) Rooms() domain.RoomRepository { 63 | ret := m.ctrl.Call(m, "Rooms") 64 | ret0, _ := ret[0].(domain.RoomRepository) 65 | return ret0 66 | } 67 | 68 | // Rooms indicates an expected call of Rooms 69 | func (mr *MockRepositoriesMockRecorder) Rooms() *gomock.Call { 70 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Rooms", reflect.TypeOf((*MockRepositories)(nil).Rooms)) 71 | } 72 | 73 | // Users mocks base method 74 | func (m *MockRepositories) Users() domain.UserRepository { 75 | ret := m.ctrl.Call(m, "Users") 76 | ret0, _ := ret[0].(domain.UserRepository) 77 | return ret0 78 | } 79 | 80 | // Users indicates an expected call of Users 81 | func (mr *MockRepositoriesMockRecorder) Users() *gomock.Call { 82 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Users", reflect.TypeOf((*MockRepositories)(nil).Users)) 83 | } 84 | -------------------------------------------------------------------------------- /internal/mocks/mock_rooms.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/domain (interfaces: RoomRepository) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | sql "database/sql" 10 | gomock "github.com/golang/mock/gomock" 11 | domain "github.com/shirasudon/go-chat/domain" 12 | reflect "reflect" 13 | ) 14 | 15 | // MockRoomRepository is a mock of RoomRepository interface 16 | type MockRoomRepository struct { 17 | ctrl *gomock.Controller 18 | recorder *MockRoomRepositoryMockRecorder 19 | } 20 | 21 | // MockRoomRepositoryMockRecorder is the mock recorder for MockRoomRepository 22 | type MockRoomRepositoryMockRecorder struct { 23 | mock *MockRoomRepository 24 | } 25 | 26 | // NewMockRoomRepository creates a new mock instance 27 | func NewMockRoomRepository(ctrl *gomock.Controller) *MockRoomRepository { 28 | mock := &MockRoomRepository{ctrl: ctrl} 29 | mock.recorder = &MockRoomRepositoryMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use 34 | func (m *MockRoomRepository) EXPECT() *MockRoomRepositoryMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // BeginTx mocks base method 39 | func (m *MockRoomRepository) BeginTx(arg0 context.Context, arg1 *sql.TxOptions) (domain.Tx, error) { 40 | ret := m.ctrl.Call(m, "BeginTx", arg0, arg1) 41 | ret0, _ := ret[0].(domain.Tx) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // BeginTx indicates an expected call of BeginTx 47 | func (mr *MockRoomRepositoryMockRecorder) BeginTx(arg0, arg1 interface{}) *gomock.Call { 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginTx", reflect.TypeOf((*MockRoomRepository)(nil).BeginTx), arg0, arg1) 49 | } 50 | 51 | // Find mocks base method 52 | func (m *MockRoomRepository) Find(arg0 context.Context, arg1 uint64) (domain.Room, error) { 53 | ret := m.ctrl.Call(m, "Find", arg0, arg1) 54 | ret0, _ := ret[0].(domain.Room) 55 | ret1, _ := ret[1].(error) 56 | return ret0, ret1 57 | } 58 | 59 | // Find indicates an expected call of Find 60 | func (mr *MockRoomRepositoryMockRecorder) Find(arg0, arg1 interface{}) *gomock.Call { 61 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockRoomRepository)(nil).Find), arg0, arg1) 62 | } 63 | 64 | // FindAllByUserID mocks base method 65 | func (m *MockRoomRepository) FindAllByUserID(arg0 context.Context, arg1 uint64) ([]domain.Room, error) { 66 | ret := m.ctrl.Call(m, "FindAllByUserID", arg0, arg1) 67 | ret0, _ := ret[0].([]domain.Room) 68 | ret1, _ := ret[1].(error) 69 | return ret0, ret1 70 | } 71 | 72 | // FindAllByUserID indicates an expected call of FindAllByUserID 73 | func (mr *MockRoomRepositoryMockRecorder) FindAllByUserID(arg0, arg1 interface{}) *gomock.Call { 74 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAllByUserID", reflect.TypeOf((*MockRoomRepository)(nil).FindAllByUserID), arg0, arg1) 75 | } 76 | 77 | // Remove mocks base method 78 | func (m *MockRoomRepository) Remove(arg0 context.Context, arg1 domain.Room) error { 79 | ret := m.ctrl.Call(m, "Remove", arg0, arg1) 80 | ret0, _ := ret[0].(error) 81 | return ret0 82 | } 83 | 84 | // Remove indicates an expected call of Remove 85 | func (mr *MockRoomRepositoryMockRecorder) Remove(arg0, arg1 interface{}) *gomock.Call { 86 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockRoomRepository)(nil).Remove), arg0, arg1) 87 | } 88 | 89 | // Store mocks base method 90 | func (m *MockRoomRepository) Store(arg0 context.Context, arg1 domain.Room) (uint64, error) { 91 | ret := m.ctrl.Call(m, "Store", arg0, arg1) 92 | ret0, _ := ret[0].(uint64) 93 | ret1, _ := ret[1].(error) 94 | return ret0, ret1 95 | } 96 | 97 | // Store indicates an expected call of Store 98 | func (mr *MockRoomRepositoryMockRecorder) Store(arg0, arg1 interface{}) *gomock.Call { 99 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Store", reflect.TypeOf((*MockRoomRepository)(nil).Store), arg0, arg1) 100 | } 101 | -------------------------------------------------------------------------------- /internal/mocks/mock_users.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/shirasudon/go-chat/domain (interfaces: UserRepository) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | sql "database/sql" 10 | gomock "github.com/golang/mock/gomock" 11 | domain "github.com/shirasudon/go-chat/domain" 12 | reflect "reflect" 13 | ) 14 | 15 | // MockUserRepository is a mock of UserRepository interface 16 | type MockUserRepository struct { 17 | ctrl *gomock.Controller 18 | recorder *MockUserRepositoryMockRecorder 19 | } 20 | 21 | // MockUserRepositoryMockRecorder is the mock recorder for MockUserRepository 22 | type MockUserRepositoryMockRecorder struct { 23 | mock *MockUserRepository 24 | } 25 | 26 | // NewMockUserRepository creates a new mock instance 27 | func NewMockUserRepository(ctrl *gomock.Controller) *MockUserRepository { 28 | mock := &MockUserRepository{ctrl: ctrl} 29 | mock.recorder = &MockUserRepositoryMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use 34 | func (m *MockUserRepository) EXPECT() *MockUserRepositoryMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // BeginTx mocks base method 39 | func (m *MockUserRepository) BeginTx(arg0 context.Context, arg1 *sql.TxOptions) (domain.Tx, error) { 40 | ret := m.ctrl.Call(m, "BeginTx", arg0, arg1) 41 | ret0, _ := ret[0].(domain.Tx) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // BeginTx indicates an expected call of BeginTx 47 | func (mr *MockUserRepositoryMockRecorder) BeginTx(arg0, arg1 interface{}) *gomock.Call { 48 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BeginTx", reflect.TypeOf((*MockUserRepository)(nil).BeginTx), arg0, arg1) 49 | } 50 | 51 | // Find mocks base method 52 | func (m *MockUserRepository) Find(arg0 context.Context, arg1 uint64) (domain.User, error) { 53 | ret := m.ctrl.Call(m, "Find", arg0, arg1) 54 | ret0, _ := ret[0].(domain.User) 55 | ret1, _ := ret[1].(error) 56 | return ret0, ret1 57 | } 58 | 59 | // Find indicates an expected call of Find 60 | func (mr *MockUserRepositoryMockRecorder) Find(arg0, arg1 interface{}) *gomock.Call { 61 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockUserRepository)(nil).Find), arg0, arg1) 62 | } 63 | 64 | // Store mocks base method 65 | func (m *MockUserRepository) Store(arg0 context.Context, arg1 domain.User) (uint64, error) { 66 | ret := m.ctrl.Call(m, "Store", arg0, arg1) 67 | ret0, _ := ret[0].(uint64) 68 | ret1, _ := ret[1].(error) 69 | return ret0, ret1 70 | } 71 | 72 | // Store indicates an expected call of Store 73 | func (mr *MockUserRepositoryMockRecorder) Store(arg0, arg1 interface{}) *gomock.Call { 74 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Store", reflect.TypeOf((*MockUserRepository)(nil).Store), arg0, arg1) 75 | } 76 | -------------------------------------------------------------------------------- /main/auto-login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | websocket sample 4 | 14 | 15 | 16 |

Login then redirect to chat page

17 | 18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /main/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | websocket sample 4 | 86 | 87 | 88 | 89 |

WebSocket Test

90 |
91 | 92 |
93 |
94 | 95 | 96 |
97 |
98 | 99 |
100 |
101 | 102 |
103 |
104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /main/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/shirasudon/go-chat/chat" 9 | "github.com/shirasudon/go-chat/domain" 10 | "github.com/shirasudon/go-chat/infra/config" 11 | "github.com/shirasudon/go-chat/infra/inmemory" 12 | "github.com/shirasudon/go-chat/infra/pubsub" 13 | "github.com/shirasudon/go-chat/server" 14 | ) 15 | 16 | type DoneFunc func() 17 | 18 | func createInfra() (domain.Repositories, *chat.Queryers, chat.Pubsub, DoneFunc) { 19 | ps := pubsub.New() 20 | doneFuncs := make([]func(), 0, 4) 21 | doneFuncs = append(doneFuncs, ps.Shutdown) 22 | 23 | repos := inmemory.OpenRepositories(ps) 24 | doneFuncs = append(doneFuncs, func() { _ = repos.Close() }) 25 | 26 | ctx, cancel := context.WithCancel(context.Background()) 27 | doneFuncs = append(doneFuncs, cancel) 28 | go repos.UpdatingService(ctx) 29 | 30 | qs := &chat.Queryers{ 31 | UserQueryer: repos.UserRepository, 32 | RoomQueryer: repos.RoomRepository, 33 | MessageQueryer: repos.MessageRepository, 34 | EventQueryer: repos.EventRepository, 35 | } 36 | 37 | done := func() { 38 | // reverse order to simulate defer statement. 39 | for i := len(doneFuncs); i >= 0; i-- { 40 | doneFuncs[i]() 41 | } 42 | } 43 | 44 | return repos, qs, ps, done 45 | } 46 | 47 | const ( 48 | DefaultConfigFile = "config.toml" 49 | KeyConfigFileENV = "GOCHAT_CONFIG_FILE" 50 | ) 51 | 52 | func main() { 53 | // get config path from environment value. 54 | var configPath = DefaultConfigFile 55 | if confPath := os.Getenv(KeyConfigFileENV); len(confPath) > 0 { 56 | configPath = confPath 57 | } 58 | 59 | // set config value to be used. 60 | var defaultConf = server.DefaultConfig 61 | if config.FileExists(configPath) { 62 | log.Printf("[Config] Loading file: %s\n", configPath) 63 | if err := config.LoadFile(&defaultConf, configPath); err != nil { 64 | log.Fatalf("[Config] Load Error: %v", err) 65 | } 66 | log.Println("[Config] Loading file: OK") 67 | } else { 68 | log.Println("[Config] Use default") 69 | } 70 | 71 | repos, qs, ps, infraDoneFunc := createInfra() 72 | s, done := server.CreateServerFromInfra(repos, qs, ps, &defaultConf) 73 | defer func() { 74 | done() 75 | infraDoneFunc() 76 | }() 77 | 78 | if err := s.ListenAndServe(); err != nil { 79 | log.Fatal(err) 80 | } 81 | log.Println("[Server] quiting...") 82 | } 83 | -------------------------------------------------------------------------------- /main/test.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | COOKIE="login.cookie" 4 | 5 | echo "# login to the server" 6 | curl http://localhost:8080/login -XPOST -d "name=user2&password=password" -c $COOKIE 7 | echo "" 8 | 9 | echo "# get login status" 10 | curl http://localhost:8080/login -b $COOKIE 11 | echo "" 12 | 13 | echo "# create new room" 14 | curl http://localhost:8080/chat/rooms -XPOST -d '{ "name": "new room"}' -b $COOKIE -H "Content-type: application/json" 15 | echo "" 16 | 17 | echo "# get room info" 18 | curl http://localhost:8080/chat/rooms/4 -b $COOKIE 19 | echo "" 20 | 21 | echo "# post new message" 22 | curl http://localhost:8080/chat/rooms/4/messages -b $COOKIE \ 23 | -XPOST -d '{ "content": "hello! new room!"}' -H "Content-type: application/json" 24 | echo "" 25 | 26 | echo "# get room messages (1)" 27 | curl http://localhost:8080/chat/rooms/4/messages -b $COOKIE 28 | echo "" 29 | 30 | echo "# get unread room messages (1)" 31 | curl http://localhost:8080/chat/rooms/4/messages/unread -b $COOKIE 32 | echo "" 33 | 34 | echo "# read new message" 35 | curl http://localhost:8080/chat/rooms/4/messages/read -b $COOKIE \ 36 | -XPOST -d '{}' -H "Content-type: application/json" 37 | echo "" 38 | 39 | echo "# get room messages (2)" 40 | curl http://localhost:8080/chat/rooms/4/messages -b $COOKIE 41 | echo "" 42 | 43 | echo "# get unread room messages (2)" 44 | curl http://localhost:8080/chat/rooms/4/messages/unread -b $COOKIE 45 | echo "" 46 | 47 | echo "# access websocket path with http protocol. (it will fail to connect websocke server.)" 48 | curl http://localhost:8080/chat/ws -b $COOKIE 49 | echo "" 50 | 51 | # finally remove cookie file 52 | rm $COOKIE 53 | -------------------------------------------------------------------------------- /server/config.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Configuration for server behavior. 9 | type Config struct { 10 | // HTTP service address for the server. 11 | // The format is `[host]:[port]`, e.g. localhost:8080. 12 | HTTP string 13 | 14 | // Prefix of URI for the chat API. 15 | // e.g. given ChatAPIPrefix = `/api` and chat API `/chat/rooms`, 16 | // the prefixed chat API is `/api/chat/rooms`. 17 | ChatAPIPrefix string 18 | 19 | // Prefix of URI for the static file server. 20 | // 21 | // Example: given local html file `/www/index.html`, 22 | // StaticHandlerPrefix = "/www" and StaticHandlerPrefix = "/static", 23 | // the requesting the server with URI `/static/index.html` responds 24 | // the html content of `/www/index.html`. 25 | StaticHandlerPrefix string 26 | 27 | // root directory to serve static files. 28 | StaticFileDir string 29 | 30 | // indicates whether serving static files is enable. 31 | // if false, StaticHandlerPrefix and StaticFileDir do not 32 | // affect the server. 33 | EnableServeStaticFile bool 34 | 35 | // show all of URI routes at server starts. 36 | ShowRoutes bool 37 | } 38 | 39 | // DefaultConfig is default configuration for the server. 40 | var DefaultConfig = Config{ 41 | HTTP: "localhost:8080", 42 | ChatAPIPrefix: "", 43 | StaticHandlerPrefix: "", 44 | StaticFileDir: "", // current directory 45 | EnableServeStaticFile: true, 46 | ShowRoutes: true, 47 | } 48 | 49 | // Validate checks whether the all of field values are correct format. 50 | func (c *Config) Validate() error { 51 | if ss := strings.Split(c.HTTP, ":"); len(ss) != 2 { 52 | return fmt.Errorf("config: HTTP should be [host]:[port], but %v", c.HTTP) 53 | } 54 | if len(c.ChatAPIPrefix) > 0 && !strings.HasPrefix(c.ChatAPIPrefix, "/") { 55 | return fmt.Errorf("config: ChatAPIPrefix should start with \"/\" but %v", c.ChatAPIPrefix) 56 | } 57 | if len(c.StaticHandlerPrefix) > 0 && !strings.HasPrefix(c.StaticHandlerPrefix, "/") { 58 | return fmt.Errorf("config: StaticHandlerPrefix should start with \"/\" but %v", c.StaticHandlerPrefix) 59 | } 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /server/config_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/labstack/echo" 8 | ) 9 | 10 | func TestDefaultConfig(t *testing.T) { 11 | if err := DefaultConfig.Validate(); err != nil { 12 | t.Errorf("DefaultConfig.Validate: %v", err) 13 | } 14 | } 15 | 16 | func TestConfigValidate(t *testing.T) { 17 | for _, c := range []Config{ 18 | {HTTP: "a"}, 19 | {HTTP: "a:a:"}, 20 | {HTTP: "a::"}, 21 | {ChatAPIPrefix: "api/chat"}, 22 | {StaticHandlerPrefix: "sta/tic"}, 23 | } { 24 | if err := c.Validate(); err == nil { 25 | t.Errorf("It should be error but not, %#v", c) 26 | } 27 | } 28 | } 29 | 30 | func findRoute(routes []*echo.Route, query echo.Route) bool { 31 | for _, r := range routes { 32 | if *r == query { 33 | return true 34 | } 35 | } 36 | return false 37 | } 38 | 39 | func TestServerConfEnableServeStaticFile(t *testing.T) { 40 | var staticFileRoute = echo.Route{ 41 | Name: "staticContents", 42 | Path: "/*", 43 | Method: echo.GET, 44 | } 45 | 46 | { // enable serve static file 47 | conf := DefaultConfig 48 | conf.EnableServeStaticFile = true 49 | server1 := NewServer(chatCmd, chatQuery, chatHub, loginService, &conf) 50 | defer server1.Shutdown(context.Background()) 51 | if !findRoute(server1.echo.Routes(), staticFileRoute) { 52 | t.Errorf("staticContents route (%#v) is not found", staticFileRoute) 53 | } 54 | } 55 | 56 | { // disable serve static file 57 | conf := DefaultConfig 58 | conf.EnableServeStaticFile = false 59 | server2 := NewServer(chatCmd, chatQuery, chatHub, loginService, &conf) 60 | defer server2.Shutdown(context.Background()) 61 | if findRoute(server2.echo.Routes(), staticFileRoute) { 62 | t.Errorf("staticContents route (%#v) should be not found", staticFileRoute) 63 | } 64 | } 65 | } 66 | 67 | func TestServerConfStaticHandlerPrefix(t *testing.T) { 68 | var Query = echo.Route{ 69 | Name: "staticContents", 70 | Path: "/static/*", 71 | Method: echo.GET, 72 | } 73 | 74 | { // set static prefix 75 | conf := DefaultConfig 76 | conf.StaticHandlerPrefix = "/static" 77 | server1 := NewServer(chatCmd, chatQuery, chatHub, loginService, &conf) 78 | defer server1.Shutdown(context.Background()) 79 | if !findRoute(server1.echo.Routes(), Query) { 80 | t.Errorf("staticContents route (%#v) is not found", Query) 81 | } 82 | } 83 | 84 | { // no static prefix 85 | conf := DefaultConfig 86 | server2 := NewServer(chatCmd, chatQuery, chatHub, loginService, &conf) 87 | defer server2.Shutdown(context.Background()) 88 | if findRoute(server2.echo.Routes(), Query) { 89 | t.Errorf("staticContents route (%#v) should be not found", Query) 90 | } 91 | } 92 | } 93 | 94 | func TestServerConfChatAPIPrefix(t *testing.T) { 95 | var Query = echo.Route{ 96 | Name: "chat.createRoom", 97 | Path: "/api/chat/rooms", 98 | Method: echo.POST, 99 | } 100 | 101 | { // set chat prefix 102 | conf := DefaultConfig 103 | conf.ChatAPIPrefix = "/api" 104 | server1 := NewServer(chatCmd, chatQuery, chatHub, loginService, &conf) 105 | defer server1.Shutdown(context.Background()) 106 | if !findRoute(server1.echo.Routes(), Query) { 107 | t.Errorf("chat API route (%#v) is not found", Query) 108 | } 109 | } 110 | 111 | { // no chat prefix 112 | conf := DefaultConfig 113 | server2 := NewServer(chatCmd, chatQuery, chatHub, loginService, &conf) 114 | defer server2.Shutdown(context.Background()) 115 | if findRoute(server2.echo.Routes(), Query) { 116 | t.Errorf("chat API route (%#v) should be not found", Query) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /server/error.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/labstack/echo" 5 | ) 6 | 7 | // Wrapper function for the echo.HTTPError. 8 | // Because echo.DefaultHTTPHandler handles only string message in 9 | // the echo.HTTPError, This converts error types to string. 10 | func NewHTTPError(statusCode int, msg ...interface{}) *echo.HTTPError { 11 | if len(msg) > 0 { 12 | var strMsg = "" 13 | switch m := msg[0].(type) { 14 | case string: 15 | strMsg = m 16 | case error: 17 | strMsg = m.Error() 18 | default: 19 | panic("NewHTTPError: support only string or error") 20 | } 21 | return echo.NewHTTPError(statusCode, strMsg) 22 | } 23 | return echo.NewHTTPError(statusCode) 24 | } 25 | -------------------------------------------------------------------------------- /server/helper_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/shirasudon/go-chat/chat/action" 5 | ) 6 | 7 | // NormTimestampNow returns normalized action.Timestamp. 8 | // Normalization is performed by dump it with MarshalText() and then 9 | // re-construct with UnmarshalParam(). 10 | func NormTimestampNow() action.Timestamp { 11 | now := action.TimestampNow() 12 | bs, _ := now.MarshalText() 13 | var newNow action.Timestamp 14 | newNow.UnmarshalParam(string(bs)) 15 | return newNow 16 | } 17 | 18 | // MustMarshal returns byte slice which ensure 19 | // the error is nil. 20 | func MustMarshal(bs []byte, err error) []byte { 21 | if err != nil { 22 | panic(err) 23 | } 24 | return bs 25 | } 26 | -------------------------------------------------------------------------------- /server/login.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/gob" 5 | "net/http" 6 | 7 | "github.com/ipfans/echo-session" 8 | "github.com/labstack/echo" 9 | 10 | "github.com/shirasudon/go-chat/chat" 11 | ) 12 | 13 | func init() { 14 | // register LoginState which is requirements 15 | // to use echo-session and backed-end gorilla/sessions. 16 | gob.Register(&LoginState{}) 17 | } 18 | 19 | type UserForm struct { 20 | Name string `json:"name" form:"name" query:"name"` 21 | Password string `json:"password" form:"password" query:"password"` 22 | RememberMe bool `json:"remember_me" form:"remember_me" query:"remember_me"` 23 | } 24 | 25 | type LoginState struct { 26 | LoggedIn bool `json:"logged_in"` 27 | RememberMe bool `json:"remember_me"` 28 | UserID uint64 `json:"user_id"` 29 | ErrorMsg string `json:"error,omitempty"` 30 | } 31 | 32 | const ( 33 | KeySessionID = "SESSION-ID" 34 | 35 | // key for session value which is user loggedin state. 36 | KeyLoginState = "LOGIN-STATE" 37 | 38 | // seconds in 365 days, where 86400 is a seconds in 1 day 39 | SecondsInYear = 86400 * 365 40 | ) 41 | 42 | var DefaultOptions = session.Options{ 43 | HttpOnly: true, 44 | } 45 | 46 | // LoginHandler handles login requests. 47 | // it holds logged-in users, so that each request can reference 48 | // any logged-in user. 49 | type LoginHandler struct { 50 | service chat.LoginService 51 | store session.Store 52 | } 53 | 54 | func NewLoginHandler(ls chat.LoginService, secretKeyPairs ...[]byte) *LoginHandler { 55 | if len(secretKeyPairs) == 0 { 56 | secretKeyPairs = [][]byte{ 57 | []byte("secret-key"), 58 | } 59 | } 60 | store := session.NewCookieStore(secretKeyPairs...) 61 | store.Options(DefaultOptions) 62 | 63 | return &LoginHandler{ 64 | service: ls, 65 | store: store, 66 | } 67 | } 68 | 69 | func (lh *LoginHandler) Login(c echo.Context) error { 70 | u := new(UserForm) 71 | if err := c.Bind(u); err != nil { 72 | return err 73 | } 74 | 75 | user, err := lh.service.Login(c.Request().Context(), u.Name, u.Password) 76 | if err != nil { 77 | return c.JSON(http.StatusOK, LoginState{ErrorMsg: err.Error()}) 78 | } 79 | 80 | loginState := LoginState{LoggedIn: true, UserID: user.ID, RememberMe: u.RememberMe} 81 | 82 | sess := session.Default(c) 83 | sess.Set(KeyLoginState, &loginState) 84 | if loginState.RememberMe { 85 | newOpt := DefaultOptions 86 | newOpt.MaxAge = SecondsInYear 87 | sess.Options(newOpt) 88 | } 89 | if err := sess.Save(); err != nil { 90 | return err 91 | } 92 | 93 | return c.JSON(http.StatusOK, loginState) 94 | } 95 | 96 | func (lh *LoginHandler) Logout(c echo.Context) error { 97 | sess := session.Default(c) 98 | state, ok := sess.Get(KeyLoginState).(*LoginState) 99 | if !ok { 100 | return c.JSON(http.StatusOK, LoginState{ErrorMsg: "you are not logged in"}) 101 | } 102 | 103 | sess.Delete(KeyLoginState) 104 | if err := sess.Save(); err != nil { 105 | return err 106 | } 107 | 108 | lh.service.Logout(c.Request().Context(), state.UserID) 109 | return c.JSON(http.StatusOK, LoginState{LoggedIn: false}) 110 | } 111 | 112 | func (lh *LoginHandler) GetLoginState(c echo.Context) error { 113 | loginState, ok := lh.Session(c) 114 | if !ok { 115 | return c.JSON(http.StatusOK, LoginState{LoggedIn: false, ErrorMsg: "you are not logged in"}) 116 | } 117 | return c.JSON(http.StatusOK, loginState) 118 | } 119 | 120 | func (lh *LoginHandler) IsLoggedInRequest(c echo.Context) bool { 121 | loginState, ok := lh.Session(c) 122 | return ok && loginState.LoggedIn 123 | } 124 | 125 | // it returns loginState as session state. 126 | // the second returned value is true when LoginState exists. 127 | func (lh *LoginHandler) Session(c echo.Context) (*LoginState, bool) { 128 | sess := session.Default(c) 129 | if sess == nil { 130 | return nil, false 131 | } 132 | loginState, ok := sess.Get(KeyLoginState).(*LoginState) 133 | return loginState, ok 134 | } 135 | 136 | // Middleware returns echo.MiddlewareFunc. 137 | // it should be registered for echo.Server to use this LoginHandler. 138 | func (lh *LoginHandler) Middleware() echo.MiddlewareFunc { 139 | return session.Sessions(KeySessionID, lh.store) 140 | } 141 | 142 | // KeyLoggedInUserID is the key for the logged-in user id in the echo.Context. 143 | // It is set when user is logged-in through LoginHandler. 144 | const KeyLoggedInUserID = "SESSION-USER-ID" 145 | 146 | // Filter is a middleware which filters unauthenticated request. 147 | // 148 | // it sets logged-in user's id for echo.Context using KeyLoggedInUserID 149 | // when the request is authenticated. 150 | func (lh *LoginHandler) Filter() echo.MiddlewareFunc { 151 | return func(handlerFunc echo.HandlerFunc) echo.HandlerFunc { 152 | return func(c echo.Context) error { 153 | if loginState, ok := lh.Session(c); ok && loginState.LoggedIn { 154 | c.Set(KeyLoggedInUserID, loginState.UserID) 155 | return handlerFunc(c) 156 | } 157 | // not logged-in 158 | return NewHTTPError(http.StatusForbidden, "require login firstly") 159 | } 160 | } 161 | } 162 | 163 | // get logged in user id which is valid after LoginHandler.Filter. 164 | // the second returned value is false if logged in 165 | // user id is not found. 166 | func LoggedInUserID(c echo.Context) (uint64, bool) { 167 | userID, ok := c.Get(KeyLoggedInUserID).(uint64) 168 | return userID, ok 169 | } 170 | -------------------------------------------------------------------------------- /server/resolve.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shirasudon/go-chat/chat" 7 | "github.com/shirasudon/go-chat/domain" 8 | ) 9 | 10 | // resolve.go provide helper functions for resolving dependencies to 11 | // construct gochat Server. 12 | 13 | // DoneFunc is function to be called after all of operations are done. 14 | type DoneFunc func() 15 | 16 | // CreateServerFromInfra creates server with infrastructure dependencies. 17 | // It returns created server and finalize function. 18 | // a nil config is OK and use DefaultConfig insteadly. 19 | func CreateServerFromInfra(repos domain.Repositories, qs *chat.Queryers, ps chat.Pubsub, conf *Config) (*Server, DoneFunc) { 20 | chatCmd := chat.NewCommandServiceImpl(repos, ps) 21 | chatQuery := chat.NewQueryServiceImpl(qs) 22 | chatHub := chat.NewHubImpl(chatCmd) 23 | go chatHub.Listen(context.Background()) 24 | 25 | login := chat.NewLoginServiceImpl(qs.UserQueryer, ps) 26 | 27 | server := NewServer(chatCmd, chatQuery, chatHub, login, conf) 28 | doneFunc := func() { 29 | chatHub.Shutdown() 30 | } 31 | return server, doneFunc 32 | } 33 | -------------------------------------------------------------------------------- /server/resolve_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/golang/mock/gomock" 9 | 10 | "github.com/shirasudon/go-chat/chat" 11 | "github.com/shirasudon/go-chat/domain" 12 | "github.com/shirasudon/go-chat/internal/mocks" 13 | ) 14 | 15 | // Check non-panic by just calling ListenAndServe for created server. 16 | // This can not t.Parallel becasue multiple server can not listen on same port. 17 | func TestCreateServerFromInfra(t *testing.T) { 18 | ctrl := gomock.NewController(t) 19 | defer ctrl.Finish() 20 | 21 | repos := domain.SimpleRepositories{ 22 | UserRepository: mocks.NewMockUserRepository(ctrl), 23 | RoomRepository: mocks.NewMockRoomRepository(ctrl), 24 | MessageRepository: mocks.NewMockMessageRepository(ctrl), 25 | EventRepository: mocks.NewMockEventRepository(ctrl), 26 | } 27 | 28 | qs := &chat.Queryers{ 29 | UserQueryer: mocks.NewMockUserQueryer(ctrl), 30 | RoomQueryer: mocks.NewMockRoomQueryer(ctrl), 31 | MessageQueryer: mocks.NewMockMessageQueryer(ctrl), 32 | } 33 | 34 | ps := mocks.NewMockPubsub(ctrl) 35 | ps.EXPECT().Sub(gomock.Any()).AnyTimes() 36 | 37 | server, done := CreateServerFromInfra(repos, qs, ps, nil) 38 | defer done() 39 | 40 | doneCh := make(chan bool, 1) 41 | timeout := time.After(20 * time.Millisecond) 42 | go func() { 43 | server.ListenAndServe() 44 | close(doneCh) 45 | }() 46 | 47 | time.Sleep(10 * time.Millisecond) 48 | server.Shutdown(context.Background()) 49 | select { 50 | case <-timeout: 51 | t.Error("timeout for Shutdown server") 52 | case <-doneCh: 53 | // PASS 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "path" 10 | "sort" 11 | "strings" 12 | 13 | "github.com/labstack/echo" 14 | "github.com/labstack/echo/middleware" 15 | 16 | "github.com/shirasudon/go-chat/chat" 17 | "github.com/shirasudon/go-chat/chat/action" 18 | "github.com/shirasudon/go-chat/domain/event" 19 | "github.com/shirasudon/go-chat/ws" 20 | ) 21 | 22 | // it represents server which can accepts chat room and its clients. 23 | type Server struct { 24 | echo *echo.Echo 25 | 26 | wsServer *ws.Server 27 | loginHandler *LoginHandler 28 | restHandler *RESTHandler 29 | 30 | chatHub chat.Hub 31 | 32 | conf Config 33 | } 34 | 35 | // it returns new constructed server with config. 36 | // nil config is ok and use DefaultConfig insteadly. 37 | func NewServer(chatCmd chat.CommandService, chatQuery chat.QueryService, chatHub chat.Hub, login chat.LoginService, conf *Config) *Server { 38 | if conf == nil { 39 | conf = &DefaultConfig 40 | } 41 | 42 | e := echo.New() 43 | e.HideBanner = true 44 | 45 | s := &Server{ 46 | echo: e, 47 | loginHandler: NewLoginHandler(login), 48 | restHandler: NewRESTHandler(chatCmd, chatQuery), 49 | chatHub: chatHub, 50 | conf: *conf, 51 | } 52 | s.wsServer = ws.NewServerFunc(s.handleWsConn) 53 | 54 | // initilize router 55 | e.Use(middleware.Logger()) 56 | e.Use(middleware.Recover()) 57 | 58 | // set login handler 59 | e.Use(s.loginHandler.Middleware()) 60 | e.POST("/login", s.loginHandler.Login). 61 | Name = "doLogin" 62 | e.GET("/login", s.loginHandler.GetLoginState). 63 | Name = "getLoginInfo" 64 | e.POST("/logout", s.loginHandler.Logout). 65 | Name = "doLogout" 66 | 67 | chatPath := path.Join(s.conf.ChatAPIPrefix, "/chat") 68 | chatGroup := e.Group(chatPath, s.loginHandler.Filter()) 69 | 70 | // set restHandler 71 | chatGroup.POST("/rooms", s.restHandler.CreateRoom). 72 | Name = "chat.createRoom" 73 | chatGroup.DELETE("/rooms/:room_id", s.restHandler.DeleteRoom). 74 | Name = "chat.deleteRoom" 75 | chatGroup.GET("/rooms/:room_id", s.restHandler.GetRoomInfo). 76 | Name = "chat.getRoomInfo" 77 | chatGroup.POST("/rooms/:room_id/members", s.restHandler.AddRoomMember). 78 | Name = "chat.addRoomMember" 79 | chatGroup.DELETE("/rooms/:room_id/members", s.restHandler.RemoveRoomMember). 80 | Name = "chat.removeRoomMember" 81 | 82 | chatGroup.GET("/users/:user_id", s.restHandler.GetUserInfo). 83 | Name = "chat.getUserInfo" 84 | 85 | chatGroup.POST("/rooms/:room_id/messages", s.restHandler.PostRoomMessage). 86 | Name = "chat.postRoomMessage" 87 | chatGroup.GET("/rooms/:room_id/messages", s.restHandler.GetRoomMessages). 88 | Name = "chat.getRoomMessages" 89 | chatGroup.POST("/rooms/:room_id/messages/read", s.restHandler.ReadRoomMessages). 90 | Name = "chat.readRoomMessages" 91 | chatGroup.GET("/rooms/:room_id/messages/unread", s.restHandler.GetUnreadRoomMessages). 92 | Name = "chat.getUnreadRoomMessages" 93 | 94 | // set websocket handler 95 | chatGroup.GET("/ws", s.serveChatWebsocket). 96 | Name = "chat.connentWebsocket" 97 | 98 | // serve static content 99 | if s.conf.EnableServeStaticFile { 100 | route := path.Join(s.conf.StaticHandlerPrefix, "/") 101 | e.Static(route, s.conf.StaticFileDir).Name = "staticContents" 102 | } 103 | return s 104 | } 105 | 106 | func (s *Server) handleWsConn(conn *ws.Conn) { 107 | log.Println("Server.acceptWSConn: ") 108 | defer conn.Close() 109 | 110 | var ctx = conn.Request().Context() 111 | 112 | conn.OnActionMessage(func(conn *ws.Conn, m action.ActionMessage) { 113 | s.chatHub.Send(conn, m) 114 | }) 115 | conn.OnError(func(conn *ws.Conn, err error) { 116 | conn.Send(event.ErrorRaised{Message: err.Error()}) 117 | }) 118 | conn.OnClosed(func(conn *ws.Conn) { 119 | s.chatHub.Disconnect(conn) 120 | }) 121 | 122 | err := s.chatHub.Connect(ctx, conn) 123 | if err != nil { 124 | conn.Send(event.ErrorRaised{Message: err.Error()}) 125 | log.Printf("websocket connect error: %v\n", err) 126 | return 127 | } 128 | 129 | // blocking to avoid connection closed 130 | conn.Listen(ctx) 131 | } 132 | 133 | func (s *Server) serveChatWebsocket(c echo.Context) error { 134 | // LoggedInUserID is valid at middleware layer, loginHandler.Filter. 135 | userID, ok := LoggedInUserID(c) 136 | if !ok { 137 | return errors.New("needs logged in, but access without logged in state") 138 | } 139 | 140 | s.wsServer.ServeHTTPWithUserID(c.Response(), c.Request(), userID) 141 | return nil 142 | } 143 | 144 | // Handler returns http.Handler interface in the server. 145 | func (s *Server) Handler() http.Handler { 146 | return s.echo 147 | } 148 | 149 | // it starts server process. 150 | // it blocks until process occurs any error and 151 | // return the error. 152 | func (s *Server) ListenAndServe() error { 153 | if err := s.conf.Validate(); err != nil { 154 | return fmt.Errorf("server: config erorr: %v", err) 155 | } 156 | 157 | e := s.echo 158 | 159 | // show registered URLs 160 | if s.conf.ShowRoutes { 161 | routes := e.Routes() 162 | sort.Slice(routes, func(i, j int) bool { 163 | ri, rj := routes[i], routes[j] 164 | return len(ri.Path) < len(rj.Path) 165 | }) 166 | for _, url := range routes { 167 | // built-in routes are ignored 168 | if strings.Contains(url.Name, "github.com/labstack/echo") { 169 | continue 170 | } 171 | log.Printf("%8s : %-35s (%v)\n", url.Method, url.Path, url.Name) 172 | } 173 | } 174 | 175 | // start server 176 | serverURL := s.conf.HTTP 177 | log.Println("server listen at " + serverURL) 178 | err := e.Start(serverURL) 179 | e.Logger.Error(err) 180 | return err 181 | } 182 | 183 | func (s *Server) Shutdown(ctx context.Context) error { 184 | return s.echo.Shutdown(ctx) 185 | } 186 | 187 | // It starts server process using default server with 188 | // user config. 189 | // A nil config is OK and use DefaultConfig insteadly. 190 | // It blocks until the process occurs any error and 191 | // return the error. 192 | func ListenAndServe(chatCmd chat.CommandService, chatQuery chat.QueryService, chatHub chat.Hub, login chat.LoginService, conf *Config) error { 193 | s := NewServer(chatCmd, chatQuery, chatHub, login, conf) 194 | defer s.Shutdown(context.Background()) 195 | return s.ListenAndServe() 196 | } 197 | -------------------------------------------------------------------------------- /server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "golang.org/x/net/websocket" 14 | 15 | "github.com/labstack/echo" 16 | 17 | "github.com/shirasudon/go-chat/chat" 18 | "github.com/shirasudon/go-chat/chat/action" 19 | "github.com/shirasudon/go-chat/domain" 20 | "github.com/shirasudon/go-chat/infra/inmemory" 21 | "github.com/shirasudon/go-chat/infra/pubsub" 22 | "github.com/shirasudon/go-chat/ws/wstest" 23 | ) 24 | 25 | var ( 26 | globalPubsub = pubsub.New() 27 | repository = inmemory.OpenRepositories(globalPubsub) 28 | 29 | queryers *chat.Queryers = &chat.Queryers{ 30 | UserQueryer: repository.UserRepository, 31 | RoomQueryer: repository.RoomRepository, 32 | MessageQueryer: repository.MessageRepository, 33 | EventQueryer: repository.EventRepository, 34 | } 35 | 36 | chatCmd = chat.NewCommandServiceImpl(repository, globalPubsub) 37 | chatQuery = chat.NewQueryServiceImpl(queryers) 38 | chatHub = chat.NewHubImpl(chatCmd) 39 | 40 | loginService = chat.NewLoginServiceImpl(queryers.UserQueryer, globalPubsub) 41 | 42 | theEcho = echo.New() 43 | ) 44 | 45 | func TestMain(m *testing.M) { 46 | defer globalPubsub.Shutdown() 47 | 48 | ctx, cancel := context.WithCancel(context.Background()) 49 | defer cancel() 50 | go repository.UpdatingService(ctx) 51 | 52 | go chatHub.Listen(ctx) 53 | defer chatHub.Shutdown() 54 | 55 | time.Sleep(1 * time.Millisecond) // wait for the starting of UpdatingServices. 56 | 57 | os.Exit(m.Run()) 58 | } 59 | 60 | const ( 61 | LoginUserID = 2 62 | ) 63 | 64 | func TestServerListenAndServeFailWithConfig(t *testing.T) { 65 | var Config = Config{ 66 | HTTP: "unknown", 67 | } 68 | if err := Config.Validate(); err == nil { 69 | t.Fatal("Config should be invalid here") 70 | } 71 | 72 | server := NewServer(chatCmd, chatQuery, chatHub, loginService, &Config) 73 | defer server.Shutdown(context.Background()) 74 | 75 | errCh := make(chan error, 1) 76 | timeout := time.After(1 * time.Millisecond) 77 | go func() { 78 | errCh <- server.ListenAndServe() 79 | }() 80 | select { 81 | case err := <-errCh: 82 | if err == nil { 83 | t.Errorf("the ListenAndServe should return error by invalid config") 84 | } 85 | // occuring error is OK 86 | case <-timeout: 87 | t.Errorf("timeout") 88 | } 89 | } 90 | 91 | func TestServerServeChatWebsocket(t *testing.T) { 92 | server := NewServer(chatCmd, chatQuery, chatHub, loginService, nil) 93 | defer server.Shutdown(context.Background()) 94 | 95 | // run server process 96 | go func() { 97 | server.ListenAndServe() 98 | }() 99 | 100 | // waiting for the server process stands up. 101 | time.Sleep(10 * time.Millisecond) 102 | 103 | e := echo.New() 104 | serverErrCh := make(chan error, 1) 105 | ts := httptest.NewServer( 106 | http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 107 | c := e.NewContext(req, w) 108 | c.Set(KeyLoggedInUserID, uint64(LoginUserID)) // To use check for login state 109 | if err := server.serveChatWebsocket(c); err != nil { 110 | serverErrCh <- err 111 | } 112 | }), 113 | ) 114 | defer func() { 115 | ts.Close() 116 | // catch server error at end 117 | select { 118 | case err := <-serverErrCh: 119 | if err != nil { 120 | t.Errorf("request handler returns erorr: %v", err) 121 | } 122 | default: 123 | } 124 | }() 125 | 126 | requestPath := ts.URL + "/chat/ws" 127 | origin := ts.URL[0:strings.LastIndex(ts.URL, ":")] 128 | 129 | // create websocket connection for testiong server ts. 130 | conn, err := wstest.NewClientConn(requestPath, origin) 131 | if err != nil { 132 | t.Fatalf("can not create websocket connetion, error: %v", err) 133 | } 134 | defer conn.Close() 135 | 136 | // read connected event from server. 137 | // to avoid infinite loops, we set read dead line to 100ms. 138 | conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) 139 | { 140 | var readAny map[string]interface{} 141 | if err := websocket.JSON.Receive(conn, &readAny); err != nil { 142 | t.Fatal(err) 143 | } 144 | 145 | if got, expect := readAny["event"], chat.EventNameActiveClientActivated; got != expect { 146 | t.Errorf("diffrent event names, expect: %v, got: %v", expect, got) 147 | } 148 | 149 | activated, ok := readAny["data"].(map[string]interface{}) 150 | if !ok { 151 | t.Fatalf("invalid data is recieved: %#v", readAny) 152 | } 153 | if got := activated["user_id"].(float64); got != LoginUserID { 154 | t.Errorf("diffrent user id, expect: %v, got: %v", LoginUserID, got) 155 | } 156 | } 157 | 158 | // write message to server 159 | cm := action.ChatMessage{Content: "hello!"} 160 | cm.RoomID = 3 161 | toSend := map[string]interface{}{ 162 | action.KeyAction: action.ActionChatMessage, 163 | "data": cm, 164 | } 165 | if err := websocket.JSON.Send(conn, toSend); err != nil { 166 | t.Fatal(err) 167 | } 168 | 169 | // read message from server. 170 | // to avoid infinite loops, we set read dead line to 100ms. 171 | conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) 172 | 173 | var readAny map[string]interface{} 174 | if err := websocket.JSON.Receive(conn, &readAny); err != nil { 175 | t.Fatal(err) 176 | } 177 | if got, expect := readAny["event"], chat.EventNameMessageCreated; got != expect { 178 | t.Errorf("diffrent event names, expect: %v, got: %v", expect, got) 179 | } 180 | created, ok := readAny["data"].(map[string]interface{}) 181 | if !ok { 182 | t.Fatalf("invalid data is recieved: %#v", readAny) 183 | } 184 | 185 | // check same message 186 | if created["content"].(string) != cm.Content { 187 | t.Errorf("different chat message fields, recieved: %#v, send: %#v", created, toSend) 188 | } 189 | } 190 | 191 | func TestServerConnIsClosedAfterLogout(t *testing.T) { 192 | // setup user to be used here 193 | var ( 194 | err error = nil 195 | testUser = domain.User{Name: "test_user", Password: "password"} 196 | ) 197 | testUser.ID, err = repository.UserRepository.Store(context.Background(), testUser) 198 | if err != nil { 199 | t.Fatal(err) 200 | } 201 | 202 | server := NewServer(chatCmd, chatQuery, chatHub, loginService, nil) 203 | defer server.Shutdown(context.Background()) 204 | 205 | // run server process 206 | go func() { 207 | server.ListenAndServe() 208 | }() 209 | 210 | // waiting for the server process stands up. 211 | time.Sleep(10 * time.Millisecond) 212 | 213 | e := echo.New() 214 | serverErrCh := make(chan error, 1) 215 | ts := httptest.NewServer( 216 | http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 217 | c := e.NewContext(req, w) 218 | c.Set(KeyLoggedInUserID, uint64(LoginUserID)) // To use check for login state 219 | if err := server.serveChatWebsocket(c); err != nil { 220 | serverErrCh <- err 221 | } 222 | }), 223 | ) 224 | defer func() { 225 | ts.Close() 226 | // catch server error at end 227 | select { 228 | case err := <-serverErrCh: 229 | if err != nil { 230 | t.Errorf("request handler returns erorr: %v", err) 231 | } 232 | default: 233 | } 234 | }() 235 | 236 | requestPath := ts.URL + "/chat/ws" 237 | origin := ts.URL[0:strings.LastIndex(ts.URL, ":")] 238 | 239 | // create websocket connection for testiong server ts. 240 | conn, err := wstest.NewClientConn(requestPath, origin) 241 | if err != nil { 242 | t.Fatalf("can not create websocket connetion, error: %v", err) 243 | } 244 | defer conn.Close() 245 | 246 | // login by using login_test.doLogin. 247 | loginC, err := doLogin(server.loginHandler, testUser.Name, testUser.Password, false) 248 | if err != nil { 249 | t.Fatal(err) 250 | } 251 | // logout by using login_test.doLogout 252 | _, err = doLogout(server.loginHandler, loginC.Response().Header()["Set-Cookie"]) 253 | if err != nil { 254 | t.Fatal(err) 255 | } 256 | 257 | // check whether conn is closed after logout. 258 | conn.SetDeadline(time.Now().Add(50 * time.Millisecond)) 259 | _, err = conn.Read(make([]byte, 0, 32)) 260 | if err, ok := err.(net.Error); ok && err.Timeout() { 261 | t.Fatal("conn is not closed after logout and timeout-ed") 262 | } 263 | if err != nil { 264 | t.Logf("got error :%#v", err) 265 | } 266 | // PASS 267 | } 268 | 269 | func TestServerHandler(t *testing.T) { 270 | server := NewServer(chatCmd, chatQuery, chatHub, loginService, nil) 271 | defer server.Shutdown(context.Background()) 272 | 273 | // check type 274 | var h http.Handler = server.Handler() 275 | if h == nil { 276 | t.Fatal("Server.Handler returns nil") 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /ws/conn.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "net/http" 8 | "sync" 9 | 10 | "github.com/shirasudon/go-chat/chat/action" 11 | "github.com/shirasudon/go-chat/domain/event" 12 | 13 | "golang.org/x/net/websocket" 14 | ) 15 | 16 | // ActionJSON is a data-transfer-object 17 | // which is sent by json the client connection. 18 | type ActionJSON struct { 19 | ActionName action.Action `json:"action"` 20 | Data action.AnyMessage `json:"data"` 21 | } 22 | 23 | // Conn is end-point for reading/writing messages from/to websocket. 24 | // One Conn corresponds to one browser-side client. 25 | type Conn struct { 26 | userID uint64 27 | 28 | conn *websocket.Conn 29 | 30 | mu *sync.Mutex 31 | closed bool // under mu 32 | done chan struct{} // done is managed by closed. 33 | 34 | messages chan interface{} 35 | 36 | onActionMessage func(*Conn, action.ActionMessage) 37 | onClosed func(*Conn) 38 | onError func(*Conn, error) 39 | } 40 | 41 | func NewConn(conn *websocket.Conn, userID uint64) *Conn { 42 | return &Conn{ 43 | userID: userID, 44 | conn: conn, 45 | mu: new(sync.Mutex), 46 | closed: false, 47 | messages: make(chan interface{}, 1), 48 | done: make(chan struct{}, 1), 49 | } 50 | } 51 | 52 | // UserID returns user ID binding to the connection. 53 | func (c *Conn) UserID() uint64 { 54 | return c.userID 55 | } 56 | 57 | // Request returns its internal http request. 58 | func (c *Conn) Request() *http.Request { 59 | return c.conn.Request() 60 | } 61 | 62 | // set callback function to handle the event for a message is received. 63 | // the callback function may be called asynchronously. 64 | func (c *Conn) OnActionMessage(f func(*Conn, action.ActionMessage)) { 65 | c.onActionMessage = f 66 | } 67 | 68 | // set callback function to handle the event for the connection is closed . 69 | // the callback function may be called asynchronously. 70 | func (c *Conn) OnClosed(f func(*Conn)) { 71 | c.onClosed = f 72 | } 73 | 74 | // set callback function to handle the event for the connection gets error. 75 | // the callback function may be called asynchronously. 76 | func (c *Conn) OnError(f func(*Conn, error)) { 77 | c.onError = f 78 | } 79 | 80 | // Send ActionMessage to browser-side client. 81 | // message is ignored when Conn is closed. 82 | func (c *Conn) Send(m event.Event) { 83 | select { 84 | case c.messages <- m: 85 | case <-c.done: 86 | } 87 | } 88 | 89 | var ErrAlreadyClosed = errors.New("already closed") 90 | 91 | // Close stops Listen() immediately. 92 | // closed Conn never listen any message. 93 | // it returns ErrAlreadyClosed when the Conn is 94 | // already closed otherwise nil. 95 | func (c *Conn) Close() error { 96 | c.mu.Lock() 97 | if c.closed { 98 | c.mu.Unlock() 99 | return ErrAlreadyClosed 100 | } 101 | 102 | c.closed = true 103 | close(c.done) 104 | c.mu.Unlock() // to avoid dead lock, Unlock before OnClosed. 105 | 106 | if c.onClosed != nil { 107 | c.onClosed(c) 108 | } 109 | return nil 110 | } 111 | 112 | // Listen starts handling reading/writing websocket. 113 | // it blocks until websocket is closed or context is done. 114 | // 115 | // when Listen() ends, Conn is closed. 116 | func (c *Conn) Listen(ctx context.Context) { 117 | ctx, cancel := context.WithCancel(ctx) 118 | defer func() { 119 | c.Close() 120 | cancel() 121 | }() 122 | 123 | // signal of receivePump is done 124 | receiveDoneCh := make(chan struct{}, 1) 125 | go func() { 126 | defer close(receiveDoneCh) 127 | c.receivePump(ctx) 128 | }() 129 | c.sendPump(ctx, receiveDoneCh) 130 | } 131 | 132 | func (c *Conn) sendPump(ctx context.Context, receiveDoneCh chan struct{}) { 133 | for { 134 | select { 135 | case <-ctx.Done(): 136 | return 137 | case <-c.done: 138 | return 139 | case <-receiveDoneCh: 140 | return 141 | case m := <-c.messages: 142 | if err := websocket.JSON.Send(c.conn, m); err != nil { 143 | // io.EOF means connection is closed 144 | if err == io.EOF { 145 | return 146 | } 147 | if c.onError != nil { 148 | c.onError(c, err) 149 | } 150 | } 151 | } 152 | } 153 | } 154 | 155 | func (c *Conn) receivePump(ctx context.Context) { 156 | // receivePump run on other goroutine. 157 | // done channel is not listened. 158 | for { 159 | select { 160 | case <-ctx.Done(): 161 | return 162 | default: 163 | message, err := c.receiveActionJSON() 164 | if err != nil { 165 | if err == io.EOF { 166 | return 167 | } 168 | // return error message to client 169 | c.Send(event.ErrorRaised{Message: err.Error()}) 170 | continue 171 | } 172 | // Receive success, handling received message 173 | c.handleActionJSON(message) 174 | } 175 | } 176 | } 177 | 178 | // return fatal error, such as io.EOF with connection closed, 179 | // otherwise handle itself. 180 | func (c *Conn) receiveActionJSON() (*ActionJSON, error) { 181 | var message ActionJSON 182 | if err := websocket.JSON.Receive(c.conn, &message); err != nil { 183 | // io.EOF means connection is closed 184 | if err == io.EOF { 185 | return nil, err 186 | } 187 | 188 | // actual error is handled by server. 189 | if c.onError != nil { 190 | c.onError(c, err) 191 | } 192 | return nil, errors.New("JSON structure must be a HashMap type") 193 | } 194 | 195 | // validate existance of data. 196 | if message.Data == nil { 197 | err := errors.New("JSON structure must have data field as HashMap type") 198 | // actual error is handled by server. 199 | if c.onError != nil { 200 | c.onError(c, err) 201 | } 202 | return nil, err 203 | } 204 | return &message, nil 205 | } 206 | 207 | func (c *Conn) handleActionJSON(m *ActionJSON) { 208 | data := m.Data 209 | if data == nil { 210 | data = make(action.AnyMessage) 211 | } 212 | data.SetString(action.KeyAction, string(m.ActionName)) 213 | data.SetNumber(action.KeySenderID, float64(c.userID)) 214 | 215 | actionMsg, err := action.ConvertAnyMessage(data) 216 | if err != nil { 217 | if c.onError != nil { 218 | c.onError(c, err) 219 | } 220 | c.Send(event.ErrorRaised{Message: err.Error()}) 221 | return 222 | } 223 | if c.onActionMessage != nil { 224 | c.onActionMessage(c, actionMsg) 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /ws/conn_test.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/shirasudon/go-chat/chat/action" 10 | "github.com/shirasudon/go-chat/domain/event" 11 | "github.com/shirasudon/go-chat/ws/wstest" 12 | 13 | "golang.org/x/net/websocket" 14 | ) 15 | 16 | const GreetingMsg = "hello!" 17 | const Timeout = 10 * time.Millisecond 18 | 19 | func TestNewConn(t *testing.T) { 20 | const ( 21 | UserID = uint64(1) 22 | ) 23 | ctx, cancel := context.WithCancel(context.Background()) 24 | defer cancel() 25 | 26 | endCh := make(chan bool, 1) 27 | server := wstest.NewServer(websocket.Handler(func(ws *websocket.Conn) { 28 | defer ws.Close() 29 | 30 | cm := event.MessageCreated{Content: GreetingMsg} 31 | conn := NewConn(ws, UserID) 32 | conn.Send(cm) 33 | conn.OnActionMessage(func(conn *Conn, m action.ActionMessage) { 34 | cm, ok := m.(action.ChatMessage) 35 | if !ok { 36 | t.Fatalf("invalid message structure: %#v", m) 37 | } 38 | if cm.Content != GreetingMsg { 39 | t.Errorf("invalid message content, expect: %v, got: %v", GreetingMsg, cm.Content) 40 | } 41 | endCh <- true // PASS this test. 42 | }) 43 | conn.OnError(func(conn *Conn, err error) { 44 | t.Fatalf("server side conn got error: %v", err) 45 | }) 46 | conn.Listen(ctx) 47 | })) 48 | defer func() { 49 | server.Close() 50 | 51 | select { 52 | case <-endCh: 53 | case <-time.After(Timeout): 54 | t.Error("Timeouted") 55 | } 56 | }() 57 | 58 | requestPath := server.URL + "/ws" 59 | origin := server.URL[0:strings.LastIndex(server.URL, ":")] 60 | conn, err := wstest.NewClientConn(requestPath, origin) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | defer conn.Close() 65 | 66 | // Receive hello message 67 | var created event.MessageCreated 68 | if err := websocket.JSON.Receive(conn, &created); err != nil { 69 | t.Fatalf("client receive error: %v", err) 70 | } 71 | 72 | if created.Content != GreetingMsg { 73 | t.Errorf("different received message, got: %v, expect: %v", created.Content, GreetingMsg) 74 | } 75 | 76 | // Send msg received from above. 77 | var toSend = map[string]interface{}{ 78 | action.KeyAction: action.ActionChatMessage, 79 | "data": action.ChatMessage{Content: created.Content}, 80 | } 81 | if err := websocket.JSON.Send(conn, toSend); err != nil { 82 | t.Fatalf("client send error: %v", err) 83 | } 84 | } 85 | 86 | func TestConnGotsInvalidMessages(t *testing.T) { 87 | const ( 88 | UserID = uint64(1) 89 | ) 90 | ctx, cancel := context.WithCancel(context.Background()) 91 | defer cancel() 92 | 93 | endCh := make(chan bool, 1) 94 | server := wstest.NewServer(websocket.Handler(func(ws *websocket.Conn) { 95 | defer ws.Close() 96 | 97 | conn := NewConn(ws, UserID) 98 | conn.OnActionMessage(func(conn *Conn, m action.ActionMessage) { 99 | t.Fatalf("In this test, server side conn will never get message, but got: %#v", m) 100 | }) 101 | conn.OnError(func(conn *Conn, err error) { 102 | t.Logf("In this test, server side conn will get error: %v", err) 103 | }) 104 | conn.Listen(ctx) 105 | endCh <- true 106 | })) 107 | defer func() { 108 | server.Close() 109 | select { 110 | case <-endCh: 111 | case <-time.After(Timeout): 112 | t.Error("Timeouted") 113 | } 114 | }() 115 | 116 | requestPath := server.URL + "/ws" 117 | origin := server.URL[0:strings.LastIndex(server.URL, ":")] 118 | conn, err := wstest.NewClientConn(requestPath, origin) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | defer conn.Close() 123 | 124 | // Send invalid message and Receive error message 125 | if err := websocket.JSON.Send(conn, "aa"); err != nil { 126 | t.Fatalf("client send error: %v", err) 127 | } 128 | 129 | var ( 130 | anyMsg map[string]interface{} 131 | ) 132 | if err := websocket.JSON.Receive(conn, &anyMsg); err != nil { 133 | t.Fatalf("client receive error: %v", err) 134 | } 135 | 136 | errMsg, ok := anyMsg["message"].(string) 137 | if !ok { 138 | t.Fatalf("got invalid error message: %#v", anyMsg) 139 | } 140 | if len(errMsg) == 0 { 141 | t.Errorf("got error message but message is empty") 142 | } 143 | t.Logf("LOG: send invalid message, then return: %v", errMsg) 144 | 145 | // Send no action Message 146 | cm := action.ChatMessage{} 147 | cm.ActionName = action.ActionEmpty 148 | if err := websocket.JSON.Send(conn, cm); err != nil { 149 | t.Fatalf("client send error: %v", err) 150 | } 151 | if err := websocket.JSON.Receive(conn, &anyMsg); err != nil { 152 | t.Fatalf("client receive error: %v", err) 153 | } 154 | 155 | errMsg, ok = anyMsg["message"].(string) 156 | if !ok { 157 | t.Fatalf("got invalid error message: %#v", anyMsg) 158 | } 159 | if len(errMsg) == 0 { 160 | t.Errorf("got error message but message is empty") 161 | } 162 | t.Logf("LOG: send invalid message, then return: %v", errMsg) 163 | } 164 | 165 | func TestConnClose(t *testing.T) { 166 | const ( 167 | UserID = uint64(1) 168 | ) 169 | ctx, cancel := context.WithCancel(context.Background()) 170 | defer cancel() 171 | 172 | endCh := make(chan bool, 1) 173 | server := wstest.NewServer(websocket.Handler(func(ws *websocket.Conn) { 174 | defer ws.Close() 175 | 176 | conn := NewConn(ws, UserID) 177 | conn.Close() // to quit Listen() immediately 178 | conn.Listen(ctx) 179 | endCh <- true 180 | })) 181 | defer func() { 182 | server.Close() 183 | <-endCh 184 | }() 185 | 186 | requestPath := server.URL + "/ws" 187 | origin := server.URL[0:strings.LastIndex(server.URL, ":")] 188 | conn, err := wstest.NewClientConn(requestPath, origin) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | defer conn.Close() 193 | } 194 | -------------------------------------------------------------------------------- /ws/doc.go: -------------------------------------------------------------------------------- 1 | // package ws defines implementation for the 2 | // websocket connetion. 3 | 4 | package ws 5 | -------------------------------------------------------------------------------- /ws/handler.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/shirasudon/go-chat/domain/event" 10 | "golang.org/x/net/websocket" 11 | ) 12 | 13 | // Handler handles websocket Conn in this package. 14 | type Handler func(*Conn) 15 | 16 | // Server serves Conn, wrapper for websocket Connetion, for each HTTP request. 17 | // It implements http.Handler interface. 18 | type Server struct { 19 | server *websocket.Server 20 | 21 | // Handler for the Conn type in this package. 22 | Handler Handler 23 | } 24 | 25 | // NewServer creates the server which serves websocket Connection and 26 | // providing customizable handler for that connection. 27 | func NewServer(handler Handler) *Server { 28 | if handler == nil { 29 | panic("nil handler") 30 | } 31 | s := &Server{Handler: handler} 32 | s.server = &websocket.Server{Handler: s.wsHandler} 33 | return s 34 | } 35 | 36 | // NewServerFunc is wrapper function for the NewServer so that 37 | // given function need not to cast to Handler type. 38 | func NewServerFunc(handler func(*Conn)) *Server { 39 | return NewServer(Handler(handler)) 40 | } 41 | 42 | func (s *Server) wsHandler(wsConn *websocket.Conn) { 43 | defer wsConn.Close() 44 | 45 | userID, err := getConnectUserID(wsConn.Request().Context()) 46 | if err != nil { 47 | websocket.JSON.Send(wsConn, event.ErrorRaised{Message: "invalid state"}) 48 | // TODO logging by external logger 49 | log.Printf("websocket server can not handling this connection, error: %v\n", err) 50 | return // to close connection. 51 | } 52 | 53 | c := NewConn(wsConn, userID) 54 | s.Handler(c) 55 | } 56 | 57 | // ServeHTTPWithUserID is similar with the http.Handler except that 58 | // it requires userID to specify the which user connects. 59 | func (s *Server) ServeHTTPWithUserID(w http.ResponseWriter, req *http.Request, userID uint64) { 60 | newCtx := setConnectUserID(req.Context(), userID) 61 | s.server.ServeHTTP(w, req.WithContext(newCtx)) 62 | } 63 | 64 | const ctxKeyConnectUserID = "_ws_connect_user_id" 65 | 66 | func setConnectUserID(ctx context.Context, userID uint64) context.Context { 67 | return context.WithValue(ctx, ctxKeyConnectUserID, userID) 68 | } 69 | 70 | func getConnectUserID(ctx context.Context) (uint64, error) { 71 | userID, ok := ctx.Value(ctxKeyConnectUserID).(uint64) 72 | if !ok { 73 | return 0, errors.New("requested websocket connection has no user ID") 74 | } 75 | return userID, nil 76 | } 77 | -------------------------------------------------------------------------------- /ws/handler_test.go: -------------------------------------------------------------------------------- 1 | package ws 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/shirasudon/go-chat/domain/event" 12 | "github.com/shirasudon/go-chat/ws/wstest" 13 | "golang.org/x/net/websocket" 14 | ) 15 | 16 | func TestNewServerFunc(t *testing.T) { 17 | t.Parallel() 18 | 19 | // succeed 20 | _ = NewServerFunc(func(c *Conn) { 21 | // do nothing 22 | }) 23 | 24 | // panic 25 | defer func() { 26 | if rec := recover(); rec == nil { 27 | t.Error("expect panic is occured but no panic") 28 | } 29 | }() 30 | _ = NewServerFunc(nil) 31 | } 32 | 33 | func TestServeHTTPWithUserID(t *testing.T) { 34 | const ( 35 | UserID = uint64(2) 36 | WaitTime = 20 * time.Millisecond 37 | ) 38 | 39 | var ( 40 | done = make(chan bool, 1) 41 | timeout = time.After(WaitTime) 42 | 43 | HandlerPassed bool = false 44 | ) 45 | 46 | testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 47 | s := NewServerFunc(func(c *Conn) { 48 | if c.UserID() != UserID { 49 | t.Errorf("different user ID in the connection, expect: %v, got: %v", UserID, c.UserID()) 50 | } 51 | HandlerPassed = true 52 | }) 53 | s.ServeHTTPWithUserID(w, req, UserID) 54 | if !HandlerPassed { 55 | t.Error("Handler function is not called") 56 | } 57 | done <- true 58 | })) 59 | 60 | defer func() { 61 | testServer.Close() 62 | select { 63 | case <-done: 64 | case <-timeout: 65 | t.Error("testing is timeouted") 66 | } 67 | }() 68 | 69 | requestPath := testServer.URL + "/ws" 70 | origin := testServer.URL[0:strings.LastIndex(testServer.URL, ":")] 71 | conn, err := wstest.NewClientConn(requestPath, origin) 72 | if err != nil { 73 | t.Fatal(err) 74 | } 75 | defer conn.Close() 76 | } 77 | 78 | func TestWsHandlerFail(t *testing.T) { 79 | const ( 80 | UserID = uint64(2) 81 | WaitTime = 20 * time.Millisecond 82 | ) 83 | 84 | var ( 85 | done = make(chan bool, 1) 86 | timeout = time.After(WaitTime) 87 | 88 | HandlerPassed bool = false 89 | ) 90 | 91 | testServer := wstest.NewServer(websocket.Handler(func(wsConn *websocket.Conn) { 92 | s := NewServerFunc(func(c *Conn) { HandlerPassed = true }) 93 | s.wsHandler(wsConn) 94 | if HandlerPassed { 95 | t.Error("Handler function is expected to never called but called") 96 | } 97 | done <- true 98 | })) 99 | 100 | defer func() { 101 | testServer.Close() 102 | select { 103 | case <-done: 104 | case <-timeout: 105 | t.Error("testing is timeouted") 106 | } 107 | }() 108 | 109 | requestPath := testServer.URL + "/ws" 110 | origin := testServer.URL[0:strings.LastIndex(testServer.URL, ":")] 111 | conn, err := wstest.NewClientConn(requestPath, origin) 112 | if err != nil { 113 | t.Fatal(err) 114 | } 115 | defer conn.Close() 116 | 117 | var errRaised = event.ErrorRaised{} 118 | conn.SetDeadline(time.Now().Add(WaitTime)) 119 | if err := websocket.JSON.Receive(conn, &errRaised); err != nil { 120 | t.Fatalf("websocket receiving fail: %v", err) 121 | } 122 | if len(errRaised.Message) == 0 { 123 | t.Errorf("error message should not be empty") 124 | } 125 | } 126 | 127 | func TestGetSetConnectUserID(t *testing.T) { 128 | t.Parallel() 129 | 130 | const ( 131 | UserID = uint64(3) 132 | ) 133 | 134 | ctx := context.Background() 135 | 136 | // can not get userID from empty context. 137 | _, err := getConnectUserID(ctx) 138 | if err == nil { 139 | t.Fatal("got userID from empty context") 140 | } 141 | 142 | newCtx := setConnectUserID(ctx, UserID) 143 | 144 | // can get userID from new context. 145 | got, err := getConnectUserID(newCtx) 146 | if err != nil { 147 | t.Fatalf("can not get userID from the context set by setConnectUserID, err: %v", err) 148 | } 149 | if got != UserID { 150 | t.Errorf("different user ID in the context, expect: %v, got: %v", UserID, got) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /ws/wstest/wstest.go: -------------------------------------------------------------------------------- 1 | // package wstest provides utility funtions for testing websocket. 2 | 3 | package wstest 4 | 5 | import ( 6 | "net/http/httptest" 7 | "strings" 8 | 9 | "golang.org/x/net/websocket" 10 | ) 11 | 12 | // create client-side conncetion for the websocket 13 | func NewClientConn(requestPath, origin string) (*websocket.Conn, error) { 14 | wsURL := strings.Replace(requestPath, "http://", "ws://", 1) 15 | return websocket.Dial(wsURL, "", origin) 16 | } 17 | 18 | // NewServer returns httptest.Server which responds 19 | // to any request by using websocket handler. 20 | func NewServer(wshandler websocket.Handler) *httptest.Server { 21 | return httptest.NewServer(wshandler) 22 | } 23 | --------------------------------------------------------------------------------