From 9e387caa8ec726856986777d434301477e333b7c Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Mon, 30 Jun 2025 18:20:14 +0300 Subject: [PATCH 1/2] synced ongoing work --- lib/sanbase/insight/post_image.ex | 6 ++++-- lib/sanbase_web/graphql/resolvers/file_resolver.ex | 9 +++++---- .../20250630112632_add_post_image_owner.exs | 9 +++++++++ priv/repo/structure.sql | 12 +++++++++++- test/sanbase_web/graphql/file_upload_test.exs | 4 ++-- 5 files changed, 31 insertions(+), 9 deletions(-) create mode 100644 priv/repo/migrations/20250630112632_add_post_image_owner.exs diff --git a/lib/sanbase/insight/post_image.ex b/lib/sanbase/insight/post_image.ex index 93148145aa..adc3fa7bb4 100644 --- a/lib/sanbase/insight/post_image.ex +++ b/lib/sanbase/insight/post_image.ex @@ -2,11 +2,13 @@ defmodule Sanbase.Insight.PostImage do use Ecto.Schema import Ecto.Changeset - alias Sanbase.Insight.Post alias __MODULE__ + alias Sanbase.Insight.Post + alias Sanbase.Accounts.User schema "post_images" do belongs_to(:post, Post) + belongs_to(:user, User) field(:file_name, :string) field(:image_url, :string) @@ -16,7 +18,7 @@ defmodule Sanbase.Insight.PostImage do def changeset(%PostImage{} = post_image, attrs \\ %{}) do post_image - |> cast(attrs, [:post_id, :file_name, :image_url, :content_hash, :hash_algorithm]) + |> cast(attrs, [:post_id, :user_id, :file_name, :image_url, :content_hash, :hash_algorithm]) |> validate_required([:image_url, :content_hash, :hash_algorithm]) |> update_change(:image_url, &String.downcase/1) |> unique_constraint(:image_url, name: :image_url_index) diff --git a/lib/sanbase_web/graphql/resolvers/file_resolver.ex b/lib/sanbase_web/graphql/resolvers/file_resolver.ex index d64ea3c9c3..f664d7560d 100644 --- a/lib/sanbase_web/graphql/resolvers/file_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/file_resolver.ex @@ -8,7 +8,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.FileResolver do The files are first uploaded to an AWS S3 bucket and then the image url, the content hash and used hash algorithm are stored in postgres. """ - def upload_image(_root, %{images: images}, _resolution) do + def upload_image(_root, %{images: images}, %{context: %{auth: %{current_user: current_user}}}) do # In S3 there are no folders so the file name just contains some random text # and a slash in it. Locally (in test and dev mode) the files are treated as if # they are located in a folder called `scope` @@ -19,7 +19,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.FileResolver do # Prepend the timestamp in milliseconds to the name to avoid name collision # when uploading images with the same hash and name arg = %{arg | filename: milliseconds_str() <> "_" <> file_name} - save_image_content(arg) + save_image_content(arg, current_user.id) end) :ok = save_image_meta_data(image_data) @@ -29,7 +29,7 @@ defmodule SanbaseWeb.Graphql.Resolvers.FileResolver do # Helper functions - defp save_image_content(%Plug.Upload{filename: file_name} = arg) do + defp save_image_content(%Plug.Upload{filename: file_name} = arg, user_id) do with {:ok, content_hash} <- FileHash.calculate(arg.path), {:ok, file_name} <- FileStore.store({arg, content_hash}) do image_url = FileStore.url({file_name, content_hash}) @@ -38,7 +38,8 @@ defmodule SanbaseWeb.Graphql.Resolvers.FileResolver do file_name: file_name, image_url: image_url, content_hash: content_hash, - hash_algorithm: FileHash.algorithm() |> Atom.to_string() + hash_algorithm: FileHash.algorithm() |> Atom.to_string(), + user_id: user_id } else {:error, error} -> diff --git a/priv/repo/migrations/20250630112632_add_post_image_owner.exs b/priv/repo/migrations/20250630112632_add_post_image_owner.exs new file mode 100644 index 0000000000..be7662f739 --- /dev/null +++ b/priv/repo/migrations/20250630112632_add_post_image_owner.exs @@ -0,0 +1,9 @@ +defmodule Sanbase.Repo.Migrations.AddPostImageOwner do + use Ecto.Migration + + def change do + alter table(:post_images) do + add(:user_id, references(:users, on_delete: :nothing), null: true) + end + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index e2c6510009..0ad36549b8 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -3028,7 +3028,8 @@ CREATE TABLE public.post_images ( image_url text NOT NULL, content_hash text NOT NULL, hash_algorithm text NOT NULL, - post_id bigint + post_id bigint, + user_id bigint ); @@ -10269,6 +10270,14 @@ ALTER TABLE ONLY public.post_images ADD CONSTRAINT post_images_post_id_fkey FOREIGN KEY (post_id) REFERENCES public.posts(id) ON DELETE CASCADE; +-- +-- Name: post_images post_images_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.post_images + ADD CONSTRAINT post_images_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: posts posts_chart_configuration_for_event_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -11555,6 +11564,7 @@ INSERT INTO public."schema_migrations" (version) VALUES (20250611104342); INSERT INTO public."schema_migrations" (version) VALUES (20250612090655); INSERT INTO public."schema_migrations" (version) VALUES (20250612131900); INSERT INTO public."schema_migrations" (version) VALUES (20250612133320); +INSERT INTO public."schema_migrations" (version) VALUES (20250630112632); INSERT INTO public."schema_migrations" (version) VALUES (20250703133723); INSERT INTO public."schema_migrations" (version) VALUES (20250703144448); INSERT INTO public."schema_migrations" (version) VALUES (20250709132930); diff --git a/test/sanbase_web/graphql/file_upload_test.exs b/test/sanbase_web/graphql/file_upload_test.exs index d699cca145..ef8dd26b98 100644 --- a/test/sanbase_web/graphql/file_upload_test.exs +++ b/test/sanbase_web/graphql/file_upload_test.exs @@ -66,8 +66,8 @@ defmodule SanbaseWeb.Graphql.FileUploadTest do mutation { uploadImage(images: ["img"]){ fileName - contentHash, - imageUrl, + contentHash + imageUrl error } } From 3098528a4fa37b00135bfec9e58f88ca439e889d Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Tue, 3 Mar 2026 19:49:02 +0200 Subject: [PATCH 2/2] backup --- lib/sanbase/insight/post.ex | 81 +++++++- .../graphql/resolvers/insight_resolver.ex | 26 ++- ...60303194227_add_user_id_to_post_images.exs | 29 +++ priv/repo/structure.sql | 12 +- .../insight/post_image_deletion_test.exs | 156 ++++++++++++++ test/sanbase_web/graphql/file_upload_test.exs | 3 +- .../graphql/insight/insight_image_test.exs | 192 ++++++++++++++++++ 7 files changed, 476 insertions(+), 23 deletions(-) create mode 100644 priv/repo/migrations/20260303194227_add_user_id_to_post_images.exs create mode 100644 test/sanbase/insight/post_image_deletion_test.exs create mode 100644 test/sanbase_web/graphql/insight/insight_image_test.exs diff --git a/lib/sanbase/insight/post.ex b/lib/sanbase/insight/post.ex index 5087f513fb..8023a60d86 100644 --- a/lib/sanbase/insight/post.ex +++ b/lib/sanbase/insight/post.ex @@ -498,6 +498,7 @@ defmodule Sanbase.Insight.Post do |> Repo.insert() |> case do {:ok, post} -> + auto_link_images(post) emit_event({:ok, post}, :create_insight, %{}) :ok = Sanbase.Insight.Search.update_document_tokens(post.id) {:ok, post} @@ -526,6 +527,8 @@ defmodule Sanbase.Insight.Post do case Repo.update(update_changeset) do {:ok, post} -> + auto_link_images(post) + # Update the embeddings only if the title or text changed and the post is published. # On embed existing embeddings are deleted and the new one are created published? = post.ready_state == @published @@ -980,16 +983,35 @@ defmodule Sanbase.Insight.Post do defp images_cast(changeset, _), do: changeset - defp extract_image_url_from_post(%Post{} = post) do - post - |> Repo.preload(:images) - |> Map.get(:images, []) - |> Enum.map(fn %{image_url: image_url} -> image_url end) - end + @doc """ + Delete S3 files for a post's images, but only when: + 1. The image was uploaded by the post's author (image.user_id == post.user_id) + 2. The image URL is not used in any other post's text + The PostImage DB records are cascade-deleted when the post is deleted, + so this only controls S3 file cleanup. + """ def delete_post_images(%Post{} = post) do - extract_image_url_from_post(post) - |> Enum.map(&Sanbase.FileStore.delete/1) + post = Repo.preload(post, :images) + + Enum.each(post.images, fn image -> + owner_uploaded? = image.user_id == post.user_id + used_elsewhere? = image_used_in_other_posts?(image.image_url, post.id) + + if owner_uploaded? and not used_elsewhere? do + Sanbase.FileStore.delete(image.image_url) + end + end) + end + + defp image_used_in_other_posts?(image_url, post_id) do + pattern = "%#{image_url}%" + + from(p in __MODULE__, + where: p.id != ^post_id and p.is_deleted != true, + where: like(p.text, ^pattern) + ) + |> Repo.exists?() end defp maybe_drop_post_tags(post, %{tags: tags}) when is_list(tags), @@ -1019,6 +1041,49 @@ defmodule Sanbase.Insight.Post do end end + @doc """ + Scan the post text for image URLs matching existing unlinked PostImage records + uploaded by the same user, and link them to this post. + """ + def auto_link_images(%__MODULE__{id: post_id, user_id: user_id, text: text}) + when is_binary(text) do + image_urls = extract_image_urls_from_text(text) + + if image_urls != [] do + from(pi in PostImage, + where: pi.image_url in ^image_urls, + where: pi.user_id == ^user_id, + where: is_nil(pi.post_id) or pi.post_id == ^post_id + ) + |> Repo.update_all(set: [post_id: post_id]) + end + + :ok + end + + def auto_link_images(_post), do: :ok + + case Application.compile_env(:sanbase, :env) do + :test -> + defp extract_image_urls_from_text(text) do + storage_dir = Application.get_env(:waffle, :storage_dir) + + storage_dir = + if String.last(storage_dir) != "/", do: storage_dir <> "/", else: storage_dir + + regex = Regex.compile!(~s{#{storage_dir}[^\s"<>]+(?:\\.jpg|\\.png|\\.gif|\\.jpeg)}) + Regex.scan(regex, text) |> Enum.map(fn [url] -> url end) + end + + _ -> + defp extract_image_urls_from_text(text) do + regex = + ~r{https://[a-zA-Z0-9\-\.]*sanbase-images.s3\.amazonaws\.com/[^\s"<>]+(?:\.jpg|\.png|\.gif|\.jpeg)} + + Regex.scan(regex, text) |> Enum.map(fn [url] -> url end) + end + end + defp async_embed_post(%__MODULE__{} = post) do if Application.get_env(:sanbase, :env) != :test do Task.Supervisor.async_nolink(Sanbase.TaskSupervisor, fn -> diff --git a/lib/sanbase_web/graphql/resolvers/insight_resolver.ex b/lib/sanbase_web/graphql/resolvers/insight_resolver.ex index e222dd8b19..1cd4b81b04 100644 --- a/lib/sanbase_web/graphql/resolvers/insight_resolver.ex +++ b/lib/sanbase_web/graphql/resolvers/insight_resolver.ex @@ -217,12 +217,28 @@ defmodule SanbaseWeb.Graphql.Resolvers.InsightResolver do ~r{https://[a-zA-Z0-9\-\.]*sanbase-images.s3\.amazonaws\.com/[^\s"<>]+(?:\.jpg|\.png|\.gif|\.jpeg)} end - def extract_images_from_text(%Post{text: text}, _args, _resolution) do - image_urls = - Regex.scan(image_url_regex(), text) - |> Enum.map(fn [url] -> url end) + def extract_images_from_text(%Post{text: text, images: images}, _args, _resolution) do + # Images from DB (PostImage records linked to this post) + db_images = + case images do + images when is_list(images) -> + Enum.map(images, fn %{image_url: image_url} -> %{image_url: image_url} end) + + _ -> + [] + end + + # Images extracted from the post text via regex (for old insights without DB records) + regex_images = + Regex.scan(image_url_regex(), text || "") + |> Enum.map(fn [url] -> %{image_url: url} end) + + # Union of both sources, deduplicated by image_url + all_images = + (db_images ++ regex_images) + |> Enum.uniq_by(fn %{image_url: url} -> url end) - {:ok, Enum.map(image_urls, fn image_url -> %{image_url: image_url} end)} + {:ok, all_images} end def insights_count(%User{id: id}, _args, %{context: %{loader: loader}}) do diff --git a/priv/repo/migrations/20260303194227_add_user_id_to_post_images.exs b/priv/repo/migrations/20260303194227_add_user_id_to_post_images.exs new file mode 100644 index 0000000000..8aff92ff96 --- /dev/null +++ b/priv/repo/migrations/20260303194227_add_user_id_to_post_images.exs @@ -0,0 +1,29 @@ +defmodule Sanbase.Repo.Migrations.AddUserIdToPostImages do + use Ecto.Migration + + def up do + unless column_exists?(:post_images, :user_id) do + alter table(:post_images) do + add(:user_id, references(:users), null: true) + end + end + end + + def down do + alter table(:post_images) do + remove(:user_id) + end + end + + defp column_exists?(table, column) do + query = """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = '#{table}' AND column_name = '#{column}' + ) + """ + + %{rows: [[exists]]} = repo().query!(query) + exists + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 0ad36549b8..b691c8ba92 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- -\restrict lPrBCVGzfDAQrezJuUuvsvvIxIMIDCrQVbcCW2A0GafidncDVtkqtUHNwYPqA5k +\restrict D6tIxYMizWBarY7UY5Vw2vV7lR6dxVzT8Y0sbdLlB5d7PcuhASx5fPAf54gNRco -- Dumped from database version 15.16 (Homebrew) -- Dumped by pg_dump version 15.16 (Homebrew) @@ -8744,13 +8744,6 @@ CREATE UNIQUE INDEX metrics_name_index ON public.metrics USING btree (name); CREATE UNIQUE INDEX monitored_twitter_handles_handle_index ON public.monitored_twitter_handles USING btree (handle); --- --- Name: notification_muted_users_muted_user_id_index; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX notification_muted_users_muted_user_id_index ON public.notification_muted_users USING btree (muted_user_id); - - -- -- Name: notification_templates_action_step_channel_mime_type_index; Type: INDEX; Schema: public; Owner: - -- @@ -11066,7 +11059,7 @@ ALTER TABLE ONLY public.webinar_registrations -- PostgreSQL database dump complete -- -\unrestrict lPrBCVGzfDAQrezJuUuvsvvIxIMIDCrQVbcCW2A0GafidncDVtkqtUHNwYPqA5k +\unrestrict D6tIxYMizWBarY7UY5Vw2vV7lR6dxVzT8Y0sbdLlB5d7PcuhASx5fPAf54gNRco INSERT INTO public."schema_migrations" (version) VALUES (20171008200815); INSERT INTO public."schema_migrations" (version) VALUES (20171008203355); @@ -11609,3 +11602,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20260116093636); INSERT INTO public."schema_migrations" (version) VALUES (20260216103643); INSERT INTO public."schema_migrations" (version) VALUES (20260224120000); INSERT INTO public."schema_migrations" (version) VALUES (20260225120000); +INSERT INTO public."schema_migrations" (version) VALUES (20260303194227); diff --git a/test/sanbase/insight/post_image_deletion_test.exs b/test/sanbase/insight/post_image_deletion_test.exs new file mode 100644 index 0000000000..b7e80d1487 --- /dev/null +++ b/test/sanbase/insight/post_image_deletion_test.exs @@ -0,0 +1,156 @@ +defmodule Sanbase.Insight.PostImageDeletionTest do + use SanbaseWeb.ConnCase, async: false + + import Sanbase.Factory + import Mock + + alias Sanbase.Insight.Post + alias Sanbase.Insight.PostImage + alias Sanbase.Repo + + setup do + user = insert(:user) + other_user = insert(:user) + + {:ok, user: user, other_user: other_user} + end + + defp create_image(user, opts) do + post_id = Keyword.get(opts, :post_id) + + image_url = + Keyword.get( + opts, + :image_url, + "/tmp/sanbase/filestore-test/#{System.unique_integer([:positive])}_image.png" + ) + + PostImage.create!(%{ + user_id: user.id, + post_id: post_id, + file_name: "test_image.png", + image_url: image_url, + content_hash: "hash_#{System.unique_integer([:positive])}", + hash_algorithm: "sha256" + }) + end + + describe "delete_post_images/1" do + test "deletes S3 file when owner uploaded the image and it's not used elsewhere", %{ + user: user + } do + post = insert(:post, user: user, text: "some text") + + image = + create_image(user, + post_id: post.id, + image_url: "/tmp/sanbase/filestore-test/owner_image.png" + ) + + with_mock Sanbase.FileStore, [:passthrough], delete: fn _url -> :ok end do + Post.delete_post_images(post) + + assert called(Sanbase.FileStore.delete(image.image_url)) + end + end + + test "does NOT delete S3 file when image was uploaded by different user", %{ + user: user, + other_user: other_user + } do + post = insert(:post, user: user, text: "some text") + + image = + create_image(other_user, + post_id: post.id, + image_url: "/tmp/sanbase/filestore-test/other_image.png" + ) + + with_mock Sanbase.FileStore, [:passthrough], delete: fn _url -> :ok end do + Post.delete_post_images(post) + + refute called(Sanbase.FileStore.delete(image.image_url)) + end + end + + test "does NOT delete S3 file when image URL appears in another post's text", %{user: user} do + image_url = "/tmp/sanbase/filestore-test/shared_image.png" + post = insert(:post, user: user, text: "some text") + _other_post = insert(:post, user: user, text: "uses the image #{image_url} here") + _image = create_image(user, post_id: post.id, image_url: image_url) + + with_mock Sanbase.FileStore, [:passthrough], delete: fn _url -> :ok end do + Post.delete_post_images(post) + + refute called(Sanbase.FileStore.delete(image_url)) + end + end + + test "deletes S3 file when image is owned and not referenced in other posts", %{user: user} do + image_url = "/tmp/sanbase/filestore-test/unique_image.png" + post = insert(:post, user: user, text: "my post with #{image_url}") + _image = create_image(user, post_id: post.id, image_url: image_url) + + with_mock Sanbase.FileStore, [:passthrough], delete: fn _url -> :ok end do + Post.delete_post_images(post) + + assert called(Sanbase.FileStore.delete(image_url)) + end + end + + test "handles post with no images", %{user: user} do + post = insert(:post, user: user, text: "no images here") + + # Should not raise + assert Post.delete_post_images(post) == :ok + end + end + + describe "auto_link_images/1" do + test "links unlinked images matching URLs in text", %{user: user} do + image_url = "/tmp/sanbase/filestore-test/auto_link_test.png" + image = create_image(user, image_url: image_url) + + post = insert(:post, user: user, text: "Check out #{image_url} in this post") + + Post.auto_link_images(post) + + updated_image = Repo.get(PostImage, image.id) + assert updated_image.post_id == post.id + end + + test "does not link images uploaded by a different user", %{ + user: user, + other_user: other_user + } do + image_url = "/tmp/sanbase/filestore-test/other_user_image.png" + image = create_image(other_user, image_url: image_url) + + post = insert(:post, user: user, text: "Using #{image_url}") + + Post.auto_link_images(post) + + updated_image = Repo.get(PostImage, image.id) + assert updated_image.post_id == nil + end + + test "does not link images already linked to a different post", %{user: user} do + image_url = "/tmp/sanbase/filestore-test/already_linked.png" + other_post = insert(:post, user: user, text: "first post") + image = create_image(user, post_id: other_post.id, image_url: image_url) + + new_post = insert(:post, user: user, text: "Using #{image_url} too") + + Post.auto_link_images(new_post) + + updated_image = Repo.get(PostImage, image.id) + # Should remain linked to the original post + assert updated_image.post_id == other_post.id + end + + test "handles post with nil text", %{user: user} do + post = %Post{id: 1, user_id: user.id, text: nil} + assert Post.auto_link_images(post) == :ok + end + end +end diff --git a/test/sanbase_web/graphql/file_upload_test.exs b/test/sanbase_web/graphql/file_upload_test.exs index ef8dd26b98..38cc4ef534 100644 --- a/test/sanbase_web/graphql/file_upload_test.exs +++ b/test/sanbase_web/graphql/file_upload_test.exs @@ -134,7 +134,7 @@ defmodule SanbaseWeb.Graphql.FileUploadTest do assert image2["imageUrl"] != nil end - test "upload metadata is correctly stored in postgres", %{conn: conn} do + test "upload metadata is correctly stored in postgres", %{conn: conn, user: user} do mutation = """ mutation { uploadImage(images: ["img"]){ @@ -165,5 +165,6 @@ defmodule SanbaseWeb.Graphql.FileUploadTest do assert String.ends_with?(image_meta_data.file_name, @test_file_name) assert image_meta_data.content_hash == @test_file_hash assert image_meta_data.hash_algorithm == @test_file_hash_algorithm + assert image_meta_data.user_id == user.id end end diff --git a/test/sanbase_web/graphql/insight/insight_image_test.exs b/test/sanbase_web/graphql/insight/insight_image_test.exs new file mode 100644 index 0000000000..0cf603aa97 --- /dev/null +++ b/test/sanbase_web/graphql/insight/insight_image_test.exs @@ -0,0 +1,192 @@ +defmodule SanbaseWeb.Graphql.InsightImageTest do + use SanbaseWeb.ConnCase, async: false + + import SanbaseWeb.Graphql.TestHelpers + import Sanbase.Factory + import Sanbase.TestHelpers + + alias Sanbase.Insight.Post + alias Sanbase.Insight.PostImage + + setup do + clean_task_supervisor_children() + + user = insert(:user) + conn = setup_jwt_auth(build_conn(), user) + + {:ok, conn: conn, user: user} + end + + @test_file_path "#{File.cwd!()}/test/sanbase_web/graphql/assets/image.png" + + describe "images field via GraphQL" do + test "returns DB-linked images", %{conn: conn} do + image_url = upload_image(conn) + + mutation = """ + mutation { + createInsight(title: "Test post", text: "some text", imageUrls: ["#{image_url}"]) { + id + images { imageUrl } + } + } + """ + + result = + conn + |> post("/graphql", mutation_skeleton(mutation)) + |> json_response(200) + + images = result["data"]["createInsight"]["images"] + assert length(images) == 1 + assert hd(images)["imageUrl"] == image_url + end + + test "returns regex-extracted images from text for old insights", %{user: user} do + # Simulate an old insight with image URL in text but no DB PostImage link + image_url = "/tmp/sanbase/filestore-test/old_image.png" + + post = + insert(:post, + user: user, + text: "Here is an image #{image_url} in the text", + state: Post.approved_state(), + ready_state: Post.published() + ) + + conn = setup_jwt_auth(build_conn(), user) + + query = """ + { + insight(id: #{post.id}) { + images { imageUrl } + } + } + """ + + result = + conn + |> post("/graphql", query_skeleton(query, "insight")) + |> json_response(200) + + images = result["data"]["insight"]["images"] + assert length(images) == 1 + assert hd(images)["imageUrl"] == image_url + end + + test "deduplicates images found in both DB and text", %{conn: conn} do + image_url = upload_image(conn) + + # Create insight with image in both imageUrls and embedded in text + mutation = """ + mutation { + createInsight(title: "Dedup test", text: "Look at #{image_url}", imageUrls: ["#{image_url}"]) { + id + images { imageUrl } + } + } + """ + + result = + conn + |> post("/graphql", mutation_skeleton(mutation)) + |> json_response(200) + + images = result["data"]["createInsight"]["images"] + # Should only appear once despite being in both DB and text + assert length(images) == 1 + assert hd(images)["imageUrl"] == image_url + end + end + + describe "auto-link on create" do + test "auto-links uploaded image when its URL appears in text", %{conn: conn, user: user} do + image_url = upload_image(conn) + + # Create insight with the image URL in the text but NOT in imageUrls + mutation = """ + mutation { + createInsight(title: "Auto-link test", text: "Check #{image_url}") { + id + } + } + """ + + result = + conn + |> post("/graphql", mutation_skeleton(mutation)) + |> json_response(200) + + post_id = result["data"]["createInsight"]["id"] + + # Verify the PostImage is now linked to the post + image = Sanbase.Repo.get_by(PostImage, image_url: image_url) + assert image.post_id == post_id + assert image.user_id == user.id + end + end + + describe "auto-link on update" do + test "auto-links images when text is updated with image URL", %{conn: conn} do + image_url = upload_image(conn) + + # Create insight without the image + create_mutation = """ + mutation { + createInsight(title: "Update link test", text: "no image here") { + id + } + } + """ + + result = + conn + |> post("/graphql", mutation_skeleton(create_mutation)) + |> json_response(200) + + post_id = result["data"]["createInsight"]["id"] + + # Update the insight to include the image URL in text + update_mutation = """ + mutation { + updateInsight(id: #{post_id}, text: "now has #{image_url}") { + id + } + } + """ + + conn + |> post("/graphql", mutation_skeleton(update_mutation)) + |> json_response(200) + + # Verify the PostImage is now linked to the post + image = Sanbase.Repo.get_by(PostImage, image_url: image_url) + assert image.post_id == post_id + end + end + + # Helper + + defp upload_image(conn) do + mutation = """ + mutation { + uploadImage(images: ["img"]){ + imageUrl + } + } + """ + + upload = %Plug.Upload{ + content_type: "application/octet-stream", + filename: "#{System.unique_integer([:positive])}_image.png", + path: @test_file_path + } + + result = + conn + |> post("/graphql", %{"query" => mutation, "img" => upload}) + + [image_data] = json_response(result, 200)["data"]["uploadImage"] + image_data["imageUrl"] + end +end