diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml new file mode 100644 index 000000000..583619eb4 --- /dev/null +++ b/.github/workflows/elixir.yml @@ -0,0 +1,94 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Build and test + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +# Sets the ENV `MIX_ENV` to `test` for running tests +env: + MIX_ENV: test + ELIXIR_VER: '1.17.1' + OTP_VER: '27.0' + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + name: Build and test + + # Set up a Postgres DB service. By default, Phoenix applications + # use Postgres. This creates a database for running tests. + # Additional services can be defined here if required. + services: + db: + image: postgres:15 + ports: ['5432:5432'] + env: + POSTGRES_DB: teiserver_test + POSTGRES_USER: teiserver_test + POSTGRES_PASSWORD: 123456789 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + # Step: Setup Elixir + Erlang image as the base. + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ env.OTP_VER }} + elixir-version: ${{ env.ELIXIR_VER }} + + # Step: Check out the code. + - name: Checkout code + uses: actions/checkout@v4 + + # Step: Define how to cache deps. Restores existing cache if present. + - name: Cache deps + id: cache-deps + uses: actions/cache@v4 + env: + cache-name: cache-elixir-deps + with: + path: deps + key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.cache-name }}- + + # Step: Define how to cache the `_build` directory. After the first run, + # this speeds up tests runs a lot. This includes not re-compiling our + # project's downloaded deps every run. + - name: Cache compiled build + id: cache-build + uses: actions/cache@v4 + env: + cache-name: cache-compiled-build + with: + path: _build + key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.cache-name }}- + + # Step: Download project dependencies. If unchanged, uses + # the cached version. + - name: Install dependencies + run: mix deps.get + + # Step: Execute the tests. + - name: Run tests + env: + DATABASE_URL: postgres://teiserver_test:123456789@localhost/teiserver_test + run: mix test.ci diff --git a/.gitignore b/.gitignore index 666215e9e..e913b2836 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,7 @@ teiserver-*.tar .elixir_ls/* .idea +mix.lock + # Notes I put in for myself I don't want on github /zignore diff --git a/.tool-versions b/.tool-versions index b05c3242c..960884cbe 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -erlang 26.2 -elixir 1.15.7-otp-26 +erlang 27.0 +elixir 1.17.1-otp-27 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3378aabe7..3792c6385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## v0.0.5 +- Swapped to Elixir 1.17 +- Swapped `team_colour` for `player_colour` +- Refactored the client update process +- Added messaging around match start/end +- Added User and Server runtime settings +- Added Telemetry events +- Added rate limiting of login attempts +- Added more options for connecting clients (currently just `bot?`) +- Added support for guest accounts +- Added caching for some db calls +- Added `player_count` as a property to matches +- Added concept of user choices to represent pre-game choices made by users (e.g. in-game faction) +- Removed clustering code so it can be handled by the application using Teiserver as a library +- Moved everything from `Teiserver.Api` to `Teiserver` +- Dropped Timex +- Swapped string error messages to atom error messages + ## v0.0.4 - Added `Lobby`, `LobbySummary`, `MatchType`, `Match`, `MatchMembership`, `MatchSettingType`, `MatchSetting` schemas, libs and queries - Added pubsub events for clients connecting, disconnecting and process destruction diff --git a/README.md b/README.md index 27378635d..495abf66c 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,11 @@ Hex Docs   Apache 2 License +   + Tests

-_Note: This README is for the unreleased master branch, please reference the +_Note: This README is for the unreleased main branch, please reference the [official documentation on hexdocs][hexdoc] for the latest stable release._ [hexdoc]: https://hexdocs.pm/teiserver/Teiserver.html @@ -20,7 +22,6 @@ _Note: This README is for the unreleased master branch, please reference the - User connectivity - User to User communications - Lobby system (planned) -- Telemetry/Event logging (planned) - Community management tools (planned) - Steam integration (planned) @@ -29,7 +30,7 @@ First add to your dependencies in `mix.exs`. ```elixir def deps do [ - {:teiserver, "~> 0.0.3"} + {:teiserver, "~> 0.0.5"} ] end ``` @@ -56,9 +57,8 @@ defmodule MyApp.Repo.Migrations.AddTeiserverTables do end ``` -Add this to your Application supervision tree: -```elixir -children = [ - {Teiserver, Application.get_env(:my_app, Teiserver)} -] +Finally, update your config to link the repo: +``` +config :teiserver, + repo: MyApp.Repo ``` diff --git a/config/config.exs b/config/config.exs index 3cbf1bbea..4e58536d0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -13,14 +13,12 @@ config :teiserver, Teiserver.Test.Repo, priv: "test/support/postgres", url: System.get_env("DATABASE_URL") || - "postgres://teiserver_test:123456789@localhost/teiserver_test" + "postgres://teiserver_test:postgres@localhost/teiserver_test" config :teiserver, # Overridden by application client_destroy_timeout_seconds: 300, lobby_join_method: :simple, - teiserver_clustering: true, - teiserver_clustering_post_join_functions: [], # User defaults default_behaviour_score: 10_000, diff --git a/config/test.exs b/config/test.exs index e502e50eb..3c7253bfe 100644 --- a/config/test.exs +++ b/config/test.exs @@ -3,12 +3,5 @@ import Config # This makes anything in our tests involving user passwords (creating or logging in) much faster config :argon2_elixir, t_cost: 1, m_cost: 8 -config :teiserver, Teiserver.Test.Repo, - database: "teiserver_test", - username: "postgres", - password: "postgres", - hostname: "localhost" - config :teiserver, - repo: Teiserver.Test.Repo, - teiserver_clustering: false + repo: Teiserver.Test.Repo diff --git a/coveralls.json b/coveralls.json index 836f1438c..9c4b0ee43 100644 --- a/coveralls.json +++ b/coveralls.json @@ -1,6 +1,9 @@ { "skip_files": [ "^test/.*", + "lib/teiserver.ex", + "lib/teiserver/contexts/telemetry.ex", + "lib/teiserver/migrations/*", "lib/teiserver/helpers/file_macros.ex", "lib/teiserver/game/servers/lobby_id_server.ex" ], diff --git a/documentation/development/features.md b/documentation/development/features.md index b2886340c..b0aaa6ae8 100644 --- a/documentation/development/features.md +++ b/documentation/development/features.md @@ -9,6 +9,7 @@ ## Connections ### Clients - Tracking +- Login attempt rate limiting ## Communication - Room messages diff --git a/documentation/development/roadmap.md b/documentation/development/roadmap.md index 593aa605a..c60e299be 100644 --- a/documentation/development/roadmap.md +++ b/documentation/development/roadmap.md @@ -6,11 +6,11 @@ This is not a rigid document, it is (especially at this stage) liable to change. * `v0.0.2` - Client features (Connections) * `v0.0.3` - Chat and Messaging (Communication) * `v0.0.4` - Lobbies (Lobby and Game) -* `v0.0.5` - Logging and Event telemetry (Telemetry and Logging) +* `v0.0.5` - Site and User Settings, plus internal Telemetry (Settings, Telemetry) * `v0.0.6` - Player relationships (Account and Community) * `v0.0.7` - Moderation (Moderation and Account) * `v0.0.8` - Parties (Community) -* `v0.0.9` - Site and User Settings (Settings) +* `v0.0.9` - tbd * `v0.1` - Stability, tests and better examples on how to use it At v0.1 I want the server to be in a state where developers can start to make use of it. @@ -41,12 +41,6 @@ At v0.1 I want the server to be in a state where developers can start to make us - Site settings - User settings -- Telemetry - - In game events - - Server events - - Lobby events - - User events - ## Planned/intended features - Administration - Moderation diff --git a/documentation/development/telemetry.md b/documentation/development/telemetry.md new file mode 100644 index 000000000..172d54b8b --- /dev/null +++ b/documentation/development/telemetry.md @@ -0,0 +1,2 @@ +# Events +Teiserver emits the following events: diff --git a/documentation/guides/config.md b/documentation/guides/config.md index b79f34f76..f42ca81eb 100644 --- a/documentation/guides/config.md +++ b/documentation/guides/config.md @@ -33,13 +33,6 @@ The `social_score` given to users registered using `Teiserver.Account.UserLib.re ## default_min_user_password_length - Default: 6 The minimum length for a user password. -# Clustering -## `teiserver_clustering` - Default: true -When enabled Teiserver will attempt to handle the clustering of nodes using a database table. Turning it off will mean this behaves like any other application and you can either not cluster it or use things like `libcluster` as you desire. See `Teiserver.System.ClusterManager` for more details. - -## `teiserver_clustering_post_join_functions` - Default: [] -When teiserver_clustering is enabled, this will be a list of functions called by the genserver handling the join once it has joined the cluster. See `Teiserver.System.ClusterManager` for more details. - # Function overrides Teiserver implements some defaults you may want to overwrite. @@ -50,14 +43,16 @@ Allows you to overwrite `Teiserver.Game.MatchTypeLib.default_calculate_match_typ Allows you to overwrite `Teiserver.Account.User.default_calculate_user_permissions/1`. This is used to generate the list of permissions held by a user. By default it mirrors their groups. ## `fn_lobby_name_acceptor` -A function used to determine if a lobby name is acceptable. Defaults to `Teiserver.Game.LobbyLib.default_lobby_name_acceptable/1` which always returns true. +A function used to determine if a lobby name is acceptable. Defaults to `Teiserver.Game.LobbyLib.default_lobby_name_acceptable?/1` which always returns true. ## `fn_user_name_acceptor` -A function used to determine if a lobby name is acceptable. Defaults to `Teiserver.Account.UserLib.default_user_name_acceptable/1` which always returns true. +A function used to determine if a lobby name is acceptable. Defaults to `Teiserver.Account.UserLib.default_user_name_acceptable?/1` which always returns true. ## `fn_uuid_generator` The function used to generate UUIDs. Defaults to `&Ecto.UUID.generate/0`. +# Function extensions +Teiserver has a number of functions which you will likely want to extend. # Complete example config ```elixir @@ -65,7 +60,6 @@ config :teiserver, repo: HelloWorldServer.Repo, client_destroy_timeout_seconds: 300, lobby_join_method: :simple, - teiserver_clustering: true, # Users default_behaviour_score: 10_000, diff --git a/documentation/guides/hello_world.md b/documentation/guides/hello_world.md index fbe85b719..cdaa35c90 100644 --- a/documentation/guides/hello_world.md +++ b/documentation/guides/hello_world.md @@ -116,20 +116,18 @@ end ``` ## Data in -Place in `lib/hellow_world_server/tcp_in.ex`, this will handle all the commands coming in. +Place in `lib/hello_world_server/tcp_in.ex`, this will handle all the commands coming in. ```elixir defmodule HelloWorldServer.TcpIn do - alias Teiserver.Api - def data_in("ping" <> _data, state) do {state, "pong"} end def data_in("login " <> data, state) do [name, password] = String.split(data, " ") - case Api.maybe_authenticate_user(name, password) do + case Teiserver.maybe_authenticate_user_by_name(name, password) do {:ok, user} -> - Api.connect_user(user) + Teiserver.connect_user(user) {%{state | user_id: user.id}, "You are now logged in as '#{user.name}'"} {:error, :no_user} -> {state, "Login failed (no user)"} @@ -145,7 +143,7 @@ defmodule HelloWorldServer.TcpIn do # this will do for the purposes of this example email = to_string(:rand.uniform()) - case Api.register_user(name, email, password) do + case Teiserver.register_user(name, email, password) do {:ok, _user} -> {state, "User created, you can now login with 'login name password'"} {:error, _} -> @@ -192,7 +190,7 @@ end ``` ## Data out -Place in `lib/hellow_world_server/tcp_out.ex`, this will handle sending data back to our users. +Place in `lib/hello_world_server/tcp_out.ex`, this will handle sending data back to our users. ```elixir defmodule HelloWorldServer.TcpOut do def data_out(msg, state) do diff --git a/documentation/guides/installation.md b/documentation/guides/installation.md index 5f13afc19..98cf10a25 100644 --- a/documentation/guides/installation.md +++ b/documentation/guides/installation.md @@ -6,7 +6,7 @@ First add to your dependencies in `mix.exs`. ```elixir def deps do [ - {:teiserver, "~> 0.0.4"} + {:teiserver, "~> 0.0.5"} ] end ``` diff --git a/documentation/guides/match_lifecycle.md b/documentation/guides/match_lifecycle.md index 6565afb37..96c248fc6 100644 --- a/documentation/guides/match_lifecycle.md +++ b/documentation/guides/match_lifecycle.md @@ -1,8 +1,6 @@ # Match lifecycle The main purpose of Teiserver is to facilitate running a game; as a result the Lobby and Match systems are one of the core features and have a lot of customisation and flexibility. -In every case where the `Game` context is used there is a similar or identical function in `Api`. - ### Overview There are multiple stages to a match taking place, they will typically follow the below diagram. ```mermaid @@ -36,7 +34,7 @@ To remove a client from a lobby you will need to call `Teiserver.Game.remove_cli - The lobby state will be updated to remove this user from the member, spectator and player lists as appropriate ### Client updates -Typically a client will update via `Teiserver.Connections.update_client/2` but if you want to update the lobby details of a client you should use `Teiserver.Connections.update_client_in_lobby/2`. +Typically a client will update via `Teiserver.Connections.update_client/3` but if you want to update the lobby details of a client you should use `Teiserver.Connections.update_client_in_lobby/3`. The standard `update_client` only contacts the ClientServer to update values but with the `update_client_in_lobby` function the ClientServer will check with the LobbyServer before updating any details to ensure it is allowed to. @@ -48,7 +46,7 @@ The standard `update_client` only contacts the ClientServer to update values but Lobbies have a key `:game_settings` which holds key-value map of the settings chosen for the upcoming match. These can be changed at any time prior to the match starting. ## Match start -When the match is started and the users move into playing the game itself (which does not take place on the middleware server) a call needs to be made to `Api` or `Game` `start_match/1` (delegated to `Teiserver.Game.MatchLib.start_match/1`). +When the match is started and the users move into playing the game itself (which does not take place on the middleware server) a call needs to be made to `Teiserver` or `Game` `start_match/1` (delegated to `Teiserver.Game.MatchLib.start_match/1`). This will update the previously added Match object with the relevant information from the Lobby. It will create `Teiserver.Game.MatchMembership` objects for each player, create the relevant `Teiserver.Game.MatchSetting` objects (along with types) and update the Lobby to show the match as ongoing. @@ -56,13 +54,13 @@ This will update the previously added Match object with the relevant information While the game is ongoing the server is not directly involved except for anything the host wishes to relay to the server such as public chat or game telemetry events. ## Match end -At the conclusion of the match players should be returned to the lobby interface and the `Api` or `Game` `end_match/2` (delegated to `Teiserver.Game.MatchLib.end_match/2`) should be called. +At the conclusion of the match players should be returned to the lobby interface, you end it with and the `Teiserver` or `Game` `end_match/2` (delegated to `Teiserver.Game.MatchLib.end_match/2`) should be called. This will update both the Match object and MatchMembership objects with the relevant data. ## Post game ### Close -In some cases you will want the lobby to close in which case `Api` or `Game` `close_lobby/1` (delegated to `Teiserver.Game.LobbyLib.close_lobby/1`) should be called. This will remove everybody from the lobby and stop the lobby process. +In some cases you will want the lobby to close in which case `Teiserver` or `Game` `close_lobby/1` (delegated to `Teiserver.Game.LobbyLib.close_lobby/1`) should be called. This will remove everybody from the lobby and stop the lobby process. ### Cycle -If you wish to keep the lobby in existence you should call `Api` or `Game` `cycle_lobby/1` (delegated to `Teiserver.Game.LobbyLib.cycle_lobby/1`) which will create a new empty Match object for the new upcoming match. +If you wish to keep the lobby in existence you should call `Teiserver` or `Game` `cycle_lobby/1` (delegated to `Teiserver.Game.LobbyLib.cycle_lobby/1`) which will create a new empty Match object for the new upcoming match. diff --git a/documentation/guides/testing.md b/documentation/guides/testing.md new file mode 100644 index 000000000..6150e58cb --- /dev/null +++ b/documentation/guides/testing.md @@ -0,0 +1,24 @@ +# Testing +Teiserver aims to have as many functions tested as possible. More importantly Teiserver supplies a number of fixtures and functions to make the life of anybody testing it easier. + +## Fixtures +Teiserver includes at least one fixture for every schema located in `/test/support/fixtures`. They follow a consistent naming pattern: +```elixir +Teiserver.Account.User -> Teiserver.Fixtures.AccountFixtures.user_fixture() +Teiserver.Game.Match -> Teiserver.GameFixtures.incomplete_match_fixture() +``` + +Each fixture can be called as is or with a map which will dictate overrides for otherwise static or random values. + +```elixir +Teiserver.Fixtures.AccountFixtures.user_fixture(%{ + name: "MySpecific TestUser", + password: "A special password" +}) +``` + +## Dummy data +Servers are much easier to debug or test when you have actual data. As such Teiserver has a number of functions dedicated to providing dummy data. + +TODO: Write docs for these, located in `/test/support/dummy_data` + diff --git a/documentation/pubsubs/client.md b/documentation/pubsubs/client.md index 079636471..aa29ad9a4 100644 --- a/documentation/pubsubs/client.md +++ b/documentation/pubsubs/client.md @@ -47,6 +47,20 @@ Sent whenever the client leaves a lobby; if you are subscribed to this topic you } ``` +### Disconnected client - `:client_connected` +Sent when the client connects when previously having had no connections + +- `:client` - A `Teiserver.Connections.Client` of the client values +- `:user_id` - The ID of the user (which should also be present in the topic) + +```elixir +%{ + event: :client_connected, + client: Client.t(), + user_id: User.id() +} +``` + ### Disconnected client - `:client_disconnected` Sent when the client has no more connections diff --git a/documentation/pubsubs/match.md b/documentation/pubsubs/match.md index e81b56c10..a38b2f0da 100644 --- a/documentation/pubsubs/match.md +++ b/documentation/pubsubs/match.md @@ -87,6 +87,28 @@ Note this will be sent in addition to normal client updated messages but by doin } ``` +### Match start - `:match_start` +Indicates the match is starting. This will typically follow the `:lobby_updated` message where `match_ongoing?` is set to true. + +```elixir +%{ + event: :match_start, + match_id: Match.id(), + lobby_id: Lobby.id() +} +``` + +### Match end - `:match_end` +Indicates the match has ended. This will typically follow the `:lobby_updated` message where `match_ongoing?` is set to false. + +```elixir +%{ + event: :match_end, + match_id: Match.id(), + lobby_id: Lobby.id() +} +``` + ### Closed - `:lobby_closed` - `:lobby_id` - The id of the lobby closed diff --git a/lib/teiserver.ex b/lib/teiserver.ex index 3d873ed18..0a143c06f 100644 --- a/lib/teiserver.ex +++ b/lib/teiserver.ex @@ -41,7 +41,6 @@ defmodule Teiserver do - **Logging**: Logging of events and numbers - **Moderation**: Handling disruptive users - **Settings**: Key-Value pairs for users and the system - - **Telemetry**: Moment to moment events """ # Aliased types @@ -72,11 +71,373 @@ defmodule Teiserver do f.() end - @spec deterministic_uuid(String.t()) :: String.t() - def deterministic_uuid(base) do - UUID.uuid5(:nil, base) + @doc """ + Gets the custom node name as set in the config `:teiserver, :node_name` + """ + @spec get_node_name() :: atom + def get_node_name() do + to_string(Application.get_env(:teiserver, :node_name) || Node.self()) + end + + alias Teiserver.{Account, Communication, Connections, Game} + + alias Account.{ + User, + UserLib + } + + alias Connections.ClientLib + + alias Communication.{ + Room, + RoomLib, + RoomMessage, + RoomMessageLib, + DirectMessage, + DirectMessageLib, + MatchMessage, + MatchMessageLib + } + + alias Game.{ + Lobby, + LobbySummary, + LobbyLib, + Match, + MatchLib + } + + @doc """ + Takes a email and password, tries to authenticate the user. + + Optionally accepts an IP for rate limiting purposes. + + ## Examples + + iex> maybe_authenticate_user_by_email("alice@domain", "password1", "127.0.0.1") + {:ok, %User{}} + + iex> maybe_authenticate_user_by_email("bob@domain", "bad password", "127.0.0.1") + {:error, :bad_password} + + iex> maybe_authenticate_user_by_email("chris@domain", "password1", "127.0.0.1") + {:error, :no_user} + """ + @doc section: :user + @spec maybe_authenticate_user_by_email(String.t(), String.t(), String.t() | nil) :: + {:ok, Account.User.t()} | {:error, :no_user | :bad_password | :rate_limit} + def maybe_authenticate_user_by_email(email, password, ip \\ nil) do + case Account.get_user_by_email(email) do + nil -> + {:error, :no_user} + + user -> + do_maybe_authenticate_user(user, password, ip) + end end + @doc """ + Takes a id and password, tries to authenticate the user. + + Optionally accepts an IP for rate limiting purposes. + + ## Examples + + iex> maybe_authenticate_user_by_id("a5f2e06b-a89b-45b2-aeae-87e45d02f8f8", "password1", "127.0.0.1") + {:ok, %User{}} + + iex> maybe_authenticate_user_by_id("7f50a62b-1e7c-440a-b851-5dc076f1a6cc", "bad password", "127.0.0.1") + {:error, :bad_password} + + iex> maybe_authenticate_user_by_id("f8cfc144-eb45-4b09-b738-07705baae6c8", "password1", "127.0.0.1") + {:error, :no_user} + """ + @doc section: :user + @spec maybe_authenticate_user_by_id(String.t(), String.t(), String.t() | nil) :: + {:ok, Account.User.t()} | {:error, :no_user | :bad_password | :rate_limit} + def maybe_authenticate_user_by_id(id, password, ip \\ nil) do + case Account.get_user_by_id(id) do + nil -> + {:error, :no_user} + + user -> + do_maybe_authenticate_user(user, password, ip) + end + end + + @spec do_maybe_authenticate_user(Account.User.t(), String.t(), String.t() | nil) :: + {:ok, Account.User.t()} | {:error, :no_user | :bad_password | :rate_limit} + defp do_maybe_authenticate_user(user, password, ip) do + rate_limit_allow? = UserLib.allow_login_attempt?(user.id, ip) + + result = + if rate_limit_allow? do + if Account.valid_password?(user, password) do + {:ok, user} + else + {:error, :bad_password} + end + else + {:error, :rate_limit} + end + + # We might want to register the failed login attempt + case result do + {:error, reason} -> + UserLib.register_failed_login(user.id, ip, reason) + + _ -> + :ok + end + + result + end + + @doc """ + Makes use of `Teiserver.Connections.ClientLib.connect_user/1` to connect + and then also subscribes you to the following pubsubs: + - [Teiserver.Connections.Client](documentation/pubsubs/client.md#teiserver-connections-client-user_id) + - [Teiserver.Communication.User](documentation/pubsubs/communication.md#teiserver-communication-user-user_id) + + Always returns `:ok` + """ + @doc section: :client + @spec connect_user(user_id(), list) :: Connections.Client.t() | nil + def connect_user(user_id, opts \\ []) when is_binary(user_id) do + client = Connections.connect_user(user_id, opts) + + if client != nil do + # Sleep to prevent this current process getting the messages related to the connection + :timer.sleep(100) + subscribe(Connections.client_topic(user_id)) + subscribe(Communication.user_messaging_topic(user_id)) + end + + # Return the client + client + end + + @doc """ + Takes a name, email and password. Creates a user with them. + + ## Examples + + iex> register_user("Alice", "alice@alice", "password1") + {:ok, %User{}} + + iex> register_user("Bob", "bob@bob", "1") + {:error, %Ecto.Changeset{}} + """ + @doc section: :user + @spec register_user(String.t(), String.t(), String.t()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def register_user(name, email, password) do + Account.register_user(%{ + "name" => name, + "password" => password, + "email" => email + }) + end + + ### Account + @doc section: :user + @spec get_user_by_id(user_id()) :: User.t() | nil + defdelegate get_user_by_id(user_id), to: UserLib + + @doc section: :user + @spec get_user_by_name(String.t()) :: User.t() | nil + defdelegate get_user_by_name(name), to: UserLib + + @doc section: :user + @spec get_user_by_email(String.t()) :: User.t() | nil + defdelegate get_user_by_email(email), to: UserLib + + ### Connections + # Client + @doc section: :client + @spec get_client(user_id()) :: Client.t() | nil + defdelegate get_client(user_id), to: ClientLib + + @doc section: :client + @spec update_client(user_id(), map, String.t()) :: Client.t() | nil + defdelegate update_client(user_id, updates, reason), to: ClientLib + + @doc section: :client + @spec update_client_in_lobby(user_id(), map, String.t()) :: Client.t() | nil + defdelegate update_client_in_lobby(user_id, updates, reason), to: ClientLib + + ### Game + # Lobby + @doc section: :lobby + @spec subscribe_to_lobby(Lobby.id() | Lobby.t()) :: :ok + defdelegate subscribe_to_lobby(lobby_or_lobby_id), to: LobbyLib + + @doc section: :lobby + @spec unsubscribe_from_lobby(Lobby.id() | Lobby.t()) :: :ok + defdelegate unsubscribe_from_lobby(lobby_or_lobby_id), to: LobbyLib + + @doc section: :lobby + @spec lobby_exists?(Lobby.id()) :: boolean + defdelegate lobby_exists?(lobby_id), to: LobbyLib + + @doc section: :lobby + @spec get_lobby(Lobby.id()) :: Lobby.t() | nil + defdelegate get_lobby(lobby_id), to: LobbyLib + + @doc section: :lobby + @spec get_lobby_summary(Lobby.id()) :: LobbySummary.t() | nil + defdelegate get_lobby_summary(lobby_id), to: LobbyLib + + @doc section: :lobby + @spec update_lobby(Lobby.id(), map) :: :ok | nil + defdelegate update_lobby(lobby_id, value_map), to: LobbyLib + + @doc section: :lobby + @spec list_lobby_ids() :: [Lobby.id()] + defdelegate list_lobby_ids, to: LobbyLib + + @doc section: :lobby + @spec stream_lobby_summaries(map) :: Enumerable.t(LobbySummary.t()) + defdelegate stream_lobby_summaries(filters \\ %{}), to: LobbyLib + + @doc section: :lobby + @spec open_lobby(user_id(), Lobby.name()) :: {:ok, Lobby.id()} | {:error, String.t()} + defdelegate open_lobby(host_id, name), to: LobbyLib + + @doc section: :lobby + @spec cycle_lobby(Lobby.id()) :: :ok + defdelegate cycle_lobby(lobby_id), to: LobbyLib + + @doc section: :lobby + @spec close_lobby(Lobby.id()) :: :ok + defdelegate close_lobby(lobby_id), to: LobbyLib + + @doc section: :lobby + @spec can_add_client_to_lobby(Teiserver.user_id(), Lobby.id(), String.t() | nil) :: + true + | {false, + :no_lobby + | :existing_member + | :client_disconnected + | :already_in_a_lobby + | :incorrect_password + | :lobby_is_locked} + defdelegate can_add_client_to_lobby(user_id, lobby_id, password \\ nil), to: LobbyLib + + @doc section: :lobby + @spec add_client_to_lobby(user_id(), Lobby.id()) :: :ok | {:error, String.t()} + defdelegate add_client_to_lobby(user_id, lobby_id), to: LobbyLib + + @doc section: :lobby + @spec remove_client_from_lobby(user_id(), Lobby.id()) :: :ok | nil + defdelegate remove_client_from_lobby(user_id, lobby_id), to: LobbyLib + + # Match + @doc section: :match + @spec start_match(Lobby.id()) :: + {:ok, Match.t()} | {:error, :no_players, :match_already_started} + defdelegate start_match(lobby_id), to: MatchLib + + @doc section: :match + @spec end_match(Match.id(), map()) :: Match.t() + defdelegate end_match(match_id, outcome), to: MatchLib + + @doc section: :match + @spec get_match(Match.id(), Teiserver.query_args()) :: Match.t() | nil + defdelegate get_match(match_id, query_args \\ []), to: MatchLib + + ### Communication + # MatchMessage + @doc section: :match_message + @spec subscribe_to_match_messages(Match.id() | Match.t()) :: :ok + defdelegate subscribe_to_match_messages(match_or_match_id), to: MatchMessageLib + + @doc section: :match_message + @spec unsubscribe_from_match_messages(Match.id() | Match.t()) :: :ok + defdelegate unsubscribe_from_match_messages(match_or_match_id), to: MatchMessageLib + + @doc section: :match_message + @spec send_match_message(user_id(), Match.id(), String.t()) :: + {:ok, MatchMessage.t()} | {:error, Ecto.Changeset.t()} + defdelegate send_match_message(sender_id, match_id, content), to: MatchMessageLib + + @doc section: :match_message + @spec send_lobby_message(user_id(), Lobby.id(), String.t()) :: + {:ok, MatchMessage.t()} | {:error, Ecto.Changeset.t()} + defdelegate send_lobby_message(sender_id, lobby_id, content), to: MatchMessageLib + + # Room and RoomMessage + @doc section: :room_message + @spec subscribe_to_room_messages(Room.id() | Room.t()) :: :ok + defdelegate subscribe_to_room_messages(room_or_room_id), to: RoomMessageLib + + @doc section: :room_message + @spec unsubscribe_from_room_messages(Room.id() | Room.t()) :: :ok + defdelegate unsubscribe_from_room_messages(room_or_room_id), to: RoomMessageLib + + @doc section: :room_message + @spec get_room_by_name_or_id(Room.name_or_id()) :: Room.t() | nil + defdelegate get_room_by_name_or_id(room_name_or_id), to: RoomLib + + @doc section: :room_message + @spec list_recent_room_messages(Room.id()) :: [RoomMessage.t()] + defdelegate list_recent_room_messages(room_name_or_id), to: RoomMessageLib + + @doc section: :room_message + @spec send_room_message(user_id(), Room.id(), String.t()) :: + {:ok, RoomMessage.t()} | {:error, Ecto.Changeset.t()} + defdelegate send_room_message(sender_id, room_id, content), to: RoomMessageLib + + # DirectMessage + @doc section: :direct_message + @spec send_direct_message(user_id(), user_id(), String.t()) :: + {:ok, DirectMessage.t()} | {:error, Ecto.Changeset.t()} + defdelegate send_direct_message(sender_id, to_id, content), to: DirectMessageLib + + @doc section: :direct_message + @spec subscribe_to_user_messaging(User.id() | User.t()) :: :ok + defdelegate subscribe_to_user_messaging(user_or_user_id), to: DirectMessageLib + + @doc section: :direct_message + @spec unsubscribe_from_user_messaging(User.id() | User.t()) :: :ok + defdelegate unsubscribe_from_user_messaging(user_or_user_id), to: DirectMessageLib + + # Settings + alias Teiserver.Settings.{ServerSettingLib, UserSettingLib} + + @doc section: :server_setting + @spec get_server_setting_value(String.t()) :: String.t() | integer() | boolean() | nil + defdelegate get_server_setting_value(key), to: ServerSettingLib + + @doc section: :server_setting + @spec set_server_setting_value(String.t(), String.t() | non_neg_integer() | boolean() | nil) :: + :ok + defdelegate set_server_setting_value(key, value), to: ServerSettingLib + + @doc section: :user_setting + @spec get_user_setting_value(user_id(), String.t()) :: + String.t() | integer() | boolean() | nil + defdelegate get_user_setting_value(user_id, key), to: UserSettingLib + + @doc section: :user_setting + @spec set_user_setting_value( + user_id(), + String.t(), + String.t() | non_neg_integer() | boolean() | nil + ) :: :ok + defdelegate set_user_setting_value(user_id, key, value), to: UserSettingLib + + # Logging + alias Teiserver.Logging.{AuditLog, AuditLogLib} + + @spec create_audit_log(user_id(), String.t(), String.t(), map()) :: + {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + defdelegate create_audit_log(user_id, ip, action, details), to: AuditLogLib + + @spec create_anonymous_audit_log(String.t(), String.t(), map()) :: + {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + defdelegate create_anonymous_audit_log(ip, action, details), to: AuditLogLib + # PubSub delegation @doc false @spec broadcast(String.t(), map()) :: :ok @@ -89,4 +450,8 @@ defmodule Teiserver do @doc false @spec unsubscribe(String.t()) :: :ok defdelegate unsubscribe(topic), to: PubSubHelper + + # Cluster cache delegation + @spec invalidate_cache(atom, any) :: :ok + defdelegate invalidate_cache(table, key_or_keys), to: Teiserver.Helpers.CacheHelper end diff --git a/lib/teiserver/account/libs/user_lib.ex b/lib/teiserver/account/libs/user_lib.ex index 459849f8c..5e153b508 100644 --- a/lib/teiserver/account/libs/user_lib.ex +++ b/lib/teiserver/account/libs/user_lib.ex @@ -73,6 +73,8 @@ defmodule Teiserver.Account.UserLib do @doc """ Gets a single user by their user_id. If no user is found, returns `nil`. + Makes use of a Cache + ## Examples iex> get_user_by_id(123) @@ -83,6 +85,19 @@ defmodule Teiserver.Account.UserLib do """ @spec get_user_by_id(Teiserver.user_id()) :: User.t() | nil def get_user_by_id(user_id) do + case Cachex.get(:ts_user_by_user_id_cache, user_id) do + {:ok, nil} -> + user = do_get_user_by_id(user_id) + Cachex.put(:ts_user_by_user_id_cache, user_id, user) + user + + {:ok, value} -> + value + end + end + + @spec do_get_user_by_id(Teiserver.user_id()) :: User.t() | nil + defp do_get_user_by_id(user_id) do UserQueries.user_query(id: user_id, limit: 1) |> Teiserver.Repo.one() end @@ -174,8 +189,63 @@ defmodule Teiserver.Account.UserLib do def update_user(%User{} = user, attrs) do User.changeset(user, attrs, :full) |> Teiserver.Repo.update() + |> maybe_decache_user() + end + + @doc """ + Removes one or more restrictions from a user. + + ## Examples + + iex> unrestrict_user(user_or_user_id, ["r1", "r2"]) + {:ok, %User{}} + + """ + @spec unrestrict_user(User.t() | User.id(), [String.t()] | String.t()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def unrestrict_user(user_or_user_id, restrictions) when is_binary(user_or_user_id), + do: unrestrict_user(get_user_by_id(user_or_user_id), restrictions) + + def unrestrict_user(%User{} = user, restrictions_to_remove) do + restrictions_to_remove = List.wrap(restrictions_to_remove) + + new_restrictions = + user.restrictions + |> Enum.filter(fn existing_restriction -> + not Enum.member?(restrictions_to_remove, existing_restriction) + end) + + update_user(user, %{restrictions: new_restrictions}) end + @doc """ + Adds one or more restrictions to a user + + ## Examples + + iex> remove_restrictions(user_or_user_id, ["r1", "r2"]) + {:ok, %User{}} + + """ + @spec restrict_user(User.t() | User.id(), [String.t()] | String.t()) :: + {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def restrict_user(user_or_user_id, restrictions) when is_binary(user_or_user_id), + do: restrict_user(get_user_by_id(user_or_user_id), restrictions) + + def restrict_user(%User{} = user, restrictions) do + new_restrictions = Enum.uniq(user.restrictions ++ List.wrap(restrictions)) + update_user(user, %{restrictions: new_restrictions}) + end + + # Clears the cache for a user after a successful database option + @spec maybe_decache_user(any()) :: any() + defp maybe_decache_user({:ok, user}) do + Teiserver.invalidate_cache(:ts_user_by_user_id_cache, user.id) + {:ok, user} + end + + defp maybe_decache_user(v), do: v + @doc """ Updates a user's password. @@ -192,6 +262,7 @@ defmodule Teiserver.Account.UserLib do def update_password(%User{} = user, attrs) do User.changeset(user, attrs, :change_password) |> Teiserver.Repo.update() + |> maybe_decache_user() end @doc """ @@ -210,6 +281,7 @@ defmodule Teiserver.Account.UserLib do def update_limited_user(%User{} = user, attrs) do User.changeset(user, attrs, :user_form) |> Teiserver.Repo.update() + |> maybe_decache_user() end @doc """ @@ -227,6 +299,7 @@ defmodule Teiserver.Account.UserLib do @spec delete_user(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def delete_user(%User{} = user) do Teiserver.Repo.delete(user) + |> maybe_decache_user() end @doc """ @@ -253,6 +326,73 @@ defmodule Teiserver.Account.UserLib do User.valid_password?(plaintext_password, user.password) end + @spec register_failed_login(User.id(), String.t() | nil, String.t() | atom) :: :ok + def register_failed_login(_, _, :rate_limit), do: :ok + def register_failed_login(nil, _, _), do: :ok + + def register_failed_login(user_id, ip, reason) do + Cachex.incr(:ts_login_count_ip, ip) + Cachex.incr(:ts_login_count_user, user_id) + + :telemetry.execute( + [:teiserver, :user, :failed_login], + %{reason: reason}, + %{user_id: user_id, ip: ip} + ) + + Teiserver.Logging.create_audit_log(user_id, ip, "failed-login", %{reason: reason}) + + :ok + end + + @doc """ + Given a userid and optionally an IP, check if we have hit the maximum number of + login attempts for this user. + """ + @spec allow_login_attempt?(User.id(), String.t() | nil) :: boolean + def allow_login_attempt?(userid, ip \\ nil) do + cond do + allow_ip_login_attempt?(ip) == false -> + false + + allow_user_login_attempt?(userid) == false -> + false + + true -> + true + end + end + + @spec allow_ip_login_attempt?(String.t()) :: boolean + defp allow_ip_login_attempt?(nil), do: true + + defp allow_ip_login_attempt?(ip) do + max_allowed_ip = Teiserver.Settings.get_server_setting_value("login.ip_rate_limit") + + if max_allowed_ip == nil do + true + else + current_ip_count = Cachex.fetch!(:ts_login_count_ip, ip, fn -> 0 end) + + # As long as we're below the max it's okay + current_ip_count <= max_allowed_ip + end + end + + @spec allow_user_login_attempt?(User.id()) :: boolean + defp allow_user_login_attempt?(userid) do + max_allowed_user = Teiserver.Settings.get_server_setting_value("login.user_rate_limit") + + if max_allowed_user == nil do + true + else + current_user_count = Cachex.fetch!(:ts_login_count_user, userid, fn -> 0 end) + + # As long as we're below the max it's okay + current_user_count <= max_allowed_user + end + end + @doc """ Generates a strong, though not very human readable, password. @@ -337,4 +477,29 @@ defmodule Teiserver.Account.UserLib do def default_user_name_acceptable?(_name) do true end + + @name_parts1 ~w(serene energised humble auspicious decisive exemplary cheerful determined playful spry springy) + @name_parts2 ~w( + maroon cherry rose ruby + amber carrot + lemon beige + mint lime cadmium + aqua cerulean + lavender indigo + magenta amethyst + ) + @name_parts3 ~w(hamster gerbil cat dog falcon eagle mole fox tiger panda elephant lion cow dove whale dolphin squid dragon snake platypus badger) + + @doc """ + Generates a name for guests + """ + @spec generate_guest_name() :: String.t() + def generate_guest_name() do + case :rand.uniform(3) do + 1 -> [@name_parts1, @name_parts2] + 2 -> [@name_parts1, @name_parts3] + 3 -> [@name_parts2, @name_parts3] + end + |> Enum.map_join(" ", fn l -> Enum.random(l) |> String.capitalize() end) + end end diff --git a/lib/teiserver/account/queries/user_queries.ex b/lib/teiserver/account/queries/user_queries.ex index f571d73ed..6172016b1 100644 --- a/lib/teiserver/account/queries/user_queries.ex +++ b/lib/teiserver/account/queries/user_queries.ex @@ -197,7 +197,7 @@ defmodule Teiserver.Account.UserQueries do @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> @@ -265,4 +265,11 @@ defmodule Teiserver.Account.UserQueries do preload: [extra_data: extra_datas] ) end + + def _preload(query, :smurf_of) do + from(user in query, + left_join: smurf_ofs in assoc(user, :smurf_of), + preload: [smurf_of: smurf_ofs] + ) + end end diff --git a/lib/teiserver/account/schemas/user.ex b/lib/teiserver/account/schemas/user.ex index e97c7a897..9c5c8474b 100644 --- a/lib/teiserver/account/schemas/user.ex +++ b/lib/teiserver/account/schemas/user.ex @@ -57,7 +57,7 @@ defmodule Teiserver.Account.User do has_one(:extra_data, Teiserver.Account.ExtraUserData) - timestamps() + timestamps(type: :utc_datetime) end @type id :: Ecto.UUID.t() @@ -95,7 +95,9 @@ defmodule Teiserver.Account.User do |> SchemaHelper.uniq_lists(~w(groups)a) # If password isn't included we won't be doing anything with it - if attrs["password"] == "" do + attr_password = Map.get(attrs, "password", Map.get(attrs, :password)) + + if attr_password == "" do user |> cast( attrs, @@ -166,8 +168,10 @@ defmodule Teiserver.Account.User do attrs |> SchemaHelper.trim_strings([:email]) + attr_password = Map.get(attrs, "password", Map.get(attrs, :password)) + cond do - attrs["password"] == nil or attrs["password"] == "" -> + attr_password == nil or attr_password == "" -> user |> cast(attrs, [:name, :email]) |> validate_required([:name, :email]) @@ -176,7 +180,7 @@ defmodule Teiserver.Account.User do "Please enter your password to change your account details." ) - valid_password?(attrs["password"], user.password) == false -> + valid_password?(attr_password, user.password) == false -> user |> cast(attrs, [:name, :email]) |> validate_required([:name, :email]) @@ -194,8 +198,11 @@ defmodule Teiserver.Account.User do # we ask for the existing password to be submitted as a test # they have not left the computer unlocked or similar def changeset(user, attrs, :change_password) do + attr_existing = Map.get(attrs, "existing", Map.get(attrs, :existing)) + attr_password = Map.get(attrs, "password", Map.get(attrs, :password)) + cond do - attrs["existing"] == nil or attrs["existing"] == "" -> + attr_existing == nil or attr_existing == "" -> user |> change_password(attrs) |> add_error( @@ -203,7 +210,15 @@ defmodule Teiserver.Account.User do "Please enter your existing password to change your password." ) - valid_password?(attrs["existing"], user.password) == false -> + attr_password != attrs["password_confirmation"] -> + user + |> change_password(attrs) + |> add_error( + :password_confirmation, + "Password and Confirmation password do not match." + ) + + valid_password?(attr_existing, user.password) == false -> user |> change_password(attrs) |> add_error(:existing, "Incorrect password") @@ -257,7 +272,10 @@ defmodule Teiserver.Account.User do min_length = Application.get_env(:teiserver, :default_min_user_password_length, 6) changeset - |> validate_length(:password, min: min_length, message: "Passwords must be at least #{min_length} characters long") + |> validate_length(:password, + min: min_length, + message: "Passwords must be at least #{min_length} characters long" + ) end @doc """ diff --git a/lib/teiserver/api.ex b/lib/teiserver/api.ex deleted file mode 100644 index 176bbe803..000000000 --- a/lib/teiserver/api.ex +++ /dev/null @@ -1,260 +0,0 @@ -defmodule Teiserver.Api do - @moduledoc """ - A set of functions for a basic usage of Teiserver. The purpose is - to allow you to start with importing only this module and then - import others as your needs grow more complex. - """ - - alias Teiserver.{Account, Communication, Connections} - alias Account.UserLib - - alias Connections.ClientLib - - alias Communication.{ - Room, - RoomLib, - RoomMessage, - RoomMessageLib, - DirectMessage, - DirectMessageLib, - MatchMessage, - MatchMessageLib - } - - alias Teiserver.Game.{ - Lobby, - LobbyLib, - Match, - MatchLib - } - - @doc """ - Takes a name and password, tries to authenticate the user. - - ## Examples - - iex> maybe_authenticate_user("Alice", "password1") - {:ok, %User{}} - - iex> maybe_authenticate_user("Bob", "bad password") - {:error, :bad_password} - - iex> maybe_authenticate_user("Chris", "password1") - {:error, :no_user} - """ - @doc section: :user - @spec maybe_authenticate_user(String.t(), String.t()) :: - {:ok, Account.User.t()} | {:error, :no_user | :bad_password} - def maybe_authenticate_user(name, password) do - case Account.get_user_by_name(name) do - nil -> - {:error, :no_user} - - user -> - if Teiserver.Account.valid_password?(user, password) do - {:ok, user} - else - {:error, :bad_password} - end - end - end - - @doc """ - Makes use of `Teiserver.Connections.ClientLib.connect_user/1` to connect - and then also subscribes you to the following pubsubs: - - [Teiserver.Connections.Client](documentation/pubsubs/client.md#teiserver-connections-client-user_id) - - [Teiserver.Communication.User](documentation/pubsubs/communication.md#teiserver-communication-user-user_id) - - Always returns `:ok` - """ - @doc section: :client - @spec connect_user(Teiserver.user_id()) :: Connections.Client.t() - def connect_user(user_id) when is_binary(user_id) do - Connections.connect_user(user_id) - # Sleep to prevent this current process getting the messages related to the connection - :timer.sleep(100) - Teiserver.subscribe(Connections.client_topic(user_id)) - Teiserver.subscribe(Communication.user_messaging_topic(user_id)) - end - - @doc """ - Takes a name, email and password. Creates a user with them. - - ## Examples - - iex> register_user("Alice", "alice@alice", "password1") - {:ok, %User{}} - - iex> register_user("Bob", "bob@bob", "1") - {:error, %Ecto.Changeset{}} - """ - @doc section: :user - @spec register_user(String.t(), String.t(), String.t()) :: - {:ok, User.t()} | {:error, Ecto.Changeset.t()} - def register_user(name, email, password) do - Teiserver.Account.register_user(%{ - "name" => name, - "password" => password, - "email" => email - }) - end - - ### Account - @doc section: :user - @spec get_user_by_id(Teiserver.user_id()) :: User.t() | nil - defdelegate get_user_by_id(user_id), to: UserLib - - @doc section: :user - @spec get_user_by_name(String.t()) :: User.t() | nil - defdelegate get_user_by_name(name), to: UserLib - - ### Connections - # Client - @doc section: :client - @spec get_client(Teiserver.user_id()) :: Client.t() | nil - defdelegate get_client(user_id), to: ClientLib - - @doc section: :client - @spec update_client(Teiserver.user_id(), map, String.t()) :: Client.t() | nil - defdelegate update_client(user_id, updates, reason), to: ClientLib - - @doc section: :client - @spec update_client_in_lobby(Teiserver.user_id(), map, String.t()) :: Client.t() | nil - defdelegate update_client_in_lobby(user_id, updates, reason), to: ClientLib - - @doc section: :client - @spec update_client_full(Teiserver.user_id(), map, String.t()) :: Client.t() | nil - defdelegate update_client_full(user_id, updates, reason), to: ClientLib - - ### Game - # Lobby - @doc section: :lobby - @spec subscribe_to_lobby(Lobby.id() | Lobby.t()) :: :ok - defdelegate subscribe_to_lobby(lobby_or_lobby_id), to: LobbyLib - - @doc section: :lobby - @spec unsubscribe_from_lobby(Lobby.id() | Lobby.t()) :: :ok - defdelegate unsubscribe_from_lobby(lobby_or_lobby_id), to: LobbyLib - - @doc section: :lobby - @spec lobby_exists?(Lobby.id()) :: boolean - defdelegate lobby_exists?(lobby_id), to: LobbyLib - - @doc section: :lobby - @spec get_lobby(Lobby.id()) :: Lobby.t() | nil - defdelegate get_lobby(lobby_id), to: LobbyLib - - @doc section: :lobby - @spec get_lobby_summary(Lobby.id()) :: LobbySummary.t() | nil - defdelegate get_lobby_summary(lobby_id), to: LobbyLib - - @doc section: :lobby - @spec update_lobby(Lobby.id(), map) :: :ok | nil - defdelegate update_lobby(lobby_id, value_map), to: LobbyLib - - @doc section: :lobby - @spec list_lobby_ids() :: [Lobby.id()] - defdelegate list_lobby_ids, to: LobbyLib - - @doc section: :lobby - @spec stream_lobby_summaries() :: Enumerable.t(LobbySummary.t()) - defdelegate stream_lobby_summaries(), to: LobbyLib - - @doc section: :lobby - @spec stream_lobby_summaries(map) :: Enumerable.t(LobbySummary.t()) - defdelegate stream_lobby_summaries(filters), to: LobbyLib - - @doc section: :lobby - @spec open_lobby(Teiserver.user_id(), Lobby.name()) :: {:ok, Lobby.id()} | {:error, String.t()} - defdelegate open_lobby(host_id, name), to: LobbyLib - - @doc section: :lobby - @spec cycle_lobby(Lobby.id()) :: :ok - defdelegate cycle_lobby(lobby_id), to: LobbyLib - - @doc section: :lobby - @spec close_lobby(Lobby.id()) :: :ok - defdelegate close_lobby(lobby_id), to: LobbyLib - - @doc section: :lobby - @spec can_add_client_to_lobby(Teiserver.user_id(), Lobby.id()) :: {boolean(), String.t() | nil} - defdelegate can_add_client_to_lobby(user_id, lobby_id), to: LobbyLib - - @doc section: :lobby - @spec can_add_client_to_lobby(Teiserver.user_id(), Lobby.id(), String.t()) :: {boolean(), String.t() | nil} - defdelegate can_add_client_to_lobby(user_id, lobby_id, password), to: LobbyLib - - - @doc section: :lobby - @spec add_client_to_lobby(Teiserver.user_id(), Lobby.id()) :: :ok | {:error, String.t()} - defdelegate add_client_to_lobby(user_id, lobby_id), to: LobbyLib - - @doc section: :lobby - @spec remove_client_from_lobby(Teiserver.user_id(), Lobby.id()) :: :ok | nil - defdelegate remove_client_from_lobby(user_id, lobby_id), to: LobbyLib - - # Match - @doc section: :match - @spec start_match(Lobby.t()) :: Match.t() - defdelegate start_match(lobby), to: MatchLib - - @doc section: :match - @spec end_match(Match.id(), map()) :: Match.t() - defdelegate end_match(match_id, outcome), to: MatchLib - - ### Communication - # MatchMessage - @doc section: :match_message - @spec subscribe_to_match_messages(Match.id() | Match.t()) :: :ok - defdelegate subscribe_to_match_messages(match_or_match_id), to: MatchMessageLib - - @doc section: :match_message - @spec unsubscribe_from_match_messages(Match.id() | Match.t()) :: :ok - defdelegate unsubscribe_from_match_messages(match_or_match_id), to: MatchMessageLib - - @doc section: :match_message - @spec send_match_message(Teiserver.user_id(), Match.id(), String.t()) :: - {:ok, MatchMessage.t()} | {:error, Ecto.Changeset.t()} - defdelegate send_match_message(sender_id, match_id, content), to: MatchMessageLib - - @doc section: :match_message - @spec send_lobby_message(Teiserver.user_id(), Lobby.id(), String.t()) :: - {:ok, MatchMessage.t()} | {:error, Ecto.Changeset.t()} - defdelegate send_lobby_message(sender_id, lobby_id, content), to: MatchMessageLib - - # Room and RoomMessage - @doc section: :room_message - @spec subscribe_to_room_messages(Room.id() | Room.t()) :: :ok - defdelegate subscribe_to_room_messages(room_or_room_id), to: RoomMessageLib - - @doc section: :room_message - @spec unsubscribe_from_room_messages(Room.id() | Room.t()) :: :ok - defdelegate unsubscribe_from_room_messages(room_or_room_id), to: RoomMessageLib - - @doc section: :room_message - @spec get_room_by_name_or_id(Room.name_or_id()) :: Room.t() | nil - defdelegate get_room_by_name_or_id(room_name_or_id), to: RoomLib - - @doc section: :room_message - @spec list_recent_room_messages(Room.id()) :: [RoomMessage.t()] - defdelegate list_recent_room_messages(room_name_or_id), to: RoomMessageLib - - @doc section: :room_message - @spec send_room_message(Teiserver.user_id(), Room.id(), String.t()) :: - {:ok, RoomMessage.t()} | {:error, Ecto.Changeset.t()} - defdelegate send_room_message(sender_id, room_id, content), to: RoomMessageLib - - # DirectMessage - @doc section: :direct_message - @spec send_direct_message(Teiserver.user_id(), Teiserver.user_id(), String.t()) :: - {:ok, DirectMessage.t()} | {:error, Ecto.Changeset.t()} - defdelegate send_direct_message(sender_id, to_id, content), to: DirectMessageLib - - @doc section: :direct_message - @spec subscribe_to_user_messaging(User.id() | User.t()) :: :ok - defdelegate subscribe_to_user_messaging(user_or_user_id), to: DirectMessageLib - - @doc section: :direct_message - @spec unsubscribe_from_user_messaging(User.id() | User.t()) :: :ok - defdelegate unsubscribe_from_user_messaging(user_or_user_id), to: DirectMessageLib -end diff --git a/lib/teiserver/application.ex b/lib/teiserver/application.ex index 92ccdbf62..8268e1eba 100644 --- a/lib/teiserver/application.ex +++ b/lib/teiserver/application.ex @@ -7,7 +7,7 @@ defmodule Teiserver.Application do def start(_type, _args) do children = [ {Phoenix.PubSub, name: Teiserver.PubSub}, - Teiserver.System.ClusterManagerSupervisor, + Teiserver.System.CacheClusterServer, # Servers not part of the general slew of things {Registry, [keys: :unique, members: :auto, name: Teiserver.ServerRegistry]}, @@ -27,7 +27,7 @@ defmodule Teiserver.Application do # Lobbies {DynamicSupervisor, strategy: :one_for_one, name: Teiserver.LobbySupervisor}, {Horde.Registry, [keys: :unique, members: :auto, name: Teiserver.LobbyRegistry]}, - {Registry, [keys: :unique, members: :auto, name: Teiserver.LocalLobbyRegistry]} + {Registry, [keys: :unique, members: :auto, name: Teiserver.LocalLobbyRegistry]}, # Matchmaking # {DynamicSupervisor, strategy: :one_for_one, name: Teiserver.MMSupervisor}, @@ -35,14 +35,18 @@ defmodule Teiserver.Application do # {Horde.Registry, [keys: :unique, members: :auto, name: Teiserver.MMMatchRegistry]}, # {Registry, [keys: :unique, members: :auto, name: Teiserver.LocalMMQueueRegistry]}, # {Registry, [keys: :unique, members: :auto, name: Teiserver.LocalMMMatchRegistry]} + + # Caches + Teiserver.Caches.UserSettingCache, + Teiserver.Caches.ServerSettingCache, + Teiserver.Caches.LoginCountCache, + Teiserver.Caches.TypeLookupCache ] opts = [strategy: :one_for_one, name: __MODULE__] start_result = Supervisor.start_link(children, opts) - if Application.get_env(:teiserver, :teiserver_clustering, true) do - Teiserver.System.ClusterManagerSupervisor.start_cluster_manager_supervisor_children() - end + Teiserver.System.StartupLib.perform() start_result end diff --git a/lib/teiserver/caches/login_count_cache.ex b/lib/teiserver/caches/login_count_cache.ex new file mode 100644 index 000000000..0aa7364ad --- /dev/null +++ b/lib/teiserver/caches/login_count_cache.ex @@ -0,0 +1,22 @@ +defmodule Teiserver.Caches.LoginCountCache do + @moduledoc """ + Cache and setup for communication stuff + """ + + use Supervisor + import Teiserver.Helpers.CacheHelper, only: [add_cache: 2] + + def start_link(opts) do + Supervisor.start_link(__MODULE__, :ok, opts) + end + + @impl true + def init(:ok) do + children = [ + add_cache(:ts_login_count_ip, ttl: :timer.minutes(5)), + add_cache(:ts_login_count_user, ttl: :timer.minutes(5)) + ] + + Supervisor.init(children, strategy: :one_for_all) + end +end diff --git a/lib/teiserver/caches/server_settings_cache.ex b/lib/teiserver/caches/server_settings_cache.ex new file mode 100644 index 000000000..a0a9292f7 --- /dev/null +++ b/lib/teiserver/caches/server_settings_cache.ex @@ -0,0 +1,22 @@ +defmodule Teiserver.Caches.ServerSettingCache do + @moduledoc """ + Cache and setup for communication stuff + """ + + use Supervisor + import Teiserver.Helpers.CacheHelper, only: [add_cache: 2] + + def start_link(opts) do + Supervisor.start_link(__MODULE__, :ok, opts) + end + + @impl true + def init(:ok) do + children = [ + add_cache(:ts_server_setting_type_store, ttl: :timer.minutes(5)), + add_cache(:ts_server_setting_cache, ttl: :timer.minutes(1)) + ] + + Supervisor.init(children, strategy: :one_for_all) + end +end diff --git a/lib/teiserver/caches/type_lookup_cache.ex b/lib/teiserver/caches/type_lookup_cache.ex new file mode 100644 index 000000000..c250d19d1 --- /dev/null +++ b/lib/teiserver/caches/type_lookup_cache.ex @@ -0,0 +1,23 @@ +defmodule Teiserver.Caches.TypeLookupCache do + @moduledoc """ + Cache for tracking type_ids of various fields to prevent repeated DB lookups + """ + + use Supervisor + import Teiserver.Helpers.CacheHelper, only: [add_cache: 2] + + def start_link(opts) do + Supervisor.start_link(__MODULE__, :ok, opts) + end + + @impl true + def init(:ok) do + children = [ + add_cache(:ts_match_type_lookup, ttl: :timer.minutes(5)), + add_cache(:ts_match_setting_type_lookup, ttl: :timer.minutes(5)), + add_cache(:ts_user_choice_type_lookup, ttl: :timer.minutes(5)) + ] + + Supervisor.init(children, strategy: :one_for_all) + end +end diff --git a/lib/teiserver/caches/user_settings_cache.ex b/lib/teiserver/caches/user_settings_cache.ex new file mode 100644 index 000000000..c4887da73 --- /dev/null +++ b/lib/teiserver/caches/user_settings_cache.ex @@ -0,0 +1,23 @@ +defmodule Teiserver.Caches.UserSettingCache do + @moduledoc """ + Cache and setup for communication stuff + """ + + use Supervisor + import Teiserver.Helpers.CacheHelper, only: [add_cache: 2] + + def start_link(opts) do + Supervisor.start_link(__MODULE__, :ok, opts) + end + + @impl true + def init(:ok) do + children = [ + add_cache(:ts_user_setting_type_store, ttl: :timer.minutes(5)), + add_cache(:ts_user_setting_cache, ttl: :timer.minutes(1)), + add_cache(:ts_user_by_user_id_cache, ttl: :timer.minutes(5)) + ] + + Supervisor.init(children, strategy: :one_for_all) + end +end diff --git a/lib/teiserver/communication/libs/direct_message_lib.ex b/lib/teiserver/communication/libs/direct_message_lib.ex index 4ab6c5ce9..0160ec441 100644 --- a/lib/teiserver/communication/libs/direct_message_lib.ex +++ b/lib/teiserver/communication/libs/direct_message_lib.ex @@ -52,7 +52,7 @@ defmodule Teiserver.Communication.DirectMessageLib do sender_id: sender_id, to_id: to_id, content: content, - inserted_at: Timex.now() + inserted_at: DateTime.utc_now() }, attrs ) diff --git a/lib/teiserver/communication/libs/match_message_lib.ex b/lib/teiserver/communication/libs/match_message_lib.ex index 48dce7ae7..c9f709e02 100644 --- a/lib/teiserver/communication/libs/match_message_lib.ex +++ b/lib/teiserver/communication/libs/match_message_lib.ex @@ -50,7 +50,6 @@ defmodule Teiserver.Communication.MatchMessageLib do list_match_messages(where: [match_id: match_id], limit: limit, order_by: ["Newest first"]) end - @doc """ Wraps `send_match_message/3` to send a message when in the lobby instead of the match @@ -82,11 +81,11 @@ defmodule Teiserver.Communication.MatchMessageLib do match_message: match_message } ) + {:ok, match_message} err -> err - end end @@ -113,7 +112,7 @@ defmodule Teiserver.Communication.MatchMessageLib do sender_id: sender_id, match_id: match_id, content: content, - inserted_at: Timex.now() + inserted_at: DateTime.utc_now() }, attrs ) diff --git a/lib/teiserver/communication/libs/room_message_lib.ex b/lib/teiserver/communication/libs/room_message_lib.ex index ab8ae3ddb..c7e84d4ae 100644 --- a/lib/teiserver/communication/libs/room_message_lib.ex +++ b/lib/teiserver/communication/libs/room_message_lib.ex @@ -71,7 +71,7 @@ defmodule Teiserver.Communication.RoomMessageLib do sender_id: sender_id, room_id: room_id, content: content, - inserted_at: Timex.now() + inserted_at: DateTime.utc_now() }, attrs ) diff --git a/lib/teiserver/communication/queries/direct_message_queries.ex b/lib/teiserver/communication/queries/direct_message_queries.ex index 119d4de2b..6ec2ecd4f 100644 --- a/lib/teiserver/communication/queries/direct_message_queries.ex +++ b/lib/teiserver/communication/queries/direct_message_queries.ex @@ -94,7 +94,7 @@ defmodule Teiserver.Communication.DirectMessageQueries do @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> diff --git a/lib/teiserver/communication/queries/match_message_queries.ex b/lib/teiserver/communication/queries/match_message_queries.ex index 928703ff4..3b5403674 100644 --- a/lib/teiserver/communication/queries/match_message_queries.ex +++ b/lib/teiserver/communication/queries/match_message_queries.ex @@ -82,7 +82,7 @@ defmodule Teiserver.Communication.MatchMessageQueries do @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> diff --git a/lib/teiserver/communication/queries/room_message_queries.ex b/lib/teiserver/communication/queries/room_message_queries.ex index 9928fc703..2a3283e59 100644 --- a/lib/teiserver/communication/queries/room_message_queries.ex +++ b/lib/teiserver/communication/queries/room_message_queries.ex @@ -82,7 +82,7 @@ defmodule Teiserver.Communication.RoomMessageQueries do @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> diff --git a/lib/teiserver/communication/queries/room_queries.ex b/lib/teiserver/communication/queries/room_queries.ex index 7aa4b6379..03a569fb2 100644 --- a/lib/teiserver/communication/queries/room_queries.ex +++ b/lib/teiserver/communication/queries/room_queries.ex @@ -64,7 +64,7 @@ defmodule Teiserver.Communication.RoomQueries do @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> diff --git a/lib/teiserver/communication/schemas/room.ex b/lib/teiserver/communication/schemas/room.ex index a4631d7ec..21ca8b8b1 100644 --- a/lib/teiserver/communication/schemas/room.ex +++ b/lib/teiserver/communication/schemas/room.ex @@ -13,7 +13,7 @@ defmodule Teiserver.Communication.Room do schema "communication_rooms" do field(:name, :string) - timestamps() + timestamps(type: :utc_datetime) end @type id :: non_neg_integer() diff --git a/lib/teiserver/connections/client_server.ex b/lib/teiserver/connections/client_server.ex index 8ef297e37..952a9a61b 100644 --- a/lib/teiserver/connections/client_server.ex +++ b/lib/teiserver/connections/client_server.ex @@ -23,9 +23,6 @@ defmodule Teiserver.Connections.ClientServer do defstruct [:client, :user_id, :connections, :client_topic, :lobby_topic] end - @standard_data_keys ~w(connected? last_disconnected in_game? afk? party_id)a - @lobby_data_keys ~w(ready? player? player_number team_number player_colour sync lobby_host?)a - @impl true def handle_call(:get_client_state, _from, state) do {:reply, state.client, state} @@ -41,8 +38,20 @@ defmodule Teiserver.Connections.ClientServer do new_connections = Enum.uniq([conn_pid | state.connections]) if state.client.connected? do + :telemetry.execute( + [:teiserver, :client, :connect], + %{type: :added_connection}, + %{user_id: state.user_id} + ) + {:noreply, %State{state | connections: new_connections}} else + :telemetry.execute( + [:teiserver, :client, :connect], + %{type: :new_connection}, + %{user_id: state.user_id} + ) + new_client = %{state.client | connected?: true} Teiserver.broadcast(state.client_topic, %{ @@ -56,42 +65,70 @@ defmodule Teiserver.Connections.ClientServer do end def handle_cast({:update_client, partial_client, reason}, state) do - partial_client = Map.take(partial_client, @standard_data_keys) + if Enum.empty?(partial_client) do + {:noreply, state} + else + :telemetry.execute( + [:teiserver, :client, :updated], + %{change_count: Enum.count(partial_client)}, + %{user_id: state.user_id} + ) - if partial_client != %{} do new_client = struct(state.client, partial_client) new_state = update_client(state, new_client, reason) {:noreply, new_state} - else - {:noreply, state} end end def handle_cast({:update_client_in_lobby, partial_client, reason}, state) do - partial_client = - partial_client - |> Map.take(@lobby_data_keys) - |> Map.put(:id, state.user_id) - |> LobbyLib.client_update_request(state.client.lobby_id) - - if partial_client != %{} do - new_client = struct(state.client, partial_client) - new_state = update_client(state, new_client, reason) - {:noreply, new_state} + if Enum.empty?(partial_client) or state.client.lobby_id == nil do + {:noreply, state} else + new_client = struct(state.client, partial_client) + diffs = MapHelper.map_diffs(state.client, new_client) + + if not Enum.empty?(diffs) do + LobbyLib.client_update_request(state.client.lobby_id, new_client, diffs, reason) + end + {:noreply, state} end end - def handle_cast({:update_client_full, partial_client, reason}, state) do - new_client = struct(state.client, partial_client) + def handle_cast({:do_update_client_in_lobby, new_client, reason}, state) do + :telemetry.execute( + [:teiserver, :client, :updated_in_lobby], + %{}, + %{user_id: state.user_id} + ) + new_state = update_client(state, new_client, reason) {:noreply, new_state} end + # Unlike with a normal :DOWN message, this is one where the person purposefully disconnects + # and thus we don't want to keep the client alive + def handle_cast({:purposeful_disconnect, pid}, state) do + new_state = lose_connection(pid, state) + + :telemetry.execute( + [:teiserver, :client, :disconnect], + %{reason: :purposeful}, + %{user_id: state.user_id} + ) + + if new_state.client.connected? do + {:noreply, new_state} + else + ClientLib.stop_client_server(state.user_id) + {:noreply, new_state} + end + end + @impl true def handle_info(:heartbeat, %State{client: %{connected?: false}} = state) do - seconds_since_disconnect = Timex.diff(Timex.now(), state.client.last_disconnected, :second) + seconds_since_disconnect = + DateTime.diff(DateTime.utc_now(), state.client.last_disconnected, :second) if seconds_since_disconnect > @client_destroy_timeout_seconds do ClientLib.stop_client_server(state.user_id) @@ -106,6 +143,12 @@ defmodule Teiserver.Connections.ClientServer do end def handle_info({:DOWN, _ref, :process, pid, _normal}, state) do + :telemetry.execute( + [:teiserver, :client, :disconnect], + %{reason: :down_message}, + %{user_id: state.user_id} + ) + new_state = lose_connection(pid, state) {:noreply, new_state} end @@ -116,7 +159,7 @@ defmodule Teiserver.Connections.ClientServer do new_connections = List.delete(state.connections, pid) if Enum.empty?(new_connections) do - new_client = %{state.client | connected?: false, last_disconnected: Timex.now()} + new_client = %{state.client | connected?: false, last_disconnected: DateTime.utc_now()} Teiserver.broadcast(state.client_topic, %{ event: :client_disconnected, @@ -143,7 +186,7 @@ defmodule Teiserver.Connections.ClientServer do defp update_client(%State{} = state, %Client{} = new_client, reason) do diffs = MapHelper.map_diffs(state.client, new_client) - if diffs == %{} do + if Enum.empty?(diffs) do # Nothing changed, we don't do anything state else @@ -175,16 +218,17 @@ defmodule Teiserver.Connections.ClientServer do ) end - new_state = cond do - state.client.lobby_id == nil && new_client.lobby_id != nil -> - added_to_lobby(new_client.lobby_id, state) + new_state = + cond do + state.client.lobby_id == nil && new_client.lobby_id != nil -> + added_to_lobby(new_client.lobby_id, state) - state.client.lobby_id != nil && new_client.lobby_id == nil -> - removed_from_lobby(state.client.lobby_id, state) + state.client.lobby_id != nil && new_client.lobby_id == nil -> + removed_from_lobby(state.client.lobby_id, state) - true -> - state - end + true -> + state + end %{new_state | client: new_client} end @@ -220,7 +264,7 @@ defmodule Teiserver.Connections.ClientServer do @impl true @spec init(map) :: {:ok, map} - def init(%{client: %Client{id: id} = client}) do + def init(%{client: %Client{id: id} = client, opts: opts}) do # Logger.metadata(request_id: "ClientServer##{id}") :timer.send_interval(@heartbeat_frequency_ms, :heartbeat) @@ -237,6 +281,12 @@ defmodule Teiserver.Connections.ClientServer do id ) + # Handle opts + client = + struct(client, %{ + bot?: opts[:bot?] || false + }) + # After being created a client will typically have # a connection be added, it is possible in some cases # for a timing issue to happen where the connection will not correctly diff --git a/lib/teiserver/connections/libs/client_lib.ex b/lib/teiserver/connections/libs/client_lib.ex index dcde32209..995fa0a1f 100644 --- a/lib/teiserver/connections/libs/client_lib.ex +++ b/lib/teiserver/connections/libs/client_lib.ex @@ -81,29 +81,30 @@ defmodule Teiserver.Connections.ClientLib do [%Client{}, %Client{}] iex> get_client_list([456]) - [nil] + [] """ - @spec get_client_list([Teiserver.user_id()]) :: [Client.t() | nil] + @spec get_client_list([Teiserver.user_id()]) :: [Client.t()] def get_client_list(user_ids) do user_ids |> Enum.uniq() |> Enum.map(fn user_id -> call_client(user_id, :get_client_state) end) + |> Enum.reject(&(&1 == nil)) end @doc """ - Updates a client with the new data (excluding lobby related details). If changes are made then it will also generate a `Teiserver.Connections.Client:{user_id}` pubsub message. + Updates a client with the new data (excluding lobby related details). If changes are made then it will also generate a `Teiserver.Connections.Client:{user_id}` pubsub message; if they are present in a lobby it will generate a similar message to `Teiserver.Game.Lobby:{lobby_id}`. Returns nil if the Client does not exist, :ok if the client does. ## Examples - iex> update_client(123, %{afk?: false}) + iex> update_client(123, %{afk?: false}, "some reason") :ok - iex> update_client(456, %{afk?: false}) + iex> update_client(456, %{afk?: false}, "some reason") nil """ @@ -113,16 +114,18 @@ defmodule Teiserver.Connections.ClientLib do end @doc """ - Updates a client with the new data for their lobby presence. If changes are made then it will also generate a `Teiserver.Connections.Client:{user_id}` pubsub message and a `Teiserver.Game.Lobby:{lobby_id}` pubsub message. + Similar to `update_client/3`, in the ClientServer it will first validate the update is acceptable and then send it off to the LobbyServer allowing it to change the update as it sees fit. Depending on other factors the LobbyServer may call out to the lobby host for further input. + + If the LobbyServer accepts any changes it will contact the ClientServer accordingly. Returns nil if the Client does not exist, :ok if the client does. ## Examples - iex> update_client_in_lobby(123, %{player_number: 123}) + iex> update_client_in_lobby(123, %{player_number: 123}, "some reason") :ok - iex> update_client_in_lobby(456, %{player_number: 123}) + iex> update_client_in_lobby(456, %{player_number: 123}, "some reason") nil """ @@ -131,48 +134,38 @@ defmodule Teiserver.Connections.ClientLib do cast_client(user_id, {:update_client_in_lobby, data, reason}) end - @doc """ - Updates a client with the new data for any and all keys (so be careful not to break things like lobby memberships). - - Returns nil if the Client does not exist, :ok if the client does. - - ## Examples - - iex> update_client_full(123, %{player_number: 123}) - :ok - - iex> update_client_full(456, %{player_number: 123}) - nil - - """ - @spec update_client_full(Teiserver.user_id(), map, String.t()) :: :ok | nil - def update_client_full(user_id, data, reason) do - cast_client(user_id, {:update_client_full, data, reason}) + # Used by the LobbyServer to update the ClientServer if `update_client_in_lobby/4` is successful + @doc false + @spec do_update_client_in_lobby(Teiserver.user_id(), map, String.t()) :: :ok | nil + def do_update_client_in_lobby(user_id, data, reason) do + cast_client(user_id, {:do_update_client_in_lobby, data, reason}) end @doc """ Given a user_id, log them in. If the user already exists as a client then the existing client is returned. + The optional `opts` argument is passed through to the ClientServer starting up. + The calling process will be listed as a connection for the client the client will monitor it for the purposes of tracking if the given client is still connected. """ - @spec connect_user(Teiserver.user_id()) :: Client.t() - def connect_user(user_id) do + @spec connect_user(Teiserver.user_id(), list()) :: Client.t() | nil + def connect_user(user_id, opts \\ []) do if client_exists?(user_id) do cast_client(user_id, {:add_connection, self()}) get_client(user_id) else client = Client.new(user_id) - _pid = start_client_server(client) + _pid = start_client_server(client, opts) cast_client(user_id, {:add_connection, self()}) client end end @doc """ - + Sends a `:disconnect` message to every connection for the client and then stops the ClientServer """ @spec disconnect_user(Teiserver.user_id()) :: :ok def disconnect_user(user_id) do @@ -185,16 +178,36 @@ defmodule Teiserver.Connections.ClientLib do stop_client_server(user_id) end + @doc """ + Sends the calling process a `:disconnect` message and informs the ClientServer it is + an intentional disconnect. This means if it is the last connection for the client the ClientServer + will stop itself rather than going into a disconnected state. + """ + @spec disconnect_single_connection(Teiserver.user_id()) :: :ok + def disconnect_single_connection(user_id) when is_binary(user_id) do + disconnect_single_connection(user_id, self()) + end + + @doc """ + Same as `disconnect_single_connection/1` except you can define the pid which is disconnected + """ + @spec disconnect_single_connection(Teiserver.user_id(), pid()) :: :ok + def disconnect_single_connection(user_id, pid) when is_binary(user_id) do + cast_client(user_id, {:purposeful_disconnect, pid}) + send(pid, :disconnect) + end + # Process stuff @doc false - @spec start_client_server(Client.t()) :: pid() - def start_client_server(%Client{} = client) do + @spec start_client_server(Client.t(), list) :: pid() + def start_client_server(%Client{} = client, opts) do {:ok, server_pid} = DynamicSupervisor.start_child(Teiserver.ClientSupervisor, { Teiserver.Connections.ClientServer, name: "client_#{client.id}", data: %{ - client: client + client: client, + opts: opts } }) diff --git a/lib/teiserver/connections/schemas/client.ex b/lib/teiserver/connections/schemas/client.ex index a509a836c..5bd1796bb 100644 --- a/lib/teiserver/connections/schemas/client.ex +++ b/lib/teiserver/connections/schemas/client.ex @@ -10,6 +10,7 @@ defmodule Teiserver.Connections.Client do * `:last_disconnected` - When disconnected, stores a DateTime of when the client was last connected * `:lobby_id` - nil or the id of the lobby current occupied by this client * `:in_game?` - True when the client is in-game + * `:bot?` - True when the client connects and registers itself as a bot, a bot cannot play games but can engage in things outside of them (e.g. chatting, hosting lobbies) * `:afk?` - True when the client has not sent an activity message for a while * `:ready?` - A flag set by the client to show it is ready to proceed in the current lobby * `:player?` - When in a lobby or match, set to true if the client is playing and false if not (e.g. spectator) @@ -27,12 +28,13 @@ defmodule Teiserver.Connections.Client do @derive {Jason.Encoder, only: - ~w(id connected? last_disconnected afk? party_id in_game? lobby_id ready? player? player_number team_number player_colour sync lobby_host? update_id)a} + ~w(id connected? last_disconnected bot? afk? party_id in_game? lobby_id ready? player? player_number team_number player_colour sync lobby_host? update_id)a} typedstruct do field(:id, Teiserver.user_id()) field(:connected?, boolean, default: false) field(:last_disconnected, DateTime.t()) + field(:bot?, boolean, default: false) field(:afk?, boolean, default: false) field(:party_id, Teiserver.party_id()) field(:in_game?, boolean, default: false) diff --git a/lib/teiserver/contexts/account.ex b/lib/teiserver/contexts/account.ex index 51dcb4944..b56d0ccdf 100644 --- a/lib/teiserver/contexts/account.ex +++ b/lib/teiserver/contexts/account.ex @@ -86,6 +86,10 @@ defmodule Teiserver.Account do @spec allow?(Teiserver.user_id() | User.t(), [String.t()] | String.t()) :: boolean defdelegate allow?(user_or_user_id, permission_or_permissions), to: UserLib + @doc section: :user + @spec generate_guest_name() :: String.t() + defdelegate generate_guest_name(), to: UserLib + @doc section: :user @spec restricted?(Teiserver.user_id() | User.t(), [String.t()] | String.t()) :: boolean defdelegate restricted?(user_or_user_id, permission_or_permissions), to: UserLib diff --git a/lib/teiserver/contexts/connections.ex b/lib/teiserver/contexts/connections.ex index b71c97158..034667850 100644 --- a/lib/teiserver/contexts/connections.ex +++ b/lib/teiserver/contexts/connections.ex @@ -40,29 +40,33 @@ defmodule Teiserver.Connections do defdelegate get_client(user_id), to: ClientLib @doc section: :client - @spec get_client_list([Teiserver.user_id()]) :: [Client.t() | nil] + @spec get_client_list([Teiserver.user_id()]) :: [Client.t()] defdelegate get_client_list(user_ids), to: ClientLib @doc section: :client - @spec update_client(Teiserver.user_id(), map, String.t()) :: Client.t() | nil + @spec update_client(Teiserver.user_id(), map, String.t()) :: :ok | nil defdelegate update_client(user_id, updates, reason), to: ClientLib @doc section: :client - @spec update_client_in_lobby(Teiserver.user_id(), map, String.t()) :: Client.t() | nil + @spec update_client_in_lobby(Teiserver.user_id(), map, String.t()) :: :ok | nil defdelegate update_client_in_lobby(user_id, updates, reason), to: ClientLib @doc section: :client - @spec update_client_full(Teiserver.user_id(), map, String.t()) :: Client.t() | nil - defdelegate update_client_full(user_id, updates, reason), to: ClientLib - - @doc section: :client - @spec connect_user(Teiserver.user_id()) :: Client.t() - defdelegate connect_user(user_id), to: ClientLib + @spec connect_user(Teiserver.user_id(), list) :: Client.t() + defdelegate connect_user(user_id, opts \\ []), to: ClientLib @doc section: :client @spec disconnect_user(Teiserver.user_id()) :: :ok defdelegate disconnect_user(user_id), to: ClientLib + @doc section: :client + @spec disconnect_single_connection(Teiserver.user_id()) :: :ok + defdelegate disconnect_single_connection(user_id), to: ClientLib + + @doc section: :client + @spec disconnect_single_connection(Teiserver.user_id(), pid) :: :ok + defdelegate disconnect_single_connection(user_id, pid), to: ClientLib + @doc false @spec client_exists?(Teiserver.user_id()) :: pid() | boolean defdelegate client_exists?(user_id), to: ClientLib diff --git a/lib/teiserver/contexts/game.ex b/lib/teiserver/contexts/game.ex index cd89074e5..26fc1cd3d 100644 --- a/lib/teiserver/contexts/game.ex +++ b/lib/teiserver/contexts/game.ex @@ -70,16 +70,24 @@ defmodule Teiserver.Game do defdelegate lobby_start_match(lobby_id), to: LobbyLib @doc section: :lobby - @spec close_lobby(Lobby.id()) :: :ok - defdelegate close_lobby(lobby_id), to: LobbyLib + @spec lobby_end_match(Lobby.id(), String.t()) :: :ok + defdelegate lobby_end_match(lobby_id, reason \\ "normal"), to: LobbyLib @doc section: :lobby - @spec can_add_client_to_lobby(Teiserver.user_id(), Lobby.id()) :: {boolean(), String.t() | nil} - defdelegate can_add_client_to_lobby(user_id, lobby_id), to: LobbyLib + @spec close_lobby(Lobby.id()) :: :ok + defdelegate close_lobby(lobby_id), to: LobbyLib @doc section: :lobby - @spec can_add_client_to_lobby(Teiserver.user_id(), Lobby.id(), String.t()) :: {boolean(), String.t() | nil} - defdelegate can_add_client_to_lobby(user_id, lobby_id, password), to: LobbyLib + @spec can_add_client_to_lobby(Teiserver.user_id(), Lobby.id(), String.t() | nil) :: + true + | {false, + :no_lobby + | :existing_member + | :client_disconnected + | :already_in_a_lobby + | :incorrect_password + | :lobby_is_locked} + defdelegate can_add_client_to_lobby(user_id, lobby_id, password \\ nil), to: LobbyLib @doc section: :lobby @spec add_client_to_lobby(Teiserver.user_id(), Lobby.id()) :: :ok | {:error, String.t()} @@ -191,7 +199,6 @@ defmodule Teiserver.Game do defdelegate get_match!(match_id, query_args \\ []), to: MatchLib @doc section: :match - @spec get_match(Match.id()) :: Match.t() | nil @spec get_match(Match.id(), Teiserver.query_args()) :: Match.t() | nil defdelegate get_match(match_id, query_args \\ []), to: MatchLib @@ -291,8 +298,8 @@ defmodule Teiserver.Game do defdelegate create_match_setting_type(attrs), to: MatchSettingTypeLib @doc section: :match_setting_type - @spec get_or_create_match_setting_type(String.t()) :: MatchSettingType.id() - defdelegate get_or_create_match_setting_type(name), to: MatchSettingTypeLib + @spec get_or_create_match_setting_type_id(String.t()) :: MatchSettingType.id() + defdelegate get_or_create_match_setting_type_id(name), to: MatchSettingTypeLib @doc section: :match_setting_type @spec update_match_setting_type(MatchSettingType, map) :: @@ -361,6 +368,118 @@ defmodule Teiserver.Game do @spec change_match_setting(MatchSetting.t(), map) :: Ecto.Changeset.t() defdelegate change_match_setting(match_setting, attrs \\ %{}), to: MatchSettingLib + # UserChoiceTypes + alias Teiserver.Game.{UserChoiceType, UserChoiceTypeLib, UserChoiceTypeQueries} + + @doc false + @spec user_choice_type_query(Teiserver.query_args()) :: Ecto.Query.t() + defdelegate user_choice_type_query(args), to: UserChoiceTypeQueries + + @doc section: :user_choice_type + @spec list_user_choice_types(Teiserver.query_args()) :: [UserChoiceType.t()] + defdelegate list_user_choice_types(args), to: UserChoiceTypeLib + + @doc section: :user_choice_type + @spec get_user_choice_type!(UserChoiceType.id()) :: UserChoiceType.t() + @spec get_user_choice_type!(UserChoiceType.id(), Teiserver.query_args()) :: + UserChoiceType.t() + defdelegate get_user_choice_type!(user_choice_type_id, query_args \\ []), + to: UserChoiceTypeLib + + @doc section: :user_choice_type + @spec get_user_choice_type(UserChoiceType.id()) :: UserChoiceType.t() | nil + @spec get_user_choice_type(UserChoiceType.id(), Teiserver.query_args()) :: + UserChoiceType.t() | nil + defdelegate get_user_choice_type(user_choice_type_id, query_args \\ []), + to: UserChoiceTypeLib + + @doc section: :user_choice_type + @spec create_user_choice_type(map) :: + {:ok, UserChoiceType.t()} | {:error, Ecto.Changeset.t()} + defdelegate create_user_choice_type(attrs), to: UserChoiceTypeLib + + @doc section: :user_choice_type + @spec get_or_create_user_choice_type_id(String.t()) :: UserChoiceType.id() + defdelegate get_or_create_user_choice_type_id(name), to: UserChoiceTypeLib + + @doc section: :user_choice_type + @spec update_user_choice_type(UserChoiceType, map) :: + {:ok, UserChoiceType.t()} | {:error, Ecto.Changeset.t()} + defdelegate update_user_choice_type(user_choice_type, attrs), to: UserChoiceTypeLib + + @doc section: :user_choice_type + @spec delete_user_choice_type(UserChoiceType.t()) :: + {:ok, UserChoiceType.t()} | {:error, Ecto.Changeset.t()} + defdelegate delete_user_choice_type(user_choice_type), to: UserChoiceTypeLib + + @doc section: :user_choice_type + @spec change_user_choice_type(UserChoiceType.t()) :: Ecto.Changeset.t() + @spec change_user_choice_type(UserChoiceType.t(), map) :: Ecto.Changeset.t() + defdelegate change_user_choice_type(user_choice_type, attrs \\ %{}), to: UserChoiceTypeLib + + # UserChoices + alias Teiserver.Game.{UserChoice, UserChoiceLib, UserChoiceQueries} + + @doc false + @spec user_choice_query(Teiserver.query_args()) :: Ecto.Query.t() + defdelegate user_choice_query(args), to: UserChoiceQueries + + @doc section: :user_choice + @spec list_user_choices(Teiserver.query_args()) :: [UserChoice.t()] + defdelegate list_user_choices(args), to: UserChoiceLib + + @doc section: :user_choice + @spec get_user_choices_map(Teiserver.match_id(), Teiserver.user_id()) :: %{ + String.t() => String.t() + } + defdelegate get_user_choices_map(match_id, user_id), to: UserChoiceLib + + @doc section: :user_choice + @spec get_user_choice!( + Teiserver.match_id(), + Teiserver.user_id(), + UserChoiceType.id(), + Teiserver.query_args() + ) :: + UserChoice.t() + defdelegate get_user_choice!(match_id, user_id, setting_type_id, query_args \\ []), + to: UserChoiceLib + + @doc section: :user_choice + @spec get_user_choice( + Teiserver.match_id(), + Teiserver.user_id(), + UserChoiceType.id(), + Teiserver.query_args() + ) :: + UserChoice.t() | nil + defdelegate get_user_choice(match_id, user_id, setting_type_id, query_args \\ []), + to: UserChoiceLib + + @doc section: :user_choice + @spec create_user_choice(map) :: {:ok, UserChoice.t()} | {:error, Ecto.Changeset.t()} + defdelegate create_user_choice(attrs), to: UserChoiceLib + + @doc section: :user_choices + @spec create_many_user_choices([map]) :: + {:ok, UserChoice.t()} | {:error, Ecto.Changeset.t()} + defdelegate create_many_user_choices(attr_list), to: UserChoiceLib + + @doc section: :user_choice + @spec update_user_choice(UserChoice, map) :: + {:ok, UserChoice.t()} | {:error, Ecto.Changeset.t()} + defdelegate update_user_choice(user_choice, attrs), to: UserChoiceLib + + @doc section: :user_choice + @spec delete_user_choice(UserChoice.t()) :: + {:ok, UserChoice.t()} | {:error, Ecto.Changeset.t()} + defdelegate delete_user_choice(user_choice), to: UserChoiceLib + + @doc section: :user_choice + @spec change_user_choice(UserChoice.t()) :: Ecto.Changeset.t() + @spec change_user_choice(UserChoice.t(), map) :: Ecto.Changeset.t() + defdelegate change_user_choice(user_choice, attrs \\ %{}), to: UserChoiceLib + # Match results/stats/extra data # Game data file stuff (e.g. unit data if added by devs) # Server Managed Lobbies diff --git a/lib/teiserver/contexts/logging.ex b/lib/teiserver/contexts/logging.ex index ba83f036b..bdc98125f 100644 --- a/lib/teiserver/contexts/logging.ex +++ b/lib/teiserver/contexts/logging.ex @@ -2,21 +2,53 @@ defmodule Teiserver.Logging do @moduledoc """ The contextual module for: - `Teiserver.Logging.AuditLog` - - `Teiserver.Logging.CrashLog` - - `Teiserver.Logging.UserLog` - - `Teiserver.Logging.UsageLog` - - `Teiserver.Logging.LobbyLog` - - `Teiserver.Logging.MatchLog` - - `Teiserver.Logging.EventLog` """ # AuditLogs - # Crash logs - - # UserLogs (UserActivity logs in Barserver) - # UsageLogs (ServerActivity logs in Barserver, add stats of things like messages sent etc) - # LobbyLogs - # MatchLogs - # EventLogs (Telemetry events) - # + Aggregates + alias Teiserver.Logging.{AuditLog, AuditLogLib, AuditLogQueries} + + @doc false + @spec audit_log_query(Teiserver.query_args()) :: Ecto.Query.t() + defdelegate audit_log_query(args), to: AuditLogQueries + + @doc section: :audit_log + @spec list_audit_logs(Teiserver.query_args()) :: [AuditLog.t()] + defdelegate list_audit_logs(args), to: AuditLogLib + + @doc section: :audit_log + @spec get_audit_log!(AuditLog.id()) :: AuditLog.t() + @spec get_audit_log!(AuditLog.id(), Teiserver.query_args()) :: AuditLog.t() + defdelegate get_audit_log!(audit_log_id, query_args \\ []), to: AuditLogLib + + @doc section: :audit_log + @spec get_audit_log(AuditLog.id()) :: AuditLog.t() | nil + @spec get_audit_log(AuditLog.id(), Teiserver.query_args()) :: AuditLog.t() | nil + defdelegate get_audit_log(audit_log_id, query_args \\ []), to: AuditLogLib + + @doc section: :audit_log + @spec create_audit_log(map) :: {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + defdelegate create_audit_log(attrs), to: AuditLogLib + + @doc section: :audit_log + @spec create_audit_log(Teiserver.user_id(), String.t(), String.t(), map()) :: + {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + defdelegate create_audit_log(user_id, ip, action, details), to: AuditLogLib + + @doc section: :audit_log + @spec create_anonymous_audit_log(String.t(), String.t(), map()) :: + {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + defdelegate create_anonymous_audit_log(ip, action, details), to: AuditLogLib + + @doc section: :audit_log + @spec update_audit_log(AuditLog, map) :: {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + defdelegate update_audit_log(audit_log, attrs), to: AuditLogLib + + @doc section: :audit_log + @spec delete_audit_log(AuditLog.t()) :: {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + defdelegate delete_audit_log(audit_log), to: AuditLogLib + + @doc section: :audit_log + @spec change_audit_log(AuditLog.t()) :: Ecto.Changeset.t() + @spec change_audit_log(AuditLog.t(), map) :: Ecto.Changeset.t() + defdelegate change_audit_log(audit_log, attrs \\ %{}), to: AuditLogLib end diff --git a/lib/teiserver/contexts/settings.ex b/lib/teiserver/contexts/settings.ex index e402b679a..64e2642e2 100644 --- a/lib/teiserver/contexts/settings.ex +++ b/lib/teiserver/contexts/settings.ex @@ -1,36 +1,55 @@ defmodule Teiserver.Settings do @moduledoc """ The contextual module for: + - `Teiserver.Settings.ServerSettingType` - `Teiserver.Settings.ServerSetting` + - `Teiserver.Settings.UserSettingType` - `Teiserver.Settings.UserSetting` """ + # ServerSettingType + alias Teiserver.Settings.{ServerSettingType, ServerSettingTypeLib} + + @doc section: :server_setting_type + @spec list_server_setting_types([String.t()]) :: [ServerSettingType.t()] + defdelegate list_server_setting_types(keys), to: ServerSettingTypeLib + + @doc section: :server_setting_type + @spec list_server_setting_type_keys() :: [String.t()] + defdelegate list_server_setting_type_keys(), to: ServerSettingTypeLib + + @doc section: :server_setting_type + @spec get_server_setting_type(String.t()) :: ServerSettingType.t() | nil + defdelegate get_server_setting_type(key), to: ServerSettingTypeLib + + @doc section: :server_setting_type + @spec add_server_setting_type(map()) :: {:ok, ServerSettingType.t()} | {:error, String.t()} + defdelegate add_server_setting_type(args), to: ServerSettingTypeLib + # ServerSettings alias Teiserver.Settings.{ServerSetting, ServerSettingLib, ServerSettingQueries} @doc false - @spec server_setting_query(list) :: Ecto.Query.t() + @spec server_setting_query(Teiserver.query_args()) :: Ecto.Query.t() defdelegate server_setting_query(args), to: ServerSettingQueries @doc section: :server_setting - @spec list_server_settings() :: [ServerSetting.t()] - defdelegate list_server_settings(), to: ServerSettingLib - - @doc section: :server_setting - @spec list_server_settings(list) :: [ServerSetting.t()] + @spec list_server_settings(Teiserver.query_args()) :: [ServerSetting.t()] defdelegate list_server_settings(args), to: ServerSettingLib @doc section: :server_setting - @spec get_server_setting!(non_neg_integer(), list) :: ServerSetting.t() + @spec get_server_setting!(ServerSetting.key()) :: ServerSetting.t() + @spec get_server_setting!(ServerSetting.key(), Teiserver.query_args()) :: ServerSetting.t() defdelegate get_server_setting!(server_setting_id, query_args \\ []), to: ServerSettingLib @doc section: :server_setting - @spec get_server_setting(String.t(), list) :: ServerSetting.t() | nil - defdelegate get_server_setting(key, query_args \\ []), to: ServerSettingLib + @spec get_server_setting(ServerSetting.key()) :: ServerSetting.t() | nil + @spec get_server_setting(ServerSetting.key(), Teiserver.query_args()) :: ServerSetting.t() | nil + defdelegate get_server_setting(server_setting_id, query_args \\ []), to: ServerSettingLib @doc section: :server_setting @spec create_server_setting(map) :: {:ok, ServerSetting.t()} | {:error, Ecto.Changeset.t()} - defdelegate create_server_setting(attrs \\ %{}), to: ServerSettingLib + defdelegate create_server_setting(attrs), to: ServerSettingLib @doc section: :server_setting @spec update_server_setting(ServerSetting, map) :: @@ -43,38 +62,63 @@ defmodule Teiserver.Settings do defdelegate delete_server_setting(server_setting), to: ServerSettingLib @doc section: :server_setting + @spec change_server_setting(ServerSetting.t()) :: Ecto.Changeset.t() @spec change_server_setting(ServerSetting.t(), map) :: Ecto.Changeset.t() defdelegate change_server_setting(server_setting, attrs \\ %{}), to: ServerSettingLib + @doc section: :server_setting + @spec get_server_setting_value(String.t()) :: String.t() | integer() | boolean() | nil + defdelegate get_server_setting_value(key), to: ServerSettingLib + + @doc section: :server_setting + @spec set_server_setting_value(String.t(), String.t() | non_neg_integer() | boolean() | nil) :: + :ok + defdelegate set_server_setting_value(key, value), to: ServerSettingLib + + # UserSettingType + alias Teiserver.Settings.{UserSettingType, UserSettingTypeLib} + + @doc section: :user_setting_type + @spec list_user_setting_types([String.t()]) :: [UserSettingType.t()] + defdelegate list_user_setting_types(keys), to: UserSettingTypeLib + + @doc section: :user_setting_type + @spec list_user_setting_type_keys() :: [String.t()] + defdelegate list_user_setting_type_keys(), to: UserSettingTypeLib + + @doc section: :user_setting_type + @spec get_user_setting_type(String.t()) :: UserSettingType.t() | nil + defdelegate get_user_setting_type(key), to: UserSettingTypeLib + + @doc section: :user_setting_type + @spec add_user_setting_type(map()) :: {:ok, UserSettingType.t()} | {:error, String.t()} + defdelegate add_user_setting_type(args), to: UserSettingTypeLib + # UserSettings alias Teiserver.Settings.{UserSetting, UserSettingLib, UserSettingQueries} @doc false - @spec user_setting_query(list) :: Ecto.Query.t() + @spec user_setting_query(Teiserver.query_args()) :: Ecto.Query.t() defdelegate user_setting_query(args), to: UserSettingQueries @doc section: :user_setting - @spec list_user_settings() :: [UserSetting.t()] - defdelegate list_user_settings(), to: UserSettingLib - - @doc section: :user_setting - @spec list_user_settings(list) :: [UserSetting.t()] + @spec list_user_settings(Teiserver.query_args()) :: [UserSetting.t()] defdelegate list_user_settings(args), to: UserSettingLib @doc section: :user_setting - @spec get_user_setting!(non_neg_integer(), list) :: UserSetting.t() - defdelegate get_user_setting!(user_setting_id, query_args \\ []), to: UserSettingLib + @spec get_user_setting!(Teiserver.user_id(), UserSetting.key()) :: UserSetting.t() + defdelegate get_user_setting!(user_id, key), to: UserSettingLib @doc section: :user_setting - @spec get_user_setting(non_neg_integer(), list) :: UserSetting.t() | nil - defdelegate get_user_setting(user_setting_id, query_args \\ []), to: UserSettingLib + @spec get_user_setting(Teiserver.user_id(), UserSetting.key()) :: UserSetting.t() | nil + defdelegate get_user_setting(user_id, key), to: UserSettingLib @doc section: :user_setting @spec create_user_setting(map) :: {:ok, UserSetting.t()} | {:error, Ecto.Changeset.t()} - defdelegate create_user_setting(attrs \\ %{}), to: UserSettingLib + defdelegate create_user_setting(attrs), to: UserSettingLib @doc section: :user_setting - @spec update_user_setting(UserSetting, map) :: + @spec update_user_setting(UserSetting.t(), map) :: {:ok, UserSetting.t()} | {:error, Ecto.Changeset.t()} defdelegate update_user_setting(user_setting, attrs), to: UserSettingLib @@ -84,6 +128,20 @@ defmodule Teiserver.Settings do defdelegate delete_user_setting(user_setting), to: UserSettingLib @doc section: :user_setting + @spec change_user_setting(UserSetting.t()) :: Ecto.Changeset.t() @spec change_user_setting(UserSetting.t(), map) :: Ecto.Changeset.t() defdelegate change_user_setting(user_setting, attrs \\ %{}), to: UserSettingLib + + @doc section: :user_setting + @spec get_user_setting_value(Teiserver.user_id(), String.t()) :: + String.t() | integer() | boolean() | nil + defdelegate get_user_setting_value(user_id, key), to: UserSettingLib + + @doc section: :user_setting + @spec set_user_setting_value( + Teiserver.user_id(), + String.t(), + String.t() | non_neg_integer() | boolean() | nil + ) :: :ok + defdelegate set_user_setting_value(user_id, key, value), to: UserSettingLib end diff --git a/lib/teiserver/contexts/telemetry.ex b/lib/teiserver/contexts/telemetry.ex index 0fee87421..391684465 100644 --- a/lib/teiserver/contexts/telemetry.ex +++ b/lib/teiserver/contexts/telemetry.ex @@ -1,23 +1,209 @@ defmodule Teiserver.Telemetry do - @moduledoc """ - The contextual module for: - - `Teiserver.Telemetry.SimpleGameEvent` - - `Teiserver.Telemetry.SimpleServerEvent` - - `Teiserver.Telemetry.SimpleLobbyEvent` - - `Teiserver.Telemetry.SimpleUserEvent` - - `Teiserver.Telemetry.SimpleClientEvent` - - `Teiserver.Telemetry.SimpleAnonEvent` - - - `Teiserver.Telemetry.ComplexGameEvent` - - `Teiserver.Telemetry.ComplexServerEvent` - - `Teiserver.Telemetry.ComplexLobbyEvent` - - `Teiserver.Telemetry.ComplexUserEvent` - - `Teiserver.Telemetry.ComplexClientEvent` - - `Teiserver.Telemetry.ComplexAnonEvent` + require Logger + + @handler_id "teiserver-default-logger" + + @doc """ + Attaches a default structured JSON Telemetry handler for logging. + + This function attaches a handler that outputs logs with the following fields for job events: + + * `args` — a map of the job's raw arguments + * `attempt` — the job's execution atttempt + * `duration` — the job's runtime duration, in the native time unit + * `event` — `job:start`, `job:stop`, `job:exception` depending on reporting telemetry event + * `error` — a formatted error banner, without the extended stacktrace + * `id` — the job's id + * `meta` — a map of the job's raw metadata + * `queue` — the job's queue + * `source` — always "teiserver" + * `state` — the execution state, one of "success", "failure", "cancelled", "discard", or + "snoozed" + * `system_time` — when the job started, in microseconds + * `tags` — the job's tags + * `worker` — the job's worker module + + And the following fields for stager events: + + * `event` — always `stager:switch` + * `message` — information about the mode switch + * `mode` — either `"local"` or `"global"` + * `source` — always "teiserver" + + ## Options + + * `:level` — The log level to use for logging output, defaults to `:info` + * `:encode` — Whether to encode log output as JSON, defaults to `true` + + ## Examples + + Attach a logger at the default `:info` level with JSON encoding: + + :ok = Teiserver.Telemetry.attach_default_logger() + + Attach a logger at the `:debug` level: + + :ok = Teiserver.Telemetry.attach_default_logger(level: :debug) + + Attach a logger with JSON logging disabled: + + :ok = Teiserver.Telemetry.attach_default_logger(encode: false) """ + @spec attach_default_logger(Logger.level() | Keyword.t()) :: :ok | {:error, :already_exists} + def attach_default_logger(opts \\ [encode: true, level: :info]) + + def attach_default_logger(level) when is_atom(level) do + attach_default_logger(level: level) + end + + def attach_default_logger(opts) when is_list(opts) do + events = event_list() + + opts = + opts + |> Keyword.put_new(:encode, true) + |> Keyword.put_new(:level, :info) + + :telemetry.attach_many(@handler_id, events, &__MODULE__.handle_event/4, opts) + end + + @spec event_list() :: list(list(atom)) + def event_list() do + [ + # User + [:teiserver, :user, :failed_login], + + # Client + [:teiserver, :client, :connect], + [:teiserver, :client, :disconnect], + [:teiserver, :client, :updated], + [:teiserver, :client, :updated_in_lobby], + + # Lobby + [:teiserver, :lobby, :start_match], + [:teiserver, :lobby, :cycle], + [:teiserver, :lobby, :add_client], + [:teiserver, :lobby, :remove_client], + + # Logging + [:teiserver, :logging, :add_audit_log] + ] + end + + @doc """ + Undoes `Teiserver.Telemetry.attach_default_logger/1` by detaching the attached logger. + + ## Examples + + Detach a previously attached logger: + + :ok = Teiserver.Telemetry.attach_default_logger() + :ok = Teiserver.Telemetry.detach_default_logger() + + Attempt to detach when a logger wasn't attached: + + {:error, :not_found} = Teiserver.Telemetry.detach_default_logger() + """ + @doc since: "2.15.0" + @spec detach_default_logger() :: :ok | {:error, :not_found} + def detach_default_logger do + :telemetry.detach(@handler_id) + end + + @doc false + @spec handle_event([atom()], map(), map(), Keyword.t()) :: :ok + # def handle_event([:teiserver, :job, event], measure, meta, opts) do + # log(opts, fn -> + # details = Map.take(meta.job, ~w(attempt args id max_attempts meta queue tags worker)a) + + # extra = + # case event do + # :start -> + # %{event: "job:start", system_time: measure.system_time} + + # :stop -> + # %{ + # duration: convert(measure.duration), + # event: "job:stop", + # queue_time: convert(measure.queue_time), + # state: meta.state + # } + + # :exception -> + # %{ + # error: Exception.format_banner(meta.kind, meta.reason, meta.stacktrace), + # event: "job:exception", + # duration: convert(measure.duration), + # queue_time: convert(measure.queue_time), + # state: meta.state + # } + # end + + # Map.merge(details, extra) + # end) + # end + + # def handle_event([:teiserver, :stager, :switch], _measure, %{mode: mode}, opts) do + # log(opts, fn -> + # case mode do + # :local -> + # %{ + # event: "stager:switch", + # mode: "local", + # message: + # "job staging switched to local mode. local mode polls for jobs for every queue; " <> + # "restore global mode with a functional notifier" + # } + + # :global -> + # %{ + # event: "stager:switch", + # mode: "global", + # message: "job staging switched back to global mode" + # } + # end + # end) + # end + + def handle_event([:teiserver, a, b], measure, meta, opts) do + IO.puts("#{__MODULE__}:#{__ENV__.line}") + IO.inspect({{a, b}, measure, meta, opts}) + IO.puts("") + + # log(opts, fn -> + # case mode do + # :local -> + # %{ + # event: "stager:switch", + # mode: "local", + # message: + # "job staging switched to local mode. local mode polls for jobs for every queue; " <> + # "restore global mode with a functional notifier" + # } + + # :global -> + # %{ + # event: "stager:switch", + # mode: "global", + # message: "job staging switched back to global mode" + # } + # end + # end) + end + + # defp log(opts, fun) do + # level = Keyword.fetch!(opts, :level) + + # Logger.log(level, fn -> + # output = Map.put(fun.(), :source, "teiserver") + + # if Keyword.fetch!(opts, :encode) do + # Jason.encode_to_iodata!(output) + # else + # output + # end + # end) + # end - # MatchEvents - # ServerEvents - # LobbyEvents - # Client or User Events + # defp convert(value), do: System.convert_time_unit(value, :native, :microsecond) end diff --git a/lib/teiserver/game/libs/lobby_lib.ex b/lib/teiserver/game/libs/lobby_lib.ex index 481c9e35f..559a395a9 100644 --- a/lib/teiserver/game/libs/lobby_lib.ex +++ b/lib/teiserver/game/libs/lobby_lib.ex @@ -11,21 +11,21 @@ defmodule Teiserver.Game.LobbyLib do def lobby_topic(lobby_id), do: "Teiserver.Game.Lobby:#{lobby_id}" @doc """ - Subscribes the process to lobby updates for this user + Subscribes the process to lobby updates for this lobby """ - @spec subscribe_to_lobby(User.id() | User.t() | Client.t()) :: :ok - def subscribe_to_lobby(lobby_or_lobby_id) do - lobby_or_lobby_id + @spec subscribe_to_lobby(Lobby.id()) :: :ok + def subscribe_to_lobby(lobby_id) do + lobby_id |> lobby_topic() |> Teiserver.subscribe() end @doc """ - Unsubscribes the process to lobby updates for this user + Unsubscribes the process to lobby updates for this lobby """ - @spec unsubscribe_from_lobby(User.id() | User.t() | Client.t()) :: :ok - def unsubscribe_from_lobby(lobby_or_lobby_id) do - lobby_or_lobby_id + @spec unsubscribe_from_lobby(Lobby.id()) :: :ok + def unsubscribe_from_lobby(lobby_id) do + lobby_id |> lobby_topic() |> Teiserver.unsubscribe() end @@ -83,26 +83,35 @@ defmodule Teiserver.Game.LobbyLib do end @doc """ - + Returns a stream of lobby summaries based on the filters; the filters are supplied as a string keyed map. + + "match_ongoing?" => boolean + "require_any_tags" => [String] + "require_all_tags" => [String] + "exclude_tags" => [String] + "passworded?" => boolean + "locked?" => boolean + "public?" => boolean + TODO: "match_type" => Not implemented yet + "rated?" => boolean + "game_version" => string, equality match + "game_name" => string, equality match + "min_player_count" => integer (inclusive) + "max_player_count" => integer (inclusive) """ - @spec stream_lobby_summaries() :: Enumerable.t(LobbySummary.t()) - def stream_lobby_summaries() do - list_lobby_ids() - |> Stream.map(&get_lobby_summary/1) - |> Stream.reject(&(&1 == nil)) - end - @spec stream_lobby_summaries(map) :: Enumerable.t(LobbySummary.t()) - def stream_lobby_summaries(filters) do + def stream_lobby_summaries(filters \\ %{}) do ids = filters["ids"] || list_lobby_ids() ids |> Stream.map(&get_lobby_summary/1) + |> Stream.reject(&(&1 == nil)) |> Stream.filter(fn l -> include_lobby?(l, filters) end) end @spec include_lobby?(LobbySummary.t(), map()) :: boolean defp include_lobby?(nil, _), do: false + defp include_lobby?(lobby, filters) do [ test_match_ongoing?(filters["match_ongoing?"], lobby), @@ -119,7 +128,7 @@ defmodule Teiserver.Game.LobbyLib do test_min_player_count(filters["min_player_count"], lobby), test_max_player_count(filters["max_player_count"], lobby) ] - |> Enum.all? + |> Enum.all?() end @spec test_match_ongoing?(nil | boolean, LobbySummary.t()) :: boolean @@ -127,6 +136,7 @@ defmodule Teiserver.Game.LobbyLib do defp test_match_ongoing?(value, lobby), do: lobby.match_ongoing? == value defp test_require_any_tags(nil, _), do: true + defp test_require_any_tags(tags, lobby) do tags |> Enum.any?(fn tag -> @@ -135,6 +145,7 @@ defmodule Teiserver.Game.LobbyLib do end defp test_require_all_tags(nil, _), do: true + defp test_require_all_tags(tags, lobby) do tags |> Enum.all?(fn tag -> @@ -143,6 +154,7 @@ defmodule Teiserver.Game.LobbyLib do end defp test_exclude_tags(nil, _), do: true + defp test_exclude_tags(tags, lobby) do tags |> Enum.all?(fn tag -> @@ -179,7 +191,6 @@ defmodule Teiserver.Game.LobbyLib do defp test_max_player_count(nil, _), do: true defp test_max_player_count(value, lobby), do: lobby.player_count <= value - @doc """ Given a user_id of the host and the initial lobby name, starts a process for tracking the lobby. @@ -192,44 +203,54 @@ defmodule Teiserver.Game.LobbyLib do {:ok, 456} iex> open_lobby(456, "Name") - {:error, "Client is not connected"} + {:error, :client_disconnected} """ - @spec open_lobby(Teiserver.user_id(), Lobby.name()) :: {:ok, Lobby.id()} | {:error, String.t()} + @spec open_lobby(Teiserver.user_id(), Lobby.name()) :: + {:ok, Lobby.id()} | {:error, :client_disconnected, :already_in_lobby, :no_name} def open_lobby(host_id, name) when is_binary(host_id) do client = Connections.get_client(host_id) cond do client == nil -> - {:error, "Client is not connected"} + {:error, :client_disconnected} client.connected? == false -> - {:error, "Client is disconnected"} + {:error, :client_disconnected} client.lobby_id != nil -> - {:error, "Already in a lobby"} + {:error, :already_in_lobby} name == nil -> - {:error, "No name supplied"} + {:error, :no_name} String.trim(name) == "" -> - {:error, "No name supplied"} + {:error, :no_name} # All checks are good, lets try to create the lobby! true -> with {:ok, lobby} <- start_lobby_server(host_id, name), :ok <- cycle_lobby(lobby.id), - _ <- ClientLib.update_client_full(host_id, %{lobby_id: lobby.id, lobby_host?: true}, "opened lobby") do + _ <- + ClientLib.update_client( + host_id, + %{lobby_id: lobby.id, lobby_host?: true}, + "opened lobby" + ) do {:ok, lobby.id} else - :failure1 -> :fail_result1 - :failure2 -> :fail_result2 - :failure3 -> :fail_result3 + {:error, reason} -> + {:error, reason} + + nil -> + {:error, + "Unable to cycle lobby, cycle_lobby returned nil indicating the process does not exist"} end end end @doc """ - Used to cycle a lobby after a match has concluded. + Used to cycle a lobby to a new match; typically at the end of the match you would call `lobby_end_match/1`, this function allows you to cycle a lobby for a different reason + should you need to. ## Examples @@ -241,18 +262,7 @@ defmodule Teiserver.Game.LobbyLib do """ @spec cycle_lobby(Lobby.id()) :: :ok def cycle_lobby(lobby_id) when is_binary(lobby_id) do - host_id = get_lobby_attribute(lobby_id, :host_id) - - {:ok, match} = - Teiserver.Game.create_match(%{ - public?: true, - rated?: true, - host_id: host_id, - processed?: false, - lobby_opened_at: Timex.now() - }) - - cast_lobby(lobby_id, {:cycle_lobby, match.id}) + cast_lobby(lobby_id, :cycle_lobby) end @doc """ @@ -271,6 +281,23 @@ defmodule Teiserver.Game.LobbyLib do cast_lobby(lobby_id, :lobby_start_match) end + @doc """ + Used to tell a lobby process the current match has stopped. Optionally provide a reason as to why + + ## Examples + + iex> lobby_end_match(123, "reason") + :ok + + iex> lobby_end_match(456, "reason") + nil + """ + @spec lobby_end_match(Lobby.id()) :: :ok + @spec lobby_end_match(Lobby.id(), String.t()) :: :ok + def lobby_end_match(lobby_id, reason \\ "normal") when is_binary(lobby_id) do + cast_lobby(lobby_id, {:lobby_end_match, reason}) + end + @doc """ Used to tell a lobby process the current match has started @@ -282,9 +309,9 @@ defmodule Teiserver.Game.LobbyLib do iex> client_update_request(%{team_number: 1, id: 456}, 456) nil """ - @spec client_update_request(map(), Lobby.id()) :: map() - def client_update_request(changes, lobby_id) when is_binary(lobby_id) do - call_lobby(lobby_id, {:client_update_request, changes}) + @spec client_update_request(Lobby.id(), Client.t(), map(), String.t()) :: map() + def client_update_request(lobby_id, new_client, diffs, reason) when is_binary(lobby_id) do + cast_lobby(lobby_id, {:client_update_request, new_client, diffs, reason}) end @doc """ @@ -303,10 +330,10 @@ defmodule Teiserver.Game.LobbyLib do if lobby do lobby.members |> Enum.each(fn user_id -> - ClientLib.update_client_full(user_id, %{lobby_id: nil, lobby_host?: false}, "lobby closed") + ClientLib.update_client(user_id, %{lobby_id: nil, lobby_host?: false}, "lobby closed") end) - ClientLib.update_client_full(lobby.host_id, %{lobby_id: nil, lobby_host?: false}, "closed lobby") + ClientLib.update_client(lobby.host_id, %{lobby_id: nil, lobby_host?: false}, "closed lobby") end stop_lobby_server(lobby_id) @@ -315,12 +342,19 @@ defmodule Teiserver.Game.LobbyLib do @doc """ Adds a client to the lobby """ - @spec can_add_client_to_lobby(Teiserver.user_id(), Lobby.id()) :: {boolean(), String.t() | nil} - @spec can_add_client_to_lobby(Teiserver.user_id(), Lobby.id(), String.t()) :: {boolean(), String.t() | nil} + @spec can_add_client_to_lobby(Teiserver.user_id(), Lobby.id(), String.t() | nil) :: + true + | {false, + :no_lobby + | :existing_member + | :client_disconnected + | :already_in_a_lobby + | :incorrect_password + | :lobby_is_locked} def can_add_client_to_lobby(user_id, lobby_id, password \\ nil) do case call_lobby(lobby_id, {:can_add_client, user_id, password}) do nil -> - {false, "No lobby"} + {false, :no_lobby} result -> result @@ -348,11 +382,13 @@ defmodule Teiserver.Game.LobbyLib do def start_lobby_server(host_id, name) do lobby = Lobby.new(host_id, name) - {:ok, _pid} = - lobby - |> do_start_lobby_server() + case do_start_lobby_server(lobby) do + {:ok, _pid} -> + {:ok, lobby} - {:ok, lobby} + v -> + v + end end # Process stuff @@ -368,7 +404,9 @@ defmodule Teiserver.Game.LobbyLib do }) end - @doc false + @doc """ + Returns a boolean regarding the existence of the lobby. + """ @spec lobby_exists?(Lobby.id()) :: boolean def lobby_exists?(lobby_id) do case Horde.Registry.lookup(Teiserver.LobbyRegistry, lobby_id) do diff --git a/lib/teiserver/game/libs/match_lib.ex b/lib/teiserver/game/libs/match_lib.ex index c83165b14..5166bda6f 100644 --- a/lib/teiserver/game/libs/match_lib.ex +++ b/lib/teiserver/game/libs/match_lib.ex @@ -1,6 +1,6 @@ defmodule Teiserver.Game.MatchLib do @moduledoc """ - TODO: Library of match related functions. + Library of match related functions. """ use TeiserverMacros, :library alias Teiserver.Game.{Match, MatchQueries, Lobby, MatchTypeLib} @@ -10,17 +10,35 @@ defmodule Teiserver.Game.MatchLib do Given a lobby_id, will update the match, memberships and settings for that lobby and then update the Lobby itself to show the lobby is now in progress. """ - @spec start_match(Lobby.id()) :: Match.t() + @spec start_match(Lobby.id()) :: + {:ok, Match.t()} | {:error, :no_players, :match_already_started} def start_match(lobby_id) do lobby = Game.get_lobby(lobby_id) - match_id = lobby.match_id - type_id = MatchTypeLib.calculate_match_type(lobby).id + players = + lobby.members + |> Connections.get_client_list() + |> Enum.filter(fn c -> c.player? end) - clients = Connections.get_client_list(lobby.members) + cond do + lobby.match_ongoing? -> + {:error, :match_already_started} + + Enum.empty?(players) -> + {:error, :no_players} + + true -> + {:ok, do_start_match(lobby, players)} + end + end + + @spec do_start_match(Lobby.t(), [Teiserver.Connections.Client.t()]) :: Match.t() + defp do_start_match(lobby, players) do + match_id = lobby.match_id + type_id = MatchTypeLib.calculate_match_type(lobby).id teams = - clients + players |> Enum.group_by(fn c -> c.team_number end) team_count = @@ -45,13 +63,14 @@ defmodule Teiserver.Game.MatchLib do game_version: lobby.game_version, team_count: team_count, team_size: team_size, - match_started_at: Timex.now(), + match_started_at: DateTime.utc_now(), + player_count: Enum.count(players), type_id: type_id }) # Do members {:ok, _memberships} = - clients + players |> Enum.map(fn client -> %{ user_id: client.id, @@ -66,13 +85,13 @@ defmodule Teiserver.Game.MatchLib do {:ok, _settings} = lobby.game_settings |> Enum.map(fn {key, value} -> - type_id = Game.get_or_create_match_setting_type(key) + type_id = Game.get_or_create_match_setting_type_id(key) %{type_id: type_id, match_id: match.id, value: value} end) |> Game.create_many_match_settings() # Tell the lobby server the match is starting - Game.lobby_start_match(lobby_id) + Game.lobby_start_match(lobby.id) # Finally return the match itself match @@ -81,52 +100,61 @@ defmodule Teiserver.Game.MatchLib do @doc """ Ends an ongoing match and updates memberships accordingly. - Second argument is a map of the outcomes for the match with the following keys: - * `:winning_team` - The team_number of the winning team - * `:ended_normally?` - A boolean indicating if the match ended normally - * `:players` - A map of player data with the keys being the user_id of the player and the value being a map of their specific outcome + Second argument is a (string keyed) map of the outcomes for the match with the following keys: + * `winning_team` - The team_number of the winning team + * `ended_normally?` - A boolean indicating if the match ended normally + * `players` - A map of player data with the keys being the user_id of the player and the value being a map of their specific outcome Player outcomes are expected to map like so: - Required: + Required Optional: - * `:left_after_seconds` - The number of seconds after the start the player left if early. If not included it is assumed the player remained until the end. + * `left_after_seconds` - The number of seconds after the start the player left if early. If not included it is assumed the player remained until the end. ## Examples - iex> end_match(123, %{winning_team: 1, ended_normally?: true, player_data: %{123: }}) - %Match{} - + iex> end_match(123, %{ + "winning_team" => 1, "ended_normally?" => true, "players" => %{ + "c3bf3539-fcf2-4409-ad68-1a5994aaa10f": %{"left_after_seconds": 123}, + "13739a7f-f39d-47a5-85cc-73652372dad0": %{}, + } + }) + %Match{} """ @spec end_match(Match.id(), map()) :: Match.t() def end_match(match_id, outcome) when is_binary(match_id) do match = get_match!(match_id) - now = Timex.now() + now = DateTime.utc_now() - duration_seconds = Timex.diff(match.match_started_at, now, :second) + duration_seconds = DateTime.diff(match.match_started_at, now, :second) {:ok, updated_match} = update_match(match, %{ - winning_team: outcome.winning_team, - ended_normally?: outcome.ended_normally?, + winning_team: outcome["winning_team"], + ended_normally?: outcome["ended_normally?"], match_ended_at: now, match_duration_seconds: duration_seconds }) Game.list_match_memberships(where: [match_id: match.id]) |> Enum.each(fn mm -> - player_outcome = outcome.players[mm.user_id] + # If no player data is included we don't want to break anything! + player_outcome = Map.get(outcome["players"], mm.user_id, %{}) - win? = mm.team_number == outcome.winning_team + win? = mm.team_number == outcome["winning_team"] attrs = %{ win?: win?, - left_after_seconds: Map.get(player_outcome, :left_after_seconds) + left_after_seconds: Map.get(player_outcome, "left_after_seconds") } Game.update_match_membership(mm, attrs) end) + # Tell the lobby server the match has ended + ending_reason = if outcome["ended_normally?"], do: "normal", else: "abnormal" + Game.lobby_end_match(match.lobby_id, ending_reason) + # Finally return the updated match updated_match end diff --git a/lib/teiserver/game/libs/match_setting_type_lib.ex b/lib/teiserver/game/libs/match_setting_type_lib.ex index a9544e68d..7afc9f4bd 100644 --- a/lib/teiserver/game/libs/match_setting_type_lib.ex +++ b/lib/teiserver/game/libs/match_setting_type_lib.ex @@ -93,15 +93,28 @@ defmodule Teiserver.Game.MatchSettingTypeLib do ## Examples - iex> get_or_create_match_setting_type("existing") + iex> get_or_create_match_setting_type_id("existing") 123 - iex> get_or_create_match_setting_type("non-existing") + iex> get_or_create_match_setting_type_id("non-existing") 1234 """ - @spec get_or_create_match_setting_type(String.t()) :: MatchSettingType.id() - def get_or_create_match_setting_type(name) do + @spec get_or_create_match_setting_type_id(String.t()) :: MatchSettingType.id() + def get_or_create_match_setting_type_id(name) do + case Cachex.get(:ts_match_setting_type_lookup, name) do + {:ok, nil} -> + result = do_get_or_create_match_setting_type_id(name) + Cachex.put(:ts_match_setting_type_lookup, name, result) + result + + {:ok, value} -> + value + end + end + + @spec do_get_or_create_match_setting_type_id(String.t()) :: MatchSettingType.id() + def do_get_or_create_match_setting_type_id(name) do name = String.trim(name) query = diff --git a/lib/teiserver/game/libs/match_type_lib.ex b/lib/teiserver/game/libs/match_type_lib.ex index 89cdfdb0a..be2506acb 100644 --- a/lib/teiserver/game/libs/match_type_lib.ex +++ b/lib/teiserver/game/libs/match_type_lib.ex @@ -27,7 +27,7 @@ defmodule Teiserver.Game.MatchTypeLib do Can be over-ridden using the config [fn_calculate_match_type](config.html#fn_calculate_match_type) """ @spec default_calculate_match_type(Lobby.t()) :: String.t() - def default_calculate_match_type(lobby) do + def default_calculate_match_type(%Lobby{} = lobby) do if Enum.count(lobby.members) == 2 do "Duel" else @@ -158,6 +158,19 @@ defmodule Teiserver.Game.MatchTypeLib do """ @spec get_or_create_match_type(String.t()) :: MatchType.t() def get_or_create_match_type(match_type_name) do + case Cachex.get(:ts_match_type_lookup, match_type_name) do + {:ok, nil} -> + result = do_get_or_create_match_type(match_type_name) + Cachex.put(:ts_match_type_lookup, match_type_name, result) + result + + {:ok, value} -> + value + end + end + + @spec do_get_or_create_match_type(String.t()) :: MatchType.t() + defp do_get_or_create_match_type(match_type_name) do case get_match_type_by_name_or_id(match_type_name) do nil -> {:ok, match_type} = create_match_type(%{name: match_type_name}) diff --git a/lib/teiserver/game/libs/user_choice_lib.ex b/lib/teiserver/game/libs/user_choice_lib.ex new file mode 100644 index 000000000..0dc625d7b --- /dev/null +++ b/lib/teiserver/game/libs/user_choice_lib.ex @@ -0,0 +1,191 @@ +defmodule Teiserver.Game.UserChoiceLib do + @moduledoc """ + TODO: Library of user_choice related functions. + """ + use TeiserverMacros, :library + alias Teiserver.Game.{UserChoice, UserChoiceQueries, UserChoiceType} + + @doc """ + Returns the list of user_choices. + + ## Examples + + iex> list_user_choices() + [%UserChoice{}, ...] + + """ + @spec list_user_choices(Teiserver.query_args()) :: [UserChoice.t()] + def list_user_choices(query_args) do + query_args + |> UserChoiceQueries.user_choice_query() + |> Repo.all() + end + + @doc """ + Returns a key => value map of match choices for a given match_id. + + ## Examples + + iex> get_user_choices_map(123) + %{"key1" => "value1", "key2" => "value2"} + + iex> get_user_choices_map(456) + %{} + + """ + @spec get_user_choices_map(Teiserver.match_id(), Teiserver.user_id()) :: %{ + String.t() => String.t() + } + def get_user_choices_map(match_id, user_id) do + list_user_choices(where: [match_id: match_id, user_id: user_id], preload: [:type]) + |> Map.new(fn ms -> + {ms.type.name, ms.value} + end) + end + + @doc """ + Gets a single user_choice. + + Raises `Ecto.NoResultsError` if the UserChoice does not exist. + + ## Examples + + iex> get_user_choice!("29a42cef-2239-4a1d-8359-947310647a3b", "74e27850-f16f-4981-9077-ea0ddd4a0d8a", 123) + %UserChoice{} + + iex> get_user_choice!(456) + ** (Ecto.NoResultsError) + + """ + @spec get_user_choice!( + Teiserver.match_id(), + Teiserver.user_id(), + UserChoiceType.id(), + Teiserver.query_args() + ) :: + UserChoice.t() + def get_user_choice!(match_id, user_id, choice_type_id, query_args \\ []) do + (query_args ++ [match_id: match_id, user_id: user_id, choice_type_id: choice_type_id]) + |> UserChoiceQueries.user_choice_query() + |> Repo.one!() + end + + @doc """ + Gets a single user_choice. + + Returns nil if the UserChoice does not exist. + + ## Examples + + iex> get_user_choice(123) + %UserChoice{} + + iex> get_user_choice(456) + nil + + """ + @spec get_user_choice( + Teiserver.match_id(), + Teiserver.user_id(), + UserChoiceType.id(), + Teiserver.query_args() + ) :: + UserChoice.t() | nil + def get_user_choice(match_id, user_id, choice_type_id, query_args \\ []) do + (query_args ++ [match_id: match_id, user_id: user_id, choice_type_id: choice_type_id]) + |> UserChoiceQueries.user_choice_query() + |> Repo.one() + end + + @doc """ + Creates a user_choice. + + ## Examples + + iex> create_user_choice(%{field: value}) + {:ok, %UserChoice{}} + + iex> create_user_choice(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + @spec create_user_choice(map) :: {:ok, UserChoice.t()} | {:error, Ecto.Changeset.t()} + def create_user_choice(attrs) do + %UserChoice{} + |> UserChoice.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Creates many user_choices. Not unlike most other create functions this will raise an exception on failure and should not be caught using the normal case functions. + + Expects a map of values which can be turned into valid match choices. + + ## Examples + + iex> create_many_user_choices([%{field: value}]) + {:ok, %UserChoice{}} + + iex> create_many_user_choices([%{field: bad_value}]) + raise Postgrex.Error + + """ + @spec create_many_user_choices([map]) :: {:ok, map} + def create_many_user_choices(attr_list) do + Ecto.Multi.new() + |> Ecto.Multi.insert_all(:insert_all, UserChoice, attr_list) + |> Teiserver.Repo.transaction() + end + + @doc """ + Updates a user_choice. + + ## Examples + + iex> update_user_choice(user_choice, %{field: new_value}) + {:ok, %UserChoice{}} + + iex> update_user_choice(user_choice, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + @spec update_user_choice(UserChoice.t(), map) :: + {:ok, UserChoice.t()} | {:error, Ecto.Changeset.t()} + def update_user_choice(%UserChoice{} = user_choice, attrs) do + user_choice + |> UserChoice.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a user_choice. + + ## Examples + + iex> delete_user_choice(user_choice) + {:ok, %UserChoice{}} + + iex> delete_user_choice(user_choice) + {:error, %Ecto.Changeset{}} + + """ + @spec delete_user_choice(UserChoice.t()) :: + {:ok, UserChoice.t()} | {:error, Ecto.Changeset.t()} + def delete_user_choice(%UserChoice{} = user_choice) do + Repo.delete(user_choice) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user_choice changes. + + ## Examples + + iex> change_user_choice(user_choice) + %Ecto.Changeset{data: %UserChoice{}} + + """ + @spec change_user_choice(UserChoice.t(), map) :: Ecto.Changeset.t() + def change_user_choice(%UserChoice{} = user_choice, attrs \\ %{}) do + UserChoice.changeset(user_choice, attrs) + end +end diff --git a/lib/teiserver/game/libs/user_choice_type_lib.ex b/lib/teiserver/game/libs/user_choice_type_lib.ex new file mode 100644 index 000000000..3e7dde92c --- /dev/null +++ b/lib/teiserver/game/libs/user_choice_type_lib.ex @@ -0,0 +1,190 @@ +defmodule Teiserver.Game.UserChoiceTypeLib do + @moduledoc """ + TODO: Library of user_choice_type related functions. + """ + use TeiserverMacros, :library + alias Teiserver.Game.{UserChoiceType, UserChoiceTypeQueries} + + @doc """ + Returns the list of user_choice_types. + + ## Examples + + iex> list_user_choice_types() + [%UserChoiceType{}, ...] + + """ + @spec list_user_choice_types(Teiserver.query_args()) :: [UserChoiceType.t()] + def list_user_choice_types(query_args) do + query_args + |> UserChoiceTypeQueries.user_choice_type_query() + |> Repo.all() + end + + @doc """ + Gets a single user_choice_type. + + Raises `Ecto.NoResultsError` if the UserChoiceType does not exist. + + ## Examples + + iex> get_user_choice_type!(123) + %UserChoiceType{} + + iex> get_user_choice_type!(456) + ** (Ecto.NoResultsError) + + """ + @spec get_user_choice_type!(UserChoiceType.id(), Teiserver.query_args()) :: + UserChoiceType.t() + def get_user_choice_type!(user_choice_type_id, query_args \\ []) do + (query_args ++ [id: user_choice_type_id]) + |> UserChoiceTypeQueries.user_choice_type_query() + |> Repo.one!() + end + + @doc """ + Gets a single user_choice_type. + + Returns nil if the UserChoiceType does not exist. + + ## Examples + + iex> get_user_choice_type(123) + %UserChoiceType{} + + iex> get_user_choice_type(456) + nil + + """ + @spec get_user_choice_type(UserChoiceType.id(), Teiserver.query_args()) :: + UserChoiceType.t() | nil + def get_user_choice_type(user_choice_type_id, query_args \\ []) do + (query_args ++ [id: user_choice_type_id]) + |> UserChoiceTypeQueries.user_choice_type_query() + |> Repo.one() + end + + @doc """ + Creates a user_choice_type. + + ## Examples + + iex> create_user_choice_type(%{field: value}) + {:ok, %UserChoiceType{}} + + iex> create_user_choice_type(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + @spec create_user_choice_type(map) :: + {:ok, UserChoiceType.t()} | {:error, Ecto.Changeset.t()} + def create_user_choice_type(attrs) do + %UserChoiceType{} + |> UserChoiceType.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets the ID of a user_choice_type, if the type doesn't exist + it will create the user_choice_type and return the id of that type + + ## Examples + + iex> get_or_create_user_choice_type_id("existing") + 123 + + iex> get_or_create_user_choice_type_id("non-existing") + 1234 + + """ + @spec get_or_create_user_choice_type_id(String.t()) :: UserChoiceType.id() + def get_or_create_user_choice_type_id(name) do + case Cachex.get(:ts_user_choice_type_lookup, name) do + {:ok, nil} -> + result = do_get_or_create_user_choice_type_id(name) + Cachex.put(:ts_user_choice_type_lookup, name, result) + result + + {:ok, value} -> + value + end + end + + @spec do_get_or_create_user_choice_type_id(String.t()) :: UserChoiceType.id() + def do_get_or_create_user_choice_type_id(name) do + name = String.trim(name) + + query = + UserChoiceTypeQueries.user_choice_type_query( + where: [name: name], + select: [:id], + order_by: ["Name (A-Z)"] + ) + + case Repo.all(query) do + [] -> + {:ok, user_choice_type} = + %UserChoiceType{} + |> UserChoiceType.changeset(%{name: name}) + |> Repo.insert() + + user_choice_type.id + + [%{id: id} | _] -> + id + end + end + + @doc """ + Updates a user_choice_type. + + ## Examples + + iex> update_user_choice_type(user_choice_type, %{field: new_value}) + {:ok, %UserChoiceType{}} + + iex> update_user_choice_type(user_choice_type, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + @spec update_user_choice_type(UserChoiceType.t(), map) :: + {:ok, UserChoiceType.t()} | {:error, Ecto.Changeset.t()} + def update_user_choice_type(%UserChoiceType{} = user_choice_type, attrs) do + user_choice_type + |> UserChoiceType.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a user_choice_type. + + ## Examples + + iex> delete_user_choice_type(user_choice_type) + {:ok, %UserChoiceType{}} + + iex> delete_user_choice_type(user_choice_type) + {:error, %Ecto.Changeset{}} + + """ + @spec delete_user_choice_type(UserChoiceType.t()) :: + {:ok, UserChoiceType.t()} | {:error, Ecto.Changeset.t()} + def delete_user_choice_type(%UserChoiceType{} = user_choice_type) do + Repo.delete(user_choice_type) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user_choice_type changes. + + ## Examples + + iex> change_user_choice_type(user_choice_type) + %Ecto.Changeset{data: %UserChoiceType{}} + + """ + @spec change_user_choice_type(UserChoiceType.t(), map) :: Ecto.Changeset.t() + def change_user_choice_type(%UserChoiceType{} = user_choice_type, attrs \\ %{}) do + UserChoiceType.changeset(user_choice_type, attrs) + end +end diff --git a/lib/teiserver/game/queries/match_membership_queries.ex b/lib/teiserver/game/queries/match_membership_queries.ex index f8ad2da6a..74f252605 100644 --- a/lib/teiserver/game/queries/match_membership_queries.ex +++ b/lib/teiserver/game/queries/match_membership_queries.ex @@ -90,7 +90,7 @@ defmodule Teiserver.Game.MatchMembershipQueries do @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> diff --git a/lib/teiserver/game/queries/match_queries.ex b/lib/teiserver/game/queries/match_queries.ex index 3f47dbd39..747624672 100644 --- a/lib/teiserver/game/queries/match_queries.ex +++ b/lib/teiserver/game/queries/match_queries.ex @@ -32,21 +32,75 @@ defmodule Teiserver.Game.MatchQueries do def _where(query, _, ""), do: query def _where(query, _, nil), do: query - def _where(query, :id, id_list) when is_list(id_list) do + def _where(query, :id, id_list) do from(matches in query, - where: matches.id in ^id_list + where: matches.id in ^List.wrap(id_list) ) end - def _where(query, :id, id) do + def _where(query, :name, name) do from(matches in query, - where: matches.id == ^id + where: matches.name == ^name ) end - def _where(query, :name, name) do + def _where(query, :ended_normally?, ended_normally?) do from(matches in query, - where: matches.name == ^name + where: matches.ended_normally? == ^ended_normally? + ) + end + + def _where(query, :processed?, processed?) do + from(matches in query, + where: matches.processed? == ^processed? + ) + end + + def _where(query, :duration_gt, seconds) do + from(matches in query, + where: matches.match_duration_seconds > ^seconds + ) + end + + def _where(query, :duration_lt, seconds) do + from(matches in query, + where: matches.match_duration_seconds < ^seconds + ) + end + + def _where(query, :started?, true) do + from(matches in query, + where: not is_nil(matches.match_started_at) + ) + end + + def _where(query, :started?, false) do + from(matches in query, + where: is_nil(matches.match_started_at) + ) + end + + def _where(query, :started_after, timestamp) do + from(matches in query, + where: matches.match_started_at >= ^timestamp + ) + end + + def _where(query, :started_before, timestamp) do + from(matches in query, + where: matches.match_started_at < ^timestamp + ) + end + + def _where(query, :ended_after, timestamp) do + from(matches in query, + where: matches.match_ended_at >= ^timestamp + ) + end + + def _where(query, :ended_before, timestamp) do + from(matches in query, + where: matches.match_ended_at < ^timestamp ) end @@ -62,10 +116,18 @@ defmodule Teiserver.Game.MatchQueries do ) end + def _where(query, :name_like, name) do + uname = "%" <> name <> "%" + + from(users in query, + where: ilike(users.name, ^uname) + ) + end + @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> @@ -101,26 +163,72 @@ defmodule Teiserver.Game.MatchQueries do @spec do_preload(Ecto.Query.t(), List.t() | nil) :: Ecto.Query.t() defp do_preload(query, nil), do: query - defp do_preload(query, _), do: query - # defp do_preload(query, preloads) do - # preloads - # |> List.wrap - # |> Enum.reduce(query, fn key, query_acc -> - # _preload(query_acc, key) - # end) - # end - - # @spec _preload(Ecto.Query.t(), any) :: Ecto.Query.t() - # def _preload(query, :relation) do - # from match in query, - # left_join: relations in assoc(match, :relation), - # preload: [relation: relations] - # end - - # def _preload(query, {:relation, join_query}) do - # from match in query, - # left_join: relations in subquery(join_query), - # on: relations.id == query.relation_id, - # preload: [relation: relations] - # end + defp do_preload(query, preloads) do + preloads + |> List.wrap() + |> Enum.reduce(query, fn key, query_acc -> + _preload(query_acc, key) + end) + end + + @spec _preload(Ecto.Query.t(), any) :: Ecto.Query.t() + def _preload(query, :host) do + from(matches in query, + left_join: hosts in assoc(matches, :host), + preload: [host: hosts] + ) + end + + def _preload(query, :type) do + from(matches in query, + left_join: types in assoc(matches, :type), + preload: [type: types] + ) + end + + def _preload(query, :members) do + from(matches in query, + left_join: members in assoc(matches, :members), + preload: [members: members] + ) + end + + def _preload(query, :members_with_users) do + from(matches in query, + left_join: memberships in assoc(matches, :members), + left_join: users in assoc(memberships, :user), + preload: [members: {memberships, user: users}] + ) + end + + def _preload(query, :settings) do + from(matches in query, + left_join: settings in assoc(matches, :settings), + preload: [settings: settings] + ) + end + + def _preload(query, :settings_with_types) do + from(matches in query, + left_join: settings in assoc(matches, :settings), + left_join: types in assoc(settings, :type), + preload: [settings: {settings, type: types}] + ) + end + + def _preload(query, :choices) do + from(matches in query, + left_join: choices in assoc(matches, :choices), + preload: [choices: choices] + ) + end + + def _preload(query, :choices_with_users_and_types) do + from(matches in query, + left_join: choices in assoc(matches, :choices), + left_join: types in assoc(choices, :type), + left_join: users in assoc(choices, :user), + preload: [choices: {choices, type: types, user: users}] + ) + end end diff --git a/lib/teiserver/game/queries/match_setting_queries.ex b/lib/teiserver/game/queries/match_setting_queries.ex index 118124595..c842f3019 100644 --- a/lib/teiserver/game/queries/match_setting_queries.ex +++ b/lib/teiserver/game/queries/match_setting_queries.ex @@ -33,45 +33,45 @@ defmodule Teiserver.Game.MatchSettingQueries do def _where(query, _, nil), do: query def _where(query, :match_id, match_ids) when is_list(match_ids) do - from(match_settingss in query, - where: match_settingss.match_id in ^match_ids + from(match_settings in query, + where: match_settings.match_id in ^match_ids ) end def _where(query, :match_id, match_id) do - from(match_settingss in query, - where: match_settingss.match_id == ^match_id + from(match_settings in query, + where: match_settings.match_id == ^match_id ) end def _where(query, :type_id, type_ids) when is_list(type_ids) do - from(match_settingss in query, - where: match_settingss.type_id in ^type_ids + from(match_settings in query, + where: match_settings.type_id in ^type_ids ) end def _where(query, :type_id, type_id) do - from(match_settingss in query, - where: match_settingss.type_id == ^type_id + from(match_settings in query, + where: match_settings.type_id == ^type_id ) end def _where(query, :value, values) when is_list(values) do - from(match_settingss in query, - where: match_settingss.value in ^values + from(match_settings in query, + where: match_settings.value in ^values ) end def _where(query, :value, value) do - from(match_settingss in query, - where: match_settingss.value == ^value + from(match_settings in query, + where: match_settings.value == ^value ) end @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> diff --git a/lib/teiserver/game/queries/match_setting_type_queries.ex b/lib/teiserver/game/queries/match_setting_type_queries.ex index dd9efe125..3b9774f85 100644 --- a/lib/teiserver/game/queries/match_setting_type_queries.ex +++ b/lib/teiserver/game/queries/match_setting_type_queries.ex @@ -59,7 +59,7 @@ defmodule Teiserver.Game.MatchSettingTypeQueries do @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> diff --git a/lib/teiserver/game/queries/match_type_queries.ex b/lib/teiserver/game/queries/match_type_queries.ex index a36d9c4c8..d7b1506e7 100644 --- a/lib/teiserver/game/queries/match_type_queries.ex +++ b/lib/teiserver/game/queries/match_type_queries.ex @@ -58,7 +58,7 @@ defmodule Teiserver.Game.MatchTypeQueries do @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> diff --git a/lib/teiserver/game/queries/user_choice_queries.ex b/lib/teiserver/game/queries/user_choice_queries.ex new file mode 100644 index 000000000..881dcb6ad --- /dev/null +++ b/lib/teiserver/game/queries/user_choice_queries.ex @@ -0,0 +1,108 @@ +defmodule Teiserver.Game.UserChoiceQueries do + @moduledoc false + use TeiserverMacros, :queries + alias Teiserver.Game.UserChoice + require Logger + + @spec user_choice_query(Teiserver.query_args()) :: Ecto.Query.t() + def user_choice_query(args) do + query = from(user_choices in UserChoice) + + query + |> do_where(id: args[:id]) + |> do_where(args[:where]) + |> do_where(args[:search]) + |> do_preload(args[:preload]) + |> do_order_by(args[:order_by]) + |> QueryHelper.query_select(args[:select]) + |> QueryHelper.limit_query(args[:limit] || 50) + end + + @spec do_where(Ecto.Query.t(), list | map | nil) :: Ecto.Query.t() + defp do_where(query, nil), do: query + + defp do_where(query, params) do + params + |> Enum.reduce(query, fn {key, value}, query_acc -> + _where(query_acc, key, value) + end) + end + + @spec _where(Ecto.Query.t(), atom(), any()) :: Ecto.Query.t() + def _where(query, _, ""), do: query + def _where(query, _, nil), do: query + + def _where(query, :match_id, match_ids) do + from(user_choices in query, + where: user_choices.match_id in ^List.wrap(match_ids) + ) + end + + def _where(query, :user_id, user_ids) do + from(user_choices in query, + where: user_choices.user_id in ^List.wrap(user_ids) + ) + end + + def _where(query, :type_id, type_ids) do + from(user_choices in query, + where: user_choices.type_id in ^List.wrap(type_ids) + ) + end + + def _where(query, :value, value) do + from(user_choices in query, + where: user_choices.value in ^List.wrap(value) + ) + end + + @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() + defp do_order_by(query, nil), do: query + + defp do_order_by(query, params) do + params + |> List.wrap() + |> Enum.reduce(query, fn key, query_acc -> + _order_by(query_acc, key) + end) + end + + @spec _order_by(Ecto.Query.t(), any()) :: Ecto.Query.t() + def _order_by(query, "Value (A-Z)") do + from(user_choices in query, + order_by: [asc: user_choices.value] + ) + end + + def _order_by(query, "Value (Z-A)") do + from(user_choices in query, + order_by: [desc: user_choices.value] + ) + end + + @spec do_preload(Ecto.Query.t(), list() | nil) :: Ecto.Query.t() + defp do_preload(query, nil), do: query + + defp do_preload(query, preloads) do + preloads + |> List.wrap() + |> Enum.reduce(query, fn key, query_acc -> + _preload(query_acc, key) + end) + end + + @spec _preload(Ecto.Query.t(), any) :: Ecto.Query.t() + def _preload(query, :type) do + from(user_choices in query, + left_join: types in assoc(user_choices, :type), + preload: [type: types] + ) + end + + def _preload(query, :match) do + from(user_choices in query, + left_join: matches in assoc(user_choices, :match), + preload: [match: matches] + ) + end +end diff --git a/lib/teiserver/game/queries/user_choice_type_queries.ex b/lib/teiserver/game/queries/user_choice_type_queries.ex new file mode 100644 index 000000000..4e6eab6fe --- /dev/null +++ b/lib/teiserver/game/queries/user_choice_type_queries.ex @@ -0,0 +1,108 @@ +defmodule Teiserver.Game.UserChoiceTypeQueries do + @moduledoc false + use TeiserverMacros, :queries + alias Teiserver.Game.UserChoiceType + require Logger + + @spec user_choice_type_query(Teiserver.query_args()) :: Ecto.Query.t() + def user_choice_type_query(args) do + query = from(user_choice_types in UserChoiceType) + + query + |> do_where(id: args[:id]) + |> do_where(args[:where]) + |> do_where(args[:search]) + |> do_preload(args[:preload]) + |> do_order_by(args[:order_by]) + |> QueryHelper.query_select(args[:select]) + |> QueryHelper.limit_query(args[:limit] || 50) + end + + @spec do_where(Ecto.Query.t(), list | map | nil) :: Ecto.Query.t() + defp do_where(query, nil), do: query + + defp do_where(query, params) do + params + |> Enum.reduce(query, fn {key, value}, query_acc -> + _where(query_acc, key, value) + end) + end + + @spec _where(Ecto.Query.t(), Atom.t(), any()) :: Ecto.Query.t() + def _where(query, _, ""), do: query + def _where(query, _, nil), do: query + + def _where(query, :id, id_list) when is_list(id_list) do + from(user_choice_types in query, + where: user_choice_types.id in ^id_list + ) + end + + def _where(query, :id, id) do + from(user_choice_types in query, + where: user_choice_types.id == ^id + ) + end + + def _where(query, :name, name_list) when is_list(name_list) do + from(user_choice_types in query, + where: user_choice_types.name in ^name_list + ) + end + + def _where(query, :name, name) do + from(user_choice_types in query, + where: user_choice_types.name == ^name + ) + end + + @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() + defp do_order_by(query, nil), do: query + + defp do_order_by(query, params) do + params + |> List.wrap() + |> Enum.reduce(query, fn key, query_acc -> + _order_by(query_acc, key) + end) + end + + @spec _order_by(Ecto.Query.t(), any()) :: Ecto.Query.t() + def _order_by(query, "Name (A-Z)") do + from(user_choice_types in query, + order_by: [asc: user_choice_types.name] + ) + end + + def _order_by(query, "Name (Z-A)") do + from(user_choice_types in query, + order_by: [desc: user_choice_types.name] + ) + end + + @spec do_preload(Ecto.Query.t(), List.t() | nil) :: Ecto.Query.t() + defp do_preload(query, nil), do: query + + defp do_preload(query, _), do: query + # defp do_preload(query, preloads) do + # preloads + # |> List.wrap + # |> Enum.reduce(query, fn key, query_acc -> + # _preload(query_acc, key) + # end) + # end + + # @spec _preload(Ecto.Query.t(), any) :: Ecto.Query.t() + # def _preload(query, :relation) do + # from user_choice_type in query, + # left_join: relations in assoc(user_choice_type, :relation), + # preload: [relation: relations] + # end + + # def _preload(query, {:relation, join_query}) do + # from user_choice_type in query, + # left_join: relations in subquery(join_query), + # on: relations.id == query.relation_id, + # preload: [relation: relations] + # end +end diff --git a/lib/teiserver/game/schemas/lobby.ex b/lib/teiserver/game/schemas/lobby.ex index 72c6c6d09..528fce8ad 100644 --- a/lib/teiserver/game/schemas/lobby.ex +++ b/lib/teiserver/game/schemas/lobby.ex @@ -24,6 +24,7 @@ defmodule Teiserver.Game.Lobby do * `:game_name` - String of the game name * `:game_version` - String of the game version * `:game_settings` - Map of the settings to be used in starting the game + * `:user_settings` - Map of the settings to be used for each player, each key is a player id and the value is a key-value map of setting type to value * `:players` - List of user_ids as players, source of truth is still client state * `:spectators` - List of user_ids spectating, source of truth is still client state * `:members` - Total list of all user_ids who are members of the lobby @@ -69,6 +70,7 @@ defmodule Teiserver.Game.Lobby do # Game stuff field(:game_settings, map(), default: %{}) + field(:user_settings, map(), default: %{}) field(:players, [Teiserver.user_id()], default: []) field(:spectators, [Teiserver.user_id()], default: []) diff --git a/lib/teiserver/game/schemas/match.ex b/lib/teiserver/game/schemas/match.ex index 7f419fa5b..962174ad8 100644 --- a/lib/teiserver/game/schemas/match.ex +++ b/lib/teiserver/game/schemas/match.ex @@ -22,7 +22,6 @@ defmodule Teiserver.Game.Match do * `:ended_normally?` - True if the match was ended in a normal manner, false if ended in an abnormal manner (e.g. crashed) * `:match_duration_seconds` - The duration of the game in seconds as indicated by the game host * `:host` - The user account hosting the lobby - """ use TeiserverMacros, :schema @@ -35,11 +34,12 @@ defmodule Teiserver.Game.Match do field(:game_name, :string) field(:game_version, :string) + field(:team_count, :integer) + field(:team_size, :integer) + field(:player_count, :integer) # Outcome field(:winning_team, :integer) - field(:team_count, :integer) - field(:team_size, :integer) field(:processed?, :boolean, default: false) field(:ended_normally?, :boolean) @@ -47,14 +47,19 @@ defmodule Teiserver.Game.Match do field(:match_started_at, :utc_datetime) field(:match_ended_at, :utc_datetime) - # This will be something queried enough it's worth storing as it's own value + # These will be something queried enough it's worth storing as it's own value + # it is also possible we will want to count the duration as something other than + # time passed between start and end, e.g. ignoring time spent paused field(:match_duration_seconds, :integer) # Memberships + field(:lobby_id, Ecto.UUID) belongs_to(:host, Teiserver.Account.User, type: Ecto.UUID) belongs_to(:type, Teiserver.Game.MatchType) + has_many(:members, Teiserver.Game.MatchMembership) - has_many(:match_settings, Teiserver.Game.MatchSetting) + has_many(:settings, Teiserver.Game.MatchSetting) + has_many(:choices, Teiserver.Game.UserChoice) # Relationships we expect to add # belongs_to :queue, Teiserver.Game.MatchmakingQueue @@ -62,7 +67,7 @@ defmodule Teiserver.Game.Match do # has_many :rating_logs, Teiserver.Game.RatingLog # belongs_to :lobby_policy, Teiserver.Game.LobbyPolicy - timestamps() + timestamps(type: :utc_datetime) end @type id :: Ecto.UUID.t() @@ -90,11 +95,15 @@ defmodule Teiserver.Game.Match do # This will be something queried enough it's worth storing as it's own value match_duration_seconds: non_neg_integer(), + player_count: non_neg_integer(), host_id: Teiserver.user_id(), + lobby_id: Teiserver.lobby_id(), host: Teiserver.Account.User.t(), type_id: Teiserver.Game.MatchType.id(), type: Teiserver.Game.MatchType.t(), - members: list + members: list, + settings: list, + choices: list } @doc """ @@ -106,7 +115,7 @@ defmodule Teiserver.Game.Match do struct |> cast( attrs, - ~w(name tags public? rated? game_name game_version winning_team team_count team_size processed? lobby_opened_at match_started_at match_ended_at ended_normally? match_duration_seconds host_id type_id)a + ~w(name tags public? rated? game_name game_version winning_team team_count team_size processed? lobby_opened_at match_started_at match_ended_at ended_normally? match_duration_seconds player_count host_id type_id lobby_id)a ) |> validate_required(~w(public? rated? host_id)a) end diff --git a/lib/teiserver/game/schemas/match_membership.ex b/lib/teiserver/game/schemas/match_membership.ex index e36e352a9..b6f38a064 100644 --- a/lib/teiserver/game/schemas/match_membership.ex +++ b/lib/teiserver/game/schemas/match_membership.ex @@ -22,7 +22,7 @@ defmodule Teiserver.Game.MatchMembership do field(:team_number, :integer, default: nil) field(:win?, :boolean, default: nil) - field(:party_id, :string, default: nil) + field(:party_id, Ecto.UUID, default: nil) field(:left_after_seconds, :integer, default: nil) end diff --git a/lib/teiserver/game/schemas/user_choice.ex b/lib/teiserver/game/schemas/user_choice.ex new file mode 100644 index 000000000..801a73652 --- /dev/null +++ b/lib/teiserver/game/schemas/user_choice.ex @@ -0,0 +1,41 @@ +defmodule Teiserver.Game.UserChoice do + @moduledoc """ + # UserChoice + Choices made by users at the onset of a match. Not to be confused with `Teiserver.Settings.UserSetting` which is a general preference. + + ### Attributes + * `:type_id`/`:type` - The type of choice + * `:match_id`/`:match` - The match the choice was made in + * `:user_id`/`:user` - The user the choice was made by + * `:value` - The value of the choice + """ + use TeiserverMacros, :schema + + @primary_key false + schema "game_user_choices" do + belongs_to(:type, Teiserver.Game.UserChoiceType, primary_key: true) + belongs_to(:match, Teiserver.Game.Match, primary_key: true, type: Ecto.UUID) + belongs_to(:user, Teiserver.Account.User, primary_key: true, type: Ecto.UUID) + + field(:value, :string) + end + + @type t :: %__MODULE__{ + type_id: Teiserver.Game.UserChoiceType.id(), + match_id: Teiserver.match_id(), + user_id: Teiserver.user_id(), + value: String.t() + } + + @doc """ + Builds a changeset based on the `struct` and `params`. + """ + @spec changeset(map()) :: Ecto.Changeset.t() + @spec changeset(map(), map()) :: Ecto.Changeset.t() + def changeset(struct, params \\ %{}) do + struct + |> cast(params, ~w(type_id match_id user_id value)a) + |> validate_required(~w(type_id match_id user_id value)a) + |> unique_constraint(~w(type_id match_id user_id)a) + end +end diff --git a/lib/teiserver/game/schemas/user_choice_type.ex b/lib/teiserver/game/schemas/user_choice_type.ex new file mode 100644 index 000000000..eb7ff5ad7 --- /dev/null +++ b/lib/teiserver/game/schemas/user_choice_type.ex @@ -0,0 +1,33 @@ +defmodule Teiserver.Game.UserChoiceType do + @moduledoc """ + # UserChoiceType + Type information regarding a choice made by a user prior to the start of a match + + ### Attributes + * `:name` - The name of the setting + """ + use TeiserverMacros, :schema + + schema "game_user_choice_types" do + field(:name, :string) + end + + @type id :: non_neg_integer() + + @type t :: %__MODULE__{ + id: id(), + name: String.t() + } + + @doc """ + Builds a changeset based on the `struct` and `params`. + """ + @spec changeset(map()) :: Ecto.Changeset.t() + @spec changeset(map(), map()) :: Ecto.Changeset.t() + def changeset(struct, params \\ %{}) do + struct + |> cast(params, ~w(name)a) + |> validate_required(~w(name)a) + |> unique_constraint(~w(name)a) + end +end diff --git a/lib/teiserver/game/servers/lobby_server.ex b/lib/teiserver/game/servers/lobby_server.ex index 35f7af10c..b1f5273e6 100644 --- a/lib/teiserver/game/servers/lobby_server.ex +++ b/lib/teiserver/game/servers/lobby_server.ex @@ -5,16 +5,16 @@ defmodule Teiserver.Game.LobbyServer do """ use GenServer require Logger - alias Teiserver.{Connections, Account} + alias Teiserver.{Connections, Account, Game} alias Teiserver.Game.{Lobby, LobbyLib, LobbySummary} - alias Teiserver.Connections.ClientLib + alias Teiserver.Connections.{Client, ClientLib} alias Teiserver.Helpers.MapHelper @heartbeat_frequency_ms 5_000 defmodule State do @moduledoc false - defstruct [:lobby, :lobby_id, :host_id, :match_id, :lobby_topic, :match_topic, :update_id] + defstruct [:lobby, :lobby_id, :host_id, :match_id, :lobby_topic, :update_id] end @impl true @@ -34,17 +34,19 @@ defmodule Teiserver.Game.LobbyServer do {:reply, can_add_client({user_id, password}, state), state} end - def handle_call({:client_update_request, %{id: _user_id} = changes}, _from, state) do - {:reply, changes, state} - end - # Attempts to add a client to the lobby def handle_call({:add_client, user_id}, _from, state) do case can_add_client({user_id, state.lobby.password}, state) do {false, reason} -> {:reply, {:error, reason}, state} - {true, _} -> + true -> + :telemetry.execute( + [:teiserver, :lobby, :add_client], + %{}, + %{user_id: user_id, lobby_id: state.lobby_id} + ) + {shared_secret, new_state} = do_add_client(user_id, state) {:reply, {:ok, shared_secret, state.lobby}, new_state} end @@ -61,8 +63,19 @@ defmodule Teiserver.Game.LobbyServer do {:noreply, new_state} end + def handle_cast({:client_update_request, new_client, diffs, reason}, state) do + state = client_update_request(state, new_client, diffs, reason) + {:noreply, state} + end + def handle_cast({:remove_client, user_id}, state) do if Enum.member?(state.lobby.members, user_id) do + :telemetry.execute( + [:teiserver, :lobby, :remove_client], + %{}, + %{lobby_id: state.lobby_id, user_id: user_id} + ) + new_state = do_remove_client(user_id, state) {:noreply, new_state} else @@ -70,26 +83,61 @@ defmodule Teiserver.Game.LobbyServer do end end - def handle_cast({:cycle_lobby, match_id}, state) do - match_topic = nil - # match_topic = Game.match_topic(match.id) + def handle_cast(:cycle_lobby, state) do + new_state = do_cycle_lobby(state) + + {:noreply, new_state} + end + def handle_cast(:lobby_start_match, state) do new_state = update_lobby(state, %{ - match_id: match_id, - match_ongoing?: false, - match_type: nil + match_ongoing?: true }) - {:noreply, %{new_state | match_id: match_id, match_topic: match_topic}} + Teiserver.broadcast( + state.lobby_topic, + %{ + event: :match_start, + match_id: state.match_id, + lobby_id: state.lobby_id + } + ) + + :telemetry.execute( + [:teiserver, :lobby, :start_match], + %{}, + %{match_id: state.match_id, lobby_id: state.lobby_id} + ) + + {:noreply, new_state} end - def handle_cast(:lobby_start_match, state) do + def handle_cast({:lobby_end_match, reason}, state) do new_state = update_lobby(state, %{ match_ongoing?: true }) + Teiserver.broadcast( + state.lobby_topic, + %{ + event: :match_end, + match_id: state.match_id, + lobby_id: state.lobby_id, + reason: reason + } + ) + + :telemetry.execute( + [:teiserver, :lobby, :end_match], + %{reason: reason}, + %{match_id: state.match_id, lobby_id: state.lobby_id} + ) + + # Cycle at the end of the match + new_state = do_cycle_lobby(new_state) + {:noreply, new_state} end @@ -119,7 +167,8 @@ defmodule Teiserver.Game.LobbyServer do end def handle_info( - %{topic: "Teiserver.Connections.Client" <> _, event: :client_updated, user_id: user_id} = msg, + %{topic: "Teiserver.Connections.Client" <> _, event: :client_updated, user_id: user_id} = + msg, state ) do lobby = state.lobby @@ -146,7 +195,7 @@ defmodule Teiserver.Game.LobbyServer do %{} end - if changes == %{} do + if Enum.empty?(changes) do {:noreply, state} else new_state = update_lobby(state, changes) @@ -165,48 +214,89 @@ defmodule Teiserver.Game.LobbyServer do GenServer.start_link(__MODULE__, opts[:data], []) end - @spec can_add_client({Teiserver.user_id(), String.t()}, State.t()) :: {boolean(), String.t() | nil} + @spec do_cycle_lobby(State.t()) :: State.t() + defp do_cycle_lobby(state) do + {:ok, match} = + Game.create_match(%{ + public?: true, + rated?: true, + host_id: state.host_id, + processed?: false, + lobby_opened_at: DateTime.utc_now(), + lobby_id: state.lobby_id + }) + + new_state = + update_lobby(state, %{ + match_id: match.id, + match_ongoing?: false, + match_type: nil + }) + + :telemetry.execute( + [:teiserver, :lobby, :cycle], + %{}, + %{match_id: match.id, lobby_id: new_state.lobby_id} + ) + + %{new_state | match_id: match.id} + end + + @spec can_add_client({Teiserver.user_id(), String.t()}, State.t()) :: + true | {false, :existing_member | :client_disconnected | :already_in_a_lobby} defp can_add_client({user_id, password}, %{lobby: lobby} = _state) do cond do Enum.member?(lobby.members, user_id) -> - {false, "Existing member"} + {false, :existing_member} true -> client = Connections.get_client(user_id) cond do client == nil -> - {false, "Client is not connected"} + {false, :client_disconnected} client.connected? == false -> - {false, "Client is disconnected"} + {false, :client_disconnected} client.lobby_id != nil -> - {false, "Already in a lobby"} + {false, :already_in_a_lobby} # Moderator short-circuit Account.allow?(user_id, "moderator") -> - {true, nil} + true # Approved player short-circuit Enum.member?(lobby.approved_members, user_id) -> - {true, nil} + true lobby.password && lobby.password != password -> - {false, "Incorrect password"} + {false, :incorrect_password} lobby.locked? -> - {false, "Lobby is locked"} + {false, :lobby_is_locked} true -> - {true, nil} + true end end end + @spec client_update_request(State.t(), Client.t(), map(), State.t()) :: State.t() + defp client_update_request(state, new_client, _diffs, reason) do + # Currently we just say yes so we pass-through the client + resulting_client = new_client + + # Assuming there are still some changes, send them over to be implemented! + ClientLib.do_update_client_in_lobby(new_client.id, resulting_client, reason) + + state + end + @spec update_lobby(State.t(), map()) :: State.t() def update_lobby(state, changes) do - new_lobby = state.lobby + new_lobby = + state.lobby |> struct(changes) |> apply_calculated_changes @@ -217,7 +307,7 @@ defmodule Teiserver.Game.LobbyServer do defp do_update_lobby(%State{} = state, %Lobby{} = new_lobby) do diffs = MapHelper.map_diffs(state.lobby, new_lobby) - if diffs == %{} do + if Enum.empty?(diffs) do # Nothing changed, we don't do anything state else @@ -243,7 +333,7 @@ defmodule Teiserver.Game.LobbyServer do @spec apply_calculated_changes(Lobby.t()) :: Lobby.t() defp apply_calculated_changes(lobby_state) do changes = %{ - passworded?: (lobby_state.password != nil && lobby_state.password != "") + passworded?: lobby_state.password != nil && lobby_state.password != "" } struct(lobby_state, changes) @@ -253,16 +343,20 @@ defmodule Teiserver.Game.LobbyServer do defp do_add_client(user_id, state) do shared_secret = Teiserver.Account.generate_password() - ClientLib.update_client_full(user_id, %{ - lobby_id: state.lobby_id, - ready?: false, - player?: false, - player_number: nil, - team_number: nil, - player_colour: nil, - sync: nil, - lobby_host?: false - }, "joined_lobby") + ClientLib.update_client( + user_id, + %{ + lobby_id: state.lobby_id, + ready?: false, + player?: false, + player_number: nil, + team_number: nil, + player_colour: nil, + sync: nil, + lobby_host?: false + }, + "joined_lobby" + ) client = ClientLib.get_client(user_id) @@ -278,26 +372,31 @@ defmodule Teiserver.Game.LobbyServer do Connections.subscribe_to_client(user_id) - {shared_secret, update_lobby(state, %{ - members: [user_id | state.lobby.members], - spectators: [user_id | state.lobby.spectators] - })} + {shared_secret, + update_lobby(state, %{ + members: [user_id | state.lobby.members], + spectators: [user_id | state.lobby.spectators] + })} end @spec do_remove_client(Teiserver.user_id(), State.t()) :: State.t() defp do_remove_client(user_id, state) do Connections.unsubscribe_from_client(user_id) - ClientLib.update_client_full(user_id, %{ - lobby_id: nil, - ready?: false, - player?: false, - player_number: nil, - team_number: nil, - player_colour: nil, - sync: nil, - lobby_host?: false - }, "left_lobby") + ClientLib.update_client( + user_id, + %{ + lobby_id: nil, + ready?: false, + player?: false, + player_number: nil, + team_number: nil, + player_colour: nil, + sync: nil, + lobby_host?: false + }, + "left_lobby" + ) Teiserver.broadcast( state.lobby_topic, @@ -343,8 +442,7 @@ defmodule Teiserver.Game.LobbyServer do host_id: lobby.host_id, match_id: nil, lobby: lobby, - lobby_topic: LobbyLib.lobby_topic(id), - match_topic: nil + lobby_topic: LobbyLib.lobby_topic(id) }} end end diff --git a/lib/teiserver/helpers/cache_helper.ex b/lib/teiserver/helpers/cache_helper.ex new file mode 100644 index 000000000..fdb8147fd --- /dev/null +++ b/lib/teiserver/helpers/cache_helper.ex @@ -0,0 +1,37 @@ +defmodule Teiserver.Helpers.CacheHelper do + @moduledoc """ + A set of functions to interact with the various caches in the system. + """ + + @doc """ + Invalidates the cache for this table/key on this node and all other nodes + in the cluster. + """ + @spec invalidate_cache(atom, any) :: :ok + def invalidate_cache(table, key_or_keys) do + key_or_keys + |> List.wrap() + |> Enum.each(fn key -> + Cachex.del(table, key) + end) + + Phoenix.PubSub.broadcast( + Teiserver.PubSub, + "cache_cluster", + {:cache_cluster, :delete, Node.self(), table, key_or_keys} + ) + end + + @spec add_cache(atom, list) :: map() + def add_cache(name, opts \\ []) when is_atom(name) do + %{ + id: name, + start: + {Cachex, :start_link, + [ + name, + opts + ]} + } + end +end diff --git a/lib/teiserver/helpers/map_helper.ex b/lib/teiserver/helpers/map_helper.ex index 0ceb573b7..e8fb9cf12 100644 --- a/lib/teiserver/helpers/map_helper.ex +++ b/lib/teiserver/helpers/map_helper.ex @@ -9,17 +9,17 @@ defmodule Teiserver.Helpers.MapHelper do @spec map_diffs(map(), map()) :: map() def map_diffs(m1, m2) do Enum.uniq(Map.keys(m1) ++ Map.keys(m2)) - |> Enum.map(fn key -> - v1 = Map.get(m1, key, nil) - v2 = Map.get(m2, key, nil) + |> Enum.map(fn key -> + v1 = Map.get(m1, key, nil) + v2 = Map.get(m2, key, nil) - if v1 != v2 do - {key, v2} - else - nil - end - end) - |> Enum.reject(&(&1 == nil)) - |> Map.new + if v1 != v2 do + {key, v2} + else + nil + end + end) + |> Enum.reject(&(&1 == nil)) + |> Map.new() end end diff --git a/lib/teiserver/helpers/query_helper.ex b/lib/teiserver/helpers/query_helper.ex index c4b6e4da9..a87e4b84a 100644 --- a/lib/teiserver/helpers/query_helper.ex +++ b/lib/teiserver/helpers/query_helper.ex @@ -2,15 +2,7 @@ defmodule Teiserver.Helpers.QueryHelper do @moduledoc false import Ecto.Query, warn: false - @spec offset_query(Ecto.Query.t(), nil | Integer.t()) :: Ecto.Query.t() - def offset_query(query, nil), do: query - - def offset_query(query, amount) do - query - |> offset(^amount) - end - - @spec limit_query(Ecto.Query.t(), Integer.t() | :infinity) :: Ecto.Query.t() + @spec limit_query(Ecto.Query.t(), non_neg_integer() | :infinity) :: Ecto.Query.t() def limit_query(query, :infinity), do: query def limit_query(query, nil), do: query diff --git a/lib/teiserver/logging/libs/audit_log_lib.ex b/lib/teiserver/logging/libs/audit_log_lib.ex new file mode 100644 index 000000000..1a4dd310f --- /dev/null +++ b/lib/teiserver/logging/libs/audit_log_lib.ex @@ -0,0 +1,181 @@ +defmodule Teiserver.Logging.AuditLogLib do + @moduledoc """ + Library of AuditLog related functions + """ + use TeiserverMacros, :library + alias Teiserver.Logging.{AuditLog, AuditLogQueries} + + @doc """ + Returns the list of audit_logs. + + ## Examples + + iex> list_audit_logs() + [%AuditLog{}, ...] + + """ + @spec list_audit_logs(Teiserver.query_args()) :: [AuditLog.t()] + def list_audit_logs(query_args) do + query_args + |> AuditLogQueries.audit_log_query() + |> Repo.all() + end + + @doc """ + Gets a single audit_log. + + Raises `Ecto.NoResultsError` if the AuditLog does not exist. + + ## Examples + + iex> get_audit_log!(123) + %AuditLog{} + + iex> get_audit_log!(456) + ** (Ecto.NoResultsError) + + """ + @spec get_audit_log!(AuditLog.id()) :: AuditLog.t() + @spec get_audit_log!(AuditLog.id(), Teiserver.query_args()) :: AuditLog.t() + def get_audit_log!(audit_log_id, query_args \\ []) do + (query_args ++ [id: audit_log_id]) + |> AuditLogQueries.audit_log_query() + |> Repo.one!() + end + + @doc """ + Gets a single audit_log. + + Returns nil if the AuditLog does not exist. + + ## Examples + + iex> get_audit_log(123) + %AuditLog{} + + iex> get_audit_log(456) + nil + + """ + @spec get_audit_log(AuditLog.id()) :: AuditLog.t() | nil + @spec get_audit_log(AuditLog.id(), Teiserver.query_args()) :: AuditLog.t() | nil + def get_audit_log(audit_log_id, query_args \\ []) do + (query_args ++ [id: audit_log_id]) + |> AuditLogQueries.audit_log_query() + |> Repo.one() + end + + @doc """ + Creates a audit_log. + + ## Examples + + iex> create_audit_log(%{field: value}) + {:ok, %AuditLog{}} + + iex> create_audit_log(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + iex> create_audit_log("user_id", "127.0.0.1", "Action", %{key: "value"}) + {:ok, %AuditLog{}} + + """ + @spec create_audit_log(map) :: {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + def create_audit_log(attrs) do + %AuditLog{} + |> AuditLog.changeset(attrs) + |> Repo.insert() + end + + @spec create_audit_log(Teiserver.user_id(), String.t(), String.t(), map()) :: + {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + def create_audit_log(user_id, ip, action, details) do + %AuditLog{} + |> AuditLog.changeset(%{ + user_id: user_id, + ip: ip, + action: action, + details: details + }) + |> Repo.insert() + |> maybe_emit_event + end + + @doc """ + See `create_audit_log/4`, this is the same but without a user. + """ + @spec create_anonymous_audit_log(String.t(), String.t(), map()) :: + {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + def create_anonymous_audit_log(ip, action, details) do + %AuditLog{} + |> AuditLog.changeset(%{ + ip: ip, + action: action, + details: details + }) + |> Repo.insert() + |> maybe_emit_event + end + + defp maybe_emit_event({:ok, %AuditLog{} = audit_log}) do + :telemetry.execute( + [:teiserver, :logging, :add_audit_log], + %{action: audit_log.action}, + %{log_id: audit_log.id} + ) + + {:ok, audit_log} + end + + defp maybe_emit_event(v), do: v + + @doc """ + Updates a audit_log. + + ## Examples + + iex> update_audit_log(audit_log, %{field: new_value}) + {:ok, %AuditLog{}} + + iex> update_audit_log(audit_log, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + @spec update_audit_log(AuditLog.t(), map) :: {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + def update_audit_log(%AuditLog{} = audit_log, attrs) do + audit_log + |> AuditLog.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a audit_log. + + ## Examples + + iex> delete_audit_log(audit_log) + {:ok, %AuditLog{}} + + iex> delete_audit_log(audit_log) + {:error, %Ecto.Changeset{}} + + """ + @spec delete_audit_log(AuditLog.t()) :: {:ok, AuditLog.t()} | {:error, Ecto.Changeset.t()} + def delete_audit_log(%AuditLog{} = audit_log) do + Repo.delete(audit_log) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking audit_log changes. + + ## Examples + + iex> change_audit_log(audit_log) + %Ecto.Changeset{data: %AuditLog{}} + + """ + @spec change_audit_log(AuditLog.t(), map) :: Ecto.Changeset.t() + def change_audit_log(%AuditLog{} = audit_log, attrs \\ %{}) do + AuditLog.changeset(audit_log, attrs) + end +end diff --git a/lib/teiserver/logging/queries/audit_log_queries.ex b/lib/teiserver/logging/queries/audit_log_queries.ex new file mode 100644 index 000000000..9b22622d3 --- /dev/null +++ b/lib/teiserver/logging/queries/audit_log_queries.ex @@ -0,0 +1,161 @@ +defmodule Teiserver.Logging.AuditLogQueries do + @moduledoc false + use TeiserverMacros, :queries + alias Teiserver.Logging.AuditLog + require Logger + + @spec audit_log_query(Teiserver.query_args()) :: Ecto.Query.t() + def audit_log_query(args) do + query = from(audit_logs in AuditLog) + + query + |> do_where(id: args[:id]) + |> do_where(args[:where]) + |> do_where(args[:search]) + |> do_preload(args[:preload]) + |> do_order_by(args[:order_by]) + |> QueryHelper.query_select(args[:select]) + |> QueryHelper.limit_query(args[:limit] || 50) + end + + @spec do_where(Ecto.Query.t(), list | map | nil) :: Ecto.Query.t() + defp do_where(query, nil), do: query + + defp do_where(query, params) do + params + |> Enum.reduce(query, fn {key, value}, query_acc -> + _where(query_acc, key, value) + end) + end + + @spec _where(Ecto.Query.t(), Atom.t(), any()) :: Ecto.Query.t() + def _where(query, _, ""), do: query + def _where(query, _, nil), do: query + + def _where(query, :id, id_list) when is_list(id_list) do + from(audit_logs in query, + where: audit_logs.id in ^id_list + ) + end + + def _where(query, :id, id) do + from(audit_logs in query, + where: audit_logs.id == ^id + ) + end + + def _where(query, :user_id, user_id_list) when is_list(user_id_list) do + from(audit_logs in query, + where: audit_logs.user_id in ^user_id_list + ) + end + + def _where(query, :user_id, user_id) do + from(audit_logs in query, + where: audit_logs.user_id == ^user_id + ) + end + + def _where(query, :action, action_list) when is_list(action_list) do + from(audit_logs in query, + where: audit_logs.action in ^action_list + ) + end + + def _where(query, :action, action) do + from(audit_logs in query, + where: audit_logs.action == ^action + ) + end + + def _where(query, :detail_equal, {field, value}) do + from(audit_logs in query, + where: fragment("? ->> ? = ?", audit_logs.details, ^field, ^value) + ) + end + + def _where(query, :detail_greater_than, {field, value}) do + from(audit_logs in query, + where: fragment("? ->> ? > ?", audit_logs.details, ^field, ^value) + ) + end + + def _where(query, :detail_less_than, {field, value}) do + from(audit_logs in query, + where: fragment("? ->> ? < ?", audit_logs.details, ^field, ^value) + ) + end + + def _where(query, :detail_not, {field, value}) do + from(audit_logs in query, + where: fragment("? ->> ? != ?", audit_logs.details, ^field, ^value) + ) + end + + def _where(query, :inserted_after, timestamp) do + from(audit_logs in query, + where: audit_logs.inserted_at >= ^timestamp + ) + end + + def _where(query, :inserted_before, timestamp) do + from(audit_logs in query, + where: audit_logs.inserted_at < ^timestamp + ) + end + + def _where(query, :updated_after, timestamp) do + from(audit_logs in query, + where: audit_logs.updated_at >= ^timestamp + ) + end + + def _where(query, :updated_before, timestamp) do + from(audit_logs in query, + where: audit_logs.updated_at < ^timestamp + ) + end + + @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() + defp do_order_by(query, nil), do: query + + defp do_order_by(query, params) do + params + |> List.wrap() + |> Enum.reduce(query, fn key, query_acc -> + _order_by(query_acc, key) + end) + end + + @spec _order_by(Ecto.Query.t(), any()) :: Ecto.Query.t() + def _order_by(query, "Newest first") do + from(audit_logs in query, + order_by: [desc: audit_logs.inserted_at] + ) + end + + def _order_by(query, "Oldest first") do + from(audit_logs in query, + order_by: [asc: audit_logs.inserted_at] + ) + end + + @spec do_preload(Ecto.Query.t(), List.t() | nil) :: Ecto.Query.t() + defp do_preload(query, nil), do: query + + defp do_preload(query, preloads) do + preloads + |> List.wrap() + |> Enum.reduce(query, fn key, query_acc -> + _preload(query_acc, key) + end) + end + + @spec _preload(Ecto.Query.t(), any) :: Ecto.Query.t() + def _preload(query, :user) do + from(audit_log in query, + left_join: users in assoc(audit_log, :user), + preload: [user: users] + ) + end +end diff --git a/lib/teiserver/logging/schemas/audit_log.ex b/lib/teiserver/logging/schemas/audit_log.ex index e69de29bb..8dd5b0a7b 100644 --- a/lib/teiserver/logging/schemas/audit_log.ex +++ b/lib/teiserver/logging/schemas/audit_log.ex @@ -0,0 +1,42 @@ +defmodule Teiserver.Logging.AuditLog do + @moduledoc """ + # AuditLog + Description here + + ### Attributes + + * `:action` - The action performed which generated the audit log + * `:details` - A key-value store of extra details related to the action + * `:ip` - The IP address of the sender of the command (if available) + * `:user` - The user who performed the action + """ + use TeiserverMacros, :schema + + schema "audit_logs" do + field(:action, :string) + field(:details, :map) + field(:ip, :string) + belongs_to(:user, Teiserver.Account.User, type: Ecto.UUID) + + timestamps(type: :utc_datetime) + end + + @type id :: non_neg_integer() + + @type t :: %__MODULE__{ + id: id(), + action: String.t(), + details: map(), + ip: String.t(), + user_id: Teiserver.user_id() + } + + @doc false + @spec changeset(map()) :: Ecto.Changeset.t() + @spec changeset(map(), map()) :: Ecto.Changeset.t() + def changeset(struct, attrs \\ %{}) do + struct + |> cast(attrs, ~w(action details ip user_id)a) + |> validate_required(~w(action details)a) + end +end diff --git a/lib/migration.ex b/lib/teiserver/migrations/migration.ex similarity index 99% rename from lib/migration.ex rename to lib/teiserver/migrations/migration.ex index 2d1e70f99..6e4050649 100644 --- a/lib/migration.ex +++ b/lib/teiserver/migrations/migration.ex @@ -187,7 +187,7 @@ defmodule Teiserver.Migration do end defp migrator do - case repo().__adapter__ do + case repo().__adapter__() do Ecto.Adapters.Postgres -> Teiserver.Migrations.Postgres Ecto.Adapters.SQLite3 -> Teiserver.Migrations.SQLite _ -> Keyword.fetch!(repo().config(), :migrator) diff --git a/lib/teiserver/migrations/postgres.ex b/lib/teiserver/migrations/postgres.ex index 8c76f12cb..433b5acb6 100644 --- a/lib/teiserver/migrations/postgres.ex +++ b/lib/teiserver/migrations/postgres.ex @@ -7,7 +7,7 @@ defmodule Teiserver.Migrations.Postgres do use Ecto.Migration @initial_version 1 - @current_version 1 + @current_version 2 @default_prefix "public" @doc false diff --git a/lib/teiserver/migrations/postgres/v01.ex b/lib/teiserver/migrations/postgres/v01.ex index 567951e26..a0a26b4ed 100644 --- a/lib/teiserver/migrations/postgres/v01.ex +++ b/lib/teiserver/migrations/postgres/v01.ex @@ -12,14 +12,6 @@ defmodule Teiserver.Migrations.Postgres.V01 do execute("CREATE EXTENSION IF NOT EXISTS citext") - # Clustering - create table(:teiserver_cluster_members, primary_key: false, prefix: prefix) do - add(:id, :uuid, primary_key: true, null: false) - add(:host, :string, null: false) - - timestamps() - end - # Accounts create_if_not_exists table(:account_users, primary_key: false, prefix: prefix) do add(:id, :uuid, primary_key: true, null: false) @@ -45,10 +37,17 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:smurf_of_id, references(:account_users, on_delete: :nothing, type: :uuid)) - timestamps() + timestamps(type: :utc_datetime) + end + + if prefix do + execute( + "CREATE INDEX IF NOT EXISTS lower_username ON #{prefix}.account_users (LOWER(name))" + ) + else + execute("CREATE INDEX IF NOT EXISTS lower_username ON account_users (LOWER(name))") end - # execute "CREATE INDEX IF NOT EXISTS lower_username ON #{prefix}.account_users (LOWER(name))" create_if_not_exists(unique_index(:account_users, [:email], prefix: prefix)) create_if_not_exists table(:account_extra_user_data, primary_key: false, prefix: prefix) do @@ -60,6 +59,8 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:data, :jsonb) end + create_if_not_exists(unique_index(:account_extra_user_data, [:user_id], prefix: prefix)) + # Game create_if_not_exists table(:game_match_types, prefix: prefix) do add(:name, :string) @@ -72,6 +73,8 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:public?, :boolean) add(:rated?, :boolean) + add(:lobby_id, :uuid) + add(:game_name, :string) add(:game_version, :string) @@ -86,13 +89,16 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:match_ended_at, :utc_datetime) add(:match_duration_seconds, :integer) + add(:player_count, :integer) add(:host_id, references(:account_users, on_delete: :nothing, type: :uuid), type: :uuid) add(:type_id, references(:game_match_types, on_delete: :nothing)) - timestamps() + timestamps(type: :utc_datetime) end + create_if_not_exists(index(:game_matches, [:match_started_at], prefix: prefix)) + create_if_not_exists table(:game_match_memberships, primary_key: false) do add(:user_id, references(:account_users, on_delete: :nothing, type: :uuid), primary_key: true, @@ -109,9 +115,11 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:win?, :boolean, default: nil, null: true) add(:left_after_seconds, :integer) - add(:party_id, :string) + add(:party_id, :uuid) end + create_if_not_exists(index(:game_match_memberships, [:match_id], prefix: prefix)) + create_if_not_exists table(:game_match_setting_types) do add(:name, :string) end @@ -127,10 +135,35 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:value, :string) end + create_if_not_exists(index(:game_match_settings, [:match_id], prefix: prefix)) + + create_if_not_exists table(:game_user_choice_types) do + add(:name, :string) + end + + create_if_not_exists table(:game_user_choices, primary_key: false) do + add(:type_id, references(:game_user_choice_types, on_delete: :nothing), primary_key: true) + + add(:user_id, references(:account_users, on_delete: :nothing, type: :uuid), + primary_key: true, + type: :uuid + ) + + add(:match_id, references(:game_matches, on_delete: :nothing, type: :uuid), + primary_key: true, + type: :uuid + ) + + add(:value, :string) + end + + create_if_not_exists(index(:game_user_choices, [:match_id], prefix: prefix)) + create_if_not_exists(index(:game_user_choices, [:user_id], prefix: prefix)) + # Communications create_if_not_exists table(:communication_rooms, prefix: prefix) do add(:name, :string) - timestamps() + timestamps(type: :utc_datetime) end create_if_not_exists table(:communication_room_messages, prefix: prefix) do @@ -141,6 +174,9 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:room_id, references(:communication_rooms, on_delete: :nothing)) end + create_if_not_exists(index(:communication_room_messages, [:sender_id], prefix: prefix)) + create_if_not_exists(index(:communication_room_messages, [:room_id], prefix: prefix)) + create_if_not_exists table(:communication_direct_messages, prefix: prefix) do add(:content, :text) add(:inserted_at, :utc_datetime) @@ -151,6 +187,9 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:to_id, references(:account_users, on_delete: :nothing, type: :uuid), type: :uuid) end + create_if_not_exists(index(:communication_direct_messages, [:sender_id], prefix: prefix)) + create_if_not_exists(index(:communication_direct_messages, [:to_id], prefix: prefix)) + create_if_not_exists table(:communication_match_messages, prefix: prefix) do add(:content, :text) add(:inserted_at, :utc_datetime) @@ -159,20 +198,14 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:match_id, references(:game_matches, on_delete: :nothing, type: :uuid), type: :uuid) end + create_if_not_exists(index(:communication_match_messages, [:match_id], prefix: prefix)) + # Settings create_if_not_exists table(:settings_server_settings, primary_key: false, prefix: prefix) do add(:key, :string, primary_key: true) - add(:value, :string) - - timestamps() - end + add(:value, :text) - create_if_not_exists table(:settings_user_setting_type, prefix: prefix) do - add(:key, :string) - add(:value, :string) - add(:user_id, references(:account_users, on_delete: :nothing, type: :uuid), type: :uuid) - - timestamps() + timestamps(type: :utc_datetime) end create_if_not_exists table(:settings_user_settings, prefix: prefix) do @@ -180,12 +213,13 @@ defmodule Teiserver.Migrations.Postgres.V01 do add(:value, :string) add(:user_id, references(:account_users, on_delete: :nothing, type: :uuid), type: :uuid) - timestamps() + timestamps(type: :utc_datetime) end - create(index(:settings_user_settings, [:user_id], prefix: prefix)) + create_if_not_exists(index(:settings_user_settings, [:user_id], prefix: prefix)) end + @spec down(map) :: any def down(%{prefix: prefix, quoted_prefix: _quoted}) do # Comms drop_if_exists(table(:communication_room_messages, prefix: prefix)) @@ -202,16 +236,12 @@ defmodule Teiserver.Migrations.Postgres.V01 do # Config drop_if_exists(table(:settings_server_settings, prefix: prefix)) - drop_if_exists(table(:settings_user_setting_type, prefix: prefix)) drop_if_exists(table(:settings_user_settings, prefix: prefix)) # Accounts drop_if_exists(table(:account_extra_user_data, prefix: prefix)) drop_if_exists(table(:account_users, prefix: prefix)) - # System - drop_if_exists(table(:teiserver_cluster_members, prefix: prefix)) - execute("DROP EXTENSION IF EXISTS citext") end end diff --git a/lib/teiserver/migrations/postgres/v02.ex b/lib/teiserver/migrations/postgres/v02.ex new file mode 100644 index 000000000..cc6ae15ce --- /dev/null +++ b/lib/teiserver/migrations/postgres/v02.ex @@ -0,0 +1,25 @@ +defmodule Teiserver.Migrations.Postgres.V02 do + @moduledoc false + # Copied and tweaked from Oban + + use Ecto.Migration + + @spec up(map) :: any + def up(%{prefix: prefix}) do + # Logging + create_if_not_exists table(:audit_logs, prefix: prefix) do + add(:action, :string) + add(:details, :jsonb) + add(:ip, :string) + add(:user_id, references(:account_users, on_delete: :nothing, type: :uuid), type: :uuid) + + timestamps(type: :utc_datetime) + end + end + + @spec down(map) :: any + def down(%{prefix: prefix, quoted_prefix: _quoted}) do + # Logging + drop_if_exists(table(:audit_logs, prefix: prefix)) + end +end diff --git a/lib/teiserver/settings/libs/server_setting_lib.ex b/lib/teiserver/settings/libs/server_setting_lib.ex index 2b1ae2817..658ce32b3 100644 --- a/lib/teiserver/settings/libs/server_setting_lib.ex +++ b/lib/teiserver/settings/libs/server_setting_lib.ex @@ -1,9 +1,16 @@ defmodule Teiserver.Settings.ServerSettingLib do @moduledoc """ - Library of server_setting related functions. + A library of functions for working with `Teiserver.Settings.ServerSetting` """ use TeiserverMacros, :library - alias Teiserver.Settings.{ServerSetting, ServerSettingQueries} + require Logger + + alias Teiserver.Settings.{ + ServerSetting, + ServerSettingQueries, + ServerSettingTypeLib, + ServerSettingType + } @doc """ Returns the list of server_settings. @@ -14,11 +21,11 @@ defmodule Teiserver.Settings.ServerSettingLib do [%ServerSetting{}, ...] """ - @spec list_server_settings(list) :: list - def list_server_settings(query_args \\ []) do + @spec list_server_settings(Teiserver.query_args()) :: [ServerSetting.t()] + def list_server_settings(query_args) do query_args |> ServerSettingQueries.server_setting_query() - |> Teiserver.Repo.all() + |> Repo.all() end @doc """ @@ -28,18 +35,18 @@ defmodule Teiserver.Settings.ServerSettingLib do ## Examples - iex> get_server_setting!(123) + iex> get_server_setting!("key123") %ServerSetting{} - iex> get_server_setting!(456) + iex> get_server_setting!("key456") ** (Ecto.NoResultsError) """ - @spec get_server_setting!(String.t()) :: ServerSetting.t() + @spec get_server_setting!(ServerSetting.key(), Teiserver.query_args()) :: ServerSetting.t() def get_server_setting!(key, query_args \\ []) do (query_args ++ [key: key]) |> ServerSettingQueries.server_setting_query() - |> Teiserver.Repo.one!() + |> Repo.one!() end @doc """ @@ -49,20 +56,119 @@ defmodule Teiserver.Settings.ServerSettingLib do ## Examples - iex> get_server_setting(123) + iex> get_server_setting("key123") %ServerSetting{} - iex> get_server_setting(456) + iex> get_server_setting("key456") nil """ - @spec get_server_setting(non_neg_integer(), list) :: ServerSetting.t() | nil + @spec get_server_setting(ServerSetting.key(), Teiserver.query_args()) :: ServerSetting.t() | nil def get_server_setting(key, query_args \\ []) do (query_args ++ [key: key]) |> ServerSettingQueries.server_setting_query() - |> Teiserver.Repo.one() + |> Repo.one() + end + + @doc """ + Gets the value of a server_setting. + + Returns nil if the ServerSetting does not exist. + + ## Examples + + iex> get_server_setting_value("key123") + "value" + + iex> get_server_setting_value("key456") + nil + + """ + @spec get_server_setting_value(ServerSetting.key()) :: + String.t() | non_neg_integer() | boolean() | nil + def get_server_setting_value(key) do + case Cachex.get(:ts_server_setting_cache, key) do + {:ok, nil} -> + setting = get_server_setting(key, limit: 1) + type = ServerSettingTypeLib.get_server_setting_type(key) + + value = + case setting do + nil -> + type.default + + %{value: nil} -> + type.default + + %{value: v} -> + v + end + + value = convert_from_raw_value(value, type.type) + + Cachex.put(:ts_server_setting_cache, key, value) + value + + {:ok, value} -> + value + end + end + + @spec convert_from_raw_value(String.t(), String.t()) :: String.t() | integer() | boolean() | nil + defp convert_from_raw_value(nil, _), do: nil + defp convert_from_raw_value(raw_value, "string"), do: raw_value + defp convert_from_raw_value(raw_value, "integer") when is_integer(raw_value), do: raw_value + defp convert_from_raw_value(raw_value, "integer"), do: String.to_integer(raw_value) + defp convert_from_raw_value(raw_value, "boolean"), do: raw_value == "t" + defp convert_from_raw_value(_, _), do: nil + + @spec set_server_setting_value(String.t(), String.t() | non_neg_integer() | boolean() | nil) :: + :ok | {:error, String.t()} + def set_server_setting_value(key, value) do + type = ServerSettingTypeLib.get_server_setting_type(key) + raw_value = convert_to_raw_value(value, type.type) + + case value_is_valid?(type, value) do + :ok -> + case get_server_setting(key) do + nil -> + {:ok, _} = + create_server_setting(%{ + key: key, + value: raw_value + }) + + Teiserver.invalidate_cache(:ts_server_setting_cache, key) + :ok + + server_setting -> + {:ok, _} = update_server_setting(server_setting, %{"value" => raw_value}) + Teiserver.invalidate_cache(:ts_server_setting_cache, key) + :ok + end + + {:error, reason} -> + {:error, reason} + end end + @doc """ + + """ + @spec value_is_valid?(ServerSettingType.t(), String.t() | non_neg_integer() | boolean() | nil) :: + :ok | {:error, String.t()} + def value_is_valid?(%{validator: nil}, _), do: :ok + + def value_is_valid?(%{validator: validator_function}, value) do + validator_function.(value) + end + + @spec convert_to_raw_value(String.t(), String.t()) :: String.t() | integer() | boolean() | nil + defp convert_to_raw_value(value, "string"), do: value + defp convert_to_raw_value(value, "integer"), do: to_string(value) + defp convert_to_raw_value(value, "boolean"), do: if(value, do: "t", else: "f") + defp convert_to_raw_value(_, _), do: nil + @doc """ Creates a server_setting. @@ -76,10 +182,10 @@ defmodule Teiserver.Settings.ServerSettingLib do """ @spec create_server_setting(map) :: {:ok, ServerSetting.t()} | {:error, Ecto.Changeset.t()} - def create_server_setting(attrs \\ %{}) do + def create_server_setting(attrs) do %ServerSetting{} |> ServerSetting.changeset(attrs) - |> Teiserver.Repo.insert() + |> Repo.insert() end @doc """ @@ -99,7 +205,7 @@ defmodule Teiserver.Settings.ServerSettingLib do def update_server_setting(%ServerSetting{} = server_setting, attrs) do server_setting |> ServerSetting.changeset(attrs) - |> Teiserver.Repo.update() + |> Repo.update() end @doc """ @@ -117,7 +223,7 @@ defmodule Teiserver.Settings.ServerSettingLib do @spec delete_server_setting(ServerSetting.t()) :: {:ok, ServerSetting.t()} | {:error, Ecto.Changeset.t()} def delete_server_setting(%ServerSetting{} = server_setting) do - Teiserver.Repo.delete(server_setting) + Repo.delete(server_setting) end @doc """ diff --git a/lib/teiserver/settings/libs/server_setting_type_lib.ex b/lib/teiserver/settings/libs/server_setting_type_lib.ex new file mode 100644 index 000000000..5430670a4 --- /dev/null +++ b/lib/teiserver/settings/libs/server_setting_type_lib.ex @@ -0,0 +1,88 @@ +defmodule Teiserver.Settings.ServerSettingTypeLib do + @moduledoc """ + A library of functions for working with `Teiserver.Settings.ServerSettingType` + """ + + @cache_table :ts_server_setting_type_store + + alias Teiserver.Settings.ServerSettingType + + @spec list_server_setting_types([String.t()]) :: [ServerSettingType.t()] + def list_server_setting_types(keys) do + keys + |> Enum.map(&get_server_setting_type/1) + |> Enum.reject(&(&1 == nil)) + end + + @spec list_server_setting_type_keys() :: [String.t()] + def list_server_setting_type_keys() do + {:ok, v} = Cachex.get(@cache_table, "_all") + v || [] + end + + @spec get_server_setting_type(String.t()) :: ServerSettingType.t() | nil + def get_server_setting_type(key) do + {:ok, v} = Cachex.get(@cache_table, key) + v + end + + @doc """ + ### Required keys + * `:key` - The string key of the setting, this is the internal name used for the setting + * `:label` - The user-facing label used for the setting + * `:section` - A string referencing how the setting should be grouped + * `:type` - The type of value which should be parsed out, can be one of: `string`, `boolean`, `integer` + + ### Optional attributes + * `:permissions` - A permission set (string or list of strings) used to check if a given user can edit this setting + * `:choices` - A list of acceptable choices for `string` based types + * `:default` - The default value for a setting if one is not set, defaults to `nil` + * `:description` - A longer description which can be used to provide more information to users + * `:validator` - A function taking a single value and returning `:ok | {:error, String.t()}` of if the value given is acceptable for the setting type + + ## Examples + ``` + add_server_setting_type(%{ + key: "login.ip_rate_limit", + label: "Login rate limit per IP", + section: "Login", + type: "integer", + permissions: "Admin", + default: 3, + description: "The upper bound on how many failed attempts a given IP can perform before all further attempts will be blocked", + validator: (fn v -> if String.length(v) > 6, do: :ok, else: {:error, "Must be at least 6 characters long"} end) + }) + ``` + """ + @spec add_server_setting_type(map()) :: {:ok, ServerSettingType.t()} | {:error, String.t()} + def add_server_setting_type(args) do + if not Enum.member?(~w(string integer boolean), args.type) do + raise "Invalid type of '#{args.type}', must be one of 'string', 'integer' or 'boolean'" + end + + existing_keys = list_server_setting_type_keys() + + if Enum.member?(existing_keys, args.key) do + raise "Key #{args.key} already exists" + end + + type = %ServerSettingType{ + key: args.key, + label: args.label, + section: args.section, + type: args.type, + permissions: Map.get(args, :permissions), + choices: Map.get(args, :choices), + default: Map.get(args, :default), + description: Map.get(args, :description), + validator: Map.get(args, :validator) + } + + # Update our list of all keys + new_all = [type.key | existing_keys] + Cachex.put(@cache_table, "_all", new_all) + + Cachex.put(@cache_table, type.key, type) + {:ok, type} + end +end diff --git a/lib/teiserver/settings/libs/user_setting_lib.ex b/lib/teiserver/settings/libs/user_setting_lib.ex index 871011d4e..22352a436 100644 --- a/lib/teiserver/settings/libs/user_setting_lib.ex +++ b/lib/teiserver/settings/libs/user_setting_lib.ex @@ -1,9 +1,9 @@ defmodule Teiserver.Settings.UserSettingLib do @moduledoc """ - Library of user_setting related functions. + A library of functions for working with `Teiserver.Settings.UserSetting` """ use TeiserverMacros, :library - alias Teiserver.Settings.{UserSetting, UserSettingQueries} + alias Teiserver.Settings.{UserSetting, UserSettingQueries, UserSettingTypeLib} @doc """ Returns the list of user_settings. @@ -14,11 +14,11 @@ defmodule Teiserver.Settings.UserSettingLib do [%UserSetting{}, ...] """ - @spec list_user_settings(Teiserver.query_args()) :: list - def list_user_settings(query_args \\ []) do + @spec list_user_settings(Teiserver.query_args()) :: [UserSetting.t()] + def list_user_settings(query_args) do query_args |> UserSettingQueries.user_setting_query() - |> Teiserver.Repo.all() + |> Repo.all() end @doc """ @@ -28,18 +28,17 @@ defmodule Teiserver.Settings.UserSettingLib do ## Examples - iex> get_user_setting!(123) + iex> get_user_setting!("userid", "key123") %UserSetting{} - iex> get_user_setting!(456) + iex> get_user_setting!("userid", "key456") ** (Ecto.NoResultsError) """ - @spec get_user_setting!(non_neg_integer()) :: UserSetting.t() - def get_user_setting!(user_setting_id, query_args \\ []) do - (query_args ++ [id: user_setting_id]) - |> UserSettingQueries.user_setting_query() - |> Teiserver.Repo.one!() + @spec get_user_setting!(Teiserver.user_id(), UserSetting.key()) :: UserSetting.t() + def get_user_setting!(user_id, key) do + UserSettingQueries.user_setting_query(where: [key: key, user_id: user_id], limit: 1) + |> Repo.one!() end @doc """ @@ -49,20 +48,123 @@ defmodule Teiserver.Settings.UserSettingLib do ## Examples - iex> get_user_setting(123) + iex> get_user_setting("userid", "key123") %UserSetting{} - iex> get_user_setting(456) + iex> get_user_setting("userid", "key456") nil """ - @spec get_user_setting(non_neg_integer(), list) :: UserSetting.t() | nil - def get_user_setting(user_setting_id, query_args \\ []) do - (query_args ++ [id: user_setting_id]) - |> UserSettingQueries.user_setting_query() - |> Teiserver.Repo.one() + @spec get_user_setting(Teiserver.user_id(), UserSetting.key()) :: UserSetting.t() | nil + def get_user_setting(user_id, key) do + UserSettingQueries.user_setting_query(where: [key: key, user_id: user_id], limit: 1) + |> Repo.one() + end + + @doc """ + Gets the value of a user_setting. + + Returns nil if the UserSetting does not exist. + + ## Examples + + iex> get_user_setting_value("key123") + "value" + + iex> get_user_setting_value("key456") + nil + + """ + @spec get_user_setting_value(Teiserver.user_id(), String.t()) :: + String.t() | integer() | boolean() | nil + def get_user_setting_value(user_id, key) do + lookup = cache_key(user_id, key) + + case Cachex.get(:ts_user_setting_cache, lookup) do + {:ok, nil} -> + setting = get_user_setting(user_id, key) + type = UserSettingTypeLib.get_user_setting_type(key) + + value = + case setting do + nil -> + type.default + + %{value: nil} -> + type.default + + %{value: v} -> + v + end + + value = convert_from_raw_value(value, type.type) + + Cachex.put(:ts_user_setting_cache, lookup, value) + value + + {:ok, value} -> + value + end + end + + @spec convert_from_raw_value(String.t(), String.t()) :: String.t() | integer() | boolean() | nil + defp convert_from_raw_value(raw_value, "string"), do: raw_value + defp convert_from_raw_value(raw_value, "integer"), do: String.to_integer(raw_value) + defp convert_from_raw_value(raw_value, "boolean"), do: raw_value == "t" + defp convert_from_raw_value(_, _), do: nil + + @spec set_user_setting_value( + Teiserver.user_id(), + String.t(), + String.t() | non_neg_integer() | boolean() | nil + ) :: :ok + def set_user_setting_value(user_id, key, value) do + lookup = cache_key(user_id, key) + + type = UserSettingTypeLib.get_user_setting_type(key) + raw_value = convert_to_raw_value(value, type.type) + + case value_is_valid?(type, value) do + :ok -> + case get_user_setting(user_id, key) do + nil -> + {:ok, _} = + create_user_setting(%{ + user_id: user_id, + key: key, + value: raw_value + }) + + :ok + + user_setting -> + {:ok, _} = update_user_setting(user_setting, %{"value" => raw_value}) + Teiserver.invalidate_cache(:ts_user_setting_cache, lookup) + :ok + end + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + + """ + @spec value_is_valid?(ServerSettingType.t(), String.t() | non_neg_integer() | boolean() | nil) :: + :ok | {:error, String.t()} + def value_is_valid?(%{validator: nil}, _), do: :ok + + def value_is_valid?(%{validator: validator_function}, value) do + validator_function.(value) end + @spec convert_to_raw_value(String.t(), String.t()) :: String.t() | integer() | boolean() | nil + defp convert_to_raw_value(value, "string"), do: value + defp convert_to_raw_value(value, "integer"), do: to_string(value) + defp convert_to_raw_value(value, "boolean"), do: if(value, do: "t", else: "f") + defp convert_to_raw_value(_, _), do: nil + @doc """ Creates a user_setting. @@ -76,10 +178,10 @@ defmodule Teiserver.Settings.UserSettingLib do """ @spec create_user_setting(map) :: {:ok, UserSetting.t()} | {:error, Ecto.Changeset.t()} - def create_user_setting(attrs \\ %{}) do + def create_user_setting(attrs) do %UserSetting{} |> UserSetting.changeset(attrs) - |> Teiserver.Repo.insert() + |> Repo.insert() end @doc """ @@ -99,7 +201,7 @@ defmodule Teiserver.Settings.UserSettingLib do def update_user_setting(%UserSetting{} = user_setting, attrs) do user_setting |> UserSetting.changeset(attrs) - |> Teiserver.Repo.update() + |> Repo.update() end @doc """ @@ -117,7 +219,7 @@ defmodule Teiserver.Settings.UserSettingLib do @spec delete_user_setting(UserSetting.t()) :: {:ok, UserSetting.t()} | {:error, Ecto.Changeset.t()} def delete_user_setting(%UserSetting{} = user_setting) do - Teiserver.Repo.delete(user_setting) + Repo.delete(user_setting) end @doc """ @@ -133,4 +235,8 @@ defmodule Teiserver.Settings.UserSettingLib do def change_user_setting(%UserSetting{} = user_setting, attrs \\ %{}) do UserSetting.changeset(user_setting, attrs) end + + defp cache_key(user_id, key) do + "#{user_id}$#{key}" + end end diff --git a/lib/teiserver/settings/libs/user_setting_type_lib.ex b/lib/teiserver/settings/libs/user_setting_type_lib.ex new file mode 100644 index 000000000..f4c01ffdb --- /dev/null +++ b/lib/teiserver/settings/libs/user_setting_type_lib.ex @@ -0,0 +1,88 @@ +defmodule Teiserver.Settings.UserSettingTypeLib do + @moduledoc """ + A library of functions for working with `Teiserver.Settings.UserSettingType` + """ + + @cache_table :ts_user_setting_type_store + + alias Teiserver.Settings.UserSettingType + + @spec list_user_setting_types([String.t()]) :: [UserSettingType.t()] + def list_user_setting_types(keys) do + keys + |> Enum.map(&get_user_setting_type/1) + |> Enum.reject(&(&1 == nil)) + end + + @spec list_user_setting_type_keys() :: [String.t()] + def list_user_setting_type_keys() do + {:ok, v} = Cachex.get(@cache_table, "_all") + v || [] + end + + @spec get_user_setting_type(String.t()) :: UserSettingType.t() | nil + def get_user_setting_type(key) do + {:ok, v} = Cachex.get(@cache_table, key) + v + end + + @doc """ + ### Required keys + * `:key` - The string key of the setting, this is the internal name used for the setting + * `:label` - The user-facing label used for the setting + * `:section` - A string referencing how the setting should be grouped + * `:type` - The type of value which should be parsed out, can be one of: `string`, `boolean`, `integer` + + ### Optional attributes + * `:permissions` - A permission set (string or list of strings) used to check if a given user can edit this setting + * `:choices` - A list of acceptable choices for `string` based types + * `:default` - The default value for a setting if one is not set, defaults to `nil` + * `:description` - A longer description which can be used to provide more information to users + * `:validator` - A function taking a single value and returning `:ok | {:error, String.t()}` of if the value given is acceptable for the setting type + + ## Examples + ``` + add_user_setting_type(%{ + key: "timezone", + label: "Timezone", + section: "interface", + type: "integer", + permissions: nil, + default: 0, + description: "The timezone to convert all Times to.", + validator: (fn v -> if -12 <= v and v <= 14, do: :ok, else: {:error, "Timezone must be within -12 and +14 hours of UTC"} end) + }) + ``` + """ + @spec add_user_setting_type(map()) :: {:ok, UserSettingType.t()} | {:error, String.t()} + def add_user_setting_type(args) do + if not Enum.member?(~w(string integer boolean), args.type) do + raise "Invalid type, must be one of `string`, `integer` or `boolean`" + end + + existing_keys = list_user_setting_type_keys() + + if Enum.member?(existing_keys, args.key) do + raise "Key #{args.key} already exists" + end + + type = %UserSettingType{ + key: args.key, + label: args.label, + section: args.section, + type: args.type, + permissions: Map.get(args, :permissions), + choices: Map.get(args, :choices), + default: Map.get(args, :default), + description: Map.get(args, :description), + validator: Map.get(args, :validator) + } + + # Update our list of all keys + new_all = [type.key | existing_keys] + Cachex.put(@cache_table, "_all", new_all) + + Cachex.put(@cache_table, type.key, type) + {:ok, type} + end +end diff --git a/lib/teiserver/settings/queries/server_setting_queries.ex b/lib/teiserver/settings/queries/server_setting_queries.ex index c65486a34..2c5b89768 100644 --- a/lib/teiserver/settings/queries/server_setting_queries.ex +++ b/lib/teiserver/settings/queries/server_setting_queries.ex @@ -9,9 +9,8 @@ defmodule Teiserver.Settings.ServerSettingQueries do query = from(server_settings in ServerSetting) query - |> do_where(id: args[:id]) + |> do_where(key: args[:key]) |> do_where(args[:where]) - |> do_preload(args[:preload]) |> do_order_by(args[:order_by]) |> QueryHelper.query_select(args[:select]) |> QueryHelper.limit_query(args[:limit] || 50) @@ -31,21 +30,27 @@ defmodule Teiserver.Settings.ServerSettingQueries do def _where(query, _, ""), do: query def _where(query, _, nil), do: query - def _where(query, :id, id) do + def _where(query, :key, key_list) when is_list(key_list) do from(server_settings in query, - where: server_settings.id == ^id + where: server_settings.key in ^key_list ) end - def _where(query, :id_in, id_list) do + def _where(query, :key, key) do from(server_settings in query, - where: server_settings.id in ^id_list + where: server_settings.key == ^key ) end - def _where(query, :name, name) do + def _where(query, :value, value_list) when is_list(value_list) do from(server_settings in query, - where: server_settings.name == ^name + where: server_settings.value in ^value_list + ) + end + + def _where(query, :value, value) do + from(server_settings in query, + where: server_settings.value == ^value ) end @@ -61,10 +66,22 @@ defmodule Teiserver.Settings.ServerSettingQueries do ) end + def _where(query, :updated_after, timestamp) do + from(server_settings in query, + where: server_settings.updated_at >= ^timestamp + ) + end + + def _where(query, :updated_before, timestamp) do + from(server_settings in query, + where: server_settings.updated_at < ^timestamp + ) + end + @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> @@ -73,18 +90,6 @@ defmodule Teiserver.Settings.ServerSettingQueries do end @spec _order_by(Ecto.Query.t(), any()) :: Ecto.Query.t() - def _order_by(query, "Name (A-Z)") do - from(server_settings in query, - order_by: [asc: server_settings.name] - ) - end - - def _order_by(query, "Name (Z-A)") do - from(server_settings in query, - order_by: [desc: server_settings.name] - ) - end - def _order_by(query, "Newest first") do from(server_settings in query, order_by: [desc: server_settings.inserted_at] @@ -96,22 +101,4 @@ defmodule Teiserver.Settings.ServerSettingQueries do order_by: [asc: server_settings.inserted_at] ) end - - @spec do_preload(Ecto.Query.t(), list | nil) :: Ecto.Query.t() - defp do_preload(query, nil), do: query - - defp do_preload(query, _), do: query - # defp do_preload(query, preloads) do - # preloads - # |> List.wrap - # |> Enum.reduce(query, fn key, query_acc -> - # _preload(query_acc, key) - # end) - # end - - # def _preload(query, :relation) do - # from server_setting in query, - # left_join: relations in assoc(server_setting, :relation), - # preload: [relation: relations] - # end end diff --git a/lib/teiserver/settings/queries/user_setting_queries.ex b/lib/teiserver/settings/queries/user_setting_queries.ex index 18611c88e..b3cda393c 100644 --- a/lib/teiserver/settings/queries/user_setting_queries.ex +++ b/lib/teiserver/settings/queries/user_setting_queries.ex @@ -9,7 +9,7 @@ defmodule Teiserver.Settings.UserSettingQueries do query = from(user_settings in UserSetting) query - |> do_where(id: args[:id]) + |> do_where(key: args[:key]) |> do_where(args[:where]) |> do_preload(args[:preload]) |> do_order_by(args[:order_by]) @@ -31,21 +31,39 @@ defmodule Teiserver.Settings.UserSettingQueries do def _where(query, _, ""), do: query def _where(query, _, nil), do: query - def _where(query, :id, id) do + def _where(query, :key, key_list) when is_list(key_list) do from(user_settings in query, - where: user_settings.id == ^id + where: user_settings.key in ^key_list ) end - def _where(query, :id_in, id_list) do + def _where(query, :key, key) do from(user_settings in query, - where: user_settings.id in ^id_list + where: user_settings.key == ^key ) end - def _where(query, :name, name) do + def _where(query, :user_id, user_id_list) when is_list(user_id_list) do from(user_settings in query, - where: user_settings.name == ^name + where: user_settings.user_id in ^user_id_list + ) + end + + def _where(query, :user_id, user_id) do + from(user_settings in query, + where: user_settings.user_id == ^user_id + ) + end + + def _where(query, :value, value_list) when is_list(value_list) do + from(user_settings in query, + where: user_settings.value in ^value_list + ) + end + + def _where(query, :value, value) do + from(user_settings in query, + where: user_settings.value == ^value ) end @@ -61,10 +79,22 @@ defmodule Teiserver.Settings.UserSettingQueries do ) end + def _where(query, :updated_after, timestamp) do + from(user_settings in query, + where: user_settings.updated_at >= ^timestamp + ) + end + + def _where(query, :updated_before, timestamp) do + from(user_settings in query, + where: user_settings.updated_at < ^timestamp + ) + end + @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() defp do_order_by(query, nil), do: query - defp do_order_by(query, params) when is_list(params) do + defp do_order_by(query, params) do params |> List.wrap() |> Enum.reduce(query, fn key, query_acc -> @@ -73,18 +103,6 @@ defmodule Teiserver.Settings.UserSettingQueries do end @spec _order_by(Ecto.Query.t(), any()) :: Ecto.Query.t() - def _order_by(query, "Name (A-Z)") do - from(user_settings in query, - order_by: [asc: user_settings.name] - ) - end - - def _order_by(query, "Name (Z-A)") do - from(user_settings in query, - order_by: [desc: user_settings.name] - ) - end - def _order_by(query, "Newest first") do from(user_settings in query, order_by: [desc: user_settings.inserted_at] diff --git a/lib/teiserver/settings/schemas/server_setting.ex b/lib/teiserver/settings/schemas/server_setting.ex index a31a09d08..38c6b0d66 100644 --- a/lib/teiserver/settings/schemas/server_setting.ex +++ b/lib/teiserver/settings/schemas/server_setting.ex @@ -1,12 +1,14 @@ defmodule Teiserver.Settings.ServerSetting do @moduledoc """ # Site setting - A key/value storage of settings used as part of the server. + A key/value storage of settings used as part of the server. While backed by the database they are cached and thus should be quick to access. ServerSettings can be changed at any stage. + + Each setting key exists only once and affects the entire Teiserver cluster. ### Attributes - * `:key` - The key of the setting - * `:email` - The value of the setting + * `:key` - The key of the setting, refers to a `Teiserver.Settings.UserSettingType` + * `:value` - The value of the setting, while stored as a string it will be converted based on the setting type """ use TeiserverMacros, :schema @@ -15,9 +17,11 @@ defmodule Teiserver.Settings.ServerSetting do field(:key, :string, primary_key: true) field(:value, :string) - timestamps() + timestamps(type: :utc_datetime) end + @type key :: String.t() + @type t :: %__MODULE__{ key: String.t(), value: String.t(), @@ -26,9 +30,11 @@ defmodule Teiserver.Settings.ServerSetting do } @doc false + @spec changeset(map()) :: Ecto.Changeset.t() + @spec changeset(map(), map()) :: Ecto.Changeset.t() def changeset(server_setting, attrs \\ %{}) do server_setting |> cast(attrs, ~w(key value)a) - |> validate_required(~w(key value)a) + |> validate_required(~w(key)a) end end diff --git a/lib/teiserver/settings/schemas/server_setting_type.ex b/lib/teiserver/settings/schemas/server_setting_type.ex new file mode 100644 index 000000000..bff45d951 --- /dev/null +++ b/lib/teiserver/settings/schemas/server_setting_type.ex @@ -0,0 +1,38 @@ +defmodule Teiserver.Settings.ServerSettingType do + @moduledoc """ + # ServerSettingType + A server setting type is a structure for server settings to reference. The setting types are created at node startup and though the values can be changed at runtime the types are not intended to be changed at runtime. + + ### Attributes + + * `:key` - The string key of the setting, this is the internal name used for the setting + * `:label` - The user-facing label used for the setting + * `:section` - A string referencing how the setting should be grouped + * `:type` - The type of value which should be parsed out, can be one of: `string`, `boolean`, `integer` + + ### Optional attributes + * `:permissions` - A permission set (string or list of strings) used to check if a given user can edit this setting + * `:choices` - A list of acceptable choices for `string` based types + * `:default` - The default value for a setting if one is not set, defaults to `nil` + * `:description` - A longer description which can be used to provide more information to users + * `:validator` - A function taking a single value and returning a `:ok | {:error, String.t()}` of if the value given is acceptable for the setting type + """ + + use TypedStruct + + @derive Jason.Encoder + typedstruct do + @typedoc "A server setting type" + + field(:key, Teiserver.Settings.ServerSetting.key()) + field(:label, String.t()) + field(:section, String.t()) + field(:type, String.t()) + + field(:permissions, String.t() | [String.t()] | nil, default: nil) + field(:choices, [String.t()] | nil, default: nil) + field(:default, String.t() | integer() | boolean | nil, default: nil) + field(:description, String.t() | nil, default: nil) + field(:validator, function() | nil, default: nil) + end +end diff --git a/lib/teiserver/settings/schemas/user_setting.ex b/lib/teiserver/settings/schemas/user_setting.ex index fa2826039..2e78c56d8 100644 --- a/lib/teiserver/settings/schemas/user_setting.ex +++ b/lib/teiserver/settings/schemas/user_setting.ex @@ -1,13 +1,17 @@ defmodule Teiserver.Settings.UserSetting do @moduledoc """ # User setting - A key/value storage of settings tied to users + A key/value storage of settings tied to users. They are backed by the database but cached so can be accessed easily. Each user has their own settings with types defined by `Teiserver.Settings.UserSettingType`. + + The intended use case for User settings is anything where you want to store a key-value store against the user. + + Not to be confused with `Teiserver.Game.UserChoice` which is a per-game "setting". ### Attributes * `:user_id` - A reference to the User in question - * `:key` - The key of the setting - * `:email` - The value of the setting + * `:key` - The key of the setting linking it to a `Teiserver.Settings.UserSettingType` + * `:value` - The value of the setting """ use TeiserverMacros, :schema @@ -16,9 +20,11 @@ defmodule Teiserver.Settings.UserSetting do field(:key, :string) field(:value, :string) - timestamps() + timestamps(type: :utc_datetime) end + @type key :: String.t() + @type t :: %__MODULE__{ id: non_neg_integer(), user_id: Teiserver.user_id(), @@ -29,6 +35,8 @@ defmodule Teiserver.Settings.UserSetting do } @doc false + @spec changeset(map()) :: Ecto.Changeset.t() + @spec changeset(map(), map()) :: Ecto.Changeset.t() def changeset(server_setting, attrs \\ %{}) do server_setting |> cast(attrs, ~w(user_id key value)a) diff --git a/lib/teiserver/settings/schemas/user_setting_type.ex b/lib/teiserver/settings/schemas/user_setting_type.ex new file mode 100644 index 000000000..f79520272 --- /dev/null +++ b/lib/teiserver/settings/schemas/user_setting_type.ex @@ -0,0 +1,40 @@ +defmodule Teiserver.Settings.UserSettingType do + @moduledoc """ + # UserSettingType + A user setting type is a structure for user settings to reference. The setting types are created at node startup and though the values can be changed at runtime the types are not intended to be changed at runtime. + + ### Attributes + + * `:key` - The string key of the setting, this is the internal name used for the setting + * `:label` - The user-facing label used for the setting + * `:section` - A string referencing how the setting should be grouped + * `:type` - The type of value which should be parsed out, can be one of: `string`, `boolean`, `integer` + + ### Optional attributes + * `:permissions` - A permission set (string or list of strings) used to check if a given user can edit this setting + * `:choices` - A list of acceptable choices for `string` based types + * `:default` - The default value for a setting if one is not set, defaults to `nil` + * `:description` - A longer description which can be used to provide more information to users + * `:validator` - A function taking a single value and returning a `:ok | {:error, String.t()}` of if the value given is acceptable for the setting type + """ + + use TypedStruct + + @type key :: String.t() + + @derive Jason.Encoder + typedstruct do + @typedoc "A user setting type" + + field(:key, key()) + field(:label, String.t()) + field(:section, String.t()) + field(:type, String.t()) + + field(:permissions, String.t() | [String.t()] | nil, default: nil) + field(:choices, [String.t()] | nil, default: nil) + field(:default, String.t() | integer() | boolean | nil, default: nil) + field(:description, String.t() | nil, default: nil) + field(:validator, function() | nil, default: nil) + end +end diff --git a/lib/teiserver/system/libs/cluster_member_lib.ex b/lib/teiserver/system/libs/cluster_member_lib.ex deleted file mode 100644 index f95dbe4f8..000000000 --- a/lib/teiserver/system/libs/cluster_member_lib.ex +++ /dev/null @@ -1,138 +0,0 @@ -defmodule Teiserver.System.ClusterMemberLib do - @moduledoc """ - Library of cluster_member related functions. - """ - use TeiserverMacros, :library - alias Teiserver.System.{ClusterMember, ClusterMemberQueries} - - @doc """ - Returns the list of cluster_members. - - ## Examples - - iex> list_cluster_members() - [%ClusterMember{}, ...] - - """ - @spec list_cluster_members(Teiserver.query_args()) :: [ClusterMember.t()] - def list_cluster_members(query_args \\ []) do - query_args - |> ClusterMemberQueries.cluster_member_query() - |> Repo.all() - end - - @doc """ - Gets a single cluster_member. - - Raises `Ecto.NoResultsError` if the ClusterMember does not exist. - - ## Examples - - iex> get_cluster_member!(123) - %ClusterMember{} - - iex> get_cluster_member!(456) - ** (Ecto.NoResultsError) - - """ - @spec get_cluster_member!(ClusterMember.id()) :: ClusterMember.t() - @spec get_cluster_member!(ClusterMember.id(), Teiserver.query_args()) :: ClusterMember.t() - def get_cluster_member!(cluster_member_id, query_args \\ []) do - (query_args ++ [id: cluster_member_id]) - |> ClusterMemberQueries.cluster_member_query() - |> Repo.one!() - end - - @doc """ - Gets a single cluster_member. - - Returns nil if the ClusterMember does not exist. - - ## Examples - - iex> get_cluster_member(123) - %ClusterMember{} - - iex> get_cluster_member(456) - nil - - """ - @spec get_cluster_member(ClusterMember.id()) :: ClusterMember.t() | nil - @spec get_cluster_member(ClusterMember.id(), Teiserver.query_args()) :: ClusterMember.t() | nil - def get_cluster_member(cluster_member_id, query_args \\ []) do - (query_args ++ [id: cluster_member_id]) - |> ClusterMemberQueries.cluster_member_query() - |> Repo.one() - end - - @doc """ - Creates a cluster_member. - - ## Examples - - iex> create_cluster_member(%{field: value}) - {:ok, %ClusterMember{}} - - iex> create_cluster_member(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - @spec create_cluster_member(map) :: {:ok, ClusterMember.t()} | {:error, Ecto.Changeset.t()} - def create_cluster_member(attrs \\ %{}) do - %ClusterMember{} - |> ClusterMember.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Updates a cluster_member. - - ## Examples - - iex> update_cluster_member(cluster_member, %{field: new_value}) - {:ok, %ClusterMember{}} - - iex> update_cluster_member(cluster_member, %{field: bad_value}) - {:error, %Ecto.Changeset{}} - - """ - @spec update_cluster_member(ClusterMember.t(), map) :: - {:ok, ClusterMember.t()} | {:error, Ecto.Changeset.t()} - def update_cluster_member(%ClusterMember{} = cluster_member, attrs) do - cluster_member - |> ClusterMember.changeset(attrs) - |> Repo.update() - end - - @doc """ - Deletes a cluster_member. - - ## Examples - - iex> delete_cluster_member(cluster_member) - {:ok, %ClusterMember{}} - - iex> delete_cluster_member(cluster_member) - {:error, %Ecto.Changeset{}} - - """ - @spec delete_cluster_member(ClusterMember.t()) :: - {:ok, ClusterMember.t()} | {:error, Ecto.Changeset.t()} - def delete_cluster_member(%ClusterMember{} = cluster_member) do - Repo.delete(cluster_member) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for tracking cluster_member changes. - - ## Examples - - iex> change_cluster_member(cluster_member) - %Ecto.Changeset{data: %ClusterMember{}} - - """ - @spec change_cluster_member(ClusterMember.t(), map) :: Ecto.Changeset.t() - def change_cluster_member(%ClusterMember{} = cluster_member, attrs \\ %{}) do - ClusterMember.changeset(cluster_member, attrs) - end -end diff --git a/lib/teiserver/system/libs/startup_lib.ex b/lib/teiserver/system/libs/startup_lib.ex new file mode 100644 index 000000000..389f55616 --- /dev/null +++ b/lib/teiserver/system/libs/startup_lib.ex @@ -0,0 +1,57 @@ +defmodule Teiserver.System.StartupLib do + @moduledoc false + # Functionality executed during the Teisever startup process. + + alias Teiserver.Settings + + @spec perform() :: any() + def perform do + Settings.add_server_setting_type(%{ + key: "login.ip_rate_limit", + label: "Login rate limit per IP", + section: "Login", + type: "integer", + permissions: "Admin", + default: 3, + description: + "The upper bound on how many failed attempts a given IP can perform before all further attempts will be blocked" + }) + + Settings.add_server_setting_type(%{ + key: "login.user_rate_limit", + label: "Login rate limit per User", + section: "Login", + type: "integer", + permissions: "Admin", + default: nil, + description: + "The upper bound on how many failed attempts a given user can have before their logins are blocked." + }) + + Settings.add_user_setting_type(%{ + key: "language", + label: "Language", + section: "interface", + type: "string", + permissions: nil, + choices: ["English", "American", "Spanish", "Lojban", "Bork bork"], + default: "English", + description: "The language used in the interface" + }) + + Settings.add_user_setting_type(%{ + key: "timezone", + label: "Timezone", + section: "interface", + type: "integer", + permissions: nil, + default: 0, + description: "The timezone to convert all Times to.", + validator: fn v -> + if -12 <= v and v <= 14, + do: :ok, + else: {:error, "Timezone must be within -12 and +14 hours of UTC"} + end + }) + end +end diff --git a/lib/teiserver/system/queries/cluster_member_queries.ex b/lib/teiserver/system/queries/cluster_member_queries.ex deleted file mode 100644 index 87cc28c6d..000000000 --- a/lib/teiserver/system/queries/cluster_member_queries.ex +++ /dev/null @@ -1,98 +0,0 @@ -defmodule Teiserver.System.ClusterMemberQueries do - @moduledoc false - use TeiserverMacros, :queries - alias Teiserver.System.ClusterMember - require Logger - - @spec cluster_member_query(Teiserver.query_args()) :: Ecto.Query.t() - def cluster_member_query(args) do - query = from(cluster_members in ClusterMember) - - query - |> do_where(id: args[:id]) - |> do_where(args[:where]) - |> do_order_by(args[:order_by]) - |> QueryHelper.query_select(args[:select]) - |> QueryHelper.limit_query(args[:limit] || 50) - end - - @spec do_where(Ecto.Query.t(), list | map | nil) :: Ecto.Query.t() - def do_where(query, nil), do: query - - def do_where(query, params) do - params - |> Enum.reduce(query, fn {key, value}, query_acc -> - _where(query_acc, key, value) - end) - end - - @spec _where(Ecto.Query.t(), atom, any()) :: Ecto.Query.t() - def _where(query, _, ""), do: query - def _where(query, _, nil), do: query - - def _where(query, :id, id_list) when is_list(id_list) do - from(cluster_members in query, - where: cluster_members.id in ^id_list - ) - end - - def _where(query, :id, id) do - from(cluster_members in query, - where: cluster_members.id == ^id - ) - end - - def _where(query, :host, host_list) when is_list(host_list) do - from(cluster_members in query, - where: cluster_members.host in ^host_list - ) - end - - def _where(query, :host, host) do - from(cluster_members in query, - where: cluster_members.host == ^host - ) - end - - def _where(query, :host_not, host) do - from(cluster_members in query, - where: cluster_members.host != ^host - ) - end - - @spec do_order_by(Ecto.Query.t(), list | nil) :: Ecto.Query.t() - defp do_order_by(query, nil), do: query - - defp do_order_by(query, params) when is_list(params) do - params - |> List.wrap() - |> Enum.reduce(query, fn key, query_acc -> - _order_by(query_acc, key) - end) - end - - @spec _order_by(Ecto.Query.t(), any()) :: Ecto.Query.t() - def _order_by(query, "Host (A-Z)") do - from(cluster_members in query, - order_by: [asc: cluster_members.host] - ) - end - - def _order_by(query, "Host (Z-A)") do - from(cluster_members in query, - order_by: [desc: cluster_members.host] - ) - end - - def _order_by(query, "Newest first") do - from(cluster_members in query, - order_by: [desc: cluster_members.inserted_at] - ) - end - - def _order_by(query, "Oldest first") do - from(cluster_members in query, - order_by: [asc: cluster_members.inserted_at] - ) - end -end diff --git a/lib/teiserver/system/schemas/cluster_member.ex b/lib/teiserver/system/schemas/cluster_member.ex deleted file mode 100644 index 41516c870..000000000 --- a/lib/teiserver/system/schemas/cluster_member.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule Teiserver.System.ClusterMember do - @moduledoc """ - # Match - A clustering method making use of a database to setup and update the cluster. - - ### Attributes - - * `:host` - The host name, e.g. "server1.host.co.uk" - - """ - use TeiserverMacros, :schema - - @primary_key {:id, Ecto.UUID, autogenerate: true} - schema "teiserver_cluster_members" do - field(:host, :string) - - timestamps() - end - - @type id :: Ecto.UUID.t() - - @type t :: %__MODULE__{ - id: id(), - host: String.t() - } - - @doc """ - Builds a changeset based on the `struct` and `params`. - """ - @spec changeset(map()) :: Ecto.Changeset.t() - @spec changeset(map(), map()) :: Ecto.Changeset.t() - def changeset(struct, attrs \\ %{}) do - struct - |> cast(attrs, ~w(host)a) - |> validate_required(~w(host)a) - end -end diff --git a/lib/teiserver/system/servers/cache_cluster_server.ex b/lib/teiserver/system/servers/cache_cluster_server.ex new file mode 100644 index 000000000..acccd9012 --- /dev/null +++ b/lib/teiserver/system/servers/cache_cluster_server.ex @@ -0,0 +1,32 @@ +defmodule Teiserver.System.CacheClusterServer do + @moduledoc """ + Allows us to track cache invalidation across a cluster. + """ + use GenServer + alias Phoenix.PubSub + + @spec start_link(list) :: :ignore | {:error, any} | {:ok, pid} + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, nil, opts) + end + + @impl true + def handle_info({:cache_cluster, :delete, from_node, table, key_or_keys}, state) do + if from_node != Node.self() do + key_or_keys + |> List.wrap() + |> Enum.each(fn key -> + Cachex.del(table, key) + end) + end + + {:noreply, state} + end + + @impl true + @spec init(any) :: {:ok, %{}} + def init(_) do + :ok = PubSub.subscribe(Teiserver.PubSub, "cache_cluster") + {:ok, %{}} + end +end diff --git a/lib/teiserver/system/servers/cluster_member_server.ex b/lib/teiserver/system/servers/cluster_member_server.ex deleted file mode 100644 index c9d216dd5..000000000 --- a/lib/teiserver/system/servers/cluster_member_server.ex +++ /dev/null @@ -1,142 +0,0 @@ -defmodule Teiserver.System.ClusterManager do - @moduledoc """ - The cluster manager for handling adding nodes to a cluster. - """ - use GenServer - require Logger - alias Teiserver.System.ClusterMemberLib - - @startup_delay 500 - - # GenServer behaviour - def start_link(params, _opts \\ []) do - GenServer.start_link( - __MODULE__, - params, - name: __MODULE__ - ) - end - - @impl GenServer - def init(_params) do - # Make sure we are told about any nodes joining or leaving the cluster - :net_kernel.monitor_nodes(true) - Process.send_after(self(), {:startup, 1}, @startup_delay) - - {:ok, :pending} - end - - @impl GenServer - def handle_call(other, from, state) do - Logger.warning("unhandled call to ClusterManager: #{inspect(other)}. From: #{inspect(from)}") - {:reply, :not_implemented, state} - end - - @impl GenServer - def handle_cast(other, state) do - Logger.warning("unhandled cast to ClusterManager: #{inspect(other)}.") - {:noreply, state} - end - - @impl GenServer - def handle_info({:startup, start_count}, _state) do - if repo_ready?() do - host_name = Node.self() |> Atom.to_string() - - # If we are running one node or something goes wrong the database will have - # a vestigial row for this node, in that case we can just leave it as is - case ClusterMemberLib.get_cluster_member(nil, where: [host: host_name]) do - nil -> - ClusterMemberLib.create_cluster_member(%{ - host: host_name - }) - - _existing -> - # Do nothing - :ok - end - - # Attempt to join the cluster, if there are any other Nodes "out there".... - case attempt_to_join_cluster() do - false -> - :ok - - true -> - Application.get_env(:teiserver, :teiserver_clustering_post_join_functions, []) - |> Enum.each(fn post_join_function -> - apply(post_join_function, []) - end) - end - - {:noreply, :running} - else - Logger.warning("") - Process.send_after(self(), {:startup, start_count + 1}, @startup_delay * start_count) - {:noreply, :pending} - end - end - - def handle_info({:nodeup, node_name}, state) do - Logger.info("nodeup message to ClusterManager: #{inspect(node_name)}.") - {:noreply, state} - end - - @impl GenServer - def handle_info({:nodedown, node_name}, state) do - node_to_remove = Atom.to_string(node_name) - ClusterMemberLib.delete_cluster_member(node_to_remove) - Logger.warning("nodedown message to ClusterManager: #{inspect(node_name)}.") - {:noreply, state} - end - - @impl GenServer - def handle_info(other, state) do - Logger.warning("unhandled message to ClusterManager: #{inspect(other)}.") - {:noreply, state} - end - - @spec attempt_to_join_cluster() :: boolean() - defp attempt_to_join_cluster() do - host_name = Node.self() |> Atom.to_string() - - case ClusterMemberLib.list_cluster_members(where: [host_not: host_name], limit: :infinity) do - [] -> - Logger.info("Empty cluster, no join has taken place: #{inspect(Node.self())}") - false - - members -> - members - |> Enum.reduce_while(false, fn cluster_member_entity, acc -> - node_name = String.to_atom(cluster_member_entity.host) - - case Node.connect(node_name) do - true -> - {:halt, true} - - false -> - {:cont, acc} - - :ignored -> - {:cont, acc} - end - end) - |> case do - true -> - Logger.info("Node successfully joined the cluster: #{inspect(Node.self())}") - true - - false -> - Logger.error("Node failed to successfully join the cluster: #{inspect(Node.self())}") - false - end - end - end - - @spec repo_ready?() :: boolean - defp repo_ready?() do - case Ecto.Repo.all_running() do - [] -> false - _ready -> true - end - end -end diff --git a/lib/teiserver/system/servers/cluster_member_supervisor.ex b/lib/teiserver/system/servers/cluster_member_supervisor.ex deleted file mode 100644 index 970900bbe..000000000 --- a/lib/teiserver/system/servers/cluster_member_supervisor.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule Teiserver.System.ClusterManagerSupervisor do - @moduledoc """ - The dynamic supervisor for the ClusterManager - """ - use DynamicSupervisor - require Logger - - def start_link(init_arg) do - DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) - end - - @impl true - def init(_init_arg) do - DynamicSupervisor.init( - strategy: :one_for_one, - max_restarts: 10000, - max_seconds: 1 - ) - end - - @spec start_cluster_manager_supervisor_children() :: - :ok | {:error, :start_failure} - def start_cluster_manager_supervisor_children() do - # Start up the Cluster Manager - result = start_cluster_manager() - - children_count = - DynamicSupervisor.count_children(Teiserver.System.ClusterManagerSupervisor) - - Logger.info( - "#{__MODULE__} Supervisor startup completed. Child data:#{inspect(children_count)}" - ) - - result - end - - @spec start_cluster_manager() :: :ok | {:error, :start_failure} - defp start_cluster_manager() do - case DynamicSupervisor.start_child( - __MODULE__, - {Teiserver.System.ClusterManager, []} - ) do - {:ok, _pid} -> - :ok - - {:ok, _pid, _info} -> - :ok - - {:error, {:already_started, _pid}} -> - :ok - - error -> - Logger.error("Failed to start Cluster Manager, error:#{inspect(error)}.") - {:error, :start_failure} - end - end -end diff --git a/mix.exs b/mix.exs index 179d08267..36b21e98f 100644 --- a/mix.exs +++ b/mix.exs @@ -2,13 +2,13 @@ defmodule Teiserver.MixProject do use Mix.Project @source_url "https://github.com/Teifion/teiserver" - @version "0.0.4" + @version "0.0.5" def project do [ app: :teiserver, version: @version, - elixir: "~> 1.14", + elixir: "~> 1.17", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), @@ -59,6 +59,8 @@ defmodule Teiserver.MixProject do "documentation/guides/program_structure.md", "documentation/guides/snippets.md", "documentation/guides/match_lifecycle.md", + "documentation/guides/telemetry.md", + "documentation/guides/testing.md", # Development "documentation/development/features.md", @@ -69,6 +71,8 @@ defmodule Teiserver.MixProject do "documentation/pubsubs/match.md", "documentation/pubsubs/user.md", "documentation/pubsubs/communication.md", + + # KW maps "CHANGELOG.md": [title: "Changelog"] ] end @@ -93,8 +97,7 @@ defmodule Teiserver.MixProject do Teiserver.Logging, Teiserver.Matchmaking, Teiserver.Moderation, - Teiserver.Settings, - Teiserver.Telemetry + Teiserver.Settings ], Account: [ ~r"Teiserver.Account.*" @@ -126,9 +129,6 @@ defmodule Teiserver.MixProject do Settings: [ ~r"Teiserver.Settings.*" ], - Telemetry: [ - ~r"Teiserver.Telemetry.*" - ], Helpers: [ ~r"Teiserver.Helpers.*" ], @@ -166,7 +166,22 @@ defmodule Teiserver.MixProject do # Settings "Site settings": &(&1[:section] == :server_setting), - "User settings": &(&1[:section] == :user_setting) + "User settings": &(&1[:section] == :user_setting), + + # Logging + "Audit logs": &(&1[:section] == :audit_log), + "Match minute logs": &(&1[:section] == :match_minute_log), + "Match day logs": &(&1[:section] == :match_day_log), + "Match week logs": &(&1[:section] == :match_week_log), + "Match month logs": &(&1[:section] == :match_month_log), + "Match quarter logs": &(&1[:section] == :match_quarter_log), + "Match year logs": &(&1[:section] == :match_year_log), + "Server minute logs": &(&1[:section] == :server_minute_log), + "Server day logs": &(&1[:section] == :server_day_log), + "Server week logs": &(&1[:section] == :server_week_log), + "Server month logs": &(&1[:section] == :server_month_log), + "Server quarter logs": &(&1[:section] == :server_quarter_log), + "Server year logs": &(&1[:section] == :server_year_log) ] end @@ -224,22 +239,19 @@ defmodule Teiserver.MixProject do {:ecto_sql, "~> 3.10"}, {:postgrex, ">= 0.0.0"}, {:phoenix_pubsub, "~> 2.1"}, - {:telemetry_metrics, "~> 0.6"}, - {:telemetry_poller, "~> 1.0"}, + {:telemetry, "~> 1.2.1"}, {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, - {:argon2_elixir, "~> 3.0"}, - {:timex, "~> 3.7.5"}, + {:argon2_elixir, "~> 4.0"}, {:typedstruct, "~> 0.5.2", runtime: false}, {:horde, "~> 0.9"}, - {:uuid, "~> 1.1"}, + {:cachex, "~> 3.6"}, # Dev and Test stuff {:ex_doc, "~> 0.31", only: :dev, runtime: false}, - {:excoveralls, "~> 0.15.3", only: :test, runtime: false}, + {:excoveralls, "~> 0.18.1", only: :test, runtime: false}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, - {:floki, ">= 0.34.0", only: :test}, - {:dialyxir, "~> 1.1", only: [:dev], runtime: false} + {:floki, ">= 0.34.0", only: :test} ] end @@ -249,7 +261,7 @@ defmodule Teiserver.MixProject do licenses: ["Apache-2.0"], files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* LICENSE*), links: %{ - "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md", + "Changelog" => "#{@source_url}/blob/main/CHANGELOG.md", "GitHub" => @source_url, "Discord" => "https://discord.gg/NmrSt9zw2p" } @@ -262,6 +274,7 @@ defmodule Teiserver.MixProject do # Oban has these and seems to do a really nice job so we're going to use them too # bench: "run bench/bench_helper.exs", release: [ + "format --check-formatted", "cmd git tag v#{@version}", "cmd git push", "cmd git push --tags", @@ -272,9 +285,8 @@ defmodule Teiserver.MixProject do "test.ci": [ "format --check-formatted", "deps.unlock --check-unused", - "credo --strict", - "test --raise", - "dialyzer" + # "credo --strict", + "test --raise" ] ] end diff --git a/mix.lock b/mix.lock deleted file mode 100644 index e56e59cf5..000000000 --- a/mix.lock +++ /dev/null @@ -1,48 +0,0 @@ -%{ - "argon2_elixir": {:hex, :argon2_elixir, "3.2.1", "f47740bf9f2a39ffef79ba48eb25dea2ee37bcc7eadf91d49615591d1a6fce1a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "a813b78217394530b5fcf4c8070feee43df03ffef938d044019169c766315690"}, - "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, - "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, - "comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"}, - "credo": {:hex, :credo, "1.7.2", "fdee3a7cb553d8f2e773569181f0a4a2bb7d192e27e325404cc31b354f59d68c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd15d6fbc280f6cf9b269f41df4e4992dee6615939653b164ef951f60afcb68e"}, - "db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"}, - "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, - "delta_crdt": {:hex, :delta_crdt, "0.6.4", "79d235eef82a58bb0cb668bc5b9558d2e65325ccb46b74045f20b36fd41671da", [:mix], [{:merkle_map, "~> 0.2.0", [hex: :merkle_map, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4a81f579c06aeeb625db54c6c109859a38aa00d837e3e7f8ac27b40cea34885a"}, - "dialyxir": {:hex, :dialyxir, "1.4.2", "764a6e8e7a354f0ba95d58418178d486065ead1f69ad89782817c296d0d746a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "516603d8067b2fd585319e4b13d3674ad4f314a5902ba8130cd97dc902ce6bbd"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, - "ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"}, - "ecto_sql": {:hex, :ecto_sql, "3.11.1", "e9abf28ae27ef3916b43545f9578b4750956ccea444853606472089e7d169470", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ce14063ab3514424276e7e360108ad6c2308f6d88164a076aac8a387e1fea634"}, - "elixir_make": {:hex, :elixir_make, "0.7.7", "7128c60c2476019ed978210c245badf08b03dbec4f24d05790ef791da11aa17c", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5bc19fff950fad52bbe5f211b12db9ec82c6b34a9647da0c2224b8b8464c7e6c"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "ex_doc": {:hex, :ex_doc, "0.31.0", "06eb1dfd787445d9cab9a45088405593dd3bb7fe99e097eaa71f37ba80c7a676", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5350cafa6b7f77bdd107aa2199fe277acf29d739aba5aee7e865fc680c62a110"}, - "excoveralls": {:hex, :excoveralls, "0.15.3", "54bb54043e1cf5fe431eb3db36b25e8fd62cf3976666bafe491e3fa5e29eba47", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f8eb5d8134d84c327685f7bb8f1db4147f1363c3c9533928234e496e3070114e"}, - "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, - "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, - "floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"}, - "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, - "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, - "horde": {:hex, :horde, "0.9.0", "522342bd7149aeed453c97692a8bca9cf7c9368c5a489afd802e575dc8df54a6", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.4", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "fae11e5bc9c980038607d0c3338cdf7f97124a5d5382fd4b6fb6beaab8e214fe"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, - "libring": {:hex, :libring, "1.6.0", "d5dca4bcb1765f862ab59f175b403e356dec493f565670e0bacc4b35e109ce0d", [:mix], [], "hexpm", "5e91ece396af4bce99953d49ee0b02f698cd38326d93cd068361038167484319"}, - "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.3", "d684f4bac8690e70b06eb52dad65d26de2eefa44cd19d64a8095e1417df7c8fd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "b78dc853d2e670ff6390b605d807263bf606da3c82be37f9d7f68635bd886fc9"}, - "merkle_map": {:hex, :merkle_map, "0.2.1", "01a88c87a6b9fb594c67c17ebaf047ee55ffa34e74297aa583ed87148006c4c8", [:mix], [], "hexpm", "fed4d143a5c8166eee4fa2b49564f3c4eace9cb252f0a82c1613bba905b2d04d"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, - "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, - "postgrex": {:hex, :postgrex, "0.17.4", "5777781f80f53b7c431a001c8dad83ee167bcebcf3a793e3906efff680ab62b3", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "6458f7d5b70652bc81c3ea759f91736c16a31be000f306d3c64bcdfe9a18b3cc"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, - "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, - "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, - "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, - "typed_ecto_schema": {:hex, :typed_ecto_schema, "0.4.1", "a373ca6f693f4de84cde474a67467a9cb9051a8a7f3f615f1e23dc74b75237fa", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "85c6962f79d35bf543dd5659c6adc340fd2480cacc6f25d2cc2933ea6e8fcb3b"}, - "typedstruct": {:hex, :typedstruct, "0.5.2", "a317be3ddfd020da09e1af8418b2653449541836eb5ff23bfdc642a5a4adcc59", [:make, :mix], [], "hexpm", "f9343bc65be73a550194e509c3110be966cb005bb7cba41614f29052e5aa408d"}, - "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, - "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, -} diff --git a/test/account/extra_user_data_test.exs b/test/account/extra_user_data_test.exs index a2302a657..067369ed4 100644 --- a/test/account/extra_user_data_test.exs +++ b/test/account/extra_user_data_test.exs @@ -2,7 +2,7 @@ defmodule Account.ExtraUserDataTest do @moduledoc false use Teiserver.Case, async: true - alias Teiserver.AccountFixtures + alias Teiserver.Fixtures.AccountFixtures alias Teiserver.Account.ExtraUserData describe "ExtraUserData" do diff --git a/test/account/user_lib_test.exs b/test/account/user_lib_test.exs index 7e28b4a69..cf30ea22f 100644 --- a/test/account/user_lib_test.exs +++ b/test/account/user_lib_test.exs @@ -3,7 +3,7 @@ defmodule Teiserver.UserLibTest do use Teiserver.Case, async: true alias Teiserver.Account - alias Teiserver.AccountFixtures + alias Teiserver.Fixtures.AccountFixtures describe "users" do alias Teiserver.Account.User @@ -88,6 +88,72 @@ defmodule Teiserver.UserLibTest do assert user == Account.get_user!(user.id) end + test "update_limited_user/2 with valid data updates the user" do + user = AccountFixtures.user_fixture() + + assert {:ok, %User{} = user} = + Account.update_limited_user(user, Map.put(@update_attrs, :password, "password")) + + assert user.name == "some updated name" + assert user.permissions == [] + assert user.name == "some updated name" + end + + test "update_limited_user/2 requires password" do + user = AccountFixtures.user_fixture() + assert {:error, %Ecto.Changeset{}} = Account.update_limited_user(user, @update_attrs) + end + + test "update_limited_user/2 with invalid data returns error changeset" do + user = AccountFixtures.user_fixture() + assert {:error, %Ecto.Changeset{}} = Account.update_limited_user(user, @invalid_attrs) + assert user == Account.get_user!(user.id) + end + + test "update_password/2 with valid data updates the user" do + user = AccountFixtures.user_fixture() + + assert Account.User.valid_password?("password", user.password) + refute Account.User.valid_password?("pleaseword123", user.password) + + assert {:ok, %User{} = user} = + Account.update_password(user, %{ + "password" => "pleaseword123", + "password_confirmation" => "pleaseword123", + "existing" => "password" + }) + + refute Account.User.valid_password?("password", user.password) + assert Account.User.valid_password?("pleaseword123", user.password) + end + + test "update_password/2 with invalid data returns error changeset" do + user = AccountFixtures.user_fixture() + assert Account.User.valid_password?("password", user.password) + + # No existing + assert {:error, %Ecto.Changeset{}} = + Account.update_password(user, %{ + "password" => "pleaseword123", + "password_confirmation" => "pleaseword123" + }) + + # No confirm + assert {:error, %Ecto.Changeset{}} = + Account.update_password(user, %{ + "password" => "pleaseword123", + "existing" => "password" + }) + + # Bad confirm + assert {:error, %Ecto.Changeset{}} = + Account.update_password(user, %{ + "password" => "pleaseword123", + "password_confirmation" => "please", + "existing" => "password" + }) + end + test "delete_user/1 deletes the user" do user = AccountFixtures.user_fixture() assert {:ok, %User{}} = Account.delete_user(user) @@ -101,14 +167,38 @@ defmodule Teiserver.UserLibTest do end test "valid_password?/2" do - user = AccountFixtures.user_fixture(%{"password" => "password"}) + user = AccountFixtures.user_fixture(%{password: "password"}) assert Account.valid_password?(user, "password") refute Account.valid_password?(user, "bad_password") end + test "restrict_user/2 and unrestrict_user/2" do + user = AccountFixtures.user_fixture() + + # As a string + Account.UserLib.restrict_user(user, "OneRestriction") + user = Account.get_user!(user.id) + assert user.restrictions == ["OneRestriction"] + + # As a list + Account.UserLib.restrict_user(user, ["TwoRestriction"]) + user = Account.get_user!(user.id) + assert user.restrictions == ["OneRestriction", "TwoRestriction"] + + # Two at once, one is an overlap + Account.UserLib.restrict_user(user, ["TwoRestriction", "ThreeRestriction"]) + user = Account.get_user!(user.id) + assert user.restrictions == ["OneRestriction", "TwoRestriction", "ThreeRestriction"] + + # Now remove + Account.UserLib.unrestrict_user(user, ["TwoRestriction", "ThreeRestriction"]) + user = Account.get_user!(user.id) + assert user.restrictions == ["OneRestriction"] + end + test "allow?/2" do # User must have all of the required permissions - user = AccountFixtures.user_fixture(%{"groups" => ["perm1", "perm2"]}) + user = AccountFixtures.user_fixture(%{groups: ["perm1", "perm2"]}) assert Account.allow?(user, "perm1") assert Account.allow?(user.id, "perm1") assert Account.allow?(user.id, ["perm1"]) @@ -129,7 +219,7 @@ defmodule Teiserver.UserLibTest do test "refute?/2" do # Possessing any of the restrictions results in a true value - user = AccountFixtures.user_fixture(%{"restrictions" => ["restrict1", "restrict2"]}) + user = AccountFixtures.user_fixture(%{restrictions: ["restrict1", "restrict2"]}) assert Account.restricted?(user, "restrict1") assert Account.restricted?(user.id, "restrict1") assert Account.restricted?(user.id, ["restrict1"]) @@ -156,9 +246,26 @@ defmodule Teiserver.UserLibTest do end describe "user related functions" do - test "generate password" do + test "generate_password/0" do p = Account.generate_password() assert String.length(p) > 30 end + + test "generate_guest_name/0" do + n = Account.generate_guest_name() + assert String.length(n) > 6 + assert String.contains?(n, " ") + end + + test "user_name_acceptable?/1" do + assert Account.user_name_acceptable?("test name") + assert Account.user_name_acceptable?("a bad word here") + + acceptable_test = fn n -> not String.contains?(n, "bad word") end + Application.put_env(:teiserver, :fn_user_name_acceptor, acceptable_test) + + assert Account.user_name_acceptable?("test name") + refute Account.user_name_acceptable?("a bad word here") + end end end diff --git a/test/account/user_queries_test.exs b/test/account/user_queries_test.exs index 0a287d53d..55447d1f7 100644 --- a/test/account/user_queries_test.exs +++ b/test/account/user_queries_test.exs @@ -38,8 +38,8 @@ defmodule Teiserver.UserQueriesTest do name_or_email: "name_or_email", name_like: "name_like", basic_search: "basic_search", - inserted_after: Timex.now(), - inserted_before: Timex.now(), + inserted_after: DateTime.utc_now(), + inserted_before: DateTime.utc_now(), has_group: "has_group", not_has_group: "not_has_group", has_permission: "has_permission", @@ -51,10 +51,10 @@ defmodule Teiserver.UserQueriesTest do smurf_of: "Non-smurf", behaviour_score_gt: 123, behaviour_score_lt: 123, - last_played_after: Timex.now(), - last_played_before: Timex.now(), - last_login_after: Timex.now(), - last_login_before: Timex.now() + last_played_after: DateTime.utc_now(), + last_played_before: DateTime.utc_now(), + last_login_after: DateTime.utc_now(), + last_login_before: DateTime.utc_now() ], order_by: [ "Name (A-Z)", diff --git a/test/account/user_test.exs b/test/account/user_test.exs index 5627d7be7..134c5d3f6 100644 --- a/test/account/user_test.exs +++ b/test/account/user_test.exs @@ -2,7 +2,7 @@ defmodule Account.UserTest do @moduledoc false use Teiserver.Case, async: true - alias Teiserver.AccountFixtures + alias Teiserver.Fixtures.AccountFixtures alias Teiserver.Account.User describe "User" do @@ -74,7 +74,11 @@ defmodule Account.UserTest do good_existing = User.changeset( user, - %{"existing" => "password", "password" => "password1"}, + %{ + "existing" => "password", + "password" => "password1", + "password_confirmation" => "password1" + }, :change_password ) diff --git a/test/api_test.exs b/test/api_test.exs index d5fee1e44..965ea2846 100644 --- a/test/api_test.exs +++ b/test/api_test.exs @@ -1,49 +1,113 @@ defmodule ApiTest do @moduledoc false - alias Teiserver.CommunicationFixtures + alias Teiserver.Fixtures.CommunicationFixtures use Teiserver.Case, async: true alias Phoenix.PubSub - alias Teiserver.Api - alias Teiserver.AccountFixtures + alias Teiserver.Connections + alias Teiserver.Fixtures.AccountFixtures alias Teiserver.Account.User - describe "API functionality" do - test "maybe_authenticate_user/2" do + describe "Teiserver API functionality" do + test "maybe_authenticate_user_by_email/2" do user = AccountFixtures.user_fixture() - assert Api.maybe_authenticate_user("--- no name ---", "password") == {:error, :no_user} - assert Api.maybe_authenticate_user(user.name, "bad_password") == {:error, :bad_password} - assert Api.maybe_authenticate_user(user.name, "password") == {:ok, user} + assert Teiserver.maybe_authenticate_user_by_email("--- no email ---", "password") == + {:error, :no_user} + + assert Teiserver.maybe_authenticate_user_by_email(user.email, "bad_password") == + {:error, :bad_password} + + assert Teiserver.maybe_authenticate_user_by_email(user.email, "password") == {:ok, user} + end + + test "maybe_authenticate_user_by_id/2" do + user = AccountFixtures.user_fixture() + + assert Teiserver.maybe_authenticate_user_by_id( + "e986ed95-b46f-4ad5-8168-fdb575a623f6", + "password" + ) == + {:error, :no_user} + + assert Teiserver.maybe_authenticate_user_by_id(user.id, "bad_password") == + {:error, :bad_password} + + assert Teiserver.maybe_authenticate_user_by_id(user.id, "password") == {:ok, user} + end + + test "maybe_authenticate_user rate limit/2" do + user = AccountFixtures.user_fixture() + + # No attempts so far, lets check for audit logs + logs = + Teiserver.Logging.list_audit_logs( + where: [ + user_id: user.id + ] + ) + + assert Enum.empty?(logs) + + # Bad login + assert Teiserver.maybe_authenticate_user_by_email(user.email, "bad_password", "my-ip") == + {:error, :bad_password} + + # Ensure it was logged + [log] = + Teiserver.Logging.list_audit_logs( + where: [ + user_id: user.id + ] + ) + + assert log.ip == "my-ip" + assert log.details == %{"reason" => "bad_password"} + assert log.action == "failed-login" + assert log.user_id == user.id + + # Now do it a few more times to trip the breaker + assert Teiserver.maybe_authenticate_user_by_email(user.email, "bad_password", "my-ip") == + {:error, :bad_password} + + assert Teiserver.maybe_authenticate_user_by_email(user.email, "bad_password", "my-ip") == + {:error, :bad_password} + + assert Teiserver.maybe_authenticate_user_by_email(user.email, "bad_password", "my-ip") == + {:error, :bad_password} + + assert Teiserver.maybe_authenticate_user_by_email(user.email, "bad_password", "my-ip") == + {:error, :rate_limit} end test "register_user/3" do # Incorrectly - assert {:error, %Ecto.Changeset{} = _} = Api.register_user("alice", "email", "") + assert {:error, %Ecto.Changeset{} = _} = Teiserver.register_user("alice", "email", "") # Correctly - assert {:ok, %User{} = user} = Api.register_user("alice", "email", "password") + assert {:ok, %User{} = user} = Teiserver.register_user("alice", "email", "password") assert user.permissions == [] assert user.name == "alice" # Now dupe the email - assert {:error, %Ecto.Changeset{} = _} = Api.register_user("alice", "email", "password") + assert {:error, %Ecto.Changeset{} = _} = + Teiserver.register_user("alice", "email", "password") end test "connect_user/1" do user = AccountFixtures.user_fixture() conn = TestConn.new() - client_ids = Teiserver.Connections.list_client_ids() + client_ids = Connections.list_client_ids() refute Enum.member?(client_ids, user.id) assert TestConn.get(conn) == [] - TestConn.run(conn, fn -> Api.connect_user(user.id) end) + TestConn.run(conn, fn -> Teiserver.connect_user(user.id) end) # Check we're subbed to the right stuff PubSub.broadcast( Teiserver.PubSub, - Teiserver.Connections.client_topic(user.id), + Connections.client_topic(user.id), "client_topic" ) @@ -56,8 +120,11 @@ defmodule ApiTest do assert TestConn.get(conn) == ["client_topic", "user_messaging_topic"] # Check we're counted as logged in - client_ids = Teiserver.Connections.list_client_ids() + client_ids = Connections.list_client_ids() assert Enum.member?(client_ids, user.id) + + assert Enum.sort(Connections.list_client_ids()) == + Enum.sort(Connections.list_local_client_ids()) end end @@ -68,15 +135,15 @@ defmodule ApiTest do user1 = AccountFixtures.user_fixture() user2 = AccountFixtures.user_fixture() - assert room == Api.get_room_by_name_or_id(room.name) + assert room == Teiserver.get_room_by_name_or_id(room.name) - Api.subscribe_to_room_messages(room) - Api.unsubscribe_from_room_messages(room) + Teiserver.subscribe_to_room_messages(room) + Teiserver.unsubscribe_from_room_messages(room) - assert Api.list_recent_room_messages(room.id) == [] + assert Teiserver.list_recent_room_messages(room.id) == [] - {:ok, _} = Api.send_room_message(user1.id, room.id, "Content") - {:ok, _} = Api.send_direct_message(user1.id, user2.id, "Content") + {:ok, _} = Teiserver.send_room_message(user1.id, room.id, "Content") + {:ok, _} = Teiserver.send_direct_message(user1.id, user2.id, "Content") end end end diff --git a/test/communication/libs/direct_message_lib_test.exs b/test/communication/libs/direct_message_lib_test.exs index adad2655d..ccc94b3e4 100644 --- a/test/communication/libs/direct_message_lib_test.exs +++ b/test/communication/libs/direct_message_lib_test.exs @@ -4,12 +4,12 @@ defmodule Teiserver.DirectMessageLibTest do alias Teiserver.Communication use Teiserver.Case, async: true - alias Teiserver.{CommunicationFixtures, ConnectionFixtures, AccountFixtures} + alias Teiserver.Fixtures.{CommunicationFixtures, ConnectionFixtures, AccountFixtures} defp valid_attrs do %{ content: "some content", - inserted_at: Timex.now(), + inserted_at: DateTime.utc_now(), sender_id: AccountFixtures.user_fixture().id, to_id: AccountFixtures.user_fixture().id } @@ -18,7 +18,7 @@ defmodule Teiserver.DirectMessageLibTest do defp update_attrs do %{ content: "some updated content", - inserted_at: Timex.now(), + inserted_at: DateTime.utc_now(), sender_id: AccountFixtures.user_fixture().id, to_id: AccountFixtures.user_fixture().id } diff --git a/test/communication/libs/match_message_lib_test.exs b/test/communication/libs/match_message_lib_test.exs index ba67dc7d3..165e57dcf 100644 --- a/test/communication/libs/match_message_lib_test.exs +++ b/test/communication/libs/match_message_lib_test.exs @@ -1,15 +1,22 @@ defmodule Teiserver.MatchMessageLibTest do @moduledoc false + alias Teiserver.Communication.MatchMessageLib alias Teiserver.Communication.MatchMessage use Teiserver.Case, async: true alias Teiserver.Communication - alias Teiserver.{CommunicationFixtures, AccountFixtures, ConnectionFixtures, GameFixtures} + + alias Teiserver.Fixtures.{ + CommunicationFixtures, + AccountFixtures, + ConnectionFixtures, + GameFixtures + } defp valid_attrs do %{ content: "some content", - inserted_at: Timex.now(), + inserted_at: DateTime.utc_now(), sender_id: AccountFixtures.user_fixture().id, match_id: GameFixtures.incomplete_match_fixture().id } @@ -18,7 +25,7 @@ defmodule Teiserver.MatchMessageLibTest do defp update_attrs do %{ content: "some updated content", - inserted_at: Timex.now(), + inserted_at: DateTime.utc_now(), sender_id: AccountFixtures.user_fixture().id, match_id: GameFixtures.incomplete_match_fixture().id } @@ -36,6 +43,13 @@ defmodule Teiserver.MatchMessageLibTest do describe "match_message" do alias Teiserver.Communication.MatchMessage + test "topic" do + match = GameFixtures.incomplete_match_fixture() + topic = MatchMessageLib.match_messaging_topic(match) + assert topic == "Teiserver.Communication.Match.#{match.id}" + assert MatchMessageLib.match_messaging_topic(match.id) == topic + end + test "match_message_query/1 returns query" do assert Communication.match_message_query([]) end diff --git a/test/communication/libs/room_lib_test.exs b/test/communication/libs/room_lib_test.exs index 8bc913fef..ca4412440 100644 --- a/test/communication/libs/room_lib_test.exs +++ b/test/communication/libs/room_lib_test.exs @@ -3,7 +3,7 @@ defmodule Teiserver.RoomLibTest do use Teiserver.Case, async: true alias Teiserver.Communication - alias Teiserver.CommunicationFixtures + alias Teiserver.Fixtures.CommunicationFixtures describe "room" do alias Teiserver.Communication.Room diff --git a/test/communication/libs/room_message_lib_test.exs b/test/communication/libs/room_message_lib_test.exs index bd682e9c7..6bfb70b98 100644 --- a/test/communication/libs/room_message_lib_test.exs +++ b/test/communication/libs/room_message_lib_test.exs @@ -5,12 +5,12 @@ defmodule Teiserver.RoomMessageLibTest do alias Teiserver.Communication alias Phoenix.PubSub - alias Teiserver.{CommunicationFixtures, AccountFixtures, ConnectionFixtures} + alias Teiserver.Fixtures.{CommunicationFixtures, AccountFixtures, ConnectionFixtures} defp valid_attrs do %{ content: "some content", - inserted_at: Timex.now(), + inserted_at: DateTime.utc_now(), sender_id: AccountFixtures.user_fixture().id, room_id: CommunicationFixtures.room_fixture().id } @@ -19,7 +19,7 @@ defmodule Teiserver.RoomMessageLibTest do defp update_attrs do %{ content: "some updated content", - inserted_at: Timex.now(), + inserted_at: DateTime.utc_now(), sender_id: AccountFixtures.user_fixture().id, room_id: CommunicationFixtures.room_fixture().id } diff --git a/test/communication/queries/direct_message_queries_test.exs b/test/communication/queries/direct_message_queries_test.exs index f72407db4..42459bd75 100644 --- a/test/communication/queries/direct_message_queries_test.exs +++ b/test/communication/queries/direct_message_queries_test.exs @@ -37,8 +37,8 @@ defmodule Teiserver.DirectMessageQueriesTest do to_id: Teiserver.uuid(), to_or_from_id: [Teiserver.uuid(), Teiserver.uuid()], to_or_from_id: Teiserver.uuid(), - inserted_after: Timex.now(), - inserted_before: Timex.now() + inserted_after: DateTime.utc_now(), + inserted_before: DateTime.utc_now() ], order_by: [ "Newest first", diff --git a/test/communication/queries/match_message_queries_test.exs b/test/communication/queries/match_message_queries_test.exs index 96e3ffb7a..39523d210 100644 --- a/test/communication/queries/match_message_queries_test.exs +++ b/test/communication/queries/match_message_queries_test.exs @@ -35,8 +35,8 @@ defmodule Teiserver.MatchMessageQueriesTest do sender_id: Teiserver.uuid(), match_id: [Teiserver.uuid(), Teiserver.uuid()], match_id: Teiserver.uuid(), - inserted_after: Timex.now(), - inserted_before: Timex.now() + inserted_after: DateTime.utc_now(), + inserted_before: DateTime.utc_now() ], order_by: [ "Newest first", diff --git a/test/communication/queries/room_message_queries_test.exs b/test/communication/queries/room_message_queries_test.exs index f4043c168..88f55f361 100644 --- a/test/communication/queries/room_message_queries_test.exs +++ b/test/communication/queries/room_message_queries_test.exs @@ -35,8 +35,8 @@ defmodule Teiserver.RoomMessageQueriesTest do sender_id: Teiserver.uuid(), room_id: [1, 2], room_id: 1, - inserted_after: Timex.now(), - inserted_before: Timex.now() + inserted_after: DateTime.utc_now(), + inserted_before: DateTime.utc_now() ], order_by: [ "Newest first", diff --git a/test/communication/queries/room_queries_test.exs b/test/communication/queries/room_queries_test.exs index 664909e17..935c7408d 100644 --- a/test/communication/queries/room_queries_test.exs +++ b/test/communication/queries/room_queries_test.exs @@ -32,8 +32,8 @@ defmodule Teiserver.RoomQueriesTest do id: [1, 2], id: 1, name: "Some name", - inserted_after: Timex.now(), - inserted_before: Timex.now() + inserted_after: DateTime.utc_now(), + inserted_before: DateTime.utc_now() ], order_by: [ "Name (A-Z)", diff --git a/test/connections/client_in_lobby_test.exs b/test/connections/client_in_lobby_test.exs new file mode 100644 index 000000000..a430569a1 --- /dev/null +++ b/test/connections/client_in_lobby_test.exs @@ -0,0 +1,28 @@ +defmodule Connections.ClientInLobbyLibTest do + @moduledoc false + use Teiserver.Case, async: false + + alias Teiserver.{Connections, Game} + alias Teiserver.Fixtures.{ConnectionFixtures, GameFixtures} + + describe "ClientLib" do + test "update_client_in_lobby" do + {_host_conn, _host_user, lobby_id} = GameFixtures.lobby_fixture_with_process() + {_conn, user} = ConnectionFixtures.client_fixture() + + Game.add_client_to_lobby(user.id, lobby_id) + + client = Connections.get_client(user.id) + assert client.lobby_id == lobby_id + assert client.player? == false + + Connections.update_client_in_lobby(user.id, %{player?: true}, "reason here") + + # Need to give the LobbyServer time to update us + :timer.sleep(50) + + client = Connections.get_client(user.id) + assert client.player? == true + end + end +end diff --git a/test/connections/client_lib_test.exs b/test/connections/client_lib_test.exs index 51a094d48..99ff3e886 100644 --- a/test/connections/client_lib_test.exs +++ b/test/connections/client_lib_test.exs @@ -3,10 +3,17 @@ defmodule Connections.ClientLibTest do use Teiserver.Case, async: true alias Teiserver.Connections - alias Teiserver.Connections.ClientLib - alias Teiserver.{ConnectionFixtures} + alias Teiserver.Fixtures.ConnectionFixtures describe "ClientLib" do + test "topic" do + {_conn, user} = ConnectionFixtures.client_fixture() + client = Connections.get_client(user.id) + + assert Connections.client_topic(user.id) == Connections.client_topic(user) + assert Connections.client_topic(user.id) == Connections.client_topic(client) + end + test "server lifecycle" do {_conn, user} = ConnectionFixtures.client_fixture() @@ -31,8 +38,8 @@ defmodule Connections.ClientLibTest do client = Connections.get_client(user.id) refute client.afk? - # Now update it - Connections.update_client(user.id, %{afk?: true, team_number: 123}, uuid) + # Now update it, the `total_number` key isn't a valid key + Connections.update_client(user.id, %{afk?: true, total_number: 123}, uuid) # Check the client has updated client = Connections.get_client(user.id) @@ -56,7 +63,7 @@ defmodule Connections.ClientLibTest do ] # Now try to update with the same details, should result in no change - Connections.update_client(user.id, %{afk?: true, team_number: 123}, "test") + Connections.update_client(user.id, %{afk?: true}, "test") client = Connections.get_client(user.id) assert client.afk? @@ -66,7 +73,7 @@ defmodule Connections.ClientLibTest do assert msgs == [] # Now swap the value around to ensure we get a new update - Connections.update_client(user.id, %{afk?: false, team_number: 123}, "test2") + Connections.update_client(user.id, %{afk?: false}, "test2") client = Connections.get_client(user.id) refute client.afk? @@ -81,70 +88,6 @@ defmodule Connections.ClientLibTest do assert update_msg.reason == "test2" end - test "update_client_full" do - uuid = Teiserver.uuid() - {conn, user} = ConnectionFixtures.client_fixture() - TestConn.subscribe(conn, Connections.client_topic(user.id)) - - msgs = TestConn.get(conn) - assert msgs == [] - - # Check the client is as we expect - client = Connections.get_client(user.id) - refute client.afk? - assert client.team_number == nil - - # Now update it - ClientLib.update_client_full(user.id, %{afk?: true, team_number: 123}, uuid) - - # Check the client has updated - client = Connections.get_client(user.id) - assert client.afk? - assert client.team_number == 123 - - # Should have gotten a new message too - msgs = TestConn.get(conn) - - assert msgs == [ - %{ - topic: Connections.client_topic(user.id), - event: :client_updated, - changes: %{ - afk?: true, - update_id: 1, - team_number: 123 - }, - reason: uuid, - user_id: user.id - } - ] - - # Now try to update with the same details, should result in no change - ClientLib.update_client_full(user.id, %{afk?: true, team_number: 123}, "test") - - client = Connections.get_client(user.id) - assert client.afk? - assert client.team_number == 123 - - msgs = TestConn.get(conn) - assert msgs == [] - - # Now swap the value around to ensure we get a new update - ClientLib.update_client_full(user.id, %{afk?: false, team_number: 456}, "test2") - - client = Connections.get_client(user.id) - refute client.afk? - assert client.team_number == 456 - - msgs = TestConn.get(conn) - assert Enum.count(msgs) == 1 - [update_msg] = msgs - - refute update_msg.changes.afk? - assert client.team_number == 456 - assert update_msg.reason == "test2" - end - test "get_client_list" do {_conn1, user1} = ConnectionFixtures.client_fixture() {_conn1, user2} = ConnectionFixtures.client_fixture() @@ -180,5 +123,48 @@ defmodule Connections.ClientLibTest do msgs = TestConn.get(conn) assert msgs == [] end + + test "disconnect_user/1" do + {_conn1, user} = ConnectionFixtures.client_fixture() + {_conn2, _user} = ConnectionFixtures.client_fixture(user) + assert Connections.client_exists?(user.id) + + Connections.disconnect_user(user.id) + refute Connections.client_exists?(user.id) + end + + test "disconnect_single_connection/2" do + {conn1, user} = ConnectionFixtures.client_fixture() + {conn2, _user} = ConnectionFixtures.client_fixture(user) + assert Connections.client_exists?(user.id) + + conn_list = Connections.call_client(user.id, :get_connections) + assert Enum.member?(conn_list, conn1) + assert Enum.member?(conn_list, conn2) + + # Disconnect conn1 + Connections.disconnect_single_connection(user.id, conn1) + assert Connections.client_exists?(user.id) + + conn_list = Connections.call_client(user.id, :get_connections) + refute Enum.member?(conn_list, conn1) + assert Enum.member?(conn_list, conn2) + + # Disconnect self, this should have no effect as we are not a connection + Connections.disconnect_single_connection(user.id, self()) + assert Connections.client_exists?(user.id) + + conn_list = Connections.call_client(user.id, :get_connections) + refute Enum.member?(conn_list, conn1) + assert Enum.member?(conn_list, conn2) + + # Now get rid of conn2, this should result in the client destroying itself + Connections.disconnect_single_connection(user.id, conn2) + + # Give it time to die + :timer.sleep(50) + + refute Connections.client_exists?(user.id) + end end end diff --git a/test/connections/client_server_test.exs b/test/connections/client_server_test.exs index 6aa319cea..cbf302f9b 100644 --- a/test/connections/client_server_test.exs +++ b/test/connections/client_server_test.exs @@ -3,7 +3,7 @@ defmodule Connections.ClientServerTest do use Teiserver.Case, async: true alias Teiserver.Connections - alias Teiserver.{AccountFixtures, ConnectionFixtures} + alias Teiserver.Fixtures.{AccountFixtures, ConnectionFixtures} describe "Client server" do test "server lifecycle" do @@ -102,11 +102,18 @@ defmodule Connections.ClientServerTest do test "heartbeat destroy process" do {conn, user} = ConnectionFixtures.client_fixture() + # Ensure it's all here and is hunky-dory + client_pid = Connections.get_client_pid(user.id) + send(client_pid, :heartbeat) + :timer.sleep(100) + + assert Connections.client_exists?(user.id) + # Kill the connecting process TestConn.stop(conn) # Set the last_disconnected to something okay - disconnected_at = Timex.now() |> Timex.shift(seconds: -100) + disconnected_at = DateTime.utc_now() |> DateTime.shift(second: -100) Connections.update_client(user.id, %{last_disconnected: disconnected_at}, "test-heartbeat") client = Connections.get_client(user.id) @@ -120,7 +127,7 @@ defmodule Connections.ClientServerTest do assert Connections.client_exists?(user.id) # Set the last_disconnected to something much larger, it should result in the client process being destroyed - disconnected_at = Timex.now() |> Timex.shift(seconds: -1_000_000) + disconnected_at = DateTime.utc_now() |> DateTime.shift(second: -1_000_000) Connections.update_client(user.id, %{last_disconnected: disconnected_at}, "test-heartbeat2") client = Connections.get_client(user.id) diff --git a/test/game/libs/lobby_lib_async_test.exs b/test/game/libs/lobby_lib_async_test.exs new file mode 100644 index 000000000..560dc96d2 --- /dev/null +++ b/test/game/libs/lobby_lib_async_test.exs @@ -0,0 +1,25 @@ +defmodule Teiserver.Game.LobbyLibAsyncTest do + @moduledoc false + use Teiserver.Case, async: true + + alias Teiserver.Game + + describe "LobbyLib" do + test "lobby_name_acceptable?/1" do + assert Game.lobby_name_acceptable?("test name") + assert Game.lobby_name_acceptable?("a bad word here") + + acceptable_test = fn n -> not String.contains?(n, "bad word") end + Application.put_env(:teiserver, :fn_lobby_name_acceptor, acceptable_test) + + assert Game.lobby_name_acceptable?("test name") + refute Game.lobby_name_acceptable?("a bad word here") + end + + test "open_lobby" do + # This client won't exist, thus it should fail + assert Game.open_lobby(Teiserver.uuid(), "no-host-lobby") == + {:error, :client_disconnected} + end + end +end diff --git a/test/game/libs/lobby_lib_test.exs b/test/game/libs/lobby_lib_sync_test.exs similarity index 53% rename from test/game/libs/lobby_lib_test.exs rename to test/game/libs/lobby_lib_sync_test.exs index 808d9a34f..f35f75a36 100644 --- a/test/game/libs/lobby_lib_test.exs +++ b/test/game/libs/lobby_lib_sync_test.exs @@ -1,9 +1,10 @@ -defmodule Teiserver.Game.LobbyLibTest do +defmodule Teiserver.Game.LobbyLibSyncTest do @moduledoc false - use Teiserver.Case, async: true + use Teiserver.Case, async: false alias Teiserver.Game - alias Teiserver.{ConnectionFixtures, GameFixtures} + alias Teiserver.Game.LobbyLib + alias Teiserver.Fixtures.{ConnectionFixtures, GameFixtures} describe "LobbyLib" do test "Creating and stopping server" do @@ -39,9 +40,6 @@ defmodule Teiserver.Game.LobbyLibTest do refute Game.lobby_exists?(lobby_id2) end - test "open_lobby" do - end - test "list_lobby_summaries" do {_host_conn, _host_user, lobby1_id} = GameFixtures.lobby_fixture_with_process() {_host_conn, _host_user, lobby2_id} = GameFixtures.lobby_fixture_with_process() @@ -56,7 +54,9 @@ defmodule Teiserver.Game.LobbyLibTest do assert Enum.member?(lobby_list_ids, lobby3_id) # Now just two of them - lobby_list = Game.stream_lobby_summaries(%{"ids" => [lobby1_id, lobby2_id]}) |> Enum.to_list() + lobby_list = + Game.stream_lobby_summaries(%{"ids" => [lobby1_id, lobby2_id]}) |> Enum.to_list() + assert Enum.count(lobby_list) == 2 lobby_list_ids = Enum.map(lobby_list, fn l -> l.id end) @@ -64,6 +64,16 @@ defmodule Teiserver.Game.LobbyLibTest do assert Enum.member?(lobby_list_ids, lobby2_id) refute Enum.member?(lobby_list_ids, lobby3_id) + # Now with a dud filter + lobby_list = + Game.stream_lobby_summaries(%{"dud-filter" => [lobby1_id, lobby2_id]}) |> Enum.to_list() + + lobby_list_ids = Enum.map(lobby_list, fn l -> l.id end) + + assert Enum.member?(lobby_list_ids, lobby1_id) + assert Enum.member?(lobby_list_ids, lobby2_id) + assert Enum.member?(lobby_list_ids, lobby3_id) + # Cleanup Game.stop_lobby_server(lobby1_id) Game.stop_lobby_server(lobby2_id) @@ -77,6 +87,13 @@ defmodule Teiserver.Game.LobbyLibTest do # The test is async so it's possible other lobbies are being created while this runs lobby_ids = Game.list_lobby_ids() + local_lobby_ids = Game.list_local_lobby_ids() + + # In theory we're not on a cluster so these _should_ be the same + # it is possible multiple tests running at once could create a lobby in between these + # two, if that starts happening we probably just want to retry it a few times + # Notably there is no promise they are in the same order so we sort them + assert Enum.sort(lobby_ids) == Enum.sort(local_lobby_ids) assert Enum.member?(lobby_ids, lobby1_id) assert Enum.member?(lobby_ids, lobby2_id) @@ -114,5 +131,67 @@ defmodule Teiserver.Game.LobbyLibTest do # Cleanup Game.stop_lobby_server(lobby_id) end + + test "cast_lobby/2" do + {_host_conn, _host_user, lobby_id} = GameFixtures.lobby_fixture_with_process() + + assert LobbyLib.cast_lobby(Teiserver.uuid(), :cycle_lobby) == nil + assert LobbyLib.cast_lobby(lobby_id, :cycle_lobby) == :ok + Game.stop_lobby_server(lobby_id) + end + + test "stop_lobby_server/2" do + {_host_conn, _host_user, lobby_id} = GameFixtures.lobby_fixture_with_process() + assert Game.lobby_exists?(lobby_id) + + assert Game.stop_lobby_server(Teiserver.uuid()) == nil + assert Game.lobby_exists?(lobby_id) + + assert Game.stop_lobby_server(lobby_id) == :ok + refute Game.lobby_exists?(lobby_id) + end + + test "lobby_end_match/1" do + {_host_conn, _host_user, lobby_id} = GameFixtures.lobby_fixture_with_process() + # Start the match so we can end it + LobbyLib.lobby_start_match(lobby_id) + :timer.sleep(1000) + started_state = Game.get_lobby(lobby_id) + assert started_state.match_ongoing? + + LobbyLib.lobby_end_match(lobby_id) + :timer.sleep(500) + ended_state = Game.get_lobby(lobby_id) + refute ended_state.match_ongoing? + + assert started_state.match_id != ended_state.match_id + end + + test "subscriptions" do + {_host_conn, _host_user, lobby_id} = GameFixtures.lobby_fixture_with_process() + lobby = Game.get_lobby(lobby_id) + + {conn1, _user1} = ConnectionFixtures.client_fixture() + {conn2, _user2} = ConnectionFixtures.client_fixture() + + TestConn.run(conn1, fn -> Game.subscribe_to_lobby(lobby.id) end) + + assert TestConn.get(conn1) == [] + assert TestConn.get(conn2) == [] + + topic = Game.lobby_topic(lobby.id) + Teiserver.broadcast(topic, %{event: :test_message, msg: "test"}) + + assert TestConn.get(conn1) == [%{msg: "test", event: :test_message, topic: topic}] + assert TestConn.get(conn2) == [] + + TestConn.run(conn1, fn -> Game.unsubscribe_from_lobby(lobby.id) end) + + Teiserver.broadcast(topic, %{event: :test_message, msg: "test2"}) + assert TestConn.get(conn1) == [] + assert TestConn.get(conn2) == [] + + Game.stop_lobby_server(lobby_id) + end end end diff --git a/test/game/libs/match_lib_test.exs b/test/game/libs/match_lib_test.exs index 439091296..aa32a378d 100644 --- a/test/game/libs/match_lib_test.exs +++ b/test/game/libs/match_lib_test.exs @@ -4,7 +4,7 @@ defmodule Teiserver.MatchLibAsyncTest do use Teiserver.Case, async: false alias Teiserver.{Game, Connections} - alias Teiserver.{GameFixtures, AccountFixtures, ConnectionFixtures} + alias Teiserver.Fixtures.{GameFixtures, AccountFixtures, ConnectionFixtures} defp valid_attrs do %{ @@ -118,15 +118,21 @@ defmodule Teiserver.MatchLibAsyncTest do {_, u4} = ConnectionFixtures.client_fixture() u5 = AccountFixtures.user_fixture() - assert Game.can_add_client_to_lobby(u1.id, lobby_id) == {true, nil} - assert Game.can_add_client_to_lobby(u5.id, lobby_id) == {false, "Client is not connected"} + assert Game.can_add_client_to_lobby(u1.id, Teiserver.uuid()) == {false, :no_lobby} + assert Game.can_add_client_to_lobby(u1.id, lobby_id) == true + assert Game.can_add_client_to_lobby(u5.id, lobby_id) == {false, :client_disconnected} Game.add_client_to_lobby(u1.id, lobby_id) Game.add_client_to_lobby(u2.id, lobby_id) Game.add_client_to_lobby(u3.id, lobby_id) Game.add_client_to_lobby(u4.id, lobby_id) - assert Game.can_add_client_to_lobby(u1.id, lobby_id) == {false, "Existing member"} + # Just to check, if we try to add the now it says no + assert Game.can_add_client_to_lobby(u1.id, lobby_id) == {false, :existing_member} + + # And trying to add them anyway won't generate a problem + resp = Game.add_client_to_lobby(u1.id, lobby_id) + assert resp == {:error, :existing_member} lobby = Game.get_lobby(lobby_id) @@ -136,13 +142,10 @@ defmodule Teiserver.MatchLibAsyncTest do assert lobby.players == [] # Update the clients by making them players and putting them on teams - Connections.update_client_in_lobby(u1.id, %{player_number: 1, team_number: 1, player?: true}, "test") - - Connections.update_client_in_lobby(u2.id, %{player_number: 2, team_number: 1, player?: true}, "test") - - Connections.update_client_in_lobby(u3.id, %{player_number: 3, team_number: 2, player?: true}, "test") - - Connections.update_client_in_lobby(u4.id, %{player_number: 4, team_number: 2, player?: true}, "test") + Connections.update_client(u1.id, %{player_number: 1, team_number: 1, player?: true}, "test") + Connections.update_client(u2.id, %{player_number: 2, team_number: 1, player?: true}, "test") + Connections.update_client(u3.id, %{player_number: 3, team_number: 2, player?: true}, "test") + Connections.update_client(u4.id, %{player_number: 4, team_number: 2, player?: true}, "test") # Give the lobby time to read and update :timer.sleep(100) @@ -165,7 +168,7 @@ defmodule Teiserver.MatchLibAsyncTest do settings = Game.get_match_settings_map(match.id) assert settings == %{} - started_match = Game.start_match(lobby.id) + {:ok, started_match} = Game.start_match(lobby.id) refute started_match.match_started_at == nil assert started_match.match_ended_at == nil @@ -186,12 +189,12 @@ defmodule Teiserver.MatchLibAsyncTest do :timer.sleep(100) outcome = %{ - winning_team: 1, - ended_normally?: true, - players: %{ + "winning_team" => 1, + "ended_normally?" => true, + "players" => %{ u1.id => %{}, u2.id => %{}, - u3.id => %{left_after_seconds: 1}, + u3.id => %{"left_after_seconds" => 1}, u4.id => %{} } } diff --git a/test/game/libs/match_membership_lib_test.exs b/test/game/libs/match_membership_lib_test.exs index b9fe2632f..435fc92c5 100644 --- a/test/game/libs/match_membership_lib_test.exs +++ b/test/game/libs/match_membership_lib_test.exs @@ -4,7 +4,7 @@ defmodule Teiserver.MatchMembershipLibTest do alias Teiserver.Game use Teiserver.Case, async: true - alias Teiserver.{GameFixtures, AccountFixtures} + alias Teiserver.Fixtures.{GameFixtures, AccountFixtures} defp valid_attrs do %{ @@ -12,7 +12,7 @@ defmodule Teiserver.MatchMembershipLibTest do user_id: AccountFixtures.user_fixture().id, team_number: 123, win?: true, - party_id: "some party_id", + party_id: "f3d93d6b-cf27-4d64-9882-ca42d220cd6b", left_after_seconds: 123 } end @@ -23,7 +23,7 @@ defmodule Teiserver.MatchMembershipLibTest do user_id: AccountFixtures.user_fixture().id, team_number: 1234, win?: true, - party_id: "some updated party_id", + party_id: "17b33fb6-89fe-4d70-b299-b1ad27a1a852", left_after_seconds: 1234 } end @@ -70,7 +70,7 @@ defmodule Teiserver.MatchMembershipLibTest do assert {:ok, %MatchMembership{} = match_membership} = Game.create_match_membership(valid_attrs()) - assert match_membership.party_id == "some party_id" + assert match_membership.party_id == "f3d93d6b-cf27-4d64-9882-ca42d220cd6b" end test "create_match_membership/1 with invalid data returns error changeset" do @@ -113,8 +113,7 @@ defmodule Teiserver.MatchMembershipLibTest do assert {:ok, %MatchMembership{} = match_membership} = Game.update_match_membership(match_membership, update_attrs()) - assert match_membership.party_id == "some updated party_id" - assert match_membership.party_id == "some updated party_id" + assert match_membership.party_id == "17b33fb6-89fe-4d70-b299-b1ad27a1a852" end test "update_match_membership/2 with invalid data returns error changeset" do diff --git a/test/game/libs/match_setting_lib_test.exs b/test/game/libs/match_setting_lib_test.exs index 317014501..d0024a02c 100644 --- a/test/game/libs/match_setting_lib_test.exs +++ b/test/game/libs/match_setting_lib_test.exs @@ -4,7 +4,7 @@ defmodule Teiserver.MatchSettingLibTest do alias Teiserver.Game use Teiserver.Case, async: true - alias Teiserver.{GameFixtures} + alias Teiserver.Fixtures.{GameFixtures} defp valid_attrs do %{ diff --git a/test/game/libs/match_setting_type_lib_test.exs b/test/game/libs/match_setting_type_lib_test.exs index a8ff40727..855d0f6c9 100644 --- a/test/game/libs/match_setting_type_lib_test.exs +++ b/test/game/libs/match_setting_type_lib_test.exs @@ -4,7 +4,7 @@ defmodule Teiserver.MatchSettingTypeLibTest do alias Teiserver.Game use Teiserver.Case, async: true - alias Teiserver.{GameFixtures} + alias Teiserver.Fixtures.{GameFixtures} defp valid_attrs do %{ @@ -41,12 +41,12 @@ defmodule Teiserver.MatchSettingTypeLibTest do assert Game.list_match_setting_types([]) != [] end - test "get_or_create_match_setting_type/1 returns an id" do + test "get_or_create_match_setting_type_id/1 returns an id" do # No match_setting_type yet assert Game.list_match_setting_types([]) == [] # Add a match_setting_type - type_id = Game.get_or_create_match_setting_type("test-name") + type_id = Game.get_or_create_match_setting_type_id("test-name") assert is_integer(type_id) [the_type] = Game.list_match_setting_types([]) diff --git a/test/game/libs/match_type_lib_test.exs b/test/game/libs/match_type_lib_test.exs index e3bb14186..8c9b582db 100644 --- a/test/game/libs/match_type_lib_test.exs +++ b/test/game/libs/match_type_lib_test.exs @@ -4,7 +4,7 @@ defmodule Teiserver.MatchTypeLibTest do alias Teiserver.Game use Teiserver.Case, async: true - alias Teiserver.{GameFixtures} + alias Teiserver.Fixtures.{GameFixtures} defp valid_attrs do %{ diff --git a/test/game/libs/user_choice_lib_test.exs b/test/game/libs/user_choice_lib_test.exs new file mode 100644 index 000000000..a21b665c7 --- /dev/null +++ b/test/game/libs/user_choice_lib_test.exs @@ -0,0 +1,189 @@ +defmodule Teiserver.UserChoiceLibTest do + @moduledoc false + alias Teiserver.Game.UserChoice + alias Teiserver.Game + use Teiserver.Case, async: true + + alias Teiserver.Fixtures.{AccountFixtures, GameFixtures} + + defp valid_attrs do + %{ + match_id: GameFixtures.completed_match_fixture().id, + type_id: GameFixtures.user_choice_type_fixture().id, + user_id: AccountFixtures.user_fixture().id, + value: "some value" + } + end + + defp update_attrs do + %{ + match_id: GameFixtures.completed_match_fixture().id, + type_id: GameFixtures.user_choice_type_fixture().id, + user_id: AccountFixtures.user_fixture().id, + value: "some updated value" + } + end + + defp invalid_attrs do + %{ + match_id: nil, + type_id: nil, + user_id: nil, + value: nil + } + end + + describe "user_choice" do + alias Teiserver.Game.UserChoice + + test "user_choice_query/0 returns a query" do + q = Game.user_choice_query([]) + assert %Ecto.Query{} = q + end + + test "list_user_choice/0 returns user_choice" do + # No user_choice yet + assert Game.list_user_choices([]) == [] + + # Add a user_choice + GameFixtures.user_choice_fixture() + assert Game.list_user_choices([]) != [] + end + + test "get_user_choice!/1 and get_user_choice/1 returns the user_choice with given id" do + user_choice = GameFixtures.user_choice_fixture() + + assert Game.get_user_choice!(user_choice.match_id, user_choice.user_id, user_choice.type_id) == + user_choice + + assert Game.get_user_choice(user_choice.match_id, user_choice.user_id, user_choice.type_id) == + user_choice + end + + test "get_user_choices_map/2" do + user = AccountFixtures.user_fixture() + match = GameFixtures.incomplete_match_fixture() + + user_choice1 = GameFixtures.user_choice_fixture(%{user_id: user.id, match_id: match.id}) + user_choice2 = GameFixtures.user_choice_fixture(%{user_id: user.id, match_id: match.id}) + user_choice3 = GameFixtures.user_choice_fixture(%{match_id: match.id}) + user_choice4 = GameFixtures.user_choice_fixture(%{user_id: user.id}) + + values = Game.get_user_choices_map(match.id, user.id) |> Map.values() + + assert Enum.member?(values, user_choice1.value) + assert Enum.member?(values, user_choice2.value) + refute Enum.member?(values, user_choice3.value) + refute Enum.member?(values, user_choice4.value) + end + + test "create_user_choice/1 with valid data creates a user_choice" do + assert {:ok, %UserChoice{} = user_choice} = + Game.create_user_choice(valid_attrs()) + + assert user_choice.value == "some value" + end + + test "create_user_choice/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Game.create_user_choice(invalid_attrs()) + end + + test "create_many_user_choices/1 with valid data creates a user_choice" do + match = GameFixtures.incomplete_match_fixture() + user = AccountFixtures.user_fixture() + assert Enum.empty?(Game.list_user_choices(where: [match_id: match.id, user_id: user.id])) + + attr_list = [ + %{ + match_id: match.id, + type_id: GameFixtures.user_choice_type_fixture().id, + user_id: user.id + }, + %{ + match_id: match.id, + type_id: GameFixtures.user_choice_type_fixture().id, + user_id: user.id + }, + %{ + match_id: match.id, + type_id: GameFixtures.user_choice_type_fixture().id, + user_id: user.id + } + ] + + # Now insert them + assert {:ok, %{insert_all: {3, nil}}} = Game.create_many_user_choices(attr_list) + + assert Enum.count(Game.list_user_choices(where: [match_id: match.id, user_id: user.id])) == + 3 + end + + test "create_many_user_choices/1 with invalid data returns error" do + match = GameFixtures.incomplete_match_fixture() + user = AccountFixtures.user_fixture() + assert Enum.empty?(Game.list_user_choices(where: [match_id: match.id, user_id: user.id])) + + attr_list = [ + %{ + match_id: nil, + type_id: GameFixtures.user_choice_type_fixture().id, + user_id: AccountFixtures.user_fixture().id + }, + %{ + match_id: nil, + type_id: GameFixtures.user_choice_type_fixture().id, + user_id: AccountFixtures.user_fixture().id + }, + %{ + match_id: nil, + type_id: GameFixtures.user_choice_type_fixture().id, + user_id: AccountFixtures.user_fixture().id + } + ] + + # Now insert them + assert_raise Postgrex.Error, fn -> Game.create_many_user_choices(attr_list) end + assert Enum.empty?(Game.list_user_choices(where: [match_id: match.id, user_id: user.id])) + end + + test "update_user_choice/2 with valid data updates the user_choice" do + user_choice = GameFixtures.user_choice_fixture() + + assert {:ok, %UserChoice{} = user_choice} = + Game.update_user_choice(user_choice, update_attrs()) + + assert user_choice.value == "some updated value" + end + + test "update_user_choice/2 with invalid data returns error changeset" do + user_choice = GameFixtures.user_choice_fixture() + + assert {:error, %Ecto.Changeset{}} = + Game.update_user_choice(user_choice, invalid_attrs()) + + assert user_choice == + Game.get_user_choice!( + user_choice.match_id, + user_choice.user_id, + user_choice.type_id + ) + end + + test "delete_user_choice/1 deletes the user_choice" do + user_choice = GameFixtures.user_choice_fixture() + assert {:ok, %UserChoice{}} = Game.delete_user_choice(user_choice) + + assert_raise Ecto.NoResultsError, fn -> + Game.get_user_choice!(user_choice.match_id, user_choice.user_id, user_choice.type_id) + end + + assert Game.get_user_choice(user_choice.match_id, user_choice.user_id, user_choice.type_id) == + nil + end + + test "change_user_choice/1 returns a user_choice changeset" do + user_choice = GameFixtures.user_choice_fixture() + assert %Ecto.Changeset{} = Game.change_user_choice(user_choice) + end + end +end diff --git a/test/game/libs/user_choice_type_lib_test.exs b/test/game/libs/user_choice_type_lib_test.exs new file mode 100644 index 000000000..3e8fbb3c7 --- /dev/null +++ b/test/game/libs/user_choice_type_lib_test.exs @@ -0,0 +1,109 @@ +defmodule Teiserver.UserChoiceTypeLibTest do + @moduledoc false + alias Teiserver.Game.UserChoiceType + alias Teiserver.Game + use Teiserver.Case, async: true + + alias Teiserver.Fixtures.{GameFixtures} + + defp valid_attrs do + %{ + name: "some name" + } + end + + defp update_attrs do + %{ + name: "some updated name" + } + end + + defp invalid_attrs do + %{ + name: nil + } + end + + describe "user_choice_type" do + alias Teiserver.Game.UserChoiceType + + test "user_choice_type_query/0 returns a query" do + q = Game.user_choice_type_query([]) + assert %Ecto.Query{} = q + end + + test "list_user_choice_type/0 returns user_choice_type" do + # No user_choice_type yet + assert Game.list_user_choice_types([]) == [] + + # Add a user_choice_type + GameFixtures.user_choice_type_fixture() + assert Game.list_user_choice_types([]) != [] + end + + test "get_or_create_user_choice_type_id/1 returns an id" do + # No user_choice_type yet + assert Game.list_user_choice_types([]) == [] + + # Add a user_choice_type + type_id = Game.get_or_create_user_choice_type_id("test-name") + assert is_integer(type_id) + [the_type] = Game.list_user_choice_types([]) + + assert the_type.id == type_id + assert the_type.name == "test-name" + end + + test "get_user_choice_type!/1 and get_user_choice_type/1 returns the user_choice_type with given id" do + user_choice_type = GameFixtures.user_choice_type_fixture() + assert Game.get_user_choice_type!(user_choice_type.id) == user_choice_type + assert Game.get_user_choice_type(user_choice_type.id) == user_choice_type + end + + test "create_user_choice_type/1 with valid data creates a user_choice_type" do + assert {:ok, %UserChoiceType{} = user_choice_type} = + Game.create_user_choice_type(valid_attrs()) + + assert user_choice_type.name == "some name" + end + + test "create_user_choice_type/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Game.create_user_choice_type(invalid_attrs()) + end + + test "update_user_choice_type/2 with valid data updates the user_choice_type" do + user_choice_type = GameFixtures.user_choice_type_fixture() + + assert {:ok, %UserChoiceType{} = user_choice_type} = + Game.update_user_choice_type(user_choice_type, update_attrs()) + + assert user_choice_type.name == "some updated name" + assert user_choice_type.name == "some updated name" + end + + test "update_user_choice_type/2 with invalid data returns error changeset" do + user_choice_type = GameFixtures.user_choice_type_fixture() + + assert {:error, %Ecto.Changeset{}} = + Game.update_user_choice_type(user_choice_type, invalid_attrs()) + + assert user_choice_type == Game.get_user_choice_type!(user_choice_type.id) + end + + test "delete_user_choice_type/1 deletes the user_choice_type" do + user_choice_type = GameFixtures.user_choice_type_fixture() + assert {:ok, %UserChoiceType{}} = Game.delete_user_choice_type(user_choice_type) + + assert_raise Ecto.NoResultsError, fn -> + Game.get_user_choice_type!(user_choice_type.id) + end + + assert Game.get_user_choice_type(user_choice_type.id) == nil + end + + test "change_user_choice_type/1 returns a user_choice_type changeset" do + user_choice_type = GameFixtures.user_choice_type_fixture() + assert %Ecto.Changeset{} = Game.change_user_choice_type(user_choice_type) + end + end +end diff --git a/test/game/lobby_server_test.exs b/test/game/lobby_server_test.exs index 0bffb7f8c..6d373ae1f 100644 --- a/test/game/lobby_server_test.exs +++ b/test/game/lobby_server_test.exs @@ -3,7 +3,7 @@ defmodule Teiserver.Game.LobbyServerTest do use Teiserver.Case, async: true alias Teiserver.{Game, Connections} - alias Teiserver.ConnectionFixtures + alias Teiserver.Fixtures.ConnectionFixtures describe "Lobby server" do test "server lifecycle" do @@ -26,15 +26,15 @@ defmodule Teiserver.Game.LobbyServerTest do [m] = TestConn.get(listener) assert m == %{ - topic: topic, - update_id: 1, - event: :lobby_updated, - changes: %{ - host_data: nil, - update_id: 1 - }, - lobby_id: lobby_id - } + topic: topic, + update_id: 1, + event: :lobby_updated, + changes: %{ + host_data: nil, + update_id: 1 + }, + lobby_id: lobby_id + } # Now destroy the client process Connections.stop_client_server(user.id) diff --git a/test/game/queries/match_queries_test.exs b/test/game/queries/match_queries_test.exs index 10b171bda..2e2558d75 100644 --- a/test/game/queries/match_queries_test.exs +++ b/test/game/queries/match_queries_test.exs @@ -32,8 +32,17 @@ defmodule Teiserver.MatchQueriesTest do id: [Teiserver.uuid(), Teiserver.uuid()], id: Teiserver.uuid(), name: "Some name", - inserted_after: Timex.now(), - inserted_before: Timex.now() + ended_normally?: true, + processed?: true, + duration_gt: 100, + duration_lt: 999, + name_like: "abc", + started_after: DateTime.utc_now(), + started_before: DateTime.utc_now(), + ended_after: DateTime.utc_now(), + ended_before: DateTime.utc_now(), + inserted_after: DateTime.utc_now(), + inserted_before: DateTime.utc_now() ], order_by: [ "Name (A-Z)", @@ -41,7 +50,22 @@ defmodule Teiserver.MatchQueriesTest do "Newest first", "Oldest first" ], - preload: [] + preload: + ~w(host type members_with_users settings_with_types choices_with_users_and_types)a, + limit: 10 + ) + + assert all_values != @empty_query + Repo.all(all_values) + + # Some preloads we missed last time because they conflict with the others + all_values = + MatchQueries.match_query( + where: [ + id: Teiserver.uuid() + ], + preload: ~w(members settings choices)a, + limit: 10 ) assert all_values != @empty_query diff --git a/test/game/queries/match_type_queries_test.exs b/test/game/queries/match_type_queries_test.exs index e581a071e..a019b9240 100644 --- a/test/game/queries/match_type_queries_test.exs +++ b/test/game/queries/match_type_queries_test.exs @@ -38,7 +38,8 @@ defmodule Teiserver.MatchTypeQueriesTest do "Name (A-Z)", "Name (Z-A)" ], - preload: [] + preload: [], + limit: :infinity ) assert all_values != @empty_query diff --git a/test/game/queries/user_choice_queries_test.exs b/test/game/queries/user_choice_queries_test.exs new file mode 100644 index 000000000..089a49e94 --- /dev/null +++ b/test/game/queries/user_choice_queries_test.exs @@ -0,0 +1,56 @@ +defmodule Teiserver.UserChoiceQueriesTest do + @moduledoc false + use Teiserver.Case, async: true + + alias Teiserver.Game.UserChoiceQueries + + describe "queries" do + @empty_query UserChoiceQueries.user_choice_query([]) + + test "clauses" do + # Null values, shouldn't error but shouldn't generate a query + null_values = + UserChoiceQueries.user_choice_query( + where: [ + key1: "", + key2: nil + ] + ) + + assert null_values == @empty_query + Repo.all(null_values) + + # If a key is not present in the query library it should error + assert_raise(FunctionClauseError, fn -> + UserChoiceQueries.user_choice_query(where: [not_a_key: 1]) + end) + + # we expect the query to run though it won't produce meaningful results + all_values = + UserChoiceQueries.user_choice_query( + where: [ + type_id: [1, 2], + type_id: 1, + match_id: [Teiserver.uuid(), Teiserver.uuid()], + match_id: Teiserver.uuid(), + user_id: [Teiserver.uuid(), Teiserver.uuid()], + user_id: Teiserver.uuid(), + value: ["abc", "def"], + value: "abc" + ], + order_by: [ + "Value (A-Z)", + "Value (Z-A)" + ], + preload: [ + :match, + :type + ], + limit: nil + ) + + assert all_values != @empty_query + Repo.all(all_values) + end + end +end diff --git a/test/game/queries/user_choice_type_queries_test.exs b/test/game/queries/user_choice_type_queries_test.exs new file mode 100644 index 000000000..80a624043 --- /dev/null +++ b/test/game/queries/user_choice_type_queries_test.exs @@ -0,0 +1,48 @@ +defmodule Teiserver.UserChoiceTypeQueriesTest do + @moduledoc false + use Teiserver.Case, async: true + + alias Teiserver.Game.UserChoiceTypeQueries + + describe "queries" do + @empty_query UserChoiceTypeQueries.user_choice_type_query([]) + + test "clauses" do + # Null values, shouldn't error but shouldn't generate a query + null_values = + UserChoiceTypeQueries.user_choice_type_query( + where: [ + key1: "", + key2: nil + ] + ) + + assert null_values == @empty_query + Repo.all(null_values) + + # If a key is not present in the query library it should error + assert_raise(FunctionClauseError, fn -> + UserChoiceTypeQueries.user_choice_type_query(where: [not_a_key: 1]) + end) + + # we expect the query to run though it won't produce meaningful results + all_values = + UserChoiceTypeQueries.user_choice_type_query( + where: [ + id: [1, 2], + id: 1, + name: ["abc", "def"], + name: "Some name" + ], + order_by: [ + "Name (A-Z)", + "Name (Z-A)" + ], + preload: [] + ) + + assert all_values != @empty_query + Repo.all(all_values) + end + end +end diff --git a/test/logging/libs/audit_log_lib_test.exs b/test/logging/libs/audit_log_lib_test.exs new file mode 100644 index 000000000..8f7b2fdc0 --- /dev/null +++ b/test/logging/libs/audit_log_lib_test.exs @@ -0,0 +1,128 @@ +defmodule Teiserver.AuditLogLibTest do + @moduledoc false + alias Teiserver.Logging.AuditLog + alias Teiserver.Logging + use Teiserver.Case, async: true + + alias Teiserver.Fixtures.{LoggingFixtures, AccountFixtures} + + defp valid_attrs do + %{ + action: "some action", + ip: "127.0.0.1", + details: %{key: 1}, + user_id: AccountFixtures.user_fixture().id + } + end + + defp update_attrs do + %{ + action: "some updated action", + ip: "127.0.0.127", + details: %{key: "updated"}, + user_id: AccountFixtures.user_fixture().id + } + end + + defp invalid_attrs do + %{ + action: nil, + ip: nil, + details: nil, + user_id: nil + } + end + + describe "audit_log" do + alias Teiserver.Logging.AuditLog + + test "audit_log_query/0 returns a query" do + q = Logging.audit_log_query([]) + assert %Ecto.Query{} = q + end + + test "list_audit_log/0 returns audit_log" do + # No audit_log yet + assert Logging.list_audit_logs([]) == [] + + # Add a audit_log + LoggingFixtures.audit_log_fixture() + assert Logging.list_audit_logs([]) != [] + end + + test "get_audit_log!/1 and get_audit_log/1 returns the audit_log with given id" do + audit_log = LoggingFixtures.audit_log_fixture() + assert Logging.get_audit_log!(audit_log.id) == audit_log + assert Logging.get_audit_log(audit_log.id) == audit_log + end + + test "create_audit_log/1 with valid data creates a audit_log" do + assert {:ok, %AuditLog{} = audit_log} = + Logging.create_audit_log(valid_attrs()) + + assert audit_log.action == "some action" + end + + test "create_audit_log/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Logging.create_audit_log(invalid_attrs()) + end + + test "create_audit_log/4 with valid data creates a audit_log" do + user_id = AccountFixtures.user_fixture().id + + assert {:ok, %AuditLog{} = audit_log} = + Logging.create_audit_log(user_id, "ip", "some action", %{}) + + assert audit_log.action == "some action" + end + + test "create_audit_log/4 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Logging.create_audit_log(nil, nil, nil, nil) + end + + test "create_anonymous_audit_log/3 with valid data creates a audit_log" do + assert {:ok, %AuditLog{} = audit_log} = + Logging.create_anonymous_audit_log("ip", "some action", %{}) + + assert audit_log.action == "some action" + end + + test "create_anonymous_audit_log/3 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Logging.create_anonymous_audit_log(nil, nil, nil) + end + + test "update_audit_log/2 with valid data updates the audit_log" do + audit_log = LoggingFixtures.audit_log_fixture() + + assert {:ok, %AuditLog{} = audit_log} = + Logging.update_audit_log(audit_log, update_attrs()) + + assert audit_log.action == "some updated action" + end + + test "update_audit_log/2 with invalid data returns error changeset" do + audit_log = LoggingFixtures.audit_log_fixture() + + assert {:error, %Ecto.Changeset{}} = + Logging.update_audit_log(audit_log, invalid_attrs()) + + assert audit_log == Logging.get_audit_log!(audit_log.id) + end + + test "delete_audit_log/1 deletes the audit_log" do + audit_log = LoggingFixtures.audit_log_fixture() + assert {:ok, %AuditLog{}} = Logging.delete_audit_log(audit_log) + + assert_raise Ecto.NoResultsError, fn -> + Logging.get_audit_log!(audit_log.id) + end + + assert Logging.get_audit_log(audit_log.id) == nil + end + + test "change_audit_log/1 returns a audit_log changeset" do + audit_log = LoggingFixtures.audit_log_fixture() + assert %Ecto.Changeset{} = Logging.change_audit_log(audit_log) + end + end +end diff --git a/test/logging/queries/audit_log_queries_test.exs b/test/logging/queries/audit_log_queries_test.exs new file mode 100644 index 000000000..0ffbd4aad --- /dev/null +++ b/test/logging/queries/audit_log_queries_test.exs @@ -0,0 +1,61 @@ +defmodule Teiserver.AuditLogQueriesTest do + @moduledoc false + use Teiserver.Case, async: true + + alias Teiserver.Logging.AuditLogQueries + + describe "queries" do + @empty_query AuditLogQueries.audit_log_query([]) + + test "clauses" do + # Null values, shouldn't error but shouldn't generate a query + null_values = + AuditLogQueries.audit_log_query( + where: [ + key1: "", + key2: nil + ] + ) + + assert null_values == @empty_query + Repo.all(null_values) + + # If a key is not present in the query library it should error + assert_raise(FunctionClauseError, fn -> + AuditLogQueries.audit_log_query(where: [not_a_key: 1]) + end) + + # we expect the query to run though it won't produce meaningful results + all_values = + AuditLogQueries.audit_log_query( + where: [ + id: [1, 2], + id: 1, + user_id: [ + "0ea89483-80da-4041-9e41-fcb152c24168", + "7fa72464-e3b6-42e8-935e-608adc65608e" + ], + user_id: "57b86216-5a32-4e76-bad0-1012a825667b", + action: ["action1", "action2"], + action: "action", + detail_equal: {"key", "value"}, + detail_greater_than: {"key", "value"}, + detail_less_than: {"key", "value"}, + detail_not: {"key", "value"}, + inserted_after: DateTime.utc_now(), + inserted_before: DateTime.utc_now(), + updated_after: DateTime.utc_now(), + updated_before: DateTime.utc_now() + ], + order_by: [ + "Newest first", + "Oldest first" + ], + preload: [:user] + ) + + assert all_values != @empty_query + Repo.all(all_values) + end + end +end diff --git a/test/settings/server_setting_queries_test.exs b/test/settings/server_setting_queries_test.exs new file mode 100644 index 000000000..10feaa5bb --- /dev/null +++ b/test/settings/server_setting_queries_test.exs @@ -0,0 +1,53 @@ +defmodule Teiserver.ServerSettingQueriesTest do + @moduledoc false + use Teiserver.Case, async: true + + alias Teiserver.Settings.ServerSettingQueries + + describe "queries" do + @empty_query ServerSettingQueries.server_setting_query([]) + + test "clauses" do + # Null values, shouldn't error but shouldn't generate a query + null_values = + ServerSettingQueries.server_setting_query( + where: [ + key1: "", + key2: nil + ] + ) + + assert null_values == @empty_query + Repo.all(null_values) + + # If a key is not present in the query library it should error + assert_raise(FunctionClauseError, fn -> + ServerSettingQueries.server_setting_query(where: [not_a_key: 1]) + end) + + # we expect the query to run though it won't produce meaningful results + all_values = + ServerSettingQueries.server_setting_query( + where: [ + key: ["key1", "key2"], + key: "key1", + value: ["value1", "value2"], + value: "value1", + inserted_after: DateTime.utc_now(), + inserted_before: DateTime.utc_now(), + updated_after: DateTime.utc_now(), + updated_before: DateTime.utc_now() + ], + order_by: [ + "Newest first", + "Oldest first" + ], + limit: nil, + select: [:key] + ) + + assert all_values != @empty_query + Repo.all(all_values) + end + end +end diff --git a/test/settings/server_setting_test.exs b/test/settings/server_setting_test.exs new file mode 100644 index 000000000..d899f5d03 --- /dev/null +++ b/test/settings/server_setting_test.exs @@ -0,0 +1,188 @@ +defmodule Teiserver.ServerSettingTest do + @moduledoc false + alias Teiserver.Settings.ServerSetting + use Teiserver.Case, async: false + + alias Teiserver.Settings + alias Teiserver.Fixtures.SettingsFixtures + + defp valid_attrs do + %{ + key: "some key", + value: "true" + } + end + + defp update_attrs do + %{ + key: "some updated key", + value: "false" + } + end + + defp invalid_attrs do + %{ + key: nil, + value: nil + } + end + + describe "server_setting" do + alias Teiserver.Settings.ServerSetting + + test "server_setting_query/0 returns a query" do + q = Settings.server_setting_query([]) + assert %Ecto.Query{} = q + end + + test "list_server_setting/0 returns server_setting" do + # No server_setting yet + assert Settings.list_server_settings([]) == [] + + # Add a server_setting + SettingsFixtures.server_setting_fixture() + assert Settings.list_server_settings([]) != [] + end + + test "get_server_setting!/1 and get_server_setting/1 returns the server_setting with given id" do + server_setting = SettingsFixtures.server_setting_fixture() + assert Settings.get_server_setting!(server_setting.key) == server_setting + assert Settings.get_server_setting(server_setting.key) == server_setting + end + + test "create_server_setting/1 with valid data creates a server_setting" do + assert {:ok, %ServerSetting{} = server_setting} = + Settings.create_server_setting(valid_attrs()) + + assert server_setting.key == "some key" + end + + test "create_server_setting/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Settings.create_server_setting(invalid_attrs()) + end + + test "update_server_setting/2 with valid data updates the server_setting" do + server_setting = SettingsFixtures.server_setting_fixture() + + assert {:ok, %ServerSetting{} = server_setting} = + Settings.update_server_setting(server_setting, update_attrs()) + + assert server_setting.key == "some updated key" + end + + test "update_server_setting/2 with invalid data returns error changeset" do + server_setting = SettingsFixtures.server_setting_fixture() + + assert {:error, %Ecto.Changeset{}} = + Settings.update_server_setting(server_setting, invalid_attrs()) + + assert server_setting == Settings.get_server_setting!(server_setting.key) + end + + test "delete_server_setting/1 deletes the server_setting" do + server_setting = SettingsFixtures.server_setting_fixture() + assert {:ok, %ServerSetting{}} = Settings.delete_server_setting(server_setting) + + assert_raise Ecto.NoResultsError, fn -> + Settings.get_server_setting!(server_setting.key) + end + + assert Settings.get_server_setting(server_setting.key) == nil + end + + test "change_server_setting/1 returns a server_setting changeset" do + server_setting = SettingsFixtures.server_setting_fixture() + assert %Ecto.Changeset{} = Settings.change_server_setting(server_setting) + end + end + + describe "values" do + test "first insert" do + type = SettingsFixtures.server_setting_type_fixture() + + Settings.set_server_setting_value(type.key, "abcdef") + value = Settings.get_server_setting_value(type.key) + assert value == "abcdef" + + Settings.set_server_setting_value(type.key, "123456") + value = Settings.get_server_setting_value(type.key) + assert value == "123456" + end + + test "strings" do + type = SettingsFixtures.server_setting_type_fixture(%{"type" => "string"}) + + _setting = + SettingsFixtures.server_setting_fixture(%{"type" => type, "value" => "123456789"}) + + value = Settings.get_server_setting_value(type.key) + assert value == "123456789" + + Settings.set_server_setting_value(type.key, "abcdef") + + value = Settings.get_server_setting_value(type.key) + assert value == "abcdef" + end + + test "integers" do + type = SettingsFixtures.server_setting_type_fixture(%{"type" => "integer"}) + + _setting = + SettingsFixtures.server_setting_fixture(%{"type" => type, "value" => "123456789"}) + + value = Settings.get_server_setting_value(type.key) + assert value == 123_456_789 + + Settings.set_server_setting_value(type.key, 123) + + value = Settings.get_server_setting_value(type.key) + assert value == 123 + end + + test "booleans" do + type = SettingsFixtures.server_setting_type_fixture(%{"type" => "boolean"}) + _setting = SettingsFixtures.server_setting_fixture(%{"type" => type, "value" => "t"}) + + value = Settings.get_server_setting_value(type.key) + assert value == true + + Settings.set_server_setting_value(type.key, false) + + value = Settings.get_server_setting_value(type.key) + assert value == false + + # Set it back again as there are only two values + Settings.set_server_setting_value(type.key, true) + + value = Settings.get_server_setting_value(type.key) + assert value == true + end + + test "validator function" do + type = + SettingsFixtures.server_setting_type_fixture(%{ + "type" => "string", + "validator" => fn v -> + if String.length(v) > 6, do: :ok, else: {:error, "string too short"} + end + }) + + _setting = + SettingsFixtures.server_setting_fixture(%{"type" => type, "value" => "123456789"}) + + value = Settings.get_server_setting_value(type.key) + assert value == "123456789" + + result = Settings.set_server_setting_value(type.key, "abcdef") + assert result == {:error, "string too short"} + + value = Settings.get_server_setting_value(type.key) + refute value == "abcdef" + + result = Settings.set_server_setting_value(type.key, "abcdefdef") + assert result == :ok + value = Settings.get_server_setting_value(type.key) + assert value == "abcdefdef" + end + end +end diff --git a/test/settings/server_setting_type_test.exs b/test/settings/server_setting_type_test.exs new file mode 100644 index 000000000..4b1f98d52 --- /dev/null +++ b/test/settings/server_setting_type_test.exs @@ -0,0 +1,79 @@ +defmodule Teiserver.ServerSettingTypeTest do + @moduledoc false + use Teiserver.Case, async: true + + alias Teiserver.Settings + + describe "server setting types" do + test "create - errors" do + assert_raise(KeyError, fn -> + Settings.add_server_setting_type(%{ + key: "#{__MODULE__} create errors" + }) + end) + + assert_raise(KeyError, fn -> + Settings.add_server_setting_type(%{ + key: "#{__MODULE__} create errors", + label: "label here" + }) + end) + + # We should get a different error (Runtime) if we + # use the wrong type + assert_raise(RuntimeError, fn -> + Settings.add_server_setting_type(%{ + key: "#{__MODULE__} create errors", + label: "label here", + section: "test", + type: "not a type" + }) + end) + + # Do it correctly so we can test for duplicate key error + {:ok, _} = + Settings.add_server_setting_type(%{ + key: "#{__MODULE__} create errors", + label: "label here", + section: "test", + type: "string" + }) + + assert_raise(RuntimeError, fn -> + Settings.add_server_setting_type(%{ + key: "#{__MODULE__} create errors", + label: "label here", + section: "test", + type: "string" + }) + end) + end + + test "create - correctly" do + key = "#{__MODULE__} create" + + assert Settings.get_server_setting_type(key) == nil + + {result, type} = + Settings.add_server_setting_type(%{ + key: key, + label: "label here", + section: "test", + type: "string" + }) + + assert result == :ok + assert type.key == key + + assert Settings.get_server_setting_type(key) == type + end + + test "list types" do + result = Settings.list_server_setting_types(["login.user_rate_limit"]) + assert Enum.count(result) == 1 + + result = Settings.list_server_setting_types(["not a type"]) + assert Enum.empty?(result) + end + end +end diff --git a/test/settings/user_setting_queries_test.exs b/test/settings/user_setting_queries_test.exs new file mode 100644 index 000000000..c82f79b6c --- /dev/null +++ b/test/settings/user_setting_queries_test.exs @@ -0,0 +1,55 @@ +defmodule Teiserver.UserSettingQueriesTest do + @moduledoc false + use Teiserver.Case, async: true + + alias Teiserver.Settings.UserSettingQueries + + describe "queries" do + @empty_query UserSettingQueries.user_setting_query([]) + + test "clauses" do + # Null values, shouldn't error but shouldn't generate a query + null_values = + UserSettingQueries.user_setting_query( + where: [ + key1: "", + key2: nil + ] + ) + + assert null_values == @empty_query + Repo.all(null_values) + + # If a key is not present in the query library it should error + assert_raise(FunctionClauseError, fn -> + UserSettingQueries.user_setting_query(where: [not_a_key: 1]) + end) + + # we expect the query to run though it won't produce meaningful results + all_values = + UserSettingQueries.user_setting_query( + where: [ + user_id: [Teiserver.uuid(), Teiserver.uuid()], + user_id: Teiserver.uuid(), + key: ["key1", "key2"], + key: "key1", + value: ["value1", "value2"], + value: "value1", + inserted_after: DateTime.utc_now(), + inserted_before: DateTime.utc_now(), + updated_after: DateTime.utc_now(), + updated_before: DateTime.utc_now() + ], + order_by: [ + "Newest first", + "Oldest first" + ], + limit: nil, + select: [:key] + ) + + assert all_values != @empty_query + Repo.all(all_values) + end + end +end diff --git a/test/settings/user_setting_test.exs b/test/settings/user_setting_test.exs new file mode 100644 index 000000000..52d5c753f --- /dev/null +++ b/test/settings/user_setting_test.exs @@ -0,0 +1,216 @@ +defmodule Teiserver.UserSettingTest do + @moduledoc false + alias Teiserver.Settings.UserSetting + use Teiserver.Case, async: false + + alias Teiserver.Settings + alias Teiserver.Fixtures.{AccountFixtures, SettingsFixtures} + + defp valid_attrs do + %{ + user_id: AccountFixtures.user_fixture().id, + key: "some key", + value: "true" + } + end + + defp update_attrs do + %{ + user_id: AccountFixtures.user_fixture().id, + key: "some updated key", + value: "false" + } + end + + defp invalid_attrs do + %{ + user_id: nil, + key: nil, + value: nil + } + end + + describe "user_setting" do + alias Teiserver.Settings.UserSetting + + test "user_setting_query/0 returns a query" do + q = Settings.user_setting_query([]) + assert %Ecto.Query{} = q + end + + test "list_user_setting/0 returns user_setting" do + # No user_setting yet + assert Settings.list_user_settings([]) == [] + + # Add a user_setting + SettingsFixtures.user_setting_fixture() + assert Settings.list_user_settings([]) != [] + end + + test "get_user_setting!/1 and get_user_setting/1 returns the user_setting with given id" do + user_setting = SettingsFixtures.user_setting_fixture() + assert Settings.get_user_setting!(user_setting.user_id, user_setting.key) == user_setting + assert Settings.get_user_setting(user_setting.user_id, user_setting.key) == user_setting + end + + test "create_user_setting/1 with valid data creates a user_setting" do + assert {:ok, %UserSetting{} = user_setting} = + Settings.create_user_setting(valid_attrs()) + + assert user_setting.key == "some key" + end + + test "create_user_setting/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Settings.create_user_setting(invalid_attrs()) + end + + test "update_user_setting/2 with valid data updates the user_setting" do + user_setting = SettingsFixtures.user_setting_fixture() + + assert {:ok, %UserSetting{} = user_setting} = + Settings.update_user_setting(user_setting, update_attrs()) + + assert user_setting.key == "some updated key" + end + + test "update_user_setting/2 with invalid data returns error changeset" do + user_setting = SettingsFixtures.user_setting_fixture() + + assert {:error, %Ecto.Changeset{}} = + Settings.update_user_setting(user_setting, invalid_attrs()) + + assert user_setting == Settings.get_user_setting!(user_setting.user_id, user_setting.key) + end + + test "delete_user_setting/1 deletes the user_setting" do + user_setting = SettingsFixtures.user_setting_fixture() + assert {:ok, %UserSetting{}} = Settings.delete_user_setting(user_setting) + + assert_raise Ecto.NoResultsError, fn -> + Settings.get_user_setting!(user_setting.user_id, user_setting.key) + end + + assert Settings.get_user_setting(user_setting.user_id, user_setting.key) == nil + end + + test "change_user_setting/1 returns a user_setting changeset" do + user_setting = SettingsFixtures.user_setting_fixture() + assert %Ecto.Changeset{} = Settings.change_user_setting(user_setting) + end + end + + describe "values" do + test "first insert" do + user_id = AccountFixtures.user_fixture().id + type = SettingsFixtures.user_setting_type_fixture() + + Settings.set_user_setting_value(user_id, type.key, "abcdef") + value = Settings.get_user_setting_value(user_id, type.key) + assert value == "abcdef" + + Settings.set_user_setting_value(user_id, type.key, "123456") + value = Settings.get_user_setting_value(user_id, type.key) + assert value == "123456" + end + + test "strings" do + user_id = AccountFixtures.user_fixture().id + type = SettingsFixtures.user_setting_type_fixture(%{"type" => "string"}) + + _setting = + SettingsFixtures.user_setting_fixture(%{ + "user_id" => user_id, + "type" => type, + "value" => "123456789" + }) + + value = Settings.get_user_setting_value(user_id, type.key) + assert value == "123456789" + + Settings.set_user_setting_value(user_id, type.key, "abcdef") + + value = Settings.get_user_setting_value(user_id, type.key) + assert value == "abcdef" + end + + test "integers" do + user_id = AccountFixtures.user_fixture().id + type = SettingsFixtures.user_setting_type_fixture(%{"type" => "integer"}) + + _setting = + SettingsFixtures.user_setting_fixture(%{ + "user_id" => user_id, + "type" => type, + "value" => "123456789" + }) + + value = Settings.get_user_setting_value(user_id, type.key) + assert value == 123_456_789 + + Settings.set_user_setting_value(user_id, type.key, 123) + + value = Settings.get_user_setting_value(user_id, type.key) + assert value == 123 + end + + test "booleans" do + user_id = AccountFixtures.user_fixture().id + type = SettingsFixtures.user_setting_type_fixture(%{"type" => "boolean"}) + + _setting = + SettingsFixtures.user_setting_fixture(%{ + "user_id" => user_id, + "type" => type, + "value" => "t" + }) + + value = Settings.get_user_setting_value(user_id, type.key) + assert value == true + + Settings.set_user_setting_value(user_id, type.key, false) + + value = Settings.get_user_setting_value(user_id, type.key) + assert value == false + + # Set it back again as there are only two values + Settings.set_user_setting_value(user_id, type.key, true) + + value = Settings.get_user_setting_value(user_id, type.key) + assert value == true + assert Settings.get_user_setting_value(user_id, type.key) == value + end + + test "validator function" do + user_id = AccountFixtures.user_fixture().id + + type = + SettingsFixtures.user_setting_type_fixture(%{ + "type" => "string", + "validator" => fn v -> + if String.length(v) > 6, do: :ok, else: {:error, "string too short"} + end + }) + + _setting = + SettingsFixtures.user_setting_fixture(%{ + "user_id" => user_id, + "type" => type, + "value" => "123456789" + }) + + value = Settings.get_user_setting_value(user_id, type.key) + assert value == "123456789" + + result = Settings.set_user_setting_value(user_id, type.key, "abcdef") + assert result == {:error, "string too short"} + + value = Settings.get_user_setting_value(user_id, type.key) + refute value == "abcdef" + + result = Settings.set_user_setting_value(user_id, type.key, "abcdefdef") + assert result == :ok + value = Settings.get_user_setting_value(user_id, type.key) + assert value == "abcdefdef" + end + end +end diff --git a/test/settings/user_setting_type_test.exs b/test/settings/user_setting_type_test.exs new file mode 100644 index 000000000..ee893edec --- /dev/null +++ b/test/settings/user_setting_type_test.exs @@ -0,0 +1,79 @@ +defmodule Teiserver.UserSettingTypeTest do + @moduledoc false + use Teiserver.Case, async: true + + alias Teiserver.Settings + + describe "user setting types" do + test "create - errors" do + assert_raise(KeyError, fn -> + Settings.add_user_setting_type(%{ + key: "#{__MODULE__} create errors" + }) + end) + + assert_raise(KeyError, fn -> + Settings.add_user_setting_type(%{ + key: "#{__MODULE__} create errors", + label: "label here" + }) + end) + + # We should get a different error (Runtime) if we + # use the wrong type + assert_raise(RuntimeError, fn -> + Settings.add_user_setting_type(%{ + key: "#{__MODULE__} create errors", + label: "label here", + section: "test", + type: "not a type" + }) + end) + + # Do it correctly so we can test for duplicate key error + {:ok, _} = + Settings.add_user_setting_type(%{ + key: "#{__MODULE__} create errors", + label: "label here", + section: "test", + type: "string" + }) + + assert_raise(RuntimeError, fn -> + Settings.add_user_setting_type(%{ + key: "#{__MODULE__} create errors", + label: "label here", + section: "test", + type: "string" + }) + end) + end + + test "create - correctly" do + key = "#{__MODULE__} create" + + assert Settings.get_user_setting_type(key) == nil + + {result, type} = + Settings.add_user_setting_type(%{ + key: key, + label: "label here", + section: "test", + type: "string" + }) + + assert result == :ok + assert type.key == key + + assert Settings.get_user_setting_type(key) == type + end + + test "list types" do + result = Settings.list_user_setting_types(["timezone"]) + assert Enum.count(result) == 1 + + result = Settings.list_user_setting_types(["not a type"]) + assert Enum.empty?(result) + end + end +end diff --git a/test/support/fixtures/account_fixtures.ex b/test/support/fixtures/account_fixtures.ex index c3ba49ee1..a008f9941 100644 --- a/test/support/fixtures/account_fixtures.ex +++ b/test/support/fixtures/account_fixtures.ex @@ -1,8 +1,7 @@ -defmodule Teiserver.AccountFixtures do +defmodule Teiserver.Fixtures.AccountFixtures do @moduledoc false alias Teiserver.Account.{User, ExtraUserData} - @spec user_fixture() :: User.t() @spec user_fixture(map) :: User.t() def user_fixture(data \\ %{}) do r = :rand.uniform(999_999_999) @@ -10,12 +9,12 @@ defmodule Teiserver.AccountFixtures do User.changeset( %User{}, %{ - name: data["name"] || "user_name_#{r}", - email: data["email"] || "user_email_#{r}", - password: data["password"] || "password", - groups: data["groups"] || [], - permissions: data["permissions"] || [], - restrictions: data["restrictions"] || [] + name: data[:name] || "user_name_#{r}", + email: data[:email] || "user_email_#{r}", + password: data[:password] || "password", + groups: data[:groups] || [], + permissions: data[:permissions] || [], + restrictions: data[:restrictions] || [] }, :full ) diff --git a/test/support/fixtures/communication_fixtures.ex b/test/support/fixtures/communication_fixtures.ex index f834ca8de..e62bfcf38 100644 --- a/test/support/fixtures/communication_fixtures.ex +++ b/test/support/fixtures/communication_fixtures.ex @@ -1,7 +1,7 @@ -defmodule Teiserver.CommunicationFixtures do +defmodule Teiserver.Fixtures.CommunicationFixtures do @moduledoc false - import Teiserver.AccountFixtures, only: [user_fixture: 0] - import Teiserver.GameFixtures, only: [incomplete_match_fixture: 0] + import Teiserver.Fixtures.AccountFixtures, only: [user_fixture: 0] + import Teiserver.Fixtures.GameFixtures, only: [incomplete_match_fixture: 0] alias Teiserver.Communication.{Room, RoomMessage, DirectMessage, MatchMessage} @spec room_fixture() :: Room.t() @@ -27,7 +27,7 @@ defmodule Teiserver.CommunicationFixtures do %RoomMessage{}, %{ content: data[:content] || "room_message_content_#{r}", - inserted_at: data[:inserted_at] || Timex.now(), + inserted_at: data[:inserted_at] || DateTime.utc_now(), sender_id: data[:sender_id] || user_fixture().id, room_id: data[:room_id] || room_fixture().id } @@ -44,7 +44,7 @@ defmodule Teiserver.CommunicationFixtures do %DirectMessage{}, %{ content: data[:content] || "room_message_content_#{r}", - inserted_at: data[:inserted_at] || Timex.now(), + inserted_at: data[:inserted_at] || DateTime.utc_now(), delivered?: data[:delivered?] || false, read?: data[:read?] || false, sender_id: data[:sender_id] || user_fixture().id, @@ -63,7 +63,7 @@ defmodule Teiserver.CommunicationFixtures do %MatchMessage{}, %{ content: data[:content] || "match_message_content_#{r}", - inserted_at: data[:inserted_at] || Timex.now(), + inserted_at: data[:inserted_at] || DateTime.utc_now(), sender_id: data[:sender_id] || user_fixture().id, match_id: data[:match_id] || incomplete_match_fixture().id } diff --git a/test/support/fixtures/connection_fixtures.ex b/test/support/fixtures/connection_fixtures.ex index df1a1a5bd..3db0f69d9 100644 --- a/test/support/fixtures/connection_fixtures.ex +++ b/test/support/fixtures/connection_fixtures.ex @@ -1,4 +1,4 @@ -defmodule Teiserver.ConnectionFixtures do +defmodule Teiserver.Fixtures.ConnectionFixtures do @moduledoc false alias Teiserver.Connections @@ -7,7 +7,7 @@ defmodule Teiserver.ConnectionFixtures do @spec client_fixture() :: {pid, Teiserver.Account.User} @spec client_fixture(Teiserver.Account.User) :: {pid, Teiserver.Account.User} def client_fixture(user \\ nil) do - user = user || Teiserver.AccountFixtures.user_fixture() + user = user || Teiserver.Fixtures.AccountFixtures.user_fixture() conn = TestConn.new() TestConn.run(conn, fn -> diff --git a/test/support/fixtures/game_fixtures.ex b/test/support/fixtures/game_fixtures.ex index 3d03e0bf2..f9d9177f0 100644 --- a/test/support/fixtures/game_fixtures.ex +++ b/test/support/fixtures/game_fixtures.ex @@ -1,11 +1,22 @@ -defmodule Teiserver.GameFixtures do +defmodule Teiserver.Fixtures.GameFixtures do @moduledoc false + alias Teiserver.Fixtures.AccountFixtures alias Teiserver.Game - alias Teiserver.Game.{Lobby, Match, MatchType, MatchMembership, MatchSettingType, MatchSetting} - import Teiserver.AccountFixtures, only: [user_fixture: 0] - import Teiserver.ConnectionFixtures, only: [client_fixture: 0] - @spec lobby_fixture() :: Lobby.t() + alias Teiserver.Game.{ + Lobby, + Match, + MatchType, + MatchMembership, + MatchSettingType, + MatchSetting, + UserChoice, + UserChoiceType + } + + import Teiserver.Fixtures.AccountFixtures, only: [user_fixture: 0] + import Teiserver.Fixtures.ConnectionFixtures, only: [client_fixture: 0] + @spec lobby_fixture(map) :: Lobby.t() def lobby_fixture(data \\ %{}) do r = :rand.uniform(999_999_999) @@ -47,7 +58,6 @@ defmodule Teiserver.GameFixtures do {host_conn, host_user, lobby_id} end - @spec match_type_fixture() :: MatchType.t() @spec match_type_fixture(map) :: MatchType.t() def match_type_fixture(data \\ %{}) do r = :rand.uniform(999_999_999) @@ -61,7 +71,6 @@ defmodule Teiserver.GameFixtures do |> Teiserver.Repo.insert!() end - @spec unstarted_match_fixture() :: Match.t() @spec unstarted_match_fixture(map) :: Match.t() def unstarted_match_fixture(data \\ %{}) do Match.changeset( @@ -71,13 +80,13 @@ defmodule Teiserver.GameFixtures do rated?: data["rated?"] || true, host_id: data["host_id"] || user_fixture().id, processed?: false, - lobby_opened_at: data["lobby_opened_at"] || Timex.now() |> Timex.shift(minutes: -5) + lobby_opened_at: + data["lobby_opened_at"] || DateTime.utc_now() |> DateTime.shift(minute: -5) } ) |> Teiserver.Repo.insert!() end - @spec incomplete_match_fixture() :: Match.t() @spec incomplete_match_fixture(map) :: Match.t() def incomplete_match_fixture(data \\ %{}) do r = :rand.uniform(999_999_999) @@ -97,8 +106,10 @@ defmodule Teiserver.GameFixtures do team_size: data["team_size"] || 2, processed?: false, game_type: data["game_type"] || "match_game_type_#{r}", - lobby_opened_at: data["lobby_opened_at"] || Timex.now() |> Timex.shift(minutes: -5), - match_started_at: data["match_started_at"] || Timex.now() |> Timex.shift(minutes: -3), + lobby_opened_at: + data["lobby_opened_at"] || DateTime.utc_now() |> DateTime.shift(minute: -5), + match_started_at: + data["match_started_at"] || DateTime.utc_now() |> DateTime.shift(minute: -3), host_id: data["host_id"] || user_fixture().id, type_id: data["type_id"] || match_type_fixture().id } @@ -106,13 +117,14 @@ defmodule Teiserver.GameFixtures do |> Teiserver.Repo.insert!() end - @spec completed_match_fixture() :: Match.t() @spec completed_match_fixture(map) :: Match.t() def completed_match_fixture(data \\ %{}) do r = :rand.uniform(999_999_999) - match_started_at = data["match_started_at"] || Timex.now() |> Timex.shift(minutes: -3) - match_ended_at = data["match_started_at"] || Timex.now() |> Timex.shift(minutes: -3) + match_started_at = + data["match_started_at"] || DateTime.utc_now() |> DateTime.shift(minute: -3) + + match_ended_at = data["match_started_at"] || DateTime.utc_now() |> DateTime.shift(minute: -3) Match.changeset( %Match{}, @@ -128,10 +140,11 @@ defmodule Teiserver.GameFixtures do team_size: data["team_size"] || 2, processed?: data["processed?"] || false, game_type: data["game_type"] || "match_game_type_#{r}", - lobby_opened_at: data["lobby_opened_at"] || Timex.now() |> Timex.shift(minutes: -5), + lobby_opened_at: + data["lobby_opened_at"] || DateTime.utc_now() |> DateTime.shift(minute: -5), match_started_at: match_started_at, match_ended_at: match_ended_at, - match_duration_seconds: Timex.diff(match_ended_at, match_started_at, :second), + match_duration_seconds: DateTime.diff(match_ended_at, match_started_at, :second), host_id: data["host_id"] || user_fixture().id, type_id: data["type_id"] || match_type_fixture().id } @@ -139,11 +152,8 @@ defmodule Teiserver.GameFixtures do |> Teiserver.Repo.insert!() end - @spec match_membership_fixture() :: Match.t() @spec match_membership_fixture(map) :: Match.t() def match_membership_fixture(data \\ %{}) do - r = :rand.uniform(999_999_999) - MatchMembership.changeset( %MatchMembership{}, %{ @@ -151,14 +161,13 @@ defmodule Teiserver.GameFixtures do match_id: data["match_id"] || completed_match_fixture().id, team_number: data["team_number"] || 1, win?: data["win?"] || false, - party_id: data["party_id"] || "party_id_#{r}", + party_id: data["party_id"] || nil, left_after_seconds: data[""] || 123 } ) |> Teiserver.Repo.insert!() end - @spec match_setting_type_fixture() :: Match.t() @spec match_setting_type_fixture(map) :: Match.t() def match_setting_type_fixture(data \\ %{}) do r = :rand.uniform(999_999_999) @@ -187,4 +196,34 @@ defmodule Teiserver.GameFixtures do ) |> Teiserver.Repo.insert!() end + + @spec user_choice_type_fixture(map) :: Match.t() + def user_choice_type_fixture(data \\ %{}) do + r = :rand.uniform(999_999_999) + + UserChoiceType.changeset( + %UserChoiceType{}, + %{ + name: data["name"] || "user_choice_type_#{r}" + } + ) + |> Teiserver.Repo.insert!() + end + + @spec user_choice_fixture() :: Match.t() + @spec user_choice_fixture(map) :: Match.t() + def user_choice_fixture(data \\ %{}) do + r = :rand.uniform(999_999_999) + + UserChoice.changeset( + %UserChoice{}, + %{ + type_id: data[:type_id] || user_choice_type_fixture().id, + match_id: data[:match_id] || completed_match_fixture().id, + user_id: data[:user_id] || AccountFixtures.user_fixture().id, + value: data[:value] || "value_#{r}" + } + ) + |> Teiserver.Repo.insert!() + end end diff --git a/test/support/fixtures/logging_fixtures.ex b/test/support/fixtures/logging_fixtures.ex new file mode 100644 index 000000000..1be442274 --- /dev/null +++ b/test/support/fixtures/logging_fixtures.ex @@ -0,0 +1,37 @@ +defmodule Teiserver.Fixtures.LoggingFixtures do + @moduledoc false + import Teiserver.Fixtures.AccountFixtures, only: [user_fixture: 0] + alias Teiserver.Logging.{AuditLog} + + @spec audit_log_fixture(map) :: AuditLog.t() + def audit_log_fixture(data \\ %{}) do + r = :rand.uniform(999_999_999) + + AuditLog.changeset( + %AuditLog{}, + %{ + action: data[:action] || "action_#{r}", + details: data[:details] || %{}, + ip: data[:ip] || "ip_#{r}", + user_id: data[:user_id] || user_fixture().id + } + ) + |> Teiserver.Repo.insert!() + end + + @spec anonymous_audit_log_fixture(map) :: AuditLog.t() + def anonymous_audit_log_fixture(data \\ %{}) do + r = :rand.uniform(999_999_999) + + AuditLog.changeset( + %AuditLog{}, + %{ + action: data[:action] || "action_#{r}", + details: data[:details] || %{}, + ip: data[:ip] || "ip_#{r}", + user_id: nil + } + ) + |> Teiserver.Repo.insert!() + end +end diff --git a/test/support/fixtures/settings_fixtures.ex b/test/support/fixtures/settings_fixtures.ex new file mode 100644 index 000000000..3e47a6de3 --- /dev/null +++ b/test/support/fixtures/settings_fixtures.ex @@ -0,0 +1,97 @@ +defmodule Teiserver.Fixtures.SettingsFixtures do + @moduledoc false + alias Teiserver.Settings + alias Teiserver.Settings.{ServerSettingType, ServerSetting, UserSetting} + import Teiserver.Fixtures.AccountFixtures, only: [user_fixture: 0] + + @spec server_setting_type_fixture() :: ServerSettingType.t() + @spec server_setting_type_fixture(map) :: ServerSettingType.t() + def server_setting_type_fixture(data \\ %{}) do + r = :rand.uniform(999_999_999) + + {:ok, type} = + Settings.add_server_setting_type(%{ + key: data["key"] || "key_#{r}", + label: data["label"] || "label_#{r}", + section: data["section"] || "section_#{r}", + type: data["type"] || "string", + permissions: data["permissions"] || nil, + choices: data["choices"] || nil, + default: data["default"] || nil, + description: data["description"] || nil, + validator: data["validator"] || nil + }) + + type + end + + @spec server_setting_fixture() :: ServerSetting.t() + @spec server_setting_fixture(map) :: ServerSetting.t() + def server_setting_fixture(data \\ %{}) do + type = data["type"] || server_setting_type_fixture() + + r = :rand.uniform(999_999_999) + + value = + case type.type do + "string" -> data["value"] || "#{r}" + "integer" -> to_string(data["value"]) || "#{r}" + "boolean" -> data["value"] || if Integer.mod(r, 2) == 1, do: "t", else: "f" + end + + ServerSetting.changeset( + %ServerSetting{}, + %{ + key: type.key, + value: value + } + ) + |> Teiserver.Repo.insert!() + end + + @spec user_setting_type_fixture() :: UserSettingType.t() + @spec user_setting_type_fixture(map) :: UserSettingType.t() + def user_setting_type_fixture(data \\ %{}) do + r = :rand.uniform(999_999_999) + + {:ok, type} = + Settings.add_user_setting_type(%{ + key: data["key"] || "key_#{r}", + label: data["label"] || "label_#{r}", + section: data["section"] || "section_#{r}", + type: data["type"] || "string", + permissions: data["permissions"] || nil, + choices: data["choices"] || nil, + default: data["default"] || nil, + description: data["description"] || nil, + validator: data["validator"] || nil + }) + + type + end + + @spec user_setting_fixture() :: UserSetting.t() + @spec user_setting_fixture(map) :: UserSetting.t() + def user_setting_fixture(data \\ %{}) do + type = data["type"] || user_setting_type_fixture() + + r = :rand.uniform(999_999_999) + + value = + case type.type do + "string" -> data["value"] || "#{r}" + "integer" -> to_string(data["value"]) || "#{r}" + "boolean" -> data["value"] || if Integer.mod(r, 2) == 1, do: "t", else: "f" + end + + UserSetting.changeset( + %UserSetting{}, + %{ + user_id: data["user_id"] || user_fixture().id, + key: type.key, + value: value + } + ) + |> Teiserver.Repo.insert!() + end +end diff --git a/test/support/postgres/migrations/20240110214150_add_teiserver_tables.exs b/test/support/postgres/migrations/20240110214150_add_teiserver_tables.exs index 4cafe2951..9f01de050 100644 --- a/test/support/postgres/migrations/20240110214150_add_teiserver_tables.exs +++ b/test/support/postgres/migrations/20240110214150_add_teiserver_tables.exs @@ -1,4 +1,4 @@ -defmodule Teiserver.Test.Repo.Postgres.Migrations.AddTeiseverTables do +defmodule Teiserver.Test.Repo.Postgres.Migrations.AddTeiserverTables do @moduledoc false use Ecto.Migration