diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1f20f2..6667ad2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,14 @@ jobs: run: | pip install --upgrade pip pip install -r requirements.txt - pip install pytest + pip install pytest pytest-cov - name: Run tests - run: cd tests && python -m pytest -v + working-directory: tests + run: python -m pytest -v --cov=da_stash --cov=da_publish --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: tests/coverage.xml + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 101a22d..4ecee99 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ __pycache__/ da_tokens.db .idea/ .pytest_cache/ +.coverage +coverage.xml diff --git a/README.md b/README.md index 2c4afa4..f9974f9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # 🎨 Send to DeviantArt — ComfyUI Node +[![Release](https://img.shields.io/github/v/release/sni10/DWA?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sni10/DWA/releases) +[![Tests](https://img.shields.io/github/actions/workflow/status/sni10/DWA/ci.yml?style=for-the-badge&logo=githubactions&logoColor=white&label=Tests)](https://github.com/sni10/DWA/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/codecov/c/github/sni10/DWA?style=for-the-badge&logo=codecov&logoColor=white)](https://codecov.io/gh/sni10/DWA) +[![Python](https://img.shields.io/badge/Python-3.12%2B-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/) +[![ComfyUI](https://img.shields.io/badge/ComfyUI-Node-1A1A1A?style=for-the-badge&logo=comfyui&logoColor=white)](https://github.com/comfyanonymous/ComfyUI) +[![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](LICENSE) + Automatically publish your generated images to DeviantArt straight from ComfyUI. Generate an image → it gets uploaded to DeviantArt. No manual downloads, no browser tabs, no extra steps. diff --git a/asset/img.png b/asset/img.png index bd7049b..531a5b8 100644 Binary files a/asset/img.png and b/asset/img.png differ diff --git a/tests/test_node.py b/tests/test_node.py index fa29942..3397b3c 100644 --- a/tests/test_node.py +++ b/tests/test_node.py @@ -12,8 +12,8 @@ import pytest import sys sys.path.insert(0, str(Path(__file__).parent.parent)) -from da_stash import get_content_type, to_base36, StashUploadResult -from da_publish import PublishResult +from da_stash import get_content_type, to_base36, StashUploadResult, upload_to_stash +from da_publish import PublishResult, publish_deviation # ── Functions copied from deviantart_node.py for testing ── @@ -22,6 +22,7 @@ from da_publish import PublishResult import glob import re from typing import Optional +from unittest.mock import patch, MagicMock SUPPORTED_EXTENSIONS = ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp"] @@ -210,3 +211,172 @@ class TestPublishResult: r = PublishResult(success=False, error="fail") assert not r.success and r.error == "fail" + +class TestUploadToStash: + """Tests for upload_to_stash with mocked HTTP requests.""" + + def test_file_not_found(self): + result = upload_to_stash("/nonexistent/file.png", "fake_token") + assert not result.success + assert "not found" in result.error.lower() + + @patch("da_stash.requests.post") + def test_success(self, mock_post, tmp_path): + img = tmp_path / "art.png" + img.write_bytes(b"\x89PNG fake") + + mock_resp = MagicMock() + mock_resp.json.return_value = { + "status": "success", + "itemid": 99999, + "stack": "test_stack", + "stackid": 42, + } + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + result = upload_to_stash(str(img), "token123", title="My Art", tags=["ai", "digital"]) + assert result.success + assert result.itemid == 99999 + assert result.stash_url == f"https://sta.sh/0{to_base36(99999)}" + + @patch("da_stash.requests.post") + def test_api_error(self, mock_post, tmp_path): + img = tmp_path / "art.png" + img.write_bytes(b"\x89PNG fake") + + mock_resp = MagicMock() + mock_resp.json.return_value = { + "status": "error", + "error": "invalid_token", + "error_description": "Token is expired", + } + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + result = upload_to_stash(str(img), "bad_token") + assert not result.success + assert "expired" in result.error.lower() + + @patch("da_stash.requests.post") + def test_no_itemid_in_response(self, mock_post, tmp_path): + img = tmp_path / "art.png" + img.write_bytes(b"\x89PNG fake") + + mock_resp = MagicMock() + mock_resp.json.return_value = {"status": "success"} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + result = upload_to_stash(str(img), "token") + assert not result.success + assert "itemid" in result.error.lower() + + @patch("da_stash.requests.post") + def test_network_error(self, mock_post, tmp_path): + img = tmp_path / "art.png" + img.write_bytes(b"\x89PNG fake") + + import requests as req + mock_post.side_effect = req.ConnectionError("Connection refused") + + result = upload_to_stash(str(img), "token") + assert not result.success + assert "failed" in result.error.lower() + + @patch("da_stash.requests.post") + def test_all_optional_params(self, mock_post, tmp_path): + img = tmp_path / "art.jpg" + img.write_bytes(b"\xFF\xD8 fake jpeg") + + mock_resp = MagicMock() + mock_resp.json.return_value = {"status": "success", "itemid": 1} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + result = upload_to_stash( + str(img), "token", + title="Test Title", + artist_comments="Nice art", + tags=["a", "b"], + original_url="https://example.com", + is_dirty=True, + noai=True, + is_ai_generated=True, + stack="mystack", + stackid=5, + ) + assert result.success + + +class TestPublishDeviation: + """Tests for publish_deviation with mocked HTTP requests.""" + + @patch("da_publish.requests.post") + def test_success(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "status": "success", + "deviationid": "ABC-123", + "url": "https://deviantart.com/art/test-123", + } + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + result = publish_deviation("token", itemid=99999, tags=["art"]) + assert result.success + assert result.deviationid == "ABC-123" + assert "deviantart.com" in result.url + + @patch("da_publish.requests.post") + def test_api_error(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "status": "error", + "error": "invalid_request", + "error_description": "itemid not found", + } + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + result = publish_deviation("token", itemid=0) + assert not result.success + assert "not found" in result.error.lower() + + @patch("da_publish.requests.post") + def test_network_error(self, mock_post): + import requests as req + mock_post.side_effect = req.ConnectionError("timeout") + + result = publish_deviation("token", itemid=1) + assert not result.success + assert "failed" in result.error.lower() + + @patch("da_publish.requests.post") + def test_all_params(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = { + "status": "success", + "deviationid": "X", + "url": "https://example.com", + } + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + result = publish_deviation( + "token", + itemid=1, + is_mature=True, + mature_level="strict", + mature_classification=["nudity", "gore"], + tags=["a", "b"], + display_resolution=8, + galleryids=["UUID-1", "UUID-2"], + allow_comments=False, + allow_free_download=False, + add_watermark=True, + is_ai_generated=True, + noai=True, + feature=False, + ) + assert result.success