;; This Source Code Form is subject to the terms of the Mozilla Public ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; ;; Copyright (c) KALEIDOS INC Sucursal en EspaƱa SL (ns backend-tests.media-remote-test (:require [app.common.exceptions :as ex] [app.config :as cf] [app.media.remote :as media.remote] [app.setup :as-alias setup] [app.util.json :as json] [backend-tests.helpers :as th] [clojure.test :as t] [cuerdas.core :as str] [datoteka.fs :as fs] [datoteka.io :as io] [mockery.core :refer [with-mocks]]) (:import java.io.ByteArrayInputStream)) (defn- mk-system "Minimal system map for media.remote/process tests." [] {::setup/shared-keys {:media-processor "test-shared-key"}}) (defn- json-stream "Create an InputStream from a Clojure data structure (JSON-encoded)." [data] (ByteArrayInputStream. (json/encode data))) (def config-mock "Standard config mock for media-processor service." {:media-processing-service-uri "http://localhost:6065" :media-processing-service-timeout 5000}) (defn- write-font-tmp "Write font bytes to a tempfile and return the Path. Caller is responsible for cleanup." [bytes suffix] (let [tmp (fs/create-tempfile :prefix "penpot-test-font-" :suffix suffix)] (io/write* tmp bytes) tmp)) ;; --------------------------------------------------------------------------- ;; :info ;; --------------------------------------------------------------------------- (t/deftest info-happy-path (t/testing "info returns dimensions and merges into input" (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (json-stream {:width 800 :height 600 :mtype "image/jpeg" :size 12345 :orientation 1})}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") result (media.remote/process (mk-system) {:cmd :info :input {:path path :mtype "image/jpeg"}})] (t/is (= 800 (:width result))) (t/is (= 600 (:height result))) (t/is (= (fs/size path) (:size result))) (t/is (some? (:ts result))) (t/is (= path (:path result))) (t/is (= "image/jpeg" (:mtype result))) (t/is (= 1 (:call-count @mock)))))))) (t/deftest info-verifies-request-params (t/testing "info sends correct endpoint, method, and x-shared-key header" (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] (media.remote/process (mk-system) {:cmd :info :input {:path path :mtype "image/jpeg"}}) (let [[system req-map] (:call-args @mock)] ;; System passed through (t/is (some? (::setup/shared-keys system))) ;; Request structure (t/is (= :post (:method req-map))) (t/is (str/includes? (str (:uri req-map)) "api/image/info")) (t/is (= "test-shared-key" (get-in req-map [:headers "x-shared-key"]))) (t/is (str/starts-with? (get-in req-map [:headers "Content-Type"]) "multipart/form-data")))))))) (t/deftest info-no-content-length-header (t/testing "info does not send Content-Length header (JDK uses chunked encoding)" (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (json-stream {:width 100 :height 100 :mtype "image/jpeg" :size 1 :orientation 1})}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] (media.remote/process (mk-system) {:cmd :info :input {:path path :mtype "image/jpeg"}}) (let [[_ req-map] (:call-args @mock)] (t/is (nil? (get-in req-map [:headers "Content-Length"]))))))))) (t/deftest info-service-uri-not-configured (t/testing "info throws when service URI is not configured" (with-redefs [cf/get (constantly nil)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") err (ex/try! (media.remote/process (mk-system) {:cmd :info :input {:path path :mtype "image/jpeg"}}))] (t/is (ex/error? err)) (t/is (= :internal (:type (ex-data err)))) (t/is (= :media-processor-not-configured (:code (ex-data err)))))))) (t/deftest info-service-unavailable (t/testing "info throws when service-request raises unavailable" (with-mocks [mock {:target 'app.media.remote/service-request :throw (ex-info "Cannot connect to media-processor service" {:type :internal :code :media-processor-unavailable})}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") err (ex/try! (media.remote/process (mk-system) {:cmd :info :input {:path path :mtype "image/jpeg"}}))] (t/is (ex/error? err)) (t/is (= :internal (:type (ex-data err)))) (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) (t/deftest info-service-timeout (t/testing "info throws when service-request raises timeout" (with-mocks [mock {:target 'app.media.remote/service-request :throw (ex-info "media-processor service request timed out" {:type :internal :code :media-processor-timeout})}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") err (ex/try! (media.remote/process (mk-system) {:cmd :info :input {:path path :mtype "image/jpeg"}}))] (t/is (ex/error? err)) (t/is (= :internal (:type (ex-data err)))) (t/is (= :media-processor-timeout (:code (ex-data err))))))))) (t/deftest info-mtype-mismatch (t/testing "info raises :media-type-mismatch when detected mtype differs from declared" (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (json-stream {:width 100 :height 100 :size 100 :mtype "image/png"})}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") err (ex/try! (media.remote/process (mk-system) {:cmd :info :input {:path path :mtype "image/jpeg"}}))] (t/is (ex/error? err)) (t/is (= :validation (:type (ex-data err)))) (t/is (= :media-type-mismatch (:code (ex-data err))))))))) ;; --------------------------------------------------------------------------- ;; :generic-thumbnail ;; --------------------------------------------------------------------------- (t/deftest generic-thumbnail-happy-path (t/testing "generic-thumbnail returns tempfile with correct format" (let [thumb-bytes (.getBytes "fake-jpeg-data" "UTF-8")] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. thumb-bytes)}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") result (media.remote/process (mk-system) {:cmd :generic-thumbnail :input {:path path :mtype "image/jpeg"} :format :jpeg :quality 80 :width 200 :height 200})] (t/is (= :jpeg (:format result))) (t/is (= "image/jpeg" (:mtype result))) (t/is (pos? (:size result))) (t/is (fs/exists? (:data result))))))))) (t/deftest generic-thumbnail-verifies-query-params (t/testing "generic-thumbnail sends correct query params with mode=fit" (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. (.getBytes "data" "UTF-8"))}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] (media.remote/process (mk-system) {:cmd :generic-thumbnail :input {:path path :mtype "image/jpeg"} :format :jpeg :quality 85 :width 300 :height 400}) (let [[_ req-map] (:call-args @mock)] (t/is (str/includes? (str (:uri req-map)) "width=300")) (t/is (str/includes? (str (:uri req-map)) "height=400")) (t/is (str/includes? (str (:uri req-map)) "quality=85")) (t/is (str/includes? (str (:uri req-map)) "format=jpeg")) (t/is (str/includes? (str (:uri req-map)) "mode=fit")))))))) (t/deftest generic-thumbnail-service-unavailable (t/testing "generic-thumbnail throws on service error" (with-mocks [mock {:target 'app.media.remote/service-request :throw (ex-info "Cannot connect to media-processor service" {:type :internal :code :media-processor-unavailable})}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") err (ex/try! (media.remote/process (mk-system) {:cmd :generic-thumbnail :input {:path path :mtype "image/jpeg"} :format :jpeg :quality 85 :width 200 :height 200}))] (t/is (ex/error? err)) (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) ;; --------------------------------------------------------------------------- ;; :profile-thumbnail ;; --------------------------------------------------------------------------- (t/deftest profile-thumbnail-happy-path (t/testing "profile-thumbnail returns tempfile and uses mode=crop" (let [thumb-bytes (.getBytes "fake-png-data" "UTF-8")] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. thumb-bytes)}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") result (media.remote/process (mk-system) {:cmd :profile-thumbnail :input {:path path :mtype "image/jpeg"} :format :jpeg :quality 85 :width 128 :height 128})] (t/is (some? (:data result))) (t/is (fs/exists? (:data result))) ;; Verify mode=crop in URI (let [[_ req-map] (:call-args @mock)] (t/is (str/includes? (str (:uri req-map)) "mode=crop"))))))))) (t/deftest profile-thumbnail-service-unavailable (t/testing "profile-thumbnail throws on service error" (with-mocks [mock {:target 'app.media.remote/service-request :throw (ex-info "Cannot connect to media-processor service" {:type :internal :code :media-processor-unavailable})}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg") err (ex/try! (media.remote/process (mk-system) {:cmd :profile-thumbnail :input {:path path :mtype "image/jpeg"} :format :jpeg :quality 85 :width 128 :height 128}))] (t/is (ex/error? err)) (t/is (= :media-processor-unavailable (:code (ex-data err))))))))) ;; --------------------------------------------------------------------------- ;; :generate-fonts ;; --------------------------------------------------------------------------- (t/deftest generate-fonts-ttf-happy-path (t/testing "generate-fonts with TTF path makes per-variant calls" (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) ttfpath (write-font-tmp ttfbytes ".ttf") fake-bytes (.getBytes "fake-font-data" "UTF-8")] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. fake-bytes)}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (try (let [result (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/ttf" ttfpath}})] ;; Original path preserved (t/is (= ttfpath (get result "font/ttf"))) ;; Variants written to tempfiles (t/is (fs/exists? (get result "font/otf"))) (t/is (fs/exists? (get result "font/woff"))) ;; Two calls: one for otf, one for woff (t/is (= 2 (:call-count @mock)))) (finally (fs/delete ttfpath)))))))) (t/deftest generate-fonts-ttf-as-path (t/testing "generate-fonts with TTF as tempfile Path works" (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) tmp-path (write-font-tmp ttfbytes ".ttf") fake-bytes (.getBytes "fake-font-data" "UTF-8")] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. fake-bytes)}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (try (let [result (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/ttf" tmp-path}})] ;; Path preserved (t/is (= tmp-path (get result "font/ttf"))) ;; Variant written (t/is (fs/exists? (get result "font/otf")))) (finally (fs/delete tmp-path)))))))) (t/deftest generate-fonts-otf-happy-path (t/testing "generate-fonts with OTF path" (let [otfbytes (io/read* (io/resource "backend_tests/test_files/font-1.otf")) otfpath (write-font-tmp otfbytes ".otf") fake-bytes (.getBytes "fake-font-data" "UTF-8")] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. fake-bytes)}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (try (let [result (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/otf" otfpath}})] (t/is (= otfpath (get result "font/otf"))) (t/is (fs/exists? (get result "font/ttf"))) (t/is (fs/exists? (get result "font/woff"))) ;; Two calls: one for ttf, one for woff (t/is (= 2 (:call-count @mock)))) (finally (fs/delete otfpath)))))))) (t/deftest generate-fonts-woff-happy-path (t/testing "generate-fonts with WOFF path" (let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff")) woffpath (write-font-tmp woffbytes ".woff") fake-bytes (.getBytes "fake-font-data" "UTF-8")] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. fake-bytes)}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (try (let [result (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/woff" woffpath}})] (t/is (= woffpath (get result "font/woff"))) (t/is (fs/exists? (get result "font/ttf"))) (t/is (fs/exists? (get result "font/otf"))) ;; Two calls: one for ttf, one for otf (t/is (= 2 (:call-count @mock)))) (finally (fs/delete woffpath))))))) (t/deftest generate-fonts-woff2-happy-path (t/testing "generate-fonts with WOFF2 path" (let [woff2bytes (io/read* (io/resource "backend_tests/test_files/font-1.woff2")) woff2path (write-font-tmp woff2bytes ".woff2") fake-bytes (.getBytes "fake-font-data" "UTF-8")] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. fake-bytes)}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (try (let [result (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/woff2" woff2path}})] (t/is (= woff2path (get result "font/woff2"))) (t/is (fs/exists? (get result "font/ttf"))) (t/is (fs/exists? (get result "font/otf"))) (t/is (fs/exists? (get result "font/woff"))) ;; Three calls: one for ttf, one for otf, one for woff (t/is (= 3 (:call-count @mock)))) (finally (fs/delete woff2path))))))))) (t/deftest generate-fonts-verifies-query-params (t/testing "generate-fonts sends target-type query param with 180s timeout" (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) ttfpath (write-font-tmp ttfbytes ".ttf") fake-bytes (.getBytes "fake-font-data" "UTF-8")] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. fake-bytes)}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (try (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/ttf" ttfpath}}) (let [[_ req-map] (:call-args @mock)] (t/is (str/includes? (str (:uri req-map)) "target-type=")) (t/is (= 180000 (:timeout req-map)))) (finally (fs/delete ttfpath)))))))) (t/deftest generate-fonts-woff-verifies-target-types (t/testing "generate-fonts with WOFF sends target-type query param" (let [woffbytes (io/read* (io/resource "backend_tests/test_files/font-1.woff")) woffpath (write-font-tmp woffbytes ".woff") fake-bytes (.getBytes "fake-font-data" "UTF-8")] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (ByteArrayInputStream. fake-bytes)}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (try (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/woff" woffpath}}) (let [[_ req-map] (:call-args @mock)] (t/is (str/includes? (str (:uri req-map)) "target-type="))) (finally (fs/delete woffpath)))))))) (t/deftest generate-fonts-no-recognized-variant (t/testing "generate-fonts throws when no recognized font variant" (with-redefs [cf/get (th/config-get-mock config-mock)] (let [err (ex/try! (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/unknown" (.getBytes "data" "UTF-8")}}))] (t/is (ex/error? err)) (t/is (= :validation (:type (ex-data err)))) (t/is (= :invalid-font (:code (ex-data err)))))))) (t/deftest generate-fonts-connection-error (t/testing "generate-fonts throws on service error" (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) ttfpath (write-font-tmp ttfbytes ".ttf")] (with-mocks [mock {:target 'app.media.remote/service-request :throw (ex-info "Cannot connect to media-processor service" {:type :internal :code :media-processor-unavailable})}] (with-redefs [cf/get (th/config-get-mock config-mock)] (try (let [err (ex/try! (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/ttf" ttfpath}}))] (t/is (ex/error? err)) (t/is (= :media-processor-unavailable (:code (ex-data err))))) (finally (fs/delete ttfpath)))))))) (t/deftest generate-fonts-timeout-error (t/testing "generate-fonts throws on service timeout" (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) ttfpath (write-font-tmp ttfbytes ".ttf")] (with-mocks [mock {:target 'app.media.remote/service-request :throw (ex-info "media-processor service request timed out" {:type :internal :code :media-processor-timeout})}] (with-redefs [cf/get (th/config-get-mock config-mock)] (try (let [err (ex/try! (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/ttf" ttfpath}}))] (t/is (ex/error? err)) (t/is (= :media-processor-timeout (:code (ex-data err))))) (finally (fs/delete ttfpath)))))))) ;; --------------------------------------------------------------------------- ;; Status code handling (service-request) ;; --------------------------------------------------------------------------- (t/deftest service-request-raises-on-400 (t/testing "service-request raises :validation on status 400" (with-mocks [mock {:target 'app.http.client/req :return {:status 400 :body (json-stream {:type "validation" :code "invalid-image" :hint "bad input"})}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [err (ex/try! (media.remote/service-request (mk-system) {:method :post :uri "http://localhost:6065/api/image/info" :body nil :headers {}}))] (t/is (ex/error? err)) (t/is (= :validation (:type (ex-data err)))) (t/is (= :invalid-image (:code (ex-data err))))))))) (t/deftest service-request-raises-on-500 (t/testing "service-request raises :internal on status 500" (with-mocks [mock {:target 'app.http.client/req :return {:status 500 :body (json-stream {:type "internal" :code "processing-error" :hint "Internal server error"})}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [err (ex/try! (media.remote/service-request (mk-system) {:method :post :uri "http://localhost:6065/api/image/info" :body nil :headers {}}))] (t/is (ex/error? err)) (t/is (= :internal (:type (ex-data err)))) (t/is (= :processing-error (:code (ex-data err))))))))) (t/deftest service-request-passes-on-200 (t/testing "service-request returns response on status 200" (with-mocks [mock {:target 'app.http.client/req :return {:status 200 :body (json-stream {:width 100 :height 100})}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [resp (media.remote/service-request (mk-system) {:method :post :uri "http://localhost:6065/api/image/info" :body nil :headers {}})] (t/is (= 200 (:status resp)))))))) ;; --------------------------------------------------------------------------- ;; Shared key ;; --------------------------------------------------------------------------- (t/deftest shared-key-sent-correctly (t/testing "x-shared-key header matches the system's shared key" (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body (json-stream {:width 1 :height 1 :mtype "image/jpeg" :size 1 :orientation 1})}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [system {::setup/shared-keys {:media-processor "my-secret-key-123"}}] (media.remote/process system {:cmd :info :input {:path (th/tempfile "backend_tests/test_files/sample.jpg") :mtype "image/jpeg"}}) (let [[system-arg _] (:call-args @mock)] ;; System passed through correctly (t/is (= "my-secret-key-123" (-> system-arg ::setup/shared-keys :media-processor))))))))) ;; --------------------------------------------------------------------------- ;; Stream closure ;; --------------------------------------------------------------------------- (defn- tracking-stream "Create an InputStream that tracks whether it was closed. Returns a map with :stream (the InputStream) and :closed (an atom)." [^bytes data] (let [closed (atom false) delegate (ByteArrayInputStream. data) stream (proxy [java.io.InputStream] [] (read ([] (.read delegate)) ([^bytes b] (.read delegate b)) ([^bytes b off len] (.read delegate b off len))) (close [] (reset! closed true) (.close delegate)))] {:stream stream :closed closed})) (t/deftest info-closes-response-stream (t/testing "info closes the response stream after parsing JSON" (let [json-str "{\"width\":100,\"height\":100,\"mtype\":\"image/jpeg\",\"size\":1,\"orientation\":1}" json-data (.getBytes json-str "UTF-8") {:keys [stream closed]} (tracking-stream json-data)] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body stream}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] (media.remote/process (mk-system) {:cmd :info :input {:path path :mtype "image/jpeg"}}) ;; Stream should be closed after processing (t/is @closed))))))) (t/deftest font-convert-closes-response-stream (t/testing "font-convert closes the response stream after writing" (let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-font-data" "UTF-8"))] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body stream}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [ttfbytes (io/read* (io/resource "backend_tests/test_files/font-1.ttf")) ttfpath (write-font-tmp ttfbytes ".ttf")] (try (media.remote/process (mk-system) {:cmd :generate-fonts :input {"font/ttf" ttfpath}}) ;; Stream should be closed after processing (t/is @closed) (finally (fs/delete ttfpath))))))))) (t/deftest thumbnail-closes-response-stream (t/testing "thumbnail closes the response stream after writing" (let [{:keys [stream closed]} (tracking-stream (.getBytes "fake-thumbnail-data" "UTF-8"))] (with-mocks [mock {:target 'app.media.remote/service-request :return {:status 200 :body stream}}] (with-redefs [cf/get (th/config-get-mock config-mock)] (let [path (th/tempfile "backend_tests/test_files/sample.jpg")] (media.remote/process (mk-system) {:cmd :generic-thumbnail :input {:path path :mtype "image/jpeg"} :format :jpeg :quality 85 :width 200 :height 200}) ;; Stream should be closed after processing (t/is @closed)))))))