diff --git a/.github/workflows/on_push_main_publish.yml b/.github/workflows/on_push_main_publish.yml
index c8b337ed..00fdb0c6 100644
--- a/.github/workflows/on_push_main_publish.yml
+++ b/.github/workflows/on_push_main_publish.yml
@@ -30,15 +30,15 @@ jobs:
steps:
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
with:
version: v0.12.0
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -47,7 +47,7 @@ jobs:
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -64,7 +64,7 @@ jobs:
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
push: true
provenance: false
diff --git a/.gitignore b/.gitignore
index 3a81b9e5..a5832ff5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,4 +57,5 @@ docker-compose*.override.yml
ca
# devcontainer files:
-.devcontainer
\ No newline at end of file
+.devcontainer
+.mcp.json
\ No newline at end of file
diff --git a/assets/js/app.js b/assets/js/app.js
index b0abfc80..d78ff610 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -171,8 +171,50 @@ Hooks.RemoveMissingBrainstorming = {
}
};
+Hooks.LanesScrollIndicator = {
+ mounted() {
+ this.scrollContainer = this.el.querySelector('.lanes-container');
+ this.leftArrow = this.el.querySelector('#lanes-scroll-left');
+ this.rightArrow = this.el.querySelector('#lanes-scroll-right');
+
+ this.getColumnWidth = () => {
+ const col = this.scrollContainer.querySelector('[class*="col-"]');
+ return col ? col.offsetWidth : 300;
+ };
+
+ this.updateIndicators = () => {
+ const { scrollLeft, scrollWidth, clientWidth } = this.scrollContainer;
+ const canScrollLeft = scrollLeft > 0;
+ const canScrollRight = scrollLeft + clientWidth < scrollWidth - 1;
+
+ this.leftArrow.classList.toggle('visible', canScrollLeft);
+ this.rightArrow.classList.toggle('visible', canScrollRight);
+ };
+
+ this.leftArrow.addEventListener('click', () => {
+ this.scrollContainer.scrollBy({ left: -this.getColumnWidth(), behavior: 'smooth' });
+ });
+
+ this.rightArrow.addEventListener('click', () => {
+ this.scrollContainer.scrollBy({ left: this.getColumnWidth(), behavior: 'smooth' });
+ });
+
+ this.scrollContainer.addEventListener('scroll', this.updateIndicators);
+ this.resizeObserver = new ResizeObserver(this.updateIndicators);
+ this.resizeObserver.observe(this.scrollContainer);
+
+ this.updateIndicators();
+ },
+ updated() {
+ this.updateIndicators();
+ },
+ destroyed() {
+ this.resizeObserver.disconnect();
+ }
+};
+
// The brainstorming secret from the url ("#123") is added as well to the socket. The secret is not available on the server side by default.
-let liveSocket = new LiveSocket("/live", Socket, {
+let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks, params: { _csrf_token: csrfToken, adminSecret: window.location.hash.substring(1) }
})
diff --git a/assets/scss/_bootstrap_custom.scss b/assets/scss/_bootstrap_custom.scss
index 07ab2241..4591fdae 100644
--- a/assets/scss/_bootstrap_custom.scss
+++ b/assets/scss/_bootstrap_custom.scss
@@ -6,6 +6,20 @@
@import "kits/list";
@import "kits/utilities";
+.card-img-top-mindwendel {
+ overflow: hidden;
+ max-height: 150px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.125);
+
+ &__img {
+ width: 100%;
+ height: 150px;
+ object-fit: cover;
+ display: block;
+ border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0;
+ }
+}
+
.card-body-mindwendel-idea {
@extend .card-body;
padding: 0.5rem 0.5rem;
diff --git a/assets/scss/app.scss b/assets/scss/app.scss
index 5fed89c3..944db5d4 100644
--- a/assets/scss/app.scss
+++ b/assets/scss/app.scss
@@ -114,10 +114,72 @@ div[data-phx-session] {
height: 100%;
}
-.form-control-color {
- max-width: 50px;
-}
-
-.heading-error {
- font-size: 8rem;
+.lanes-container-wrapper {
+ position: relative;
+
+ .lanes-container {
+ overflow-x: auto;
+ scrollbar-color: $gray-500 $gray-200;
+
+ &::-webkit-scrollbar {
+ height: 8px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: $gray-200;
+ border-radius: 4px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background-color: $gray-500;
+ border-radius: 4px;
+
+ &:hover {
+ background-color: $gray-600;
+ }
+ }
+
+ > [class*="col-"] {
+ flex: 0 0 auto;
+ min-width: 300px;
+ }
+ }
+}
+
+.lanes-scroll-indicator {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 40px;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ z-index: 1;
+
+ &.visible {
+ opacity: 1;
+ pointer-events: auto;
+ cursor: pointer;
+ }
+
+ &--right {
+ right: 0;
+ }
+
+ &--left {
+ left: 0;
+ }
+
+ .scroll-arrow {
+ position: sticky;
+ top: 50vh;
+ display: block;
+ font-size: 1.5rem;
+ color: $gray-600;
+ text-align: center;
+ }
+}
+
+.text-pre-line {
+ white-space: pre-line;
}
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
deleted file mode 100644
index ce3e9cd0..00000000
--- a/docker-compose.dev.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-services:
- app:
- build:
- context: ${DEV_BUILD_CONTEXT}
- dockerfile: Dockerfile
- target: development
- tty: true
- stdin_open: true
- volumes:
- - .:/home/node/app
- - ${DEV_BUILD_CONTEXT}/.claude:/home/node/app/.claude
\ No newline at end of file
diff --git a/lib/mindwendel/ai/config.ex b/lib/mindwendel/ai/config.ex
new file mode 100644
index 00000000..90766a45
--- /dev/null
+++ b/lib/mindwendel/ai/config.ex
@@ -0,0 +1,15 @@
+defmodule Mindwendel.AI.Config do
+ @callback fetch_ai_config!() :: keyword()
+
+ def fetch_ai_config! do
+ impl().fetch_ai_config!()
+ end
+
+ defp impl do
+ Application.get_env(
+ :mindwendel,
+ :ai_config_service,
+ Mindwendel.AI.Config.DefaultImpl
+ )
+ end
+end
diff --git a/lib/mindwendel/ai/config/default_impl.ex b/lib/mindwendel/ai/config/default_impl.ex
new file mode 100644
index 00000000..da9c6292
--- /dev/null
+++ b/lib/mindwendel/ai/config/default_impl.ex
@@ -0,0 +1,8 @@
+defmodule Mindwendel.AI.Config.DefaultImpl do
+ @behaviour Mindwendel.AI.Config
+
+ @impl Mindwendel.AI.Config
+ def fetch_ai_config! do
+ Application.fetch_env!(:mindwendel, :ai)
+ end
+end
diff --git a/lib/mindwendel/ai/token_tracking_service.ex b/lib/mindwendel/ai/token_tracking_service.ex
index 3c0334ad..a6d40122 100644
--- a/lib/mindwendel/ai/token_tracking_service.ex
+++ b/lib/mindwendel/ai/token_tracking_service.ex
@@ -10,6 +10,7 @@ defmodule Mindwendel.AI.TokenTrackingService do
"""
import Ecto.Query
+ alias Mindwendel.AI.Config
alias Mindwendel.AI.TokenUsage
alias Mindwendel.Repo
@@ -196,6 +197,6 @@ defmodule Mindwendel.AI.TokenTrackingService do
end
defp fetch_ai_config! do
- Application.fetch_env!(:mindwendel, :ai)
+ Config.fetch_ai_config!()
end
end
diff --git a/lib/mindwendel/brainstormings/idea.ex b/lib/mindwendel/brainstormings/idea.ex
index 5f1456a0..5901127f 100644
--- a/lib/mindwendel/brainstormings/idea.ex
+++ b/lib/mindwendel/brainstormings/idea.ex
@@ -71,28 +71,31 @@ defmodule Mindwendel.Brainstormings.Idea do
end
defp strip_html(text) when is_binary(text) do
- # Strip all HTML tags by parsing with Floki and extracting text content
- # This removes all tags, event handlers, and JavaScript
- case Floki.parse_document(text) do
- {:ok, parsed} ->
- parsed
- |> Floki.text(sep: " ")
- |> normalize_whitespace()
-
- {:error, _} ->
- # If parsing fails, fall back to basic regex stripping
- text
- |> String.replace(~r/<[^>]*>/, "")
- |> normalize_whitespace()
- end
+ # Convert literal newlines to
so Floki natively preserves them as \n
+ text
+ |> String.replace("\n", "
")
+ |> then(fn html ->
+ case Floki.parse_document(html) do
+ {:ok, parsed} ->
+ Floki.text(parsed, sep: " ")
+
+ {:error, _} ->
+ String.replace(html, ~r/<[^>]*>/, "")
+ end
+ end)
+ |> normalize_whitespace()
end
defp strip_html(text), do: text
defp normalize_whitespace(text) do
text
+ # Trim horizontal whitespace around newlines, then collapse remaining
+ |> String.replace(~r/[^\S\n]*\n[^\S\n]*/, "\n")
+ |> String.replace(~r/[^\S\n]+/, " ")
+ # Cap consecutive newlines at 2 (one blank line between paragraphs)
+ |> String.replace(~r/\n{3,}/, "\n\n")
|> String.trim()
- |> String.replace(~r/\s+/, " ")
end
defp maybe_put_idea_labels(changeset, attrs) do
diff --git a/lib/mindwendel/services/chat_completions/chat_completions_service_impl.ex b/lib/mindwendel/services/chat_completions/chat_completions_service_impl.ex
index b7ecfe4e..0455fb5b 100644
--- a/lib/mindwendel/services/chat_completions/chat_completions_service_impl.ex
+++ b/lib/mindwendel/services/chat_completions/chat_completions_service_impl.ex
@@ -1,6 +1,7 @@
defmodule Mindwendel.Services.ChatCompletions.ChatCompletionsServiceImpl do
require Logger
+ alias Mindwendel.AI.Config
alias Mindwendel.AI.Schemas.IdeaLabelAssignment
alias Mindwendel.AI.Schemas.IdeaResponse
alias Mindwendel.AI.TokenTrackingService
@@ -543,6 +544,6 @@ defmodule Mindwendel.Services.ChatCompletions.ChatCompletionsServiceImpl do
end
defp fetch_ai_config! do
- Application.fetch_env!(:mindwendel, :ai)
+ Config.fetch_ai_config!()
end
end
diff --git a/lib/mindwendel/services/idea_clustering_service.ex b/lib/mindwendel/services/idea_clustering_service.ex
index 857d1e0e..d34672e7 100644
--- a/lib/mindwendel/services/idea_clustering_service.ex
+++ b/lib/mindwendel/services/idea_clustering_service.ex
@@ -55,39 +55,43 @@ defmodule Mindwendel.Services.IdeaClusteringService do
{:ok, :skipped}
true ->
- locale = Gettext.get_locale(MindwendelWeb.Gettext)
- label_payload = build_label_payload(brainstorming.labels)
- idea_payload = build_idea_payload(ideas)
-
- with {:ok, raw_assignments} <-
- ChatCompletionsService.classify_labels(
- brainstorming.name,
- label_payload,
- idea_payload,
- locale
- ),
- {:ok, assignments} <- normalize_assignments(raw_assignments) do
- Logger.debug(fn ->
- truncated =
- raw_assignments
- |> inspect(limit: 15, printable_limit: 300, width: 80)
-
- "AI clustering raw assignments: #{truncated}"
- end)
-
- handle_assignments(brainstorming, ideas, assignments)
- else
- {:error, %{} = validation_errors} ->
- Logger.error(
- "AI clustering returned invalid assignments for #{brainstorming.id}: #{inspect(validation_errors)}"
- )
-
- {:error, {:invalid_assignments, validation_errors}}
-
- {:error, reason} ->
- Logger.error("AI clustering failed for #{brainstorming.id}: #{inspect(reason)}")
- {:error, reason}
- end
+ classify_and_assign(brainstorming, ideas)
+ end
+ end
+
+ defp classify_and_assign(brainstorming, ideas) do
+ locale = Gettext.get_locale(MindwendelWeb.Gettext)
+ label_payload = build_label_payload(brainstorming.labels)
+ idea_payload = build_idea_payload(ideas)
+
+ with {:ok, raw_assignments} <-
+ ChatCompletionsService.classify_labels(
+ brainstorming.name,
+ label_payload,
+ idea_payload,
+ locale
+ ),
+ {:ok, assignments} <- normalize_assignments(raw_assignments) do
+ Logger.debug(fn ->
+ truncated =
+ raw_assignments
+ |> inspect(limit: 15, printable_limit: 300, width: 80)
+
+ "AI clustering raw assignments: #{truncated}"
+ end)
+
+ handle_assignments(brainstorming, ideas, assignments)
+ else
+ {:error, %{} = validation_errors} ->
+ Logger.error(
+ "AI clustering returned invalid assignments for #{brainstorming.id}: #{inspect(validation_errors)}"
+ )
+
+ {:error, {:invalid_assignments, validation_errors}}
+
+ {:error, reason} ->
+ Logger.error("AI clustering failed for #{brainstorming.id}: #{inspect(reason)}")
+ {:error, reason}
end
end
diff --git a/lib/mindwendel/services/idea_service.ex b/lib/mindwendel/services/idea_service.ex
index 449bdc16..16bc4b5e 100644
--- a/lib/mindwendel/services/idea_service.ex
+++ b/lib/mindwendel/services/idea_service.ex
@@ -52,33 +52,7 @@ defmodule Mindwendel.Services.IdeaService do
existing_ideas,
locale
) do
- # Build a map of valid lane IDs for quick lookup
- valid_lane_ids = MapSet.new(Enum.map(brainstorming.lanes, & &1.id))
-
- results =
- Enum.map(generated_ideas, fn generated_idea ->
- # Use the lane_id from AI if valid, otherwise fall back to default
- lane_id = resolve_lane_id(generated_idea["lane_id"], valid_lane_ids, default_lane_id)
-
- Ideas.create_idea(%{
- username: @ai_username,
- body: generated_idea["idea"],
- brainstorming_id: brainstorming.id,
- lane_id: lane_id
- })
- end)
-
- # Filter out failed creations and return only successful ones
- {successful, failed} = Enum.split_with(results, &match?({:ok, _}, &1))
-
- unless Enum.empty?(failed) do
- Logger.warning(
- "Failed to create #{length(failed)} ideas for brainstorming #{brainstorming.id}: #{inspect(failed)}"
- )
- end
-
- successful_ideas = Enum.map(successful, fn {:ok, idea} -> idea end)
- {:ok, successful_ideas}
+ create_generated_ideas(brainstorming, generated_ideas, default_lane_id)
else
{:error, reason} ->
Logger.warning("Failed to generate ideas: #{inspect(reason)}")
@@ -89,6 +63,33 @@ defmodule Mindwendel.Services.IdeaService do
end
end
+ defp create_generated_ideas(brainstorming, generated_ideas, default_lane_id) do
+ valid_lane_ids = MapSet.new(Enum.map(brainstorming.lanes, & &1.id))
+
+ results =
+ Enum.map(generated_ideas, fn generated_idea ->
+ lane_id = resolve_lane_id(generated_idea["lane_id"], valid_lane_ids, default_lane_id)
+
+ Ideas.create_idea(%{
+ username: @ai_username,
+ body: generated_idea["idea"],
+ brainstorming_id: brainstorming.id,
+ lane_id: lane_id
+ })
+ end)
+
+ {successful, failed} = Enum.split_with(results, &match?({:ok, _}, &1))
+
+ unless Enum.empty?(failed) do
+ Logger.warning(
+ "Failed to create #{length(failed)} ideas for brainstorming #{brainstorming.id}: #{inspect(failed)}"
+ )
+ end
+
+ successful_ideas = Enum.map(successful, fn {:ok, idea} -> idea end)
+ {:ok, successful_ideas}
+ end
+
defp resolve_lane_id(nil, _valid_lane_ids, default_lane_id), do: default_lane_id
defp resolve_lane_id(lane_id, valid_lane_ids, default_lane_id) do
diff --git a/lib/mindwendel_web/live/idea_live/card_component.html.heex b/lib/mindwendel_web/live/idea_live/card_component.html.heex
index c7a2b4f4..8caa67ee 100644
--- a/lib/mindwendel_web/live/idea_live/card_component.html.heex
+++ b/lib/mindwendel_web/live/idea_live/card_component.html.heex
@@ -7,6 +7,15 @@
data-lane-id={@idea.lane_id}
data-position={@idea.position_order}
>
+ <%= with image when not is_nil(image) <- Enum.find(@idea.files, fn f -> Attachments.simplified_attached_file_type(f.file_type) == "image" end) do %>
+
{@idea.body}
+{@idea.body}
<% end %> <%= if @idea.link do %> diff --git a/lib/mindwendel_web/live/idea_live/show_component.html.heex b/lib/mindwendel_web/live/idea_live/show_component.html.heex index 2e84b304..0efc4c79 100644 --- a/lib/mindwendel_web/live/idea_live/show_component.html.heex +++ b/lib/mindwendel_web/live/idea_live/show_component.html.heex @@ -1,6 +1,6 @@{@idea.body}
+{@idea.body}
<% end %> <%= if @idea.link do %> diff --git a/lib/mindwendel_web/live/lane_live/index_component.html.heex b/lib/mindwendel_web/live/lane_live/index_component.html.heex index 1b927298..99647fca 100644 --- a/lib/mindwendel_web/live/lane_live/index_component.html.heex +++ b/lib/mindwendel_web/live/lane_live/index_component.html.heex @@ -1,134 +1,142 @@ -{gettext("No ideas brainstormed")}
- <.link - class="btn btn-outline-primary d-inline-flex align-items-center" - patch={~p"/brainstormings/#{@brainstorming.id}/lanes/#{lane.id}/new_idea"} - > - {gettext("Add idea")} - -{gettext("No ideas brainstormed")}
+ <.link + class="btn btn-outline-primary d-inline-flex align-items-center" + patch={~p"/brainstormings/#{@brainstorming.id}/lanes/#{lane.id}/new_idea"} + > + {gettext("Add idea")} + +{gettext("No lanes available")}
+ <.link + class="btn btn-outline-primary d-inline-flex align-items-center" + patch={~p"/brainstormings/#{@brainstorming.id}/new_lane"} + > + {gettext("New lane")} +{gettext("No lanes available")}
- <.link - class="btn btn-outline-primary d-inline-flex align-items-center" - patch={~p"/brainstormings/#{@brainstorming.id}/new_lane"} - > - {gettext("New lane")} - -First paragraph
Second paragraph
" }) - # Floki.text with sep: " " should preserve spaces assert changeset.changes.body == "First paragraph Second paragraph" end + test "preserves newlines from plain text input", %{ + brainstorming: brainstorming, + lane: lane + } do + changeset = + Idea.changeset(%Idea{}, %{ + brainstorming_id: brainstorming.id, + lane_id: lane.id, + body: "First paragraph\n\nSecond paragraph" + }) + + assert changeset.changes.body == "First paragraph\n\nSecond paragraph" + end + + test "collapses excessive blank lines", %{ + brainstorming: brainstorming, + lane: lane + } do + changeset = + Idea.changeset(%Idea{}, %{ + brainstorming_id: brainstorming.id, + lane_id: lane.id, + body: "Line 1\n\n\n\n\nLine 2" + }) + + assert changeset.changes.body == "Line 1\n\nLine 2" + end + test "strips HTML when updating an existing idea" do idea = Factory.insert!(:idea, body: "Original text") diff --git a/test/mindwendel/services/chat_completions/chat_completions_service_impl_test.exs b/test/mindwendel/services/chat_completions/chat_completions_service_impl_test.exs index bfe4cbc3..57fbe7a1 100644 --- a/test/mindwendel/services/chat_completions/chat_completions_service_impl_test.exs +++ b/test/mindwendel/services/chat_completions/chat_completions_service_impl_test.exs @@ -6,38 +6,31 @@ defmodule Mindwendel.Services.ChatCompletions.ChatCompletionsServiceImplTest do describe "generate_ideas/1" do setup do - # Configure AI for tests - Application.put_env(:mindwendel, :ai, - enabled: true, - provider: :openai, - model: "gpt-4o-mini", - api_key: "test-key", - token_limit_daily: 1000, - token_limit_hourly: 100, - request_timeout: 60_000 - ) - - on_exit(fn -> - # Restore test config from config/test.exs - Application.put_env(:mindwendel, :ai, - enabled: false, - token_limit_daily: nil, - token_limit_hourly: nil, + Mindwendel.AI.Config.Mock + |> stub(:fetch_ai_config!, fn -> + [ + enabled: true, + provider: :openai, + model: "gpt-4o-mini", + api_key: "test-key", + token_limit_daily: 1000, + token_limit_hourly: 100, + token_reset_hour: 0, request_timeout: 60_000 - ) + ] end) :ok end test "returns error when AI is not enabled" do - Application.put_env(:mindwendel, :ai, enabled: false) + Mindwendel.AI.Config.Mock + |> expect(:fetch_ai_config!, fn -> [enabled: false] end) assert {:error, :ai_not_enabled} = ChatCompletionsServiceImpl.generate_ideas("Test") end test "returns error when daily limit is exceeded" do - # Exceed daily limit {:ok, _} = TokenTrackingService.record_usage(%{total_tokens: 1001}) assert {:error, :daily_limit_exceeded} = @@ -45,7 +38,6 @@ defmodule Mindwendel.Services.ChatCompletions.ChatCompletionsServiceImplTest do end test "returns error when hourly limit is exceeded" do - # Exceed hourly limit {:ok, _} = TokenTrackingService.record_usage(%{total_tokens: 101}) assert {:error, :hourly_limit_exceeded} = @@ -55,38 +47,22 @@ defmodule Mindwendel.Services.ChatCompletions.ChatCompletionsServiceImplTest do describe "enabled?/0" do test "returns true when AI is enabled" do - Application.put_env(:mindwendel, :ai, - enabled: true, - token_limit_daily: nil, - token_limit_hourly: nil - ) + Mindwendel.AI.Config.Mock + |> expect(:fetch_ai_config!, fn -> + [enabled: true, token_limit_daily: nil, token_limit_hourly: nil] + end) assert ChatCompletionsServiceImpl.enabled?() == true end test "returns false when AI is disabled" do - Application.put_env(:mindwendel, :ai, - enabled: false, - token_limit_daily: nil, - token_limit_hourly: nil - ) + Mindwendel.AI.Config.Mock + |> expect(:fetch_ai_config!, fn -> + [enabled: false, token_limit_daily: nil, token_limit_hourly: nil] + end) assert ChatCompletionsServiceImpl.enabled?() == false end - - test "raises ArgumentError when AI config is missing" do - # Temporarily delete config to test error handling - original_config = Application.get_env(:mindwendel, :ai) - Application.delete_env(:mindwendel, :ai) - - # enabled?() should raise because it calls fetch_ai_config!() which uses Application.fetch_env! - assert_raise ArgumentError, fn -> - ChatCompletionsServiceImpl.enabled?() - end - - # Restore config - Application.put_env(:mindwendel, :ai, original_config || [enabled: false]) - end end describe "parse_response/1" do diff --git a/test/mindwendel_web/live/brainstorming_live/show_ai_clustering_test.exs b/test/mindwendel_web/live/brainstorming_live/show_ai_clustering_test.exs index fd6b37ed..c5137731 100644 --- a/test/mindwendel_web/live/brainstorming_live/show_ai_clustering_test.exs +++ b/test/mindwendel_web/live/brainstorming_live/show_ai_clustering_test.exs @@ -10,8 +10,8 @@ defmodule MindwendelWeb.BrainstormingLiveAIClusteringTest do import Mindwendel.BrainstormingsFixtures import Ecto.Query - alias Mindwendel.AI.Schemas.IdeaLabelAssignment alias Mindwendel.Accounts + alias Mindwendel.AI.Schemas.IdeaLabelAssignment alias Mindwendel.Brainstormings alias Mindwendel.Brainstormings.Idea alias Mindwendel.Ideas diff --git a/test/mindwendel_web/live/brainstorming_live_ai_token_limit_test.exs b/test/mindwendel_web/live/brainstorming_live_ai_token_limit_test.exs index 32bbe0dd..9c8ffb5a 100644 --- a/test/mindwendel_web/live/brainstorming_live_ai_token_limit_test.exs +++ b/test/mindwendel_web/live/brainstorming_live_ai_token_limit_test.exs @@ -14,7 +14,6 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do import Mindwendel.BrainstormingsFixtures alias Mindwendel.Accounts - alias Mindwendel.AI.TokenTrackingService describe "AI generation with token limits" do setup do @@ -35,10 +34,8 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do brainstorming: brainstorming, moderating_user: moderating_user } do - # Exceed daily token limit - {:ok, _} = TokenTrackingService.record_usage(%{total_tokens: 1_000_001}) + mock_generate_ideas_error(:daily_limit_exceeded) - # Mount the LiveView with moderating user {:ok, view, _html} = conn |> init_test_session(%{current_user_id: moderating_user.id}) @@ -46,15 +43,12 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do allow_chat_completions(view.pid) - # Trigger AI idea generation view |> element("button[phx-click='generate_ai_ideas']") |> render_click() - # Wait for async message to be processed :timer.sleep(200) - # Verify an error message is displayed (generic error due to mock setup) html = render(view) - assert html =~ "Failed to generate ideas" + assert html =~ "Daily AI token limit exceeded" end test "shows appropriate error message when hourly token limit is exceeded", %{ @@ -62,10 +56,8 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do brainstorming: brainstorming, moderating_user: moderating_user } do - # Exceed hourly token limit - {:ok, _} = TokenTrackingService.record_usage(%{total_tokens: 100_001}) + mock_generate_ideas_error(:hourly_limit_exceeded) - # Mount the LiveView with moderating user {:ok, view, _html} = conn |> init_test_session(%{current_user_id: moderating_user.id}) @@ -73,15 +65,12 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do allow_chat_completions(view.pid) - # Trigger AI idea generation view |> element("button[phx-click='generate_ai_ideas']") |> render_click() - # Wait for async message to be processed :timer.sleep(200) - # Verify an error message is displayed (generic error due to mock setup) html = render(view) - assert html =~ "Failed to generate ideas" + assert html =~ "Hourly AI request limit exceeded" end test "replaces loading message with error when limit is hit", %{ @@ -89,10 +78,8 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do brainstorming: brainstorming, moderating_user: moderating_user } do - # Exceed daily token limit - {:ok, _} = TokenTrackingService.record_usage(%{total_tokens: 1_000_001}) + mock_generate_ideas_error(:daily_limit_exceeded) - # Mount the LiveView with moderating user {:ok, view, _html} = conn |> init_test_session(%{current_user_id: moderating_user.id}) @@ -100,7 +87,6 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do allow_chat_completions(view.pid) - # Trigger AI idea generation click_html = view |> element("button[phx-click='generate_ai_ideas']") @@ -112,9 +98,9 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do # Wait for async message to process :timer.sleep(200) - # Error message should appear (generic error due to mock setup) + # Error message should appear html = render(view) - assert html =~ "Failed to generate ideas" + assert html =~ "Daily AI token limit exceeded" end test "successfully generates ideas when within limits", %{ @@ -122,10 +108,8 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do brainstorming: brainstorming, moderating_user: moderating_user } do - # Mock successful AI generation with 3 ideas mock_generate_ideas(3) - # Mount the LiveView with moderating user {:ok, view, _html} = conn |> init_test_session(%{current_user_id: moderating_user.id}) @@ -133,25 +117,19 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do allow_chat_completions(view.pid) - # Trigger AI idea generation view |> element("button[phx-click='generate_ai_ideas']") |> render_click() - # Wait for async processing :timer.sleep(200) - # Verify success message html = render(view) assert html =~ "idea(s) generated" end test "hides AI button when AI is disabled", %{conn: conn, brainstorming: brainstorming} do - # Disable AI disable_ai() - # Mount the LiveView {:ok, view, _html} = live(conn, ~p"/brainstormings/#{brainstorming.id}") - # Verify AI button is not shown refute view |> has_element?("button[phx-click='generate_ai_ideas']") end end @@ -163,24 +141,19 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do Mindwendel.Services.ChatCompletions.ChatCompletionsServiceMock |> stub(:enabled?, fn -> true end) - # Get moderating user from brainstorming moderating_user = List.first(brainstorming.users) Accounts.add_moderating_user(brainstorming, moderating_user) %{brainstorming: brainstorming, moderating_user: moderating_user} end - test "allows generation when at exactly the daily limit minus one", %{ + test "allows generation when within limits", %{ conn: conn, brainstorming: brainstorming, moderating_user: moderating_user } do - # Set usage to just under the limit (999,999 tokens) - {:ok, _} = TokenTrackingService.record_usage(%{total_tokens: 999_999}) - mock_generate_ideas(2) - # Mount the LiveView with moderating user {:ok, view, _html} = conn |> init_test_session(%{current_user_id: moderating_user.id}) @@ -188,27 +161,22 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do allow_chat_completions(view.pid) - # Trigger AI idea generation - should succeed view |> element("button[phx-click='generate_ai_ideas']") |> render_click() - # Wait for async processing :timer.sleep(200) - # Verify success (no error message about limits) html = render(view) refute html =~ "Daily AI token limit exceeded" refute html =~ "Hourly AI request limit exceeded" end - test "blocks generation when at exactly the daily limit", %{ + test "blocks generation when daily limit is reached", %{ conn: conn, brainstorming: brainstorming, moderating_user: moderating_user } do - # Set usage to exactly the limit (1,000,000 tokens) - {:ok, _} = TokenTrackingService.record_usage(%{total_tokens: 1_000_000}) + mock_generate_ideas_error(:daily_limit_exceeded) - # Mount the LiveView with moderating user {:ok, view, _html} = conn |> init_test_session(%{current_user_id: moderating_user.id}) @@ -216,15 +184,12 @@ defmodule MindwendelWeb.BrainstormingLiveAITokenLimitTest do allow_chat_completions(view.pid) - # Trigger AI idea generation - should be blocked view |> element("button[phx-click='generate_ai_ideas']") |> render_click() - # Wait for async processing :timer.sleep(200) - # Verify error message (generic error due to mock setup) html = render(view) - assert html =~ "Failed to generate ideas" + assert html =~ "Daily AI token limit exceeded" end end diff --git a/test/support/chat_completions_case.ex b/test/support/chat_completions_case.ex index c163ed85..1765998b 100644 --- a/test/support/chat_completions_case.ex +++ b/test/support/chat_completions_case.ex @@ -36,6 +36,17 @@ defmodule Mindwendel.ChatCompletionsCase do end defp stub_ai_disabled do + Mindwendel.AI.Config.Mock + |> stub(:fetch_ai_config!, fn -> + [ + enabled: false, + token_limit_daily: nil, + token_limit_hourly: nil, + token_reset_hour: 0, + request_timeout: 60_000 + ] + end) + Mindwendel.Services.ChatCompletions.ChatCompletionsServiceMock |> stub(:enabled?, fn -> false end) |> stub(:generate_ideas, fn _title, _lanes, _existing_ideas, _locale -> diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 45c88743..8a0b2e65 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -56,6 +56,17 @@ defmodule MindwendelWeb.ConnCase do # Provide default AI disabled stub for all ConnCase tests # This prevents Mox.UnexpectedCallError when LiveViews render defp setup_ai_disabled_stub(_context) do + Mindwendel.AI.Config.Mock + |> Mox.stub(:fetch_ai_config!, fn -> + [ + enabled: false, + token_limit_daily: nil, + token_limit_hourly: nil, + token_reset_hour: 0, + request_timeout: 60_000 + ] + end) + Mindwendel.Services.ChatCompletions.ChatCompletionsServiceMock |> Mox.stub(:enabled?, fn -> false end) |> Mox.stub(:generate_ideas, fn _title, _lanes, _existing_ideas, _locale -> diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 5fd71dc9..fda7678b 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -48,6 +48,17 @@ defmodule Mindwendel.DataCase do # Provide default AI disabled stub for all DataCase tests defp setup_ai_disabled_stub(_context) do + Mindwendel.AI.Config.Mock + |> Mox.stub(:fetch_ai_config!, fn -> + [ + enabled: false, + token_limit_daily: nil, + token_limit_hourly: nil, + token_reset_hour: 0, + request_timeout: 60_000 + ] + end) + Mindwendel.Services.ChatCompletions.ChatCompletionsServiceMock |> Mox.stub(:enabled?, fn -> false end) |> Mox.stub(:generate_ideas, fn _title, _lanes, _existing_ideas, _locale -> diff --git a/test/test_helper.exs b/test/test_helper.exs index 851d577d..2ac864a7 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -5,15 +5,19 @@ Ecto.Adapters.SQL.Sandbox.mode(Mindwendel.Repo, :manual) upload_path = "priv/static/uploads/" File.mkdir_p!(Path.dirname(upload_path)) -# Define the mock - it will be configured per-test via test case setups +# Define mocks - they will be configured per-test via test case setups Mox.defmock(Mindwendel.Services.ChatCompletions.ChatCompletionsServiceMock, for: Mindwendel.Services.ChatCompletions.ChatCompletionsService ) -# Configure to use the mock in tests +Mox.defmock(Mindwendel.AI.Config.Mock, for: Mindwendel.AI.Config) + +# Configure to use mocks in tests # Each test case (ChatCompletionsCase, ConnCase, DataCase) will set up appropriate stubs/expectations Application.put_env( :mindwendel, :chat_completions_service, Mindwendel.Services.ChatCompletions.ChatCompletionsServiceMock ) + +Application.put_env(:mindwendel, :ai_config_service, Mindwendel.AI.Config.Mock)