Initial storage implementation

This commit is contained in:
Broks Randolfs Gailītis 2025-08-18 12:54:26 +03:00
commit 8c4377499c
12 changed files with 289 additions and 0 deletions

4
.formatter.exs Normal file
View File

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

23
.gitignore vendored Normal file
View File

@ -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/

21
README.md Normal file
View File

@ -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 <https://hexdocs.pm/message_server>.

10
lib/message_server.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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

29
mix.exs Normal file
View File

@ -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

16
mix.lock Normal file
View File

@ -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"},
}

View File

@ -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!

View File

@ -0,0 +1 @@
test_server-bob: Hi Charlie

View File

@ -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

1
test/test_helper.exs Normal file
View File

@ -0,0 +1 @@
ExUnit.start()