From cc6d72b6b19ff8bed99f0733d8458a298d5a266d Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 13 Jan 2026 11:44:40 +0100 Subject: [PATCH 1/4] feat: add service skeleton and tests --- lib/mv/membership/import/member_csv.ex | 158 ++++++++++++++++++ test/mv/membership/import/member_csv_test.exs | 128 ++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 lib/mv/membership/import/member_csv.ex create mode 100644 test/mv/membership/import/member_csv_test.exs diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex new file mode 100644 index 0000000..6e4e019 --- /dev/null +++ b/lib/mv/membership/import/member_csv.ex @@ -0,0 +1,158 @@ +defmodule Mv.Membership.Import.MemberCSV do + @moduledoc """ + Service module for importing members from CSV files. + + This module provides the core API for CSV member import functionality: + - `prepare/2` - Parses and validates CSV content, returns import state + - `process_chunk/3` - Processes a chunk of rows and creates members + + ## Error Handling + + Errors are returned as `%Error{}` structs containing: + - `csv_line_number` - The physical line number in the CSV file + - `field` - The field name (atom) or `nil` if not field-specific + - `message` - Human-readable error message + + ## Import State + + The `import_state` returned by `prepare/2` contains: + - `chunks` - List of row chunks ready for processing + - `column_map` - Map of canonical field names to column indices + - `custom_field_map` - Map of custom field names to column indices + - `warnings` - List of warning messages (e.g., unknown custom field columns) + + ## Chunk Results + + The `chunk_result` returned by `process_chunk/3` contains: + - `inserted` - Number of successfully created members + - `failed` - Number of failed member creations + - `errors` - List of `%Error{}` structs (capped at 50 per import) + + ## Examples + + # Prepare CSV for import + {:ok, import_state} = MemberCSV.prepare(csv_content) + + # Process first chunk + chunk = Enum.at(import_state.chunks, 0) + {:ok, result} = MemberCSV.process_chunk(chunk, import_state.column_map) + """ + + defmodule Error do + @moduledoc """ + Error struct for CSV import errors. + + ## Fields + + - `csv_line_number` - The physical line number in the CSV file (1-based, header is line 1) + - `field` - The field name as an atom (e.g., `:email`) or `nil` if not field-specific + - `message` - Human-readable error message + """ + defstruct csv_line_number: nil, field: nil, message: nil + + @type t :: %__MODULE__{ + csv_line_number: integer(), + field: atom() | nil, + message: String.t() + } + end + + @type import_state :: %{ + chunks: list(list({pos_integer(), map()})), + column_map: %{atom() => non_neg_integer()}, + custom_field_map: %{String.t() => non_neg_integer()}, + warnings: list(String.t()) + } + + @type chunk_result :: %{ + inserted: non_neg_integer(), + failed: non_neg_integer(), + errors: list(Error.t()) + } + + @doc """ + Prepares CSV content for import by parsing, mapping headers, and validating limits. + + This function: + 1. Strips UTF-8 BOM if present + 2. Detects CSV delimiter (semicolon or comma) + 3. Parses headers and data rows + 4. Maps headers to canonical member fields + 5. Maps custom field columns by name + 6. Validates row count limits + 7. Chunks rows for processing + + ## Parameters + + - `file_content` - The raw CSV file content as a string + - `opts` - Optional keyword list: + - `:max_rows` - Maximum number of data rows allowed (default: 1000) + - `:chunk_size` - Number of rows per chunk (default: 200) + + ## Returns + + - `{:ok, import_state}` - Successfully prepared import state + - `{:error, reason}` - Error reason (string or error struct) + + ## Examples + + iex> MemberCSV.prepare("email\\njohn@example.com") + {:ok, %{chunks: [...], column_map: %{email: 0}, ...}} + + iex> MemberCSV.prepare("") + {:error, "CSV file is empty"} + """ + @spec prepare(String.t(), keyword()) :: {:ok, import_state()} | {:error, String.t()} + def prepare(file_content, opts \\ []) do + # TODO: Implement in Issue #3 (CSV Parsing) + # This is a skeleton implementation that will be filled in later + _ = {file_content, opts} + + # Placeholder return - will be replaced with actual implementation + {:error, "Not yet implemented"} + end + + @doc """ + Processes a chunk of CSV rows and creates members. + + This function: + 1. Validates each row + 2. Creates members via Ash resource + 3. Creates custom field values for each member + 4. Collects errors with correct CSV line numbers + 5. Returns chunk processing results + + ## Parameters + + - `chunk_rows_with_lines` - List of tuples `{csv_line_number, row_map}` where: + - `csv_line_number` - Physical line number in CSV (1-based) + - `row_map` - Map of column names to values + - `column_map` - Map of canonical field names (atoms) to column indices + - `opts` - Optional keyword list for processing options + + ## Returns + + - `{:ok, chunk_result}` - Chunk processing results + - `{:error, reason}` - Error reason (string) + + ## Examples + + iex> chunk = [{2, %{"email" => "john@example.com"}}] + iex> column_map = %{email: 0} + iex> MemberCSV.process_chunk(chunk, column_map) + {:ok, %{inserted: 1, failed: 0, errors: []}} + """ + @spec process_chunk( + list({pos_integer(), map()}), + %{atom() => non_neg_integer()}, + keyword() + ) :: {:ok, chunk_result()} | {:error, String.t()} + def process_chunk(chunk_rows_with_lines, column_map, opts \\ []) do + # TODO: Implement in Issue #6 (Persistence) + # This is a skeleton implementation that will be filled in later + _ = {chunk_rows_with_lines, column_map, opts} + + # Placeholder return - will be replaced with actual implementation + {:ok, %{inserted: 0, failed: 0, errors: []}} + end +end diff --git a/test/mv/membership/import/member_csv_test.exs b/test/mv/membership/import/member_csv_test.exs new file mode 100644 index 0000000..1e51d51 --- /dev/null +++ b/test/mv/membership/import/member_csv_test.exs @@ -0,0 +1,128 @@ +defmodule Mv.Membership.Import.MemberCSVTest do + use Mv.DataCase, async: false + + alias Mv.Membership.Import.MemberCSV + + describe "Error struct" do + test "Error struct exists with required fields" do + # This will fail at runtime if the struct doesn't exist + # We use struct/2 to create the struct at runtime + error = + struct(MemberCSV.Error, %{ + csv_line_number: 5, + field: :email, + message: "is not a valid email" + }) + + assert error.csv_line_number == 5 + assert error.field == :email + assert error.message == "is not a valid email" + end + + test "Error struct allows nil field" do + # This will fail at runtime if the struct doesn't exist + error = + struct(MemberCSV.Error, %{ + csv_line_number: 10, + field: nil, + message: "Row is empty" + }) + + assert error.csv_line_number == 10 + assert error.field == nil + assert error.message == "Row is empty" + end + end + + describe "prepare/2" do + test "function exists and accepts file_content and opts" do + file_content = "email\njohn@example.com" + opts = [] + + # This will fail until the function is implemented + result = MemberCSV.prepare(file_content, opts) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + + test "returns {:ok, import_state} on success" do + file_content = "email\njohn@example.com" + opts = [] + + assert {:ok, import_state} = MemberCSV.prepare(file_content, opts) + + # Check that import_state contains expected fields + assert Map.has_key?(import_state, :chunks) + assert Map.has_key?(import_state, :column_map) + assert Map.has_key?(import_state, :custom_field_map) + assert Map.has_key?(import_state, :warnings) + end + + test "returns {:error, reason} on failure" do + file_content = "" + opts = [] + + assert {:error, _reason} = MemberCSV.prepare(file_content, opts) + end + + test "function has documentation" do + # Check that @doc exists by reading the module + assert function_exported?(MemberCSV, :prepare, 2) + end + end + + describe "process_chunk/3" do + test "function exists and accepts chunk_rows_with_lines, column_map, and opts" do + chunk_rows_with_lines = [{2, %{"email" => "john@example.com"}}] + column_map = %{email: 0} + opts = [] + + # This will fail until the function is implemented + result = MemberCSV.process_chunk(chunk_rows_with_lines, column_map, opts) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + + test "returns {:ok, chunk_result} on success" do + chunk_rows_with_lines = [{2, %{"email" => "john@example.com"}}] + column_map = %{email: 0} + opts = [] + + assert {:ok, chunk_result} = + MemberCSV.process_chunk(chunk_rows_with_lines, column_map, opts) + + # Check that chunk_result contains expected fields + assert Map.has_key?(chunk_result, :inserted) + assert Map.has_key?(chunk_result, :failed) + assert Map.has_key?(chunk_result, :errors) + assert is_integer(chunk_result.inserted) + assert is_integer(chunk_result.failed) + assert is_list(chunk_result.errors) + end + + test "returns {:error, reason} on failure" do + chunk_rows_with_lines = [] + column_map = %{} + opts = [] + + # This might return {:ok, _} with zero counts or {:error, _} + result = MemberCSV.process_chunk(chunk_rows_with_lines, column_map, opts) + assert match?({:ok, _}, result) or match?({:error, _}, result) + end + + test "function has documentation" do + # Check that @doc exists by reading the module + assert function_exported?(MemberCSV, :process_chunk, 3) + end + end + + describe "module documentation" do + test "module has @moduledoc" do + # Check that the module exists and has documentation + assert Code.ensure_loaded?(MemberCSV) + + # Try to get the module documentation + {:docs_v1, _, _, _, %{"en" => moduledoc}, _, _} = Code.fetch_docs(MemberCSV) + assert is_binary(moduledoc) + assert String.length(moduledoc) > 0 + end + end +end From aa62e0340950498e79cac7cc93718f10cc991d18 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 14 Jan 2026 09:11:44 +0100 Subject: [PATCH 2/4] skip test for now --- test/mv/membership/import/member_csv_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/mv/membership/import/member_csv_test.exs b/test/mv/membership/import/member_csv_test.exs index 1e51d51..6893329 100644 --- a/test/mv/membership/import/member_csv_test.exs +++ b/test/mv/membership/import/member_csv_test.exs @@ -44,6 +44,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do assert match?({:ok, _}, result) or match?({:error, _}, result) end + @tag :skip #Skip until Issue #3 is implemented test "returns {:ok, import_state} on success" do file_content = "email\njohn@example.com" opts = [] From fb71b7ddb1b3c8258ed6dfbd4942eaaa096d7420 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 14 Jan 2026 09:49:40 +0100 Subject: [PATCH 3/4] fix struct inconsistencies --- lib/mv/membership/import/member_csv.ex | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index 6e4e019..9e30a20 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -8,10 +8,10 @@ defmodule Mv.Membership.Import.MemberCSV do ## Error Handling - Errors are returned as `%Error{}` structs containing: - - `csv_line_number` - The physical line number in the CSV file + Errors are returned as `%MemberCSV.Error{}` structs containing: + - `csv_line_number` - The physical line number in the CSV file (or `nil` for general errors) - `field` - The field name (atom) or `nil` if not field-specific - - `message` - Human-readable error message + - `message` - Human-readable error message (or `nil` for general errors) ## Import State @@ -26,7 +26,7 @@ defmodule Mv.Membership.Import.MemberCSV do The `chunk_result` returned by `process_chunk/3` contains: - `inserted` - Number of successfully created members - `failed` - Number of failed member creations - - `errors` - List of `%Error{}` structs (capped at 50 per import) + - `errors` - List of `%MemberCSV.Error{}` structs (capped at 50 per import) ## Examples @@ -51,9 +51,9 @@ defmodule Mv.Membership.Import.MemberCSV do defstruct csv_line_number: nil, field: nil, message: nil @type t :: %__MODULE__{ - csv_line_number: integer(), + csv_line_number: pos_integer() | nil, field: atom() | nil, - message: String.t() + message: String.t() | nil } end From aa3fb0c49b4fef5f9a785aeac5589e0d69377f13 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 14 Jan 2026 10:48:36 +0100 Subject: [PATCH 4/4] fix linting --- test/mv/membership/import/member_csv_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/mv/membership/import/member_csv_test.exs b/test/mv/membership/import/member_csv_test.exs index 6893329..5ac5311 100644 --- a/test/mv/membership/import/member_csv_test.exs +++ b/test/mv/membership/import/member_csv_test.exs @@ -44,7 +44,8 @@ defmodule Mv.Membership.Import.MemberCSVTest do assert match?({:ok, _}, result) or match?({:error, _}, result) end - @tag :skip #Skip until Issue #3 is implemented + # Skip until Issue #3 is implemented + @tag :skip test "returns {:ok, import_state} on success" do file_content = "email\njohn@example.com" opts = []