From 8c4377499c739c197ab87e59bfcf1e7b1d795202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Broks=20Randolfs=20Gail=C4=ABtis?= Date: Mon, 18 Aug 2025 12:54:26 +0300 Subject: [PATCH] Initial storage implementation --- .formatter.exs | 4 + .gitignore | 23 ++++ README.md | 21 ++++ lib/message_server.ex | 10 ++ lib/message_server/application.ex | 20 +++ lib/message_server/storage.ex | 41 +++++++ mix.exs | 29 +++++ mix.lock | 16 +++ .../server_test_server/test_server-alice.txt | 7 ++ .../test_server-charlie.txt | 1 + test/message_server/storage_test.exs | 116 ++++++++++++++++++ test/test_helper.exs | 1 + 12 files changed, 289 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 lib/message_server.ex create mode 100644 lib/message_server/application.ex create mode 100644 lib/message_server/storage.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 storage/server_test_server/test_server-alice.txt create mode 100644 storage/server_test_server/test_server-charlie.txt create mode 100644 test/message_server/storage_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43cdc28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +message_server-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..456f2e8 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# MessageServer + +**TODO: Add description** + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `message_server` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:message_server, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + diff --git a/lib/message_server.ex b/lib/message_server.ex new file mode 100644 index 0000000..e675e31 --- /dev/null +++ b/lib/message_server.ex @@ -0,0 +1,10 @@ +defmodule MessageServer do + @moduledoc """ + MessageServer - a small system of two connected servers + + The servers each own some users from which they accept requests. + If a request requires updating state of a user that is located on + another server, then the servers must communicate these changes + between themselves. Users only communicate with their β€œhome” server. + """ +end diff --git a/lib/message_server/application.ex b/lib/message_server/application.ex new file mode 100644 index 0000000..4daab16 --- /dev/null +++ b/lib/message_server/application.ex @@ -0,0 +1,20 @@ +defmodule MessageServer.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + # Starts a worker by calling: MessageServer.Worker.start_link(arg) + # {MessageServer.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: MessageServer.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/message_server/storage.ex b/lib/message_server/storage.ex new file mode 100644 index 0000000..9fe7ec6 --- /dev/null +++ b/lib/message_server/storage.ex @@ -0,0 +1,41 @@ +defmodule MessageServer.Storage do + use GenServer + require Logger + + def start_link(server_id) do + GenServer.start_link(__MODULE__, server_id, name: __MODULE__) + end + + @spec append_message(String.t(), String.t(), String.t()) :: :ok | {:error, String.t()} + def append_message(user_id, from_user, message) do + GenServer.call(__MODULE__, {:append_message, user_id, from_user, message}) + end + + @impl true + def init(server_id) do + storage_dir = "storage/server_#{server_id}" + File.mkdir_p!(storage_dir) + {:ok, %{storage_dir: storage_dir, server_id: server_id}} + end + + @impl true + def handle_call({:append_message, user_id, from_user, message}, _from, state) do + case write_message_to_file(state, user_id, from_user, message) do + :ok -> + Logger.info("Message stored for user #{user_id}") + {:reply, :ok, state} + + {:error, reason} = error -> + Logger.error("Failed to store message for user #{user_id}: #{reason}") + {:reply, error, state} + end + end + + @spec write_message_to_file(map(), String.t(), String.t(), String.t()) :: :ok | {:error, atom()} + defp write_message_to_file(%{storage_dir: storage_dir}, user_id, from_user, message) do + file_path = Path.join(storage_dir, "#{user_id}.txt") + content = "#{from_user}: #{message}\n" + + File.write(file_path, content, [:append]) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..4791c33 --- /dev/null +++ b/mix.exs @@ -0,0 +1,29 @@ +defmodule MessageServer.MixProject do + use Mix.Project + + def project do + [ + app: :message_server, + version: "0.1.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {MessageServer.Application, []} + ] + end + + defp deps do + [ + {:plug, "~> 1.16"}, + {:bandit, "~> 1.5"}, + {:req, "~> 0.5"}, + {:jason, "~> 1.4"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..94e9385 --- /dev/null +++ b/mix.lock @@ -0,0 +1,16 @@ +%{ + "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, +} diff --git a/storage/server_test_server/test_server-alice.txt b/storage/server_test_server/test_server-alice.txt new file mode 100644 index 0000000..07547a8 --- /dev/null +++ b/storage/server_test_server/test_server-alice.txt @@ -0,0 +1,7 @@ +test_server-bob: Hello Alice! +test_server-bob: Hi Alice +test_server-bob: Hello! πŸŽ‰ Special chars: @#$%^&*() +test_server-bob: Test message +test_server-bob: First message +test_server-charlie: Second message +test_server-bob: Hello Alice! diff --git a/storage/server_test_server/test_server-charlie.txt b/storage/server_test_server/test_server-charlie.txt new file mode 100644 index 0000000..dfc86be --- /dev/null +++ b/storage/server_test_server/test_server-charlie.txt @@ -0,0 +1 @@ +test_server-bob: Hi Charlie diff --git a/test/message_server/storage_test.exs b/test/message_server/storage_test.exs new file mode 100644 index 0000000..98dc778 --- /dev/null +++ b/test/message_server/storage_test.exs @@ -0,0 +1,116 @@ +defmodule MessageServer.StorageTest do + use ExUnit.Case + import ExUnit.CaptureLog + + @test_server_id "test_server" + @test_storage_dir "storage/server_#{@test_server_id}" + + setup do + # clean up any existing test storage + File.rm_rf!(@test_storage_dir) + + # start the Storage GenServer for testing + {:ok, pid} = MessageServer.Storage.start_link(@test_server_id) + + on_exit(fn -> + # clean up after test + if Process.alive?(pid), do: GenServer.stop(pid) + File.rm_rf!(@test_storage_dir) + end) + + %{storage_pid: pid} + end + + test "creates storage directory on startup" do + assert File.exists?(@test_storage_dir) + assert File.dir?(@test_storage_dir) + end + + test "appends message to user file" do + user_id = "test_server-alice" + from_user = "test_server-bob" + message = "Hello Alice!" + + assert :ok = MessageServer.Storage.append_message(user_id, from_user, message) + + file_path = Path.join(@test_storage_dir, "#{user_id}.txt") + assert File.exists?(file_path) + + content = File.read!(file_path) + assert content == "#{from_user}: #{message}\n" + end + + test "appends multiple messages to same user file" do + user_id = "test_server-alice" + + assert :ok = MessageServer.Storage.append_message(user_id, "test_server-bob", "First message") + + assert :ok = + MessageServer.Storage.append_message( + user_id, + "test_server-charlie", + "Second message" + ) + + file_path = Path.join(@test_storage_dir, "#{user_id}.txt") + content = File.read!(file_path) + + expected_content = """ + test_server-bob: First message + test_server-charlie: Second message + """ + + assert content == expected_content + end + + test "creates separate files for different users" do + assert :ok = + MessageServer.Storage.append_message( + "test_server-alice", + "test_server-bob", + "Hi Alice" + ) + + assert :ok = + MessageServer.Storage.append_message( + "test_server-charlie", + "test_server-bob", + "Hi Charlie" + ) + + alice_file = Path.join(@test_storage_dir, "test_server-alice.txt") + charlie_file = Path.join(@test_storage_dir, "test_server-charlie.txt") + + assert File.exists?(alice_file) + assert File.exists?(charlie_file) + + assert File.read!(alice_file) == "test_server-bob: Hi Alice\n" + assert File.read!(charlie_file) == "test_server-bob: Hi Charlie\n" + end + + test "handles messages with special characters" do + user_id = "test_server-alice" + from_user = "test_server-bob" + message = "Hello! πŸŽ‰ Special chars: @#$%^&*()" + + assert :ok = MessageServer.Storage.append_message(user_id, from_user, message) + + file_path = Path.join(@test_storage_dir, "#{user_id}.txt") + content = File.read!(file_path) + + assert content == "#{from_user}: #{message}\n" + end + + test "logs successful message storage" do + user_id = "test_server-alice" + from_user = "test_server-bob" + message = "Test message" + + log_output = + capture_log(fn -> + assert :ok = MessageServer.Storage.append_message(user_id, from_user, message) + end) + + assert log_output =~ "Message stored for user #{user_id}" + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()