chore: import upstream snapshot with attribution
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""Tests for SlidingWindowTokenMiddleware and token refresh behavior."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.services.auth.token_service import TokenData, create_access_token, set_jwt_secret
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_jwt_secret():
|
||||
"""Ensure JWT secret is set for all tests."""
|
||||
set_jwt_secret("test-secret-key-for-sliding-window-tests")
|
||||
|
||||
|
||||
def _create_test_app() -> FastAPI:
|
||||
"""Create a minimal FastAPI app with the SlidingWindowTokenMiddleware."""
|
||||
from invokeai.app.api_app import SlidingWindowTokenMiddleware
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.add_middleware(SlidingWindowTokenMiddleware)
|
||||
|
||||
@test_app.get("/test")
|
||||
async def get_endpoint():
|
||||
return {"ok": True}
|
||||
|
||||
@test_app.post("/test")
|
||||
async def post_endpoint():
|
||||
return {"ok": True}
|
||||
|
||||
@test_app.put("/test")
|
||||
async def put_endpoint():
|
||||
return {"ok": True}
|
||||
|
||||
@test_app.delete("/test")
|
||||
async def delete_endpoint():
|
||||
return {"ok": True}
|
||||
|
||||
return test_app
|
||||
|
||||
|
||||
def _make_token(remember_me: bool = False, expires_delta: timedelta | None = None) -> str:
|
||||
"""Create a test token."""
|
||||
token_data = TokenData(
|
||||
user_id="test-user",
|
||||
email="test@test.com",
|
||||
is_admin=False,
|
||||
remember_me=remember_me,
|
||||
)
|
||||
return create_access_token(token_data, expires_delta)
|
||||
|
||||
|
||||
class TestSlidingWindowTokenMiddleware:
|
||||
"""Tests for SlidingWindowTokenMiddleware."""
|
||||
|
||||
def test_mutating_request_returns_refreshed_token(self):
|
||||
"""Authenticated POST/PUT/PATCH/DELETE requests return X-Refreshed-Token."""
|
||||
app = _create_test_app()
|
||||
client = TestClient(app)
|
||||
token = _make_token()
|
||||
|
||||
for method in ["post", "put", "delete"]:
|
||||
response = getattr(client, method)("/test", headers={"Authorization": f"Bearer {token}"})
|
||||
assert response.status_code == 200
|
||||
assert "X-Refreshed-Token" in response.headers, f"{method.upper()} should return refreshed token"
|
||||
|
||||
def test_get_request_does_not_return_refreshed_token(self):
|
||||
"""Authenticated GET requests do NOT return X-Refreshed-Token."""
|
||||
app = _create_test_app()
|
||||
client = TestClient(app)
|
||||
token = _make_token()
|
||||
|
||||
response = client.get("/test", headers={"Authorization": f"Bearer {token}"})
|
||||
assert response.status_code == 200
|
||||
assert "X-Refreshed-Token" not in response.headers
|
||||
|
||||
def test_unauthenticated_request_does_not_return_refreshed_token(self):
|
||||
"""Requests without a token do NOT return X-Refreshed-Token."""
|
||||
app = _create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/test")
|
||||
assert response.status_code == 200
|
||||
assert "X-Refreshed-Token" not in response.headers
|
||||
|
||||
def test_remember_me_token_refreshes_to_remember_me_duration(self):
|
||||
"""A remember_me=True token refreshes with the remember-me duration, not the normal duration."""
|
||||
from jose import jwt
|
||||
|
||||
from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_REMEMBER_ME
|
||||
from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
|
||||
|
||||
app = _create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Create a remember-me token with only 1 hour remaining (less than 24h)
|
||||
token = _make_token(remember_me=True, expires_delta=timedelta(hours=1))
|
||||
|
||||
response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
|
||||
assert "X-Refreshed-Token" in response.headers
|
||||
|
||||
# Decode the refreshed token and check its expiry
|
||||
refreshed_token = response.headers["X-Refreshed-Token"]
|
||||
payload = jwt.decode(refreshed_token, get_jwt_secret(), algorithms=[ALGORITHM])
|
||||
|
||||
# The refreshed token should have ~7 days of remaining life, not ~1 day
|
||||
from datetime import datetime, timezone
|
||||
|
||||
remaining_seconds = payload["exp"] - datetime.now(timezone.utc).timestamp()
|
||||
remaining_days = remaining_seconds / 86400
|
||||
|
||||
# Should be close to TOKEN_EXPIRATION_REMEMBER_ME (7 days), not TOKEN_EXPIRATION_NORMAL (1 day)
|
||||
assert remaining_days > TOKEN_EXPIRATION_REMEMBER_ME - 0.1, (
|
||||
f"Remember-me token was downgraded: {remaining_days:.1f} days remaining, "
|
||||
f"expected ~{TOKEN_EXPIRATION_REMEMBER_ME}"
|
||||
)
|
||||
|
||||
def test_normal_token_refreshes_to_normal_duration(self):
|
||||
"""A remember_me=False token refreshes with the normal duration."""
|
||||
from jose import jwt
|
||||
|
||||
from invokeai.app.api.routers.auth import TOKEN_EXPIRATION_NORMAL
|
||||
from invokeai.app.services.auth.token_service import ALGORITHM, get_jwt_secret
|
||||
|
||||
app = _create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
token = _make_token(remember_me=False)
|
||||
|
||||
response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
|
||||
refreshed_token = response.headers["X-Refreshed-Token"]
|
||||
payload = jwt.decode(refreshed_token, get_jwt_secret(), algorithms=[ALGORITHM])
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
remaining_seconds = payload["exp"] - datetime.now(timezone.utc).timestamp()
|
||||
remaining_days = remaining_seconds / 86400
|
||||
|
||||
# Should be close to TOKEN_EXPIRATION_NORMAL (1 day), not TOKEN_EXPIRATION_REMEMBER_ME (7 days)
|
||||
assert remaining_days < TOKEN_EXPIRATION_NORMAL + 0.1, (
|
||||
f"Normal token got remember-me duration: {remaining_days:.1f} days"
|
||||
)
|
||||
assert remaining_days > TOKEN_EXPIRATION_NORMAL - 0.1, (
|
||||
f"Normal token duration too short: {remaining_days:.1f} days"
|
||||
)
|
||||
|
||||
def test_remember_me_claim_preserved_in_refreshed_token(self):
|
||||
"""The remember_me claim is preserved when a token is refreshed."""
|
||||
from invokeai.app.services.auth.token_service import verify_token
|
||||
|
||||
app = _create_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
# Test with remember_me=True
|
||||
token = _make_token(remember_me=True)
|
||||
response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
|
||||
refreshed_data = verify_token(response.headers["X-Refreshed-Token"])
|
||||
assert refreshed_data is not None
|
||||
assert refreshed_data.remember_me is True
|
||||
|
||||
# Test with remember_me=False
|
||||
token = _make_token(remember_me=False)
|
||||
response = client.post("/test", headers={"Authorization": f"Bearer {token}"})
|
||||
refreshed_data = verify_token(response.headers["X-Refreshed-Token"])
|
||||
assert refreshed_data is not None
|
||||
assert refreshed_data.remember_me is False
|
||||
@@ -0,0 +1,144 @@
|
||||
import pytest
|
||||
|
||||
from invokeai.app.invocations.anima_denoise import (
|
||||
ANIMA_SHIFT,
|
||||
AnimaDenoiseInvocation,
|
||||
inverse_loglinear_timestep_shift,
|
||||
loglinear_timestep_shift,
|
||||
)
|
||||
|
||||
|
||||
class TestLoglinearTimestepShift:
|
||||
"""Test the log-linear timestep shift function used for Anima's noise schedule."""
|
||||
|
||||
def test_shift_1_is_identity(self):
|
||||
"""With alpha=1.0, shift should be identity."""
|
||||
for t in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
assert loglinear_timestep_shift(1.0, t) == t
|
||||
|
||||
def test_shift_at_zero(self):
|
||||
"""At t=0, shifted sigma should be 0 regardless of alpha."""
|
||||
assert loglinear_timestep_shift(3.0, 0.0) == 0.0
|
||||
|
||||
def test_shift_at_one(self):
|
||||
"""At t=1, shifted sigma should be 1 regardless of alpha."""
|
||||
assert loglinear_timestep_shift(3.0, 1.0) == pytest.approx(1.0)
|
||||
|
||||
def test_shift_3_increases_sigma(self):
|
||||
"""With alpha=3.0, sigma should be larger than t (spends more time at high noise)."""
|
||||
for t in [0.1, 0.25, 0.5, 0.75, 0.9]:
|
||||
sigma = loglinear_timestep_shift(3.0, t)
|
||||
assert sigma > t, f"At t={t}, sigma={sigma} should be > t"
|
||||
|
||||
def test_shift_monotonic(self):
|
||||
"""Shifted sigmas should be monotonically increasing with t."""
|
||||
prev = 0.0
|
||||
for i in range(1, 101):
|
||||
t = i / 100.0
|
||||
sigma = loglinear_timestep_shift(3.0, t)
|
||||
assert sigma > prev, f"Not monotonic at t={t}"
|
||||
prev = sigma
|
||||
|
||||
def test_known_value(self):
|
||||
"""Test a known value: at t=0.5, alpha=3.0, sigma = 3*0.5 / (1 + 2*0.5) = 0.75."""
|
||||
assert loglinear_timestep_shift(3.0, 0.5) == pytest.approx(0.75)
|
||||
|
||||
|
||||
class TestInverseLoglinearTimestepShift:
|
||||
"""Test the inverse log-linear timestep shift (used for inpainting mask correction)."""
|
||||
|
||||
def test_inverse_shift_1_is_identity(self):
|
||||
"""With alpha=1.0, inverse should be identity."""
|
||||
for sigma in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
assert inverse_loglinear_timestep_shift(1.0, sigma) == sigma
|
||||
|
||||
def test_roundtrip(self):
|
||||
"""shift(inverse(sigma)) should recover sigma, and inverse(shift(t)) should recover t."""
|
||||
for t in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0]:
|
||||
sigma = loglinear_timestep_shift(3.0, t)
|
||||
recovered_t = inverse_loglinear_timestep_shift(3.0, sigma)
|
||||
assert recovered_t == pytest.approx(t, abs=1e-7), (
|
||||
f"Roundtrip failed: t={t} -> sigma={sigma} -> recovered_t={recovered_t}"
|
||||
)
|
||||
|
||||
def test_known_value(self):
|
||||
"""At sigma=0.75, alpha=3.0, t should be 0.5 (inverse of the known shift value)."""
|
||||
assert inverse_loglinear_timestep_shift(3.0, 0.75) == pytest.approx(0.5)
|
||||
|
||||
|
||||
class TestGetSigmas:
|
||||
"""Test the sigma schedule generation."""
|
||||
|
||||
def test_schedule_length(self):
|
||||
"""Schedule should have num_steps + 1 entries."""
|
||||
inv = AnimaDenoiseInvocation(
|
||||
positive_conditioning=None, # type: ignore
|
||||
transformer=None, # type: ignore
|
||||
)
|
||||
sigmas = inv._get_sigmas(30)
|
||||
assert len(sigmas) == 31
|
||||
|
||||
def test_schedule_endpoints(self):
|
||||
"""Schedule should start near 1.0 and end at 0.0."""
|
||||
inv = AnimaDenoiseInvocation(
|
||||
positive_conditioning=None, # type: ignore
|
||||
transformer=None, # type: ignore
|
||||
)
|
||||
sigmas = inv._get_sigmas(30)
|
||||
assert sigmas[0] == pytest.approx(loglinear_timestep_shift(ANIMA_SHIFT, 1.0))
|
||||
assert sigmas[-1] == pytest.approx(0.0)
|
||||
|
||||
def test_schedule_monotonically_decreasing(self):
|
||||
"""Sigmas should decrease from noise to clean."""
|
||||
inv = AnimaDenoiseInvocation(
|
||||
positive_conditioning=None, # type: ignore
|
||||
transformer=None, # type: ignore
|
||||
)
|
||||
sigmas = inv._get_sigmas(30)
|
||||
for i in range(len(sigmas) - 1):
|
||||
assert sigmas[i] > sigmas[i + 1], f"Not decreasing at index {i}: {sigmas[i]} <= {sigmas[i + 1]}"
|
||||
|
||||
def test_schedule_uses_shift(self):
|
||||
"""With shift=3.0, middle sigmas should be larger than the linear midpoint."""
|
||||
inv = AnimaDenoiseInvocation(
|
||||
positive_conditioning=None, # type: ignore
|
||||
transformer=None, # type: ignore
|
||||
)
|
||||
sigmas = inv._get_sigmas(10)
|
||||
# At step 5/10, linear t = 0.5, shifted sigma should be 0.75
|
||||
assert sigmas[5] == pytest.approx(loglinear_timestep_shift(3.0, 0.5))
|
||||
|
||||
|
||||
class TestGetSigmasEdgeCases:
|
||||
"""Test edge cases for sigma schedule generation."""
|
||||
|
||||
def test_single_step_produces_valid_schedule(self):
|
||||
"""_get_sigmas(num_steps=1) should produce a valid 2-element schedule."""
|
||||
inv = AnimaDenoiseInvocation(
|
||||
positive_conditioning=None, # type: ignore
|
||||
transformer=None, # type: ignore
|
||||
)
|
||||
sigmas = inv._get_sigmas(1)
|
||||
assert len(sigmas) == 2
|
||||
assert sigmas[0] > sigmas[1]
|
||||
assert sigmas[0] == pytest.approx(loglinear_timestep_shift(ANIMA_SHIFT, 1.0))
|
||||
assert sigmas[-1] == pytest.approx(0.0)
|
||||
|
||||
|
||||
class TestInverseLoglinearEdgeCases:
|
||||
"""Test edge cases for inverse_loglinear_timestep_shift."""
|
||||
|
||||
def test_alpha_zero_does_not_divide_by_zero(self):
|
||||
"""inverse_loglinear_timestep_shift with alpha=0 should not raise ZeroDivisionError.
|
||||
|
||||
With alpha=0: denominator = 0 - (0-1)*sigma = sigma.
|
||||
At sigma=0, denominator=0 which hits the epsilon guard and returns 1.0.
|
||||
At sigma>0, denominator=sigma, result = sigma/sigma = 1.0.
|
||||
"""
|
||||
# Should not raise
|
||||
result = inverse_loglinear_timestep_shift(0.0, 0.5)
|
||||
assert isinstance(result, float)
|
||||
|
||||
# At sigma=0, denominator would be 0 — should hit the epsilon guard
|
||||
result_zero = inverse_loglinear_timestep_shift(0.0, 0.0)
|
||||
assert isinstance(result_zero, float)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Dispatch wiring and sigma-contract tests for Anima ER-SDE.
|
||||
|
||||
Verifies that ANIMA_SCHEDULER_MAP['er_sde'] produces a correctly configured
|
||||
ERSDEScheduler, that set_timesteps accepts sigmas= (the contract Anima relies
|
||||
on to pass its pre-shifted schedule), and that the sigma state is set up as
|
||||
expected after set_timesteps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from invokeai.backend.flux.schedulers import ANIMA_SCHEDULER_MAP
|
||||
from invokeai.backend.rectified_flow.er_sde_scheduler import ERSDEScheduler
|
||||
|
||||
|
||||
def test_anima_scheduler_map_er_sde_constructs_correctly():
|
||||
"""The map entry must produce a valid ERSDEScheduler when instantiated."""
|
||||
cls, kwargs = ANIMA_SCHEDULER_MAP["er_sde"]
|
||||
scheduler = cls(num_train_timesteps=1000, **kwargs)
|
||||
assert isinstance(scheduler, ERSDEScheduler)
|
||||
assert scheduler.config.prediction_type == "flow_prediction"
|
||||
assert scheduler.config.use_flow_sigmas is True
|
||||
assert scheduler.config.solver_order == 3
|
||||
assert scheduler.config.stochastic is True
|
||||
|
||||
|
||||
def test_anima_er_sde_set_timesteps_accepts_sigmas():
|
||||
"""Anima passes pre-shifted sigmas via set_timesteps(sigmas=...).
|
||||
|
||||
The legacy elif is_er_sde: branch consumed Anima's pre-shifted sigmas
|
||||
directly. The universal path requires ERSDEScheduler.set_timesteps to
|
||||
accept sigmas= as a keyword argument. This is the contract that makes
|
||||
the cutover safe.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
cls, kwargs = ANIMA_SCHEDULER_MAP["er_sde"]
|
||||
scheduler = cls(num_train_timesteps=1000, **kwargs)
|
||||
sig = inspect.signature(scheduler.set_timesteps)
|
||||
assert "sigmas" in sig.parameters, "ERSDEScheduler.set_timesteps must accept sigmas= for Anima compatibility"
|
||||
|
||||
|
||||
def test_anima_er_sde_set_timesteps_with_pre_shifted_sigmas():
|
||||
"""End-to-end set_timesteps with a small pre-shifted sigma schedule."""
|
||||
import torch
|
||||
|
||||
cls, kwargs = ANIMA_SCHEDULER_MAP["er_sde"]
|
||||
scheduler = cls(num_train_timesteps=1000, **kwargs)
|
||||
# Synthetic 5-step pre-shifted schedule, sigma_max=0.95 down to terminal 0.
|
||||
sigmas = torch.tensor([0.95, 0.75, 0.5, 0.3, 0.1, 0.0], dtype=torch.float32)
|
||||
scheduler.set_timesteps(sigmas=sigmas, device="cpu")
|
||||
assert scheduler.num_inference_steps == 5
|
||||
assert torch.allclose(scheduler.sigmas, sigmas)
|
||||
# Multistep state must be reset.
|
||||
assert scheduler.lower_order_nums == 0
|
||||
assert all(x is None for x in scheduler.model_outputs)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Tests for the Anima VAE invocations: working-memory estimation, the tiled-decode
|
||||
decision, and the tiled retry on out-of-memory."""
|
||||
|
||||
import math
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers.models.autoencoders import AutoencoderKLWan
|
||||
|
||||
from invokeai.app.invocations.anima_image_to_latents import AnimaImageToLatentsInvocation
|
||||
from invokeai.app.invocations.anima_latents_to_image import (
|
||||
ANIMA_VAE_TILE_SIZE,
|
||||
ANIMA_VAE_TILE_STRIDE,
|
||||
AnimaLatentsToImageInvocation,
|
||||
)
|
||||
from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
|
||||
from invokeai.backend.util.devices import TorchDevice
|
||||
from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_anima
|
||||
|
||||
|
||||
def _mock_wan_vae(dtype: torch.dtype = torch.float16) -> MagicMock:
|
||||
vae = MagicMock(spec=AutoencoderKLWan)
|
||||
param = torch.zeros(1, dtype=dtype)
|
||||
# Return a fresh iterator on every call so the estimator can be called repeatedly.
|
||||
vae.parameters.side_effect = lambda: iter([param])
|
||||
return vae
|
||||
|
||||
|
||||
class TestEstimateVaeWorkingMemoryAnima:
|
||||
def test_untiled_decode_uses_decode_constant_and_scales_latent_dims(self):
|
||||
latents = torch.zeros(1, 16, 1, 128, 128)
|
||||
result = estimate_vae_working_memory_anima(
|
||||
operation="decode", image_tensor=latents, vae=_mock_wan_vae(torch.float16), tile_size=None
|
||||
)
|
||||
out_h = out_w = 128 * LATENT_SCALE_FACTOR
|
||||
assert result == int(out_h * out_w * 2 * 2900)
|
||||
|
||||
def test_untiled_encode_uses_encode_constant_and_pixel_dims(self):
|
||||
image = torch.zeros(1, 3, 1, 1024, 1024)
|
||||
result = estimate_vae_working_memory_anima(
|
||||
operation="encode", image_tensor=image, vae=_mock_wan_vae(torch.float16), tile_size=None
|
||||
)
|
||||
assert result == int(1024 * 1024 * 2 * 1450)
|
||||
|
||||
@pytest.mark.parametrize("latent_hw", [(64, 64), (160, 160)])
|
||||
def test_tiled_decode_estimate_is_independent_of_image_size(self, latent_hw):
|
||||
latents = torch.zeros(1, 16, 1, *latent_hw)
|
||||
result = estimate_vae_working_memory_anima(
|
||||
operation="decode", image_tensor=latents, vae=_mock_wan_vae(torch.float16), tile_size=512
|
||||
)
|
||||
assert result == int(512 * 512 * 2 * 2900 * 1.25)
|
||||
|
||||
def test_estimate_scales_with_element_size(self):
|
||||
latents = torch.zeros(1, 16, 1, 128, 128)
|
||||
fp16 = estimate_vae_working_memory_anima(
|
||||
operation="decode", image_tensor=latents, vae=_mock_wan_vae(torch.float16), tile_size=None
|
||||
)
|
||||
fp32 = estimate_vae_working_memory_anima(
|
||||
operation="decode", image_tensor=latents, vae=_mock_wan_vae(torch.float32), tile_size=None
|
||||
)
|
||||
assert fp32 == 2 * fp16
|
||||
|
||||
|
||||
class TestUseTiledDecode:
|
||||
@pytest.mark.parametrize("device_type", ["cpu", "mps"])
|
||||
def test_non_cuda_never_tiles(self, device_type):
|
||||
assert AnimaLatentsToImageInvocation._use_tiled_decode(torch.device(device_type), 10**12) is False
|
||||
|
||||
def test_cuda_flips_at_70_percent_of_total_vram(self):
|
||||
total_vram = 8 * 2**30
|
||||
boundary = 0.7 * total_vram
|
||||
device = torch.device("cuda")
|
||||
with patch("torch.cuda.get_device_properties", return_value=MagicMock(total_memory=total_vram)) as mock_props:
|
||||
assert AnimaLatentsToImageInvocation._use_tiled_decode(device, math.floor(boundary)) is False
|
||||
assert AnimaLatentsToImageInvocation._use_tiled_decode(device, math.ceil(boundary) + 1) is True
|
||||
mock_props.assert_called_with(device)
|
||||
|
||||
|
||||
def _build_decode_mocks(latents: torch.Tensor, decoded: torch.Tensor):
|
||||
"""Mock the Wan VAE decode path: a spec'd AutoencoderKLWan, its LoadedModel wrapper, and the
|
||||
invocation context, wired so `AnimaLatentsToImageInvocation.invoke` runs end-to-end on CPU."""
|
||||
vae = _mock_wan_vae(torch.float32)
|
||||
vae.config.latents_mean = [0.0] * 16
|
||||
vae.config.latents_std = [1.0] * 16
|
||||
vae.decode.return_value = (decoded,)
|
||||
|
||||
vae_info = MagicMock()
|
||||
vae_info.model = vae
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(return_value=(None, vae))
|
||||
cm.__exit__ = MagicMock(return_value=None)
|
||||
vae_info.model_on_device.return_value = cm
|
||||
|
||||
context = MagicMock()
|
||||
context.models.load.return_value = vae_info
|
||||
context.tensors.load.return_value = latents
|
||||
image_dto = MagicMock()
|
||||
image_dto.image_name = "test.png"
|
||||
image_dto.width = decoded.shape[-1]
|
||||
image_dto.height = decoded.shape[-2]
|
||||
context.images.save.return_value = image_dto
|
||||
|
||||
return vae, vae_info, context
|
||||
|
||||
|
||||
def _build_l2i_invocation() -> AnimaLatentsToImageInvocation:
|
||||
return AnimaLatentsToImageInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="test_latents"),
|
||||
vae=MagicMock(vae=MagicMock()),
|
||||
)
|
||||
|
||||
|
||||
class TestAnimaLatentsToImageOomFallback:
|
||||
@pytest.mark.parametrize(
|
||||
"oom_error",
|
||||
[
|
||||
torch.cuda.OutOfMemoryError("CUDA out of memory. Tried to allocate 5.9 GiB"),
|
||||
RuntimeError("CUDA error: out of memory"),
|
||||
RuntimeError("cuDNN error: CUDNN_STATUS_ALLOC_FAILED"),
|
||||
],
|
||||
)
|
||||
def test_untiled_decode_oom_retries_with_tiling(self, oom_error):
|
||||
decoded = torch.zeros(1, 3, 1, 64, 64)
|
||||
vae, _, context = _build_decode_mocks(latents=torch.zeros(1, 16, 32, 32), decoded=decoded)
|
||||
vae.decode.side_effect = [oom_error, (decoded,)]
|
||||
|
||||
with patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")):
|
||||
result = _build_l2i_invocation().invoke(context)
|
||||
|
||||
assert vae.decode.call_count == 2
|
||||
vae.enable_tiling.assert_called_once_with(
|
||||
tile_sample_min_height=ANIMA_VAE_TILE_SIZE,
|
||||
tile_sample_min_width=ANIMA_VAE_TILE_SIZE,
|
||||
tile_sample_stride_height=ANIMA_VAE_TILE_STRIDE,
|
||||
tile_sample_stride_width=ANIMA_VAE_TILE_STRIDE,
|
||||
)
|
||||
assert result.width == 64
|
||||
|
||||
def test_non_oom_runtime_error_propagates_without_retry(self):
|
||||
vae, _, context = _build_decode_mocks(latents=torch.zeros(1, 16, 32, 32), decoded=torch.zeros(1, 3, 1, 64, 64))
|
||||
vae.decode.side_effect = RuntimeError("Input type (float) and weight type (half) should be the same")
|
||||
|
||||
with patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")):
|
||||
with pytest.raises(RuntimeError, match="weight type"):
|
||||
_build_l2i_invocation().invoke(context)
|
||||
|
||||
assert vae.decode.call_count == 1
|
||||
vae.enable_tiling.assert_not_called()
|
||||
|
||||
def test_oom_while_already_tiled_reraises(self):
|
||||
vae, _, context = _build_decode_mocks(latents=torch.zeros(1, 16, 32, 32), decoded=torch.zeros(1, 3, 1, 64, 64))
|
||||
vae.decode.side_effect = torch.cuda.OutOfMemoryError("CUDA out of memory")
|
||||
|
||||
with (
|
||||
patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")),
|
||||
patch.object(AnimaLatentsToImageInvocation, "_use_tiled_decode", return_value=True),
|
||||
):
|
||||
with pytest.raises(torch.cuda.OutOfMemoryError):
|
||||
_build_l2i_invocation().invoke(context)
|
||||
|
||||
# No second attempt: the initial enable_tiling is the only one, and decode is not retried.
|
||||
assert vae.decode.call_count == 1
|
||||
vae.enable_tiling.assert_called_once()
|
||||
|
||||
def test_decode_requests_estimated_working_memory(self):
|
||||
decoded = torch.zeros(1, 3, 1, 64, 64)
|
||||
vae, vae_info, context = _build_decode_mocks(latents=torch.zeros(1, 16, 32, 32), decoded=decoded)
|
||||
|
||||
estimation_path = "invokeai.app.invocations.anima_latents_to_image.estimate_vae_working_memory_anima"
|
||||
expected_memory = 1024 * 1024 * 500
|
||||
with (
|
||||
patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")),
|
||||
patch(estimation_path, return_value=expected_memory) as mock_estimate,
|
||||
):
|
||||
_build_l2i_invocation().invoke(context)
|
||||
|
||||
# Called once for the full-decode estimate (tiling decision) and once for the actual request.
|
||||
assert mock_estimate.call_count == 2
|
||||
vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
|
||||
|
||||
|
||||
class TestAnimaImageToLatentsEncode:
|
||||
def test_encode_disables_tiling_and_requests_working_memory(self):
|
||||
vae = _mock_wan_vae(torch.float32)
|
||||
vae.config.latents_mean = [0.0] * 16
|
||||
vae.config.latents_std = [1.0] * 16
|
||||
mock_dist = MagicMock()
|
||||
mock_dist.sample.return_value = torch.zeros(1, 16, 1, 4, 4)
|
||||
vae.encode.return_value = (mock_dist,)
|
||||
|
||||
vae_info = MagicMock()
|
||||
vae_info.model = vae
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(return_value=(None, vae))
|
||||
cm.__exit__ = MagicMock(return_value=None)
|
||||
vae_info.model_on_device.return_value = cm
|
||||
|
||||
estimation_path = "invokeai.app.invocations.anima_image_to_latents.estimate_vae_working_memory_anima"
|
||||
expected_memory = 1024 * 1024 * 250
|
||||
with (
|
||||
patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")),
|
||||
patch(estimation_path, return_value=expected_memory) as mock_estimate,
|
||||
):
|
||||
latents = AnimaImageToLatentsInvocation.vae_encode(
|
||||
vae_info=vae_info, image_tensor=torch.zeros(1, 3, 32, 32)
|
||||
)
|
||||
|
||||
# The shared cached VAE may have tiling enabled from a previous decode; encode must reset it.
|
||||
vae.disable_tiling.assert_called_once()
|
||||
vae.enable_tiling.assert_not_called()
|
||||
mock_estimate.assert_called_once()
|
||||
vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
|
||||
assert latents.shape == (1, 16, 4, 4)
|
||||
@@ -0,0 +1,421 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.users.users_common import UserDTO
|
||||
from invokeai.app.services.workflow_records.workflow_records_common import (
|
||||
Workflow,
|
||||
WorkflowCategory,
|
||||
WorkflowMeta,
|
||||
WorkflowNotFoundError,
|
||||
WorkflowRecordDTO,
|
||||
WorkflowWithoutIDValidator,
|
||||
)
|
||||
|
||||
|
||||
def build_workflow_record_dto(
|
||||
*,
|
||||
workflow_id: str = "workflow-123",
|
||||
user_id: str = "owner-1",
|
||||
category: WorkflowCategory = WorkflowCategory.User,
|
||||
is_public: bool = False,
|
||||
) -> WorkflowRecordDTO:
|
||||
workflow = Workflow(
|
||||
id=workflow_id,
|
||||
name="Saved Workflow",
|
||||
author="Tester",
|
||||
description="",
|
||||
version="1.0.0",
|
||||
contact="",
|
||||
tags="",
|
||||
notes="",
|
||||
exposedFields=[],
|
||||
meta=WorkflowMeta(version="1.0.0", category=category),
|
||||
nodes=[],
|
||||
edges=[],
|
||||
form=None,
|
||||
)
|
||||
|
||||
return WorkflowRecordDTO(
|
||||
workflow_id=workflow_id,
|
||||
workflow=workflow,
|
||||
name=workflow.name,
|
||||
created_at="2026-04-08T00:00:00Z",
|
||||
updated_at="2026-04-08T00:00:00Z",
|
||||
opened_at=None,
|
||||
user_id=user_id,
|
||||
is_public=is_public,
|
||||
)
|
||||
|
||||
|
||||
def build_user_dto(*, user_id: str = "user-1", is_admin: bool = False) -> UserDTO:
|
||||
return UserDTO(
|
||||
user_id=user_id,
|
||||
email=f"{user_id}@example.test",
|
||||
display_name=user_id,
|
||||
is_admin=is_admin,
|
||||
is_active=True,
|
||||
created_at="2026-04-08T00:00:00Z",
|
||||
updated_at="2026-04-08T00:00:00Z",
|
||||
last_login_at=None,
|
||||
)
|
||||
|
||||
|
||||
def build_context(
|
||||
*,
|
||||
workflow_record: WorkflowRecordDTO | None = None,
|
||||
queue_user_id: str = "owner-1",
|
||||
multiuser: bool = False,
|
||||
user_is_admin: bool = False,
|
||||
user_exists: bool = True,
|
||||
workflow_not_found: bool = False,
|
||||
):
|
||||
services = SimpleNamespace(
|
||||
configuration=SimpleNamespace(multiuser=multiuser),
|
||||
users=Mock(),
|
||||
workflow_records=Mock(),
|
||||
)
|
||||
services.users.get.return_value = (
|
||||
build_user_dto(user_id=queue_user_id, is_admin=user_is_admin) if user_exists else None
|
||||
)
|
||||
|
||||
if workflow_not_found:
|
||||
services.workflow_records.get.side_effect = WorkflowNotFoundError("missing")
|
||||
else:
|
||||
services.workflow_records.get.return_value = workflow_record or build_workflow_record_dto()
|
||||
|
||||
context = Mock()
|
||||
context._services = services
|
||||
context._data = SimpleNamespace(queue_item=SimpleNamespace(user_id=queue_user_id))
|
||||
return context
|
||||
|
||||
|
||||
def test_call_saved_workflow_invocation_contract():
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
from invokeai.app.invocations.workflow_return import WorkflowReturnOutput
|
||||
|
||||
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="workflow-123")
|
||||
|
||||
assert invocation.get_type() == "call_saved_workflow"
|
||||
assert invocation.workflow_id == "workflow-123"
|
||||
|
||||
output = invocation.invoke(build_context())
|
||||
|
||||
assert isinstance(output, WorkflowReturnOutput)
|
||||
assert output.values == {}
|
||||
|
||||
|
||||
def test_call_saved_workflow_invocation_raises_when_workflow_id_is_empty():
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
|
||||
invocation = CallSavedWorkflowInvocation(id="test-node")
|
||||
|
||||
with pytest.raises(ValueError, match="saved workflow must be selected"):
|
||||
invocation.invoke(build_context())
|
||||
|
||||
|
||||
def test_call_saved_workflow_invocation_raises_when_workflow_does_not_exist():
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
|
||||
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="missing-workflow")
|
||||
|
||||
with pytest.raises(ValueError, match="could not be found"):
|
||||
invocation.invoke(build_context(workflow_not_found=True))
|
||||
|
||||
|
||||
def test_call_saved_workflow_invocation_raises_when_workflow_is_not_accessible():
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
|
||||
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
|
||||
|
||||
with pytest.raises(ValueError, match="is not accessible"):
|
||||
invocation.invoke(
|
||||
build_context(
|
||||
workflow_record=build_workflow_record_dto(
|
||||
workflow_id="private-workflow",
|
||||
user_id="owner-1",
|
||||
category=WorkflowCategory.User,
|
||||
is_public=False,
|
||||
),
|
||||
queue_user_id="other-user",
|
||||
multiuser=True,
|
||||
user_is_admin=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_call_saved_workflow_invocation_allows_shared_workflow_for_non_owner():
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
|
||||
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="shared-workflow")
|
||||
|
||||
output = invocation.invoke(
|
||||
build_context(
|
||||
workflow_record=build_workflow_record_dto(
|
||||
workflow_id="shared-workflow",
|
||||
user_id="owner-1",
|
||||
category=WorkflowCategory.User,
|
||||
is_public=True,
|
||||
),
|
||||
queue_user_id="other-user",
|
||||
multiuser=True,
|
||||
user_is_admin=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert output.values == {}
|
||||
|
||||
|
||||
def test_call_saved_workflow_invocation_allows_default_workflow_for_non_owner():
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
|
||||
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="default-workflow")
|
||||
|
||||
output = invocation.invoke(
|
||||
build_context(
|
||||
workflow_record=build_workflow_record_dto(
|
||||
workflow_id="default-workflow",
|
||||
user_id="system",
|
||||
category=WorkflowCategory.Default,
|
||||
is_public=False,
|
||||
),
|
||||
queue_user_id="other-user",
|
||||
multiuser=True,
|
||||
user_is_admin=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert output.values == {}
|
||||
|
||||
|
||||
def test_call_saved_workflow_invocation_allows_admin_to_access_private_workflow():
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
|
||||
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
|
||||
|
||||
output = invocation.invoke(
|
||||
build_context(
|
||||
workflow_record=build_workflow_record_dto(
|
||||
workflow_id="private-workflow",
|
||||
user_id="owner-1",
|
||||
category=WorkflowCategory.User,
|
||||
is_public=False,
|
||||
),
|
||||
queue_user_id="admin-user",
|
||||
multiuser=True,
|
||||
user_is_admin=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert output.values == {}
|
||||
|
||||
|
||||
def test_call_saved_workflow_invocation_raises_when_private_workflow_user_record_is_missing():
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
|
||||
invocation = CallSavedWorkflowInvocation(id="test-node", workflow_id="private-workflow")
|
||||
|
||||
with pytest.raises(ValueError, match="is not accessible"):
|
||||
invocation.invoke(
|
||||
build_context(
|
||||
workflow_record=build_workflow_record_dto(
|
||||
workflow_id="private-workflow",
|
||||
user_id="owner-1",
|
||||
category=WorkflowCategory.User,
|
||||
is_public=False,
|
||||
),
|
||||
queue_user_id="other-user",
|
||||
multiuser=True,
|
||||
user_exists=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_call_saved_workflow_invocation_schema_declares_saved_workflow_ui_type():
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
|
||||
schema = CallSavedWorkflowInvocation.model_json_schema()
|
||||
workflow_id = schema["properties"]["workflow_id"]
|
||||
workflow_inputs = schema["properties"]["workflow_inputs"]
|
||||
|
||||
assert workflow_id["default"] == ""
|
||||
assert workflow_id["input"] == "any"
|
||||
assert workflow_id["ui_type"] == "SavedWorkflowField"
|
||||
assert workflow_inputs["default"] == {}
|
||||
assert workflow_inputs["ui_hidden"] is True
|
||||
|
||||
|
||||
def test_workflow_return_invocation_contract():
|
||||
from invokeai.app.invocations.workflow_return import (
|
||||
WorkflowReturnInvocation,
|
||||
WorkflowReturnOutput,
|
||||
WorkflowReturnValueField,
|
||||
)
|
||||
|
||||
invocation = WorkflowReturnInvocation(
|
||||
id="return-node",
|
||||
values=[
|
||||
WorkflowReturnValueField(key="prompt", value="a"),
|
||||
WorkflowReturnValueField(key="count", value=1),
|
||||
WorkflowReturnValueField(key="metadata", value={"x": True}),
|
||||
],
|
||||
)
|
||||
|
||||
assert invocation.get_type() == "workflow_return"
|
||||
|
||||
output = invocation.invoke(build_context())
|
||||
|
||||
assert isinstance(output, WorkflowReturnOutput)
|
||||
assert output.values == {"prompt": "a", "count": 1, "metadata": {"x": True}}
|
||||
assert not hasattr(output, "collection")
|
||||
|
||||
|
||||
def test_workflow_return_invocation_accepts_single_return_value():
|
||||
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnValueField
|
||||
|
||||
invocation = WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="sum", value=3))
|
||||
|
||||
output = invocation.invoke(build_context())
|
||||
|
||||
assert output.values == {"sum": 3}
|
||||
|
||||
|
||||
def test_workflow_return_values_schema_preserves_single_or_list_cardinality():
|
||||
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation
|
||||
|
||||
values_schema = WorkflowReturnInvocation.model_json_schema()["properties"]["values"]
|
||||
|
||||
assert values_schema["anyOf"] == [
|
||||
{"$ref": "#/$defs/WorkflowReturnValueField"},
|
||||
{"items": {"$ref": "#/$defs/WorkflowReturnValueField"}, "type": "array"},
|
||||
]
|
||||
assert values_schema.get("ui_type") != "CollectionField"
|
||||
|
||||
|
||||
def test_workflow_return_value_invocation_contract():
|
||||
from invokeai.app.invocations.workflow_return import WorkflowReturnValueField, WorkflowReturnValueInvocation
|
||||
|
||||
invocation = WorkflowReturnValueInvocation(id="return-value-node", key="image", value={"image_name": "image-a"})
|
||||
|
||||
output = invocation.invoke(build_context())
|
||||
|
||||
assert output.value == WorkflowReturnValueField(key="image", value={"image_name": "image-a"})
|
||||
|
||||
|
||||
def test_workflow_return_value_field_survives_exclude_none_session_roundtrip():
|
||||
from invokeai.app.invocations.workflow_return import (
|
||||
WorkflowReturnInvocation,
|
||||
WorkflowReturnValueField,
|
||||
WorkflowReturnValueOutput,
|
||||
)
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
|
||||
graph = Graph()
|
||||
graph.add_node(
|
||||
WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="nullable", value=None))
|
||||
)
|
||||
session = GraphExecutionState(graph=graph)
|
||||
session.execution_graph.add_node(
|
||||
WorkflowReturnInvocation(id="return-node", values=WorkflowReturnValueField(key="nullable", value=None))
|
||||
)
|
||||
session.results["return-value-node"] = WorkflowReturnValueOutput(
|
||||
value=WorkflowReturnValueField(key="nullable", value=None)
|
||||
)
|
||||
|
||||
reloaded = GraphExecutionState.model_validate_json(session.model_dump_json(warnings=False, exclude_none=True))
|
||||
|
||||
assert reloaded.execution_graph.nodes["return-node"].values == WorkflowReturnValueField(key="nullable", value=None)
|
||||
assert reloaded.results["return-value-node"].value == WorkflowReturnValueField(key="nullable", value=None)
|
||||
|
||||
|
||||
def test_workflow_return_invocation_rejects_duplicate_keys():
|
||||
from invokeai.app.invocations.workflow_return import WorkflowReturnInvocation, WorkflowReturnValueField
|
||||
|
||||
invocation = WorkflowReturnInvocation(
|
||||
id="return-node",
|
||||
values=[
|
||||
WorkflowReturnValueField(key="image", value="image-a"),
|
||||
WorkflowReturnValueField(key="image", value="image-b"),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate workflow return key 'image'"):
|
||||
invocation.invoke(build_context())
|
||||
|
||||
|
||||
def test_workflow_return_get_invocation_contract():
|
||||
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
|
||||
|
||||
invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key="image")
|
||||
|
||||
output = invocation.invoke(build_context())
|
||||
|
||||
assert output.value == "image-a"
|
||||
|
||||
|
||||
def test_workflow_return_get_invocation_rejects_missing_key():
|
||||
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
|
||||
|
||||
invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key="mask")
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow return key 'mask' was not found"):
|
||||
invocation.invoke(build_context())
|
||||
|
||||
|
||||
def test_workflow_return_get_invocation_rejects_empty_key():
|
||||
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation
|
||||
|
||||
invocation = WorkflowReturnGetInvocation(id="return-get-node", values={"image": "image-a"}, key=" ")
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow return key must not be empty"):
|
||||
invocation.invoke(build_context())
|
||||
|
||||
|
||||
def test_workflow_return_invocation_schema_declares_named_values_contract():
|
||||
from invokeai.app.invocations.workflow_return import WorkflowReturnGetInvocation, WorkflowReturnInvocation
|
||||
|
||||
schema = WorkflowReturnInvocation.model_json_schema()
|
||||
assert "collection" not in schema["properties"]
|
||||
values = schema["properties"]["values"]
|
||||
|
||||
assert values["input"] == "connection"
|
||||
assert "ui_type" not in values
|
||||
|
||||
get_schema = WorkflowReturnGetInvocation.model_json_schema()
|
||||
get_values = get_schema["properties"]["values"]
|
||||
assert get_values["input"] == "connection"
|
||||
assert get_values["ui_type"] == "AnyField"
|
||||
|
||||
|
||||
def test_workflow_without_id_validator_rejects_duplicate_workflow_return_nodes():
|
||||
with pytest.raises(ValueError, match="workflow_return"):
|
||||
WorkflowWithoutIDValidator.validate_python(
|
||||
{
|
||||
"name": "Workflow With Duplicate Returns",
|
||||
"author": "Tester",
|
||||
"description": "",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "",
|
||||
"notes": "",
|
||||
"exposedFields": [],
|
||||
"meta": {"version": "1.0.0", "category": "user"},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "return-1",
|
||||
"type": "invocation",
|
||||
"data": {"id": "return-1", "type": "workflow_return"},
|
||||
"position": {"x": 0, "y": 0},
|
||||
},
|
||||
{
|
||||
"id": "return-2",
|
||||
"type": "invocation",
|
||||
"data": {"id": "return-2", "type": "workflow_return"},
|
||||
"position": {"x": 100, "y": 0},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
"form": None,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import torch
|
||||
|
||||
from invokeai.app.invocations.cogview4_text_encoder import CogView4TextEncoderInvocation
|
||||
|
||||
|
||||
class FakeGlmModel(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.register_parameter("weight", torch.nn.Parameter(torch.ones(1)))
|
||||
self.repaired = False
|
||||
self.forward_input_device: torch.device | None = None
|
||||
|
||||
def forward(self, input_ids: torch.Tensor, output_hidden_states: bool = False):
|
||||
assert output_hidden_states
|
||||
if not self.repaired:
|
||||
raise RuntimeError("model must be repaired before forward")
|
||||
|
||||
self.forward_input_device = input_ids.device
|
||||
hidden = input_ids.unsqueeze(-1).float()
|
||||
return SimpleNamespace(hidden_states=[hidden, hidden + 1])
|
||||
|
||||
|
||||
class FakeTokenizer:
|
||||
pad_token_id = 0
|
||||
|
||||
def __call__(self, prompt, padding, max_length=None, truncation=None, add_special_tokens=None, return_tensors=None):
|
||||
del prompt, padding, max_length, truncation, add_special_tokens, return_tensors
|
||||
return SimpleNamespace(input_ids=torch.tensor([[1, 2, 3]], dtype=torch.long))
|
||||
|
||||
def batch_decode(self, input_ids):
|
||||
del input_ids
|
||||
return ["decoded"]
|
||||
|
||||
|
||||
class FakeLoadedModel:
|
||||
def __init__(self, model):
|
||||
self._model = model
|
||||
self.repair_calls = 0
|
||||
|
||||
@contextmanager
|
||||
def model_on_device(self):
|
||||
yield (None, self._model)
|
||||
|
||||
def repair_required_tensors_on_device(self) -> int:
|
||||
self.repair_calls += 1
|
||||
self._model.repaired = True
|
||||
return 1
|
||||
|
||||
|
||||
def test_cogview4_text_encoder_repairs_model_before_forward(monkeypatch):
|
||||
fake_model = FakeGlmModel()
|
||||
fake_tokenizer = FakeTokenizer()
|
||||
fake_model_info = FakeLoadedModel(fake_model)
|
||||
fake_tokenizer_info = FakeLoadedModel(fake_tokenizer)
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.models.load.side_effect = [fake_model_info, fake_tokenizer_info]
|
||||
mock_context.util.signal_progress = MagicMock()
|
||||
mock_context.logger.warning = MagicMock()
|
||||
|
||||
invocation = CogView4TextEncoderInvocation.model_construct(
|
||||
prompt="test prompt",
|
||||
glm_encoder=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace()),
|
||||
)
|
||||
|
||||
module_path = "invokeai.app.invocations.cogview4_text_encoder"
|
||||
monkeypatch.setattr(f"{module_path}.GlmModel", FakeGlmModel)
|
||||
monkeypatch.setattr(f"{module_path}.PreTrainedTokenizerFast", FakeTokenizer)
|
||||
|
||||
embeds = invocation._glm_encode(mock_context, max_seq_len=16)
|
||||
|
||||
assert fake_model_info.repair_calls == 1
|
||||
mock_context.logger.warning.assert_called_once()
|
||||
mock_context.util.signal_progress.assert_called_once_with("Running GLM text encoder")
|
||||
assert fake_model.forward_input_device == torch.device("cpu")
|
||||
assert embeds.shape == (1, 16, 1)
|
||||
@@ -0,0 +1,139 @@
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import torch
|
||||
|
||||
from invokeai.app.invocations.compel import SDXLPromptInvocationBase
|
||||
|
||||
|
||||
class FakeClipTextEncoder(torch.nn.Module):
|
||||
def __init__(self, effective_device: torch.device):
|
||||
super().__init__()
|
||||
self.register_parameter("cpu_param", torch.nn.Parameter(torch.ones(1)))
|
||||
self.register_buffer("active_buffer", torch.ones(1, device=effective_device))
|
||||
self.dtype = torch.float32
|
||||
|
||||
@property
|
||||
def device(self) -> torch.device:
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
class FakeTokenizer:
|
||||
pass
|
||||
|
||||
|
||||
class FakeLoadedModel:
|
||||
def __init__(self, model, config=None):
|
||||
self._model = model
|
||||
self.config = config
|
||||
|
||||
@contextmanager
|
||||
def model_on_device(self):
|
||||
yield (None, self._model)
|
||||
|
||||
def __enter__(self):
|
||||
return self._model
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class FakeCompel:
|
||||
last_init_device: torch.device | None = None
|
||||
|
||||
def __init__(self, *args, device: torch.device, **kwargs):
|
||||
del args, kwargs
|
||||
FakeCompel.last_init_device = device
|
||||
self.conditioning_provider = SimpleNamespace(
|
||||
get_pooled_embeddings=lambda prompts: torch.ones((len(prompts), 4), dtype=torch.float32)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_prompt_string(prompt: str) -> str:
|
||||
return prompt
|
||||
|
||||
def build_conditioning_tensor_for_conjunction(self, conjunction: str):
|
||||
del conjunction
|
||||
return torch.ones((1, 4, 4), dtype=torch.float32), {}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def fake_apply_ti(tokenizer, text_encoder, ti_list):
|
||||
del text_encoder, ti_list
|
||||
yield tokenizer, object()
|
||||
|
||||
|
||||
def test_sdxl_run_clip_compel_uses_effective_device_for_partially_loaded_model(monkeypatch):
|
||||
module_path = "invokeai.app.invocations.compel"
|
||||
effective_device = torch.device("meta")
|
||||
text_encoder = FakeClipTextEncoder(effective_device=effective_device)
|
||||
tokenizer = FakeTokenizer()
|
||||
text_encoder_info = FakeLoadedModel(text_encoder, config=SimpleNamespace(base="sdxl"))
|
||||
tokenizer_info = FakeLoadedModel(tokenizer)
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.models.load.side_effect = [text_encoder_info, tokenizer_info]
|
||||
mock_context.config.get.return_value.log_tokenization = False
|
||||
mock_context.util.signal_progress = MagicMock()
|
||||
|
||||
monkeypatch.setattr(f"{module_path}.CLIPTextModel", FakeClipTextEncoder)
|
||||
monkeypatch.setattr(f"{module_path}.CLIPTextModelWithProjection", FakeClipTextEncoder)
|
||||
monkeypatch.setattr(f"{module_path}.CLIPTokenizer", FakeTokenizer)
|
||||
monkeypatch.setattr(f"{module_path}.Compel", FakeCompel)
|
||||
monkeypatch.setattr(f"{module_path}.generate_ti_list", lambda prompt, base, context: [])
|
||||
monkeypatch.setattr(f"{module_path}.LayerPatcher.apply_smart_model_patches", lambda **kwargs: nullcontext())
|
||||
monkeypatch.setattr(f"{module_path}.ModelPatcher.apply_clip_skip", lambda *args, **kwargs: nullcontext())
|
||||
monkeypatch.setattr(f"{module_path}.ModelPatcher.apply_ti", fake_apply_ti)
|
||||
|
||||
base = SDXLPromptInvocationBase()
|
||||
cond, pooled = base.run_clip_compel(
|
||||
context=mock_context,
|
||||
clip_field=SimpleNamespace(
|
||||
text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[], skipped_layers=0
|
||||
),
|
||||
prompt="test prompt",
|
||||
get_pooled=False,
|
||||
lora_prefix="lora_te1_",
|
||||
zero_on_empty=False,
|
||||
)
|
||||
|
||||
assert FakeCompel.last_init_device == effective_device
|
||||
assert cond.shape == (1, 4, 4)
|
||||
assert pooled is None
|
||||
|
||||
|
||||
def test_sdxl_run_clip_compel_uses_cpu_for_fully_cpu_model(monkeypatch):
|
||||
module_path = "invokeai.app.invocations.compel"
|
||||
text_encoder = FakeClipTextEncoder(effective_device=torch.device("cpu"))
|
||||
tokenizer = FakeTokenizer()
|
||||
text_encoder_info = FakeLoadedModel(text_encoder, config=SimpleNamespace(base="sdxl"))
|
||||
tokenizer_info = FakeLoadedModel(tokenizer)
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.models.load.side_effect = [text_encoder_info, tokenizer_info]
|
||||
mock_context.config.get.return_value.log_tokenization = False
|
||||
mock_context.util.signal_progress = MagicMock()
|
||||
|
||||
monkeypatch.setattr(f"{module_path}.CLIPTextModel", FakeClipTextEncoder)
|
||||
monkeypatch.setattr(f"{module_path}.CLIPTextModelWithProjection", FakeClipTextEncoder)
|
||||
monkeypatch.setattr(f"{module_path}.CLIPTokenizer", FakeTokenizer)
|
||||
monkeypatch.setattr(f"{module_path}.Compel", FakeCompel)
|
||||
monkeypatch.setattr(f"{module_path}.generate_ti_list", lambda prompt, base, context: [])
|
||||
monkeypatch.setattr(f"{module_path}.LayerPatcher.apply_smart_model_patches", lambda **kwargs: nullcontext())
|
||||
monkeypatch.setattr(f"{module_path}.ModelPatcher.apply_clip_skip", lambda *args, **kwargs: nullcontext())
|
||||
monkeypatch.setattr(f"{module_path}.ModelPatcher.apply_ti", fake_apply_ti)
|
||||
|
||||
base = SDXLPromptInvocationBase()
|
||||
base.run_clip_compel(
|
||||
context=mock_context,
|
||||
clip_field=SimpleNamespace(
|
||||
text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[], skipped_layers=0
|
||||
),
|
||||
prompt="test prompt",
|
||||
get_pooled=False,
|
||||
lora_prefix="lora_te1_",
|
||||
zero_on_empty=False,
|
||||
)
|
||||
|
||||
assert FakeCompel.last_init_device == torch.device("cpu")
|
||||
@@ -0,0 +1,664 @@
|
||||
import inspect
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from invokeai.app.invocations.anima_denoise import AnimaDenoiseInvocation
|
||||
from invokeai.app.invocations.cogview4_denoise import CogView4DenoiseInvocation
|
||||
from invokeai.app.invocations.flux2_denoise import Flux2DenoiseInvocation
|
||||
from invokeai.app.invocations.flux_denoise import FluxDenoiseInvocation
|
||||
from invokeai.app.invocations.metadata_linked import FluxDenoiseLatentsMetaInvocation, ZImageDenoiseMetaInvocation
|
||||
from invokeai.app.invocations.primitives import LatentsOutput
|
||||
from invokeai.app.invocations.sd3_denoise import SD3DenoiseInvocation
|
||||
from invokeai.app.invocations.z_image_denoise import ZImageDenoiseInvocation
|
||||
from invokeai.backend.flux.sampling_utils import clip_timestep_schedule_fractional, get_schedule
|
||||
from invokeai.backend.flux.schedulers import ANIMA_SCHEDULER_MAP, FLUX_SCHEDULER_MAP, ZIMAGE_SCHEDULER_MAP
|
||||
from invokeai.backend.flux2.sampling_utils import get_schedule_flux2
|
||||
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
|
||||
|
||||
|
||||
def test_flux_prepare_noise_uses_external_noise():
|
||||
invocation = FluxDenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
expected = torch.zeros(1, 16, 8, 8)
|
||||
mock_context.tensors.load.return_value = expected
|
||||
|
||||
with patch("invokeai.app.invocations.flux_denoise.get_noise") as mock_get_noise:
|
||||
noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
|
||||
mock_get_noise.assert_not_called()
|
||||
|
||||
|
||||
def test_flux_prepare_noise_rejects_invalid_shape():
|
||||
invocation = FluxDenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = torch.zeros(1, 15, 8, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="Expected noise with shape"):
|
||||
invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
|
||||
def test_flux_add_noise_false_ignores_connected_noise():
|
||||
invocation = FluxDenoiseInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="latents"),
|
||||
noise=MagicMock(latents_name="noise"),
|
||||
add_noise=False,
|
||||
width=64,
|
||||
height=64,
|
||||
num_steps=4,
|
||||
denoising_start=0.25,
|
||||
denoising_end=0.25,
|
||||
positive_text_conditioning=MagicMock(conditioning_name="positive"),
|
||||
transformer=MagicMock(transformer="transformer"),
|
||||
seed=123,
|
||||
)
|
||||
init_latents = torch.full((1, 16, 8, 8), 2.0)
|
||||
dummy_conditioning = SimpleNamespace(
|
||||
t5_embeds=torch.zeros(1, 4, 16),
|
||||
clip_embeds=torch.zeros(1, 768),
|
||||
to=lambda **_: dummy_conditioning,
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = init_latents
|
||||
mock_context.conditioning.load.return_value = SimpleNamespace(conditionings=[dummy_conditioning])
|
||||
mock_context.models.get_config.return_value = SimpleNamespace(
|
||||
base=BaseModelType.Flux, type=ModelType.Main, variant=None
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"invokeai.app.invocations.flux_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
|
||||
),
|
||||
patch("invokeai.app.invocations.flux_denoise.FLUXConditioningInfo", object),
|
||||
patch(
|
||||
"invokeai.app.invocations.flux_denoise.RegionalPromptingExtension.from_text_conditioning",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")),
|
||||
patch.object(invocation, "_load_redux_conditioning", return_value=[]),
|
||||
patch("invokeai.app.invocations.flux_denoise.get_schedule", return_value=[0.75]),
|
||||
):
|
||||
result = invocation._run_diffusion(mock_context)
|
||||
|
||||
assert torch.equal(result, init_latents)
|
||||
|
||||
|
||||
def test_flux2_prepare_noise_uses_external_noise():
|
||||
invocation = Flux2DenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
expected = torch.zeros(1, 32, 8, 8)
|
||||
mock_context.tensors.load.return_value = expected
|
||||
|
||||
with patch("invokeai.app.invocations.flux2_denoise.get_noise_flux2") as mock_get_noise:
|
||||
noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
|
||||
mock_get_noise.assert_not_called()
|
||||
|
||||
|
||||
def test_flux2_prepare_noise_rejects_invalid_shape():
|
||||
invocation = Flux2DenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = torch.zeros(1, 16, 8, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="Expected noise with shape"):
|
||||
invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
|
||||
def test_sd3_prepare_noise_uses_external_noise():
|
||||
invocation = SD3DenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
expected = torch.zeros(1, 16, 8, 8)
|
||||
mock_context.tensors.load.return_value = expected
|
||||
|
||||
with patch.object(invocation, "_get_noise") as mock_get_noise:
|
||||
noise = invocation._prepare_noise_tensor(mock_context, 16, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
|
||||
mock_get_noise.assert_not_called()
|
||||
|
||||
|
||||
def test_sd3_prepare_noise_rejects_invalid_shape():
|
||||
invocation = SD3DenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = torch.zeros(1, 8, 8, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="Expected noise with shape"):
|
||||
invocation._prepare_noise_tensor(mock_context, 16, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
|
||||
def test_cogview4_prepare_noise_uses_external_noise():
|
||||
invocation = CogView4DenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
expected = torch.zeros(1, 16, 8, 8)
|
||||
mock_context.tensors.load.return_value = expected
|
||||
|
||||
with patch.object(invocation, "_get_noise") as mock_get_noise:
|
||||
noise = invocation._prepare_noise_tensor(mock_context, 16, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
|
||||
mock_get_noise.assert_not_called()
|
||||
|
||||
|
||||
def test_cogview4_prepare_noise_rejects_invalid_shape():
|
||||
invocation = CogView4DenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = torch.zeros(1, 4, 8, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="Expected noise with shape"):
|
||||
invocation._prepare_noise_tensor(mock_context, 16, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
|
||||
def test_z_image_prepare_noise_uses_external_noise():
|
||||
invocation = ZImageDenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
expected = torch.zeros(1, 16, 8, 8)
|
||||
mock_context.tensors.load.return_value = expected
|
||||
|
||||
with patch.object(invocation, "_get_noise") as mock_get_noise:
|
||||
noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
|
||||
mock_get_noise.assert_not_called()
|
||||
|
||||
|
||||
def test_z_image_prepare_noise_rejects_invalid_shape():
|
||||
invocation = ZImageDenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = torch.zeros(1, 8, 8, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="Expected noise with shape"):
|
||||
invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
|
||||
def test_z_image_add_noise_false_ignores_connected_noise():
|
||||
invocation = ZImageDenoiseInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="latents"),
|
||||
noise=MagicMock(latents_name="noise"),
|
||||
add_noise=False,
|
||||
width=64,
|
||||
height=64,
|
||||
steps=4,
|
||||
denoising_start=0.0,
|
||||
denoising_end=1.0,
|
||||
positive_conditioning=SimpleNamespace(conditioning_name="positive", mask=None),
|
||||
transformer=MagicMock(transformer="transformer"),
|
||||
seed=123,
|
||||
scheduler="euler",
|
||||
)
|
||||
init_latents = torch.full((1, 16, 8, 8), 2.0)
|
||||
dummy_conditioning = SimpleNamespace(prompt_embeds=torch.zeros(1, 4, 16))
|
||||
dummy_conditioning.to = lambda **_: dummy_conditioning
|
||||
regional_extension = SimpleNamespace(
|
||||
regional_text_conditioning=SimpleNamespace(prompt_embeds=torch.zeros(1, 4, 16))
|
||||
)
|
||||
loaded_text_conditioning = [SimpleNamespace(prompt_embeds=torch.zeros(1, 4, 16), mask=None)]
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = init_latents
|
||||
mock_context.conditioning.load.return_value = SimpleNamespace(conditionings=[dummy_conditioning])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"invokeai.app.invocations.z_image_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
|
||||
),
|
||||
patch(
|
||||
"invokeai.app.invocations.z_image_denoise.TorchDevice.choose_bfloat16_safe_dtype",
|
||||
return_value=torch.bfloat16,
|
||||
),
|
||||
patch("invokeai.app.invocations.z_image_denoise.ZImageConditioningInfo", object),
|
||||
patch(
|
||||
"invokeai.app.invocations.z_image_denoise.ZImageRegionalPromptingExtension.from_text_conditionings",
|
||||
return_value=regional_extension,
|
||||
),
|
||||
patch.object(invocation, "_load_text_conditioning", return_value=loaded_text_conditioning),
|
||||
patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")),
|
||||
patch.object(invocation, "_get_sigmas", return_value=[0.75]),
|
||||
):
|
||||
result = invocation._run_diffusion(mock_context)
|
||||
|
||||
assert torch.equal(result, init_latents)
|
||||
|
||||
|
||||
def test_anima_prepare_noise_uses_external_noise():
|
||||
invocation = AnimaDenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
expected = torch.zeros(1, 16, 1, 8, 8)
|
||||
mock_context.tensors.load.return_value = expected
|
||||
|
||||
with patch.object(invocation, "_get_noise") as mock_get_noise:
|
||||
noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
assert torch.equal(noise, expected.to(dtype=torch.bfloat16))
|
||||
mock_get_noise.assert_not_called()
|
||||
|
||||
|
||||
def test_anima_prepare_noise_rejects_invalid_rank():
|
||||
invocation = AnimaDenoiseInvocation.model_construct(
|
||||
width=64, height=64, seed=0, noise=MagicMock(latents_name="noise")
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = torch.zeros(1, 16, 8, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="Expected noise with shape"):
|
||||
invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu"))
|
||||
|
||||
|
||||
def test_anima_add_noise_false_ignores_connected_noise():
|
||||
invocation = AnimaDenoiseInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="latents"),
|
||||
noise=MagicMock(latents_name="noise"),
|
||||
add_noise=False,
|
||||
width=64,
|
||||
height=64,
|
||||
steps=4,
|
||||
denoising_start=0.0,
|
||||
denoising_end=1.0,
|
||||
positive_conditioning=SimpleNamespace(conditioning_name="positive", mask=None),
|
||||
transformer=MagicMock(transformer="transformer"),
|
||||
seed=123,
|
||||
scheduler="euler",
|
||||
)
|
||||
init_latents = torch.full((1, 16, 8, 8), 2.0)
|
||||
loaded_text_conditioning = [SimpleNamespace(mask=None)]
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = init_latents
|
||||
mock_context.models.load.return_value = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"invokeai.app.invocations.anima_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
|
||||
),
|
||||
patch(
|
||||
"invokeai.app.invocations.anima_denoise.TorchDevice.choose_bfloat16_safe_dtype", return_value=torch.bfloat16
|
||||
),
|
||||
patch.object(invocation, "_load_text_conditionings", return_value=loaded_text_conditioning),
|
||||
patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")),
|
||||
patch.object(invocation, "_get_sigmas", return_value=[0.75]),
|
||||
):
|
||||
result = invocation._run_diffusion(mock_context)
|
||||
|
||||
assert torch.equal(result, init_latents)
|
||||
|
||||
|
||||
def test_flux2_add_noise_false_ignores_connected_noise():
|
||||
invocation = Flux2DenoiseInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="latents"),
|
||||
noise=MagicMock(latents_name="noise"),
|
||||
add_noise=False,
|
||||
width=64,
|
||||
height=64,
|
||||
num_steps=4,
|
||||
denoising_start=0.25,
|
||||
denoising_end=0.25,
|
||||
positive_text_conditioning=MagicMock(conditioning_name="positive"),
|
||||
transformer=MagicMock(transformer="transformer"),
|
||||
vae=MagicMock(vae="vae"),
|
||||
seed=123,
|
||||
)
|
||||
init_latents = torch.full((1, 32, 8, 8), 2.0)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = init_latents
|
||||
mock_context.conditioning.load.return_value = SimpleNamespace(
|
||||
conditionings=[
|
||||
SimpleNamespace(
|
||||
t5_embeds=torch.zeros(1, 4, 16), to=lambda **_: SimpleNamespace(t5_embeds=torch.zeros(1, 4, 16))
|
||||
)
|
||||
]
|
||||
)
|
||||
mock_context.models.get_config.return_value = SimpleNamespace(base=BaseModelType.Flux2, type=ModelType.Main)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"invokeai.app.invocations.flux2_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
|
||||
),
|
||||
patch("invokeai.app.invocations.flux2_denoise.FLUXConditioningInfo", object),
|
||||
patch.object(invocation, "_get_bn_stats", return_value=None),
|
||||
patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")),
|
||||
):
|
||||
result = invocation._run_diffusion(mock_context)
|
||||
|
||||
assert torch.equal(result, init_latents)
|
||||
|
||||
|
||||
def test_flux_metadata_ignores_external_noise_seed_when_noise_not_used():
|
||||
invocation = FluxDenoiseLatentsMetaInvocation.model_construct(
|
||||
width=64,
|
||||
height=64,
|
||||
num_steps=4,
|
||||
guidance=3.5,
|
||||
denoising_start=0.0,
|
||||
denoising_end=1.0,
|
||||
latents=MagicMock(latents_name="latents"),
|
||||
transformer=MagicMock(transformer="transformer", loras=[]),
|
||||
noise=MagicMock(seed=123),
|
||||
seed=999,
|
||||
add_noise=False,
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
output = LatentsOutput.build("latents", torch.zeros(1, 16, 8, 8), seed=None)
|
||||
|
||||
with patch("invokeai.app.invocations.metadata_linked.FluxDenoiseInvocation.invoke", return_value=output):
|
||||
result = invocation.invoke(mock_context)
|
||||
|
||||
assert result.metadata.root["seed"] == 999
|
||||
|
||||
|
||||
def test_z_image_metadata_ignores_external_noise_seed_when_noise_not_used():
|
||||
invocation = ZImageDenoiseMetaInvocation.model_construct(
|
||||
width=64,
|
||||
height=64,
|
||||
steps=8,
|
||||
guidance_scale=1.0,
|
||||
denoising_start=0.0,
|
||||
denoising_end=1.0,
|
||||
scheduler="euler",
|
||||
latents=MagicMock(latents_name="latents"),
|
||||
transformer=MagicMock(transformer="transformer", loras=[]),
|
||||
noise=MagicMock(seed=123),
|
||||
seed=999,
|
||||
add_noise=False,
|
||||
)
|
||||
mock_context = MagicMock()
|
||||
output = LatentsOutput.build("latents", torch.zeros(1, 16, 8, 8), seed=None)
|
||||
|
||||
with patch("invokeai.app.invocations.metadata_linked.ZImageDenoiseInvocation.invoke", return_value=output):
|
||||
result = invocation.invoke(mock_context)
|
||||
|
||||
assert result.metadata.root["seed"] == 999
|
||||
|
||||
|
||||
def _get_first_scheduler_sigma(
|
||||
scheduler, *, scheduler_name: str, sigmas: list[float], mu: float | None = None
|
||||
) -> float:
|
||||
set_timesteps_signature = inspect.signature(scheduler.set_timesteps)
|
||||
if scheduler_name != "lcm" and "sigmas" in set_timesteps_signature.parameters:
|
||||
kwargs: dict[str, object] = {"sigmas": sigmas, "device": "cpu"}
|
||||
if mu is not None and "mu" in set_timesteps_signature.parameters:
|
||||
kwargs["mu"] = mu
|
||||
scheduler.set_timesteps(**kwargs)
|
||||
else:
|
||||
kwargs = {"num_inference_steps": len(sigmas) - 1, "device": "cpu"}
|
||||
if mu is not None and "mu" in set_timesteps_signature.parameters:
|
||||
kwargs["mu"] = mu
|
||||
scheduler.set_timesteps(**kwargs)
|
||||
return float(scheduler.sigmas[0])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scheduler_name",
|
||||
[
|
||||
"euler",
|
||||
pytest.param(
|
||||
"heun",
|
||||
marks=pytest.mark.xfail(
|
||||
reason="Known img2img preblend mismatch for FLUX with scheduler-defined first step.",
|
||||
strict=True,
|
||||
),
|
||||
),
|
||||
pytest.param(
|
||||
"lcm",
|
||||
marks=pytest.mark.xfail(
|
||||
reason="Known img2img preblend mismatch for FLUX with scheduler-defined first step.",
|
||||
strict=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_flux_img2img_preblend_matches_scheduler_first_sigma(scheduler_name: str):
|
||||
sigmas = clip_timestep_schedule_fractional(get_schedule(num_steps=4, image_seq_len=16, shift=True), 0.25, 1.0)
|
||||
scheduler_class = FLUX_SCHEDULER_MAP[scheduler_name]
|
||||
scheduler = scheduler_class(num_train_timesteps=1000)
|
||||
|
||||
assert sigmas[0] == pytest.approx(
|
||||
_get_first_scheduler_sigma(scheduler, scheduler_name=scheduler_name, sigmas=sigmas)
|
||||
)
|
||||
|
||||
|
||||
def test_flux2_partial_denoise_short_circuit_uses_first_clipped_timestep():
|
||||
invocation = Flux2DenoiseInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="latents"),
|
||||
width=64,
|
||||
height=64,
|
||||
num_steps=4,
|
||||
denoising_start=0.25,
|
||||
denoising_end=0.25,
|
||||
positive_text_conditioning=MagicMock(conditioning_name="positive"),
|
||||
transformer=MagicMock(transformer="transformer"),
|
||||
vae=MagicMock(vae="vae"),
|
||||
seed=0,
|
||||
scheduler="lcm",
|
||||
)
|
||||
init_latents = torch.full((1, 32, 8, 8), 2.0)
|
||||
noise = torch.full((1, 32, 8, 8), 10.0)
|
||||
dummy_conditioning = SimpleNamespace(t5_embeds=torch.zeros(1, 4, 16))
|
||||
dummy_conditioning.to = lambda **_: dummy_conditioning
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = init_latents
|
||||
mock_context.conditioning.load.return_value = SimpleNamespace(conditionings=[dummy_conditioning])
|
||||
mock_context.models.get_config.return_value = SimpleNamespace(base=BaseModelType.Flux2, type=ModelType.Main)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"invokeai.app.invocations.flux2_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")
|
||||
),
|
||||
patch("invokeai.app.invocations.flux2_denoise.FLUXConditioningInfo", object),
|
||||
patch.object(invocation, "_get_bn_stats", return_value=None),
|
||||
patch.object(invocation, "_prepare_noise_tensor", return_value=noise),
|
||||
):
|
||||
result = invocation._run_diffusion(mock_context)
|
||||
|
||||
timesteps = clip_timestep_schedule_fractional(get_schedule_flux2(num_steps=4, image_seq_len=16), 0.25, 0.25)
|
||||
expected = timesteps[0] * noise + (1.0 - timesteps[0]) * init_latents
|
||||
assert torch.equal(result, expected)
|
||||
|
||||
|
||||
def test_flux2_lcm_scheduler_setup_passes_mu():
|
||||
from invokeai.backend.flux2.denoise import denoise
|
||||
|
||||
class DummyScheduler:
|
||||
def __init__(self) -> None:
|
||||
self.received_mu = None
|
||||
self.timesteps = torch.tensor([750.0, 500.0], dtype=torch.float32)
|
||||
self.sigmas = torch.tensor([0.75, 0.5, 0.0], dtype=torch.float32)
|
||||
self.config = SimpleNamespace(num_train_timesteps=1000)
|
||||
|
||||
def set_timesteps(self, num_inference_steps: int, device: str | torch.device, mu: float | None = None) -> None:
|
||||
self.received_mu = mu
|
||||
|
||||
def step(self, model_output: torch.Tensor, timestep: torch.Tensor, sample: torch.Tensor):
|
||||
return SimpleNamespace(prev_sample=sample)
|
||||
|
||||
class DummyModel(torch.nn.Module):
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
encoder_hidden_states: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
img_ids: torch.Tensor,
|
||||
txt_ids: torch.Tensor,
|
||||
guidance: torch.Tensor,
|
||||
return_dict: bool = False,
|
||||
):
|
||||
return (torch.zeros_like(hidden_states),)
|
||||
|
||||
scheduler = DummyScheduler()
|
||||
denoise(
|
||||
model=DummyModel(),
|
||||
img=torch.zeros(1, 4, 8),
|
||||
img_ids=torch.zeros(1, 4, 4, dtype=torch.long),
|
||||
txt=torch.zeros(1, 4, 8),
|
||||
txt_ids=torch.zeros(1, 4, 4, dtype=torch.long),
|
||||
timesteps=[0.75, 0.5, 0.0],
|
||||
step_callback=lambda _: None,
|
||||
guidance=1.0,
|
||||
cfg_scale=[1.0, 1.0],
|
||||
scheduler=scheduler,
|
||||
mu=0.42,
|
||||
)
|
||||
|
||||
assert scheduler.received_mu == pytest.approx(0.42)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scheduler_name",
|
||||
[
|
||||
"euler",
|
||||
pytest.param(
|
||||
"heun",
|
||||
marks=pytest.mark.xfail(
|
||||
reason="Known img2img preblend mismatch for Z-Image with scheduler-defined first step.",
|
||||
strict=True,
|
||||
),
|
||||
),
|
||||
pytest.param(
|
||||
"lcm",
|
||||
marks=pytest.mark.xfail(
|
||||
reason="Known img2img preblend mismatch for Z-Image with scheduler-defined first step.",
|
||||
strict=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_z_image_img2img_preblend_matches_scheduler_first_sigma(scheduler_name: str):
|
||||
invocation = ZImageDenoiseInvocation.model_construct(steps=8, width=1024, height=1024)
|
||||
img_seq_len = (invocation.height // 8 // 2) * (invocation.width // 8 // 2)
|
||||
shift = invocation._calculate_shift(img_seq_len)
|
||||
sigmas = invocation._get_sigmas(shift, invocation.steps)
|
||||
sigmas = sigmas[int(0.25 * (len(sigmas) - 1)) :]
|
||||
scheduler_class = ZIMAGE_SCHEDULER_MAP[scheduler_name]
|
||||
scheduler = scheduler_class(num_train_timesteps=1000, shift=1.0)
|
||||
|
||||
assert sigmas[0] == pytest.approx(
|
||||
_get_first_scheduler_sigma(scheduler, scheduler_name=scheduler_name, sigmas=sigmas)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scheduler_name",
|
||||
[
|
||||
"euler",
|
||||
pytest.param(
|
||||
"heun",
|
||||
marks=pytest.mark.xfail(
|
||||
reason="Known img2img preblend mismatch for Anima with scheduler-defined first step.",
|
||||
strict=True,
|
||||
),
|
||||
),
|
||||
pytest.param(
|
||||
"lcm",
|
||||
marks=pytest.mark.xfail(
|
||||
reason="Known img2img preblend mismatch for Anima with scheduler-defined first step.",
|
||||
strict=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_anima_img2img_preblend_matches_scheduler_first_sigma(scheduler_name: str):
|
||||
invocation = AnimaDenoiseInvocation.model_construct(steps=30)
|
||||
sigmas = invocation._get_sigmas(invocation.steps)
|
||||
sigmas = sigmas[int(0.25 * (len(sigmas) - 1)) :]
|
||||
scheduler_class, scheduler_kwargs = ANIMA_SCHEDULER_MAP[scheduler_name]
|
||||
scheduler = scheduler_class(num_train_timesteps=1000, **scheduler_kwargs)
|
||||
|
||||
assert sigmas[0] == pytest.approx(
|
||||
_get_first_scheduler_sigma(scheduler, scheduler_name=scheduler_name, sigmas=sigmas)
|
||||
)
|
||||
|
||||
|
||||
def test_sd3_partial_denoise_short_circuit_uses_first_clipped_timestep():
|
||||
invocation = SD3DenoiseInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="latents"),
|
||||
width=64,
|
||||
height=64,
|
||||
steps=4,
|
||||
denoising_start=0.25,
|
||||
denoising_end=0.25,
|
||||
positive_conditioning=MagicMock(conditioning_name="positive"),
|
||||
negative_conditioning=MagicMock(conditioning_name="negative"),
|
||||
transformer=MagicMock(transformer="transformer"),
|
||||
seed=0,
|
||||
)
|
||||
init_latents = torch.full((1, 16, 8, 8), 2.0)
|
||||
noise = torch.full((1, 16, 8, 8), 10.0)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = init_latents
|
||||
mock_context.models.load.return_value = MagicMock(
|
||||
model=MagicMock(config=MagicMock(in_channels=16, joint_attention_dim=4096))
|
||||
)
|
||||
|
||||
with (
|
||||
patch("invokeai.app.invocations.sd3_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")),
|
||||
patch("invokeai.app.invocations.sd3_denoise.TorchDevice.choose_torch_dtype", return_value=torch.float32),
|
||||
patch.object(invocation, "_prepare_noise_tensor", return_value=noise),
|
||||
patch.object(invocation, "_load_text_conditioning", return_value=(torch.zeros(1, 1, 1), torch.zeros(1, 1))),
|
||||
):
|
||||
result = invocation._run_diffusion(mock_context)
|
||||
|
||||
timesteps = clip_timestep_schedule_fractional(torch.linspace(1, 0, invocation.steps + 1).tolist(), 0.25, 0.25)
|
||||
expected = timesteps[0] * noise + (1.0 - timesteps[0]) * init_latents
|
||||
assert torch.equal(result, expected)
|
||||
|
||||
|
||||
def test_cogview4_partial_denoise_short_circuit_uses_first_clipped_sigma():
|
||||
invocation = CogView4DenoiseInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="latents"),
|
||||
width=64,
|
||||
height=64,
|
||||
steps=4,
|
||||
denoising_start=0.25,
|
||||
denoising_end=0.25,
|
||||
positive_conditioning=MagicMock(conditioning_name="positive"),
|
||||
negative_conditioning=MagicMock(conditioning_name="negative"),
|
||||
transformer=MagicMock(transformer="transformer"),
|
||||
seed=0,
|
||||
)
|
||||
init_latents = torch.full((1, 16, 8, 8), 2.0)
|
||||
noise = torch.full((1, 16, 8, 8), 10.0)
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.load.return_value = init_latents
|
||||
transformer_model = MagicMock(config=MagicMock(in_channels=16, patch_size=2))
|
||||
mock_context.models.load.return_value = MagicMock(model=transformer_model)
|
||||
|
||||
with (
|
||||
patch("invokeai.app.invocations.cogview4_denoise.CogView4Transformer2DModel", object),
|
||||
patch(
|
||||
"invokeai.app.invocations.cogview4_denoise.TorchDevice.choose_torch_device",
|
||||
return_value=torch.device("cpu"),
|
||||
),
|
||||
patch.object(invocation, "_prepare_noise_tensor", return_value=noise),
|
||||
patch.object(invocation, "_load_text_conditioning", return_value=torch.zeros(1, 1, 1)),
|
||||
):
|
||||
result = invocation._run_diffusion(mock_context)
|
||||
|
||||
timesteps = clip_timestep_schedule_fractional(torch.linspace(1, 0, invocation.steps + 1).tolist(), 0.25, 0.25)
|
||||
sigmas = invocation._convert_timesteps_to_sigmas(
|
||||
image_seq_len=((invocation.height // 8) * (invocation.width // 8)) // (2**2),
|
||||
timesteps=torch.tensor(timesteps),
|
||||
)
|
||||
expected = sigmas[0] * noise + (1.0 - sigmas[0]) * init_latents
|
||||
assert torch.allclose(result, expected, atol=2e-3, rtol=0)
|
||||
@@ -0,0 +1,111 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.invocations.external_image_generation import OpenAIImageGenerationInvocation
|
||||
from invokeai.app.invocations.fields import ImageField
|
||||
from invokeai.app.invocations.model import ModelIdentifierField
|
||||
from invokeai.app.services.external_generation.external_generation_common import (
|
||||
ExternalGeneratedImage,
|
||||
ExternalGenerationResult,
|
||||
)
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
|
||||
|
||||
|
||||
def _build_model() -> ExternalApiModelConfig:
|
||||
return ExternalApiModelConfig(
|
||||
key="external_test",
|
||||
name="External Test",
|
||||
provider_id="openai",
|
||||
provider_model_id="gpt-image-1",
|
||||
capabilities=ExternalModelCapabilities(
|
||||
modes=["txt2img"],
|
||||
supports_reference_images=True,
|
||||
supports_seed=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_context(model_config: ExternalApiModelConfig, generated_image: Image.Image) -> MagicMock:
|
||||
context = MagicMock()
|
||||
context.models.get_config.return_value = model_config
|
||||
context.images.get_pil.return_value = generated_image
|
||||
context.images.save.return_value = SimpleNamespace(image_name="result.png")
|
||||
context._services.external_generation.generate.return_value = ExternalGenerationResult(
|
||||
images=[ExternalGeneratedImage(image=generated_image, seed=42)],
|
||||
provider_request_id="req-123",
|
||||
provider_metadata={"model": model_config.provider_model_id},
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def test_external_invocation_builds_request_and_outputs() -> None:
|
||||
model_config = _build_model()
|
||||
model_field = ModelIdentifierField.from_config(model_config)
|
||||
generated_image = Image.new("RGB", (16, 16), color="black")
|
||||
context = _build_context(model_config, generated_image)
|
||||
|
||||
invocation = OpenAIImageGenerationInvocation(
|
||||
id="external_node",
|
||||
model=model_field,
|
||||
mode="txt2img",
|
||||
prompt="A prompt",
|
||||
seed=123,
|
||||
num_images=1,
|
||||
width=512,
|
||||
height=512,
|
||||
reference_images=[ImageField(image_name="ref.png")],
|
||||
)
|
||||
|
||||
output = invocation.invoke(context)
|
||||
|
||||
request = context._services.external_generation.generate.call_args[0][0]
|
||||
assert request.prompt == "A prompt"
|
||||
assert request.seed == 123
|
||||
assert len(request.reference_images) == 1
|
||||
assert output.collection[0].image_name == "result.png"
|
||||
|
||||
|
||||
def test_provider_specific_external_invocation_rejects_wrong_provider() -> None:
|
||||
model_config = _build_model().model_copy(update={"provider_id": "gemini"})
|
||||
model_field = ModelIdentifierField.from_config(model_config)
|
||||
generated_image = Image.new("RGB", (16, 16), color="black")
|
||||
context = _build_context(model_config, generated_image)
|
||||
|
||||
invocation = OpenAIImageGenerationInvocation(
|
||||
id="external_node",
|
||||
model=model_field,
|
||||
mode="txt2img",
|
||||
prompt="A prompt",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not match node provider"):
|
||||
invocation.invoke(context)
|
||||
|
||||
|
||||
def test_external_graph_execution_state_runs_node() -> None:
|
||||
model_config = _build_model()
|
||||
model_field = ModelIdentifierField.from_config(model_config)
|
||||
generated_image = Image.new("RGB", (16, 16), color="black")
|
||||
context = _build_context(model_config, generated_image)
|
||||
|
||||
invocation = OpenAIImageGenerationInvocation(
|
||||
id="external_node",
|
||||
model=model_field,
|
||||
mode="txt2img",
|
||||
prompt="A prompt",
|
||||
)
|
||||
|
||||
graph = Graph()
|
||||
graph.add_node(invocation)
|
||||
|
||||
session = GraphExecutionState(graph=graph)
|
||||
node = session.next()
|
||||
assert node is not None
|
||||
output = node.invoke(context)
|
||||
session.complete(node.id, output)
|
||||
|
||||
assert session.results[node.id] == output
|
||||
@@ -0,0 +1,62 @@
|
||||
import pytest
|
||||
|
||||
from invokeai.app.invocations.flux_denoise import FluxDenoiseInvocation
|
||||
|
||||
TIMESTEPS = [1.0, 0.75, 0.5, 0.25, 0.0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["cfg_scale", "timesteps", "cfg_scale_start_step", "cfg_scale_end_step", "expected"],
|
||||
[
|
||||
# Test scalar cfg_scale.
|
||||
(2.0, TIMESTEPS, 0, -1, [2.0, 2.0, 2.0, 2.0]),
|
||||
# Test list cfg_scale.
|
||||
([1.0, 2.0, 3.0, 4.0], TIMESTEPS, 0, -1, [1.0, 2.0, 3.0, 4.0]),
|
||||
# Test positive cfg_scale_start_step.
|
||||
(2.0, TIMESTEPS, 1, -1, [1.0, 2.0, 2.0, 2.0]),
|
||||
# Test positive cfg_scale_end_step.
|
||||
(2.0, TIMESTEPS, 0, 2, [2.0, 2.0, 2.0, 1.0]),
|
||||
# Test negative cfg_scale_start_step.
|
||||
(2.0, TIMESTEPS, -3, -1, [1.0, 2.0, 2.0, 2.0]),
|
||||
# Test negative cfg_scale_end_step.
|
||||
(2.0, TIMESTEPS, 0, -2, [2.0, 2.0, 2.0, 1.0]),
|
||||
# Test single step application.
|
||||
(2.0, TIMESTEPS, 2, 2, [1.0, 1.0, 2.0, 1.0]),
|
||||
],
|
||||
)
|
||||
def test_prep_cfg_scale(
|
||||
cfg_scale: float | list[float],
|
||||
timesteps: list[float],
|
||||
cfg_scale_start_step: int,
|
||||
cfg_scale_end_step: int,
|
||||
expected: list[float],
|
||||
):
|
||||
result = FluxDenoiseInvocation.prep_cfg_scale(cfg_scale, timesteps, cfg_scale_start_step, cfg_scale_end_step)
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_prep_cfg_scale_invalid_type():
|
||||
with pytest.raises(ValueError, match="Unsupported cfg_scale type"):
|
||||
FluxDenoiseInvocation.prep_cfg_scale("invalid", [1.0, 0.5], 0, -1) # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cfg_scale_start_step", [4, -5])
|
||||
def test_prep_cfg_scale_invalid_start_step(cfg_scale_start_step: int):
|
||||
with pytest.raises(ValueError, match="Invalid cfg_scale_start_step"):
|
||||
FluxDenoiseInvocation.prep_cfg_scale(2.0, TIMESTEPS, cfg_scale_start_step, -1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cfg_scale_end_step", [4, -5])
|
||||
def test_prep_cfg_scale_invalid_end_step(cfg_scale_end_step: int):
|
||||
with pytest.raises(ValueError, match="Invalid cfg_scale_end_step"):
|
||||
FluxDenoiseInvocation.prep_cfg_scale(2.0, TIMESTEPS, 0, cfg_scale_end_step)
|
||||
|
||||
|
||||
def test_prep_cfg_scale_start_after_end():
|
||||
with pytest.raises(ValueError, match="cfg_scale_start_step .* must be before cfg_scale_end_step"):
|
||||
FluxDenoiseInvocation.prep_cfg_scale(2.0, TIMESTEPS, 3, 2)
|
||||
|
||||
|
||||
def test_prep_cfg_scale_list_length_mismatch():
|
||||
with pytest.raises(AssertionError):
|
||||
FluxDenoiseInvocation.prep_cfg_scale([1.0, 2.0, 3.0], TIMESTEPS, 0, -1)
|
||||
@@ -0,0 +1,435 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy
|
||||
import torch
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from invokeai.app.invocations.image import ImageField, OklabUnsharpMaskInvocation, OklchImageHueAdjustmentInvocation
|
||||
from invokeai.app.invocations.primitives import ImageCollectionInvocation
|
||||
from invokeai.backend.image_util.color_conversion import (
|
||||
linear_srgb_from_oklab,
|
||||
linear_srgb_from_oklch,
|
||||
linear_srgb_from_srgb,
|
||||
okhsl_from_srgb,
|
||||
oklab_from_linear_srgb,
|
||||
oklch_from_oklab,
|
||||
srgb_from_hsl,
|
||||
srgb_from_linear_srgb,
|
||||
srgb_from_okhsl,
|
||||
)
|
||||
|
||||
_COMPOSITION_NODES_SPEC = importlib.util.spec_from_file_location(
|
||||
"invokeai.app.invocations.composition_nodes",
|
||||
Path(__file__).resolve().parents[3] / "invokeai/app/invocations/composition-nodes.py",
|
||||
)
|
||||
assert _COMPOSITION_NODES_SPEC is not None
|
||||
assert _COMPOSITION_NODES_SPEC.loader is not None
|
||||
composition_nodes = importlib.util.module_from_spec(_COMPOSITION_NODES_SPEC)
|
||||
_COMPOSITION_NODES_SPEC.loader.exec_module(composition_nodes)
|
||||
InvokeAdjustImageHuePlusInvocation = composition_nodes.InvokeAdjustImageHuePlusInvocation
|
||||
InvokeImageBlendInvocation = composition_nodes.InvokeImageBlendInvocation
|
||||
|
||||
|
||||
def _build_context(input_image: Image.Image) -> MagicMock:
|
||||
context = MagicMock()
|
||||
context.images.get_pil.return_value = input_image
|
||||
context.images.save.side_effect = lambda image: SimpleNamespace(
|
||||
image_name="out", width=image.width, height=image.height
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def _max_abs_diff_uint8(left: Image.Image, right: Image.Image) -> int:
|
||||
left_arr = numpy.asarray(left, dtype=numpy.int16)
|
||||
right_arr = numpy.asarray(right, dtype=numpy.int16)
|
||||
return int(numpy.abs(left_arr - right_arr).max())
|
||||
|
||||
|
||||
def test_image_collection_invocation_preserves_existing_collection_values() -> None:
|
||||
images = [ImageField(image_name="first"), ImageField(image_name="second")]
|
||||
|
||||
output = ImageCollectionInvocation(collection=images).invoke(MagicMock())
|
||||
|
||||
assert output.collection == images
|
||||
|
||||
|
||||
def test_image_collection_invocation_appends_direct_images_after_chained_collection() -> None:
|
||||
chained_images = [ImageField(image_name="chained")]
|
||||
direct_images = [ImageField(image_name="direct_1"), ImageField(image_name="direct_2")]
|
||||
|
||||
output = ImageCollectionInvocation(collection=chained_images, images=direct_images).invoke(MagicMock())
|
||||
|
||||
assert output.collection == [*chained_images, *direct_images]
|
||||
|
||||
|
||||
def test_image_collection_invocation_supports_empty_direct_images() -> None:
|
||||
chained_images = [ImageField(image_name="chained")]
|
||||
|
||||
output = ImageCollectionInvocation(collection=chained_images, images=None).invoke(MagicMock())
|
||||
|
||||
assert output.collection == chained_images
|
||||
|
||||
|
||||
def test_image_collection_invocation_outputs_empty_collection_when_inputs_are_empty() -> None:
|
||||
output = ImageCollectionInvocation(collection=None, images=None).invoke(MagicMock())
|
||||
|
||||
assert output.collection == []
|
||||
|
||||
|
||||
def test_oklab_unsharp_mask_invocation_preserves_alpha_and_sharpens_lightness_only() -> None:
|
||||
input_image = Image.new("RGBA", (3, 1))
|
||||
input_image.putdata(
|
||||
[
|
||||
(255, 0, 0, 32),
|
||||
(0, 255, 0, 128),
|
||||
(0, 0, 255, 224),
|
||||
]
|
||||
)
|
||||
|
||||
context = _build_context(input_image)
|
||||
|
||||
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.0, strength=50.0)
|
||||
output = invocation.invoke(context)
|
||||
saved_image = context.images.save.call_args.kwargs["image"]
|
||||
|
||||
assert output.image.image_name == "out"
|
||||
assert output.width == 3
|
||||
assert output.height == 1
|
||||
assert numpy.asarray(saved_image.getchannel("A")).reshape(-1).tolist() == [32, 128, 224]
|
||||
|
||||
rgb = torch.from_numpy(numpy.asarray(input_image.convert("RGB"), dtype=numpy.float32) / 255.0).permute(2, 0, 1)
|
||||
blurred_rgb = torch.from_numpy(
|
||||
numpy.asarray(input_image.convert("RGB").filter(ImageFilter.GaussianBlur(radius=1.0)), dtype=numpy.float32)
|
||||
/ 255.0
|
||||
).permute(2, 0, 1)
|
||||
|
||||
rgb_unsharp = torch.clamp(rgb + (rgb - blurred_rgb) * 0.5, 0.0, 1.0)
|
||||
rgb_oklab = oklab_from_linear_srgb(linear_srgb_from_srgb(rgb))
|
||||
blurred_oklab = oklab_from_linear_srgb(linear_srgb_from_srgb(blurred_rgb))
|
||||
expected_oklab = rgb_oklab.clone()
|
||||
expected_oklab[0, ...] = torch.clamp(
|
||||
rgb_oklab[0, ...] + (rgb_oklab[0, ...] - blurred_oklab[0, ...]) * 0.5,
|
||||
-1.0,
|
||||
1.0,
|
||||
)
|
||||
oklab_unsharp = srgb_from_linear_srgb(linear_srgb_from_oklab(expected_oklab))
|
||||
|
||||
assert not torch.allclose(oklab_unsharp, rgb_unsharp, atol=1e-3)
|
||||
assert numpy.allclose(
|
||||
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
|
||||
oklab_unsharp.permute(1, 2, 0).numpy(),
|
||||
atol=1 / 255.0,
|
||||
)
|
||||
|
||||
|
||||
def test_oklch_hue_adjustment_invocation_preserves_alpha_and_rotates_hue_in_oklch() -> None:
|
||||
input_image = Image.new("RGBA", (2, 1))
|
||||
input_image.putdata(
|
||||
[
|
||||
(210, 80, 30, 64),
|
||||
(40, 160, 220, 192),
|
||||
]
|
||||
)
|
||||
|
||||
context = _build_context(input_image)
|
||||
|
||||
invocation = OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=180)
|
||||
output = invocation.invoke(context)
|
||||
saved_image = context.images.save.call_args.kwargs["image"]
|
||||
|
||||
rgb = torch.from_numpy(numpy.asarray(input_image.convert("RGB"), dtype=numpy.float32) / 255.0).permute(2, 0, 1)
|
||||
oklch = oklch_from_oklab(oklab_from_linear_srgb(linear_srgb_from_srgb(rgb)))
|
||||
rotated_oklch = oklch.clone()
|
||||
rotated_oklch[2, ...] = (rotated_oklch[2, ...] + 180.0) % 360.0
|
||||
expected_rgb = srgb_from_linear_srgb(linear_srgb_from_oklch(rotated_oklch))
|
||||
|
||||
assert output.image.image_name == "out"
|
||||
assert output.width == 2
|
||||
assert output.height == 1
|
||||
assert numpy.asarray(saved_image.getchannel("A")).reshape(-1).tolist() == [64, 192]
|
||||
assert numpy.allclose(
|
||||
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
|
||||
expected_rgb.permute(1, 2, 0).numpy(),
|
||||
atol=1 / 255.0,
|
||||
)
|
||||
|
||||
|
||||
def test_oklab_unsharp_mask_invocation_zero_strength_returns_original_image() -> None:
|
||||
input_image = Image.new("RGBA", (2, 2))
|
||||
input_image.putdata(
|
||||
[
|
||||
(12, 34, 56, 78),
|
||||
(90, 123, 45, 67),
|
||||
(210, 40, 80, 90),
|
||||
(255, 200, 10, 255),
|
||||
]
|
||||
)
|
||||
context = _build_context(input_image)
|
||||
|
||||
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.5, strength=0.0)
|
||||
invocation.invoke(context)
|
||||
saved_image = context.images.save.call_args.kwargs["image"]
|
||||
|
||||
assert _max_abs_diff_uint8(saved_image, input_image) <= 1
|
||||
|
||||
|
||||
def test_oklab_unsharp_mask_invocation_does_not_introduce_color_on_grayscale_image() -> None:
|
||||
input_image = Image.new("RGB", (3, 1))
|
||||
input_image.putdata([(32, 32, 32), (128, 128, 128), (224, 224, 224)])
|
||||
context = _build_context(input_image)
|
||||
|
||||
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.0, strength=80.0)
|
||||
invocation.invoke(context)
|
||||
saved_image = context.images.save.call_args.kwargs["image"]
|
||||
saved_rgb = numpy.asarray(saved_image.convert("RGB"), dtype=numpy.uint8)
|
||||
|
||||
assert numpy.abs(saved_rgb[..., 0].astype(numpy.int16) - saved_rgb[..., 1].astype(numpy.int16)).max() <= 1
|
||||
assert numpy.abs(saved_rgb[..., 1].astype(numpy.int16) - saved_rgb[..., 2].astype(numpy.int16)).max() <= 1
|
||||
|
||||
|
||||
def test_oklab_unsharp_mask_invocation_clips_extreme_values_to_valid_rgb_range() -> None:
|
||||
input_image = Image.new("RGB", (3, 1))
|
||||
input_image.putdata([(255, 255, 255), (0, 0, 0), (255, 255, 255)])
|
||||
context = _build_context(input_image)
|
||||
|
||||
invocation = OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=2.0, strength=500.0)
|
||||
invocation.invoke(context)
|
||||
saved_rgb = numpy.asarray(context.images.save.call_args.kwargs["image"].convert("RGB"), dtype=numpy.uint8)
|
||||
|
||||
assert saved_rgb.min() >= 0
|
||||
assert saved_rgb.max() <= 255
|
||||
|
||||
|
||||
def test_oklch_hue_adjustment_invocation_wraps_hue_values_and_supports_rgb_input() -> None:
|
||||
input_image = Image.new("RGB", (2, 1))
|
||||
input_image.putdata([(210, 80, 30), (40, 160, 220)])
|
||||
|
||||
base_context = _build_context(input_image)
|
||||
zero_output = OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=0).invoke(base_context)
|
||||
zero_saved = base_context.images.save.call_args.kwargs["image"]
|
||||
|
||||
full_turn_context = _build_context(input_image)
|
||||
full_turn_output = OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=360).invoke(
|
||||
full_turn_context
|
||||
)
|
||||
full_turn_saved = full_turn_context.images.save.call_args.kwargs["image"]
|
||||
|
||||
negative_context = _build_context(input_image)
|
||||
OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=-180).invoke(negative_context)
|
||||
negative_saved = negative_context.images.save.call_args.kwargs["image"]
|
||||
|
||||
positive_context = _build_context(input_image)
|
||||
OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=180).invoke(positive_context)
|
||||
positive_saved = positive_context.images.save.call_args.kwargs["image"]
|
||||
|
||||
assert zero_output.width == 2
|
||||
assert zero_output.height == 1
|
||||
assert full_turn_output.width == 2
|
||||
assert full_turn_output.height == 1
|
||||
assert _max_abs_diff_uint8(zero_saved, input_image) <= 1
|
||||
assert _max_abs_diff_uint8(full_turn_saved, input_image) <= 1
|
||||
assert _max_abs_diff_uint8(negative_saved, positive_saved) <= 1
|
||||
|
||||
|
||||
def test_new_oklab_nodes_preserve_alpha_for_non_rgba_alpha_modes() -> None:
|
||||
la_image = Image.new("LA", (2, 1))
|
||||
la_image.putdata([(32, 64), (192, 224)])
|
||||
|
||||
unsharp_context = _build_context(la_image)
|
||||
OklabUnsharpMaskInvocation(image=ImageField(image_name="in"), radius=1.0, strength=25.0).invoke(unsharp_context)
|
||||
unsharp_saved = unsharp_context.images.save.call_args.kwargs["image"]
|
||||
|
||||
hue_context = _build_context(la_image)
|
||||
OklchImageHueAdjustmentInvocation(image=ImageField(image_name="in"), hue=45).invoke(hue_context)
|
||||
hue_saved = hue_context.images.save.call_args.kwargs["image"]
|
||||
|
||||
assert unsharp_saved.mode == "LA"
|
||||
assert hue_saved.mode == "LA"
|
||||
assert numpy.asarray(unsharp_saved.getchannel("A")).reshape(-1).tolist() == [64, 224]
|
||||
assert numpy.asarray(hue_saved.getchannel("A")).reshape(-1).tolist() == [64, 224]
|
||||
|
||||
|
||||
def test_hue_adjust_plus_oklch_uses_degree_based_oklch_contract() -> None:
|
||||
input_image = Image.new("RGB", (2, 1))
|
||||
input_image.putdata([(210, 80, 30), (40, 160, 220)])
|
||||
|
||||
context = _build_context(input_image)
|
||||
invocation = InvokeAdjustImageHuePlusInvocation(
|
||||
image=ImageField(image_name="in"),
|
||||
space="*Oklch / Oklab",
|
||||
degrees=180.0,
|
||||
ok_adaptive_gamut=0.0,
|
||||
)
|
||||
|
||||
output = invocation.invoke(context)
|
||||
saved_image = context.images.save.call_args.args[0]
|
||||
|
||||
rgb = torch.from_numpy(numpy.asarray(input_image, dtype=numpy.float32) / 255.0).permute(2, 0, 1)
|
||||
oklch = oklch_from_oklab(oklab_from_linear_srgb(linear_srgb_from_srgb(rgb)))
|
||||
rotated_oklch = oklch.clone()
|
||||
rotated_oklch[2, ...] = (rotated_oklch[2, ...] + 180.0) % 360.0
|
||||
expected_rgb = srgb_from_linear_srgb(linear_srgb_from_oklch(rotated_oklch))
|
||||
|
||||
assert output.width == 2
|
||||
assert output.height == 1
|
||||
assert numpy.allclose(
|
||||
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
|
||||
expected_rgb.permute(1, 2, 0).numpy(),
|
||||
atol=1 / 255.0,
|
||||
)
|
||||
|
||||
|
||||
def test_hue_adjust_plus_hsv_uses_degree_hue_contract() -> None:
|
||||
input_image = Image.new("RGB", (2, 1))
|
||||
input_image.putdata([(210, 80, 30), (40, 160, 220)])
|
||||
|
||||
context = _build_context(input_image)
|
||||
invocation = InvokeAdjustImageHuePlusInvocation(
|
||||
image=ImageField(image_name="in"),
|
||||
space="HSV / HSL / RGB",
|
||||
degrees=90.0,
|
||||
)
|
||||
|
||||
output = invocation.invoke(context)
|
||||
saved_image = context.images.save.call_args.args[0]
|
||||
|
||||
hsv = numpy.asarray(input_image.convert("HSV"), dtype=numpy.float32) / 255.0
|
||||
hsv[..., 0] = ((hsv[..., 0] * 360.0) + 90.0) % 360.0 / 360.0
|
||||
expected_rgb = Image.fromarray((hsv * 255.0).astype(numpy.uint8), mode="HSV").convert("RGB")
|
||||
|
||||
assert output.width == 2
|
||||
assert output.height == 1
|
||||
assert _max_abs_diff_uint8(saved_image.convert("RGB"), expected_rgb) <= 1
|
||||
|
||||
|
||||
def test_hue_adjust_plus_okhsl_uses_degree_hue_contract() -> None:
|
||||
input_image = Image.new("RGB", (2, 1))
|
||||
input_image.putdata([(210, 80, 30), (40, 160, 220)])
|
||||
|
||||
context = _build_context(input_image)
|
||||
invocation = InvokeAdjustImageHuePlusInvocation(
|
||||
image=ImageField(image_name="in"),
|
||||
space="Okhsl",
|
||||
degrees=90.0,
|
||||
ok_adaptive_gamut=0.0,
|
||||
)
|
||||
|
||||
output = invocation.invoke(context)
|
||||
saved_image = context.images.save.call_args.args[0]
|
||||
|
||||
rgb = torch.from_numpy(numpy.asarray(input_image, dtype=numpy.float32) / 255.0).permute(2, 0, 1)
|
||||
okhsl = okhsl_from_srgb(rgb)
|
||||
rotated_okhsl = okhsl.clone()
|
||||
rotated_okhsl[0, ...] = (rotated_okhsl[0, ...] + 90.0) % 360.0
|
||||
expected_rgb = srgb_from_okhsl(rotated_okhsl)
|
||||
|
||||
assert output.width == 2
|
||||
assert output.height == 1
|
||||
assert numpy.allclose(
|
||||
numpy.asarray(saved_image.convert("RGB"), dtype=numpy.float32) / 255.0,
|
||||
expected_rgb.permute(1, 2, 0).numpy(),
|
||||
atol=1 / 255.0,
|
||||
)
|
||||
|
||||
|
||||
def test_image_blend_oklch_subtract_wraps_hue_in_degrees() -> None:
|
||||
invocation = InvokeImageBlendInvocation(
|
||||
layer_upper=ImageField(image_name="upper"),
|
||||
layer_base=ImageField(image_name="base"),
|
||||
blend_mode="Subtract",
|
||||
color_space="Oklch (Oklab)",
|
||||
opacity=1.0,
|
||||
adaptive_gamut=0.0,
|
||||
)
|
||||
|
||||
upper_oklch = torch.tensor([[[0.0]], [[0.0]], [[20.0]]], dtype=torch.float32)
|
||||
lower_oklch = torch.tensor([[[0.6]], [[0.18]], [[350.0]]], dtype=torch.float32)
|
||||
expected_linear_srgb = linear_srgb_from_oklch(torch.tensor([[[0.6]], [[0.18]], [[330.0]]], dtype=torch.float32))
|
||||
|
||||
blank_rgb = torch.zeros((3, 1, 1), dtype=torch.float32)
|
||||
blank_alpha = torch.ones((1, 1), dtype=torch.float32)
|
||||
image_tensors = (
|
||||
blank_rgb,
|
||||
blank_rgb,
|
||||
blank_rgb,
|
||||
blank_rgb,
|
||||
blank_alpha,
|
||||
blank_alpha,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
upper_oklch,
|
||||
lower_oklch,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
blended = invocation.apply_blend(image_tensors)
|
||||
|
||||
assert torch.allclose(blended, expected_linear_srgb, atol=1e-5)
|
||||
|
||||
|
||||
def test_image_blend_hsl_subtract_wraps_hue_in_degrees() -> None:
|
||||
invocation = InvokeImageBlendInvocation(
|
||||
layer_upper=ImageField(image_name="upper"),
|
||||
layer_base=ImageField(image_name="base"),
|
||||
blend_mode="Subtract",
|
||||
color_space="HSL (RGB)",
|
||||
opacity=1.0,
|
||||
adaptive_gamut=0.0,
|
||||
)
|
||||
|
||||
upper_hsl = torch.tensor([[[20.0]], [[0.0]], [[0.0]]], dtype=torch.float32)
|
||||
lower_hsl = torch.tensor([[[350.0]], [[1.0]], [[0.5]]], dtype=torch.float32)
|
||||
expected_linear_srgb = linear_srgb_from_srgb(
|
||||
srgb_from_hsl(torch.tensor([[[330.0]], [[1.0]], [[0.5]]], dtype=torch.float32))
|
||||
)
|
||||
|
||||
blank_rgb = torch.zeros((3, 1, 1), dtype=torch.float32)
|
||||
blank_alpha = torch.ones((1, 1), dtype=torch.float32)
|
||||
image_tensors = (
|
||||
blank_rgb,
|
||||
blank_rgb,
|
||||
blank_rgb,
|
||||
blank_rgb,
|
||||
blank_alpha,
|
||||
blank_alpha,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
upper_hsl,
|
||||
lower_hsl,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
blended = invocation.apply_blend(image_tensors)
|
||||
|
||||
assert torch.allclose(blended, expected_linear_srgb, atol=1e-5)
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import Any, Literal, Optional, Union
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TestModel(BaseModel):
|
||||
foo: Literal["bar"] = "bar"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_type, expected",
|
||||
[
|
||||
(str, False),
|
||||
(list[str], False),
|
||||
(list[dict[str, Any]], False),
|
||||
(list[None], False),
|
||||
(list[dict[str, None]], False),
|
||||
(Any, False),
|
||||
(True, False),
|
||||
(False, False),
|
||||
(Union[str, False], False),
|
||||
(Union[str, True], False),
|
||||
(None, False),
|
||||
(str | None, True),
|
||||
(Union[str, None], True),
|
||||
(Optional[str], True),
|
||||
(str | int | None, True),
|
||||
(None | str | int, True),
|
||||
(Union[None, str], True),
|
||||
(Optional[str], True),
|
||||
(Optional[int], True),
|
||||
(Optional[str], True),
|
||||
(TestModel | None, True),
|
||||
(Union[TestModel, None], True),
|
||||
(Optional[TestModel], True),
|
||||
],
|
||||
)
|
||||
def test_is_optional(input_type: Any, expected: bool) -> None:
|
||||
"""
|
||||
Test the is_optional function.
|
||||
"""
|
||||
from invokeai.app.invocations.baseinvocation import is_optional
|
||||
|
||||
result = is_optional(input_type)
|
||||
assert result == expected, f"Expected {expected} but got {result} for input type {input_type}"
|
||||
@@ -0,0 +1,101 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("noise_type", "width", "height", "expected_shape"),
|
||||
[
|
||||
("SD", 64, 64, (1, 4, 8, 8)),
|
||||
("FLUX", 64, 64, (1, 16, 8, 8)),
|
||||
("FLUX.2", 64, 64, (1, 32, 8, 8)),
|
||||
("SD3", 64, 64, (1, 16, 8, 8)),
|
||||
("CogView4", 64, 64, (1, 16, 8, 8)),
|
||||
("Z-Image", 64, 64, (1, 16, 8, 8)),
|
||||
("Anima", 64, 64, (1, 16, 1, 8, 8)),
|
||||
],
|
||||
)
|
||||
def test_noise_invocation_generates_expected_shapes(noise_type: str, width: int, height: int, expected_shape):
|
||||
from invokeai.app.invocations.noise import NoiseInvocation
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.save.return_value = "noise-name"
|
||||
|
||||
invocation = NoiseInvocation(noise_type=noise_type, width=width, height=height, seed=123)
|
||||
|
||||
output = invocation.invoke(mock_context)
|
||||
|
||||
saved_tensor = mock_context.tensors.save.call_args.kwargs["tensor"]
|
||||
assert saved_tensor.shape == expected_shape
|
||||
assert output.noise.seed == 123
|
||||
assert output.width == width
|
||||
assert output.height == height
|
||||
|
||||
|
||||
def test_noise_invocation_defaults_to_sd_shape():
|
||||
from invokeai.app.invocations.noise import NoiseInvocation
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.save.return_value = "noise-name"
|
||||
|
||||
invocation = NoiseInvocation(width=64, height=64, seed=1)
|
||||
|
||||
invocation.invoke(mock_context)
|
||||
|
||||
saved_tensor = mock_context.tensors.save.call_args.kwargs["tensor"]
|
||||
assert saved_tensor.shape == (1, 4, 8, 8)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("noise_type", "width", "height", "message"),
|
||||
[
|
||||
("SD", 66, 64, "multiple of 8"),
|
||||
("FLUX", 72, 64, "multiple of 16"),
|
||||
("FLUX.2", 64, 72, "multiple of 16"),
|
||||
("SD3", 72, 64, "multiple of 16"),
|
||||
("Z-Image", 64, 72, "multiple of 16"),
|
||||
("CogView4", 64, 80, "multiple of 32"),
|
||||
("Anima", 66, 64, "multiple of 8"),
|
||||
],
|
||||
)
|
||||
def test_noise_invocation_rejects_invalid_dimensions(noise_type: str, width: int, height: int, message: str):
|
||||
from invokeai.app.invocations.noise import NoiseInvocation
|
||||
|
||||
mock_context = MagicMock()
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
invocation = NoiseInvocation(noise_type=noise_type, width=width, height=height, seed=0)
|
||||
invocation.invoke(mock_context)
|
||||
|
||||
|
||||
def test_noise_invocation_is_deterministic_for_identical_inputs():
|
||||
from invokeai.app.invocations.noise import NoiseInvocation
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.tensors.save.side_effect = ["noise-1", "noise-2"]
|
||||
|
||||
invocation = NoiseInvocation(noise_type="FLUX", width=64, height=64, seed=7)
|
||||
|
||||
invocation.invoke(mock_context)
|
||||
first = mock_context.tensors.save.call_args_list[0].kwargs["tensor"]
|
||||
invocation.invoke(mock_context)
|
||||
second = mock_context.tensors.save.call_args_list[1].kwargs["tensor"]
|
||||
assert torch.equal(first, second)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("noise_type", "expected_shape"), [("FLUX", (1, 16, 8, 8)), ("FLUX.2", (1, 32, 8, 8))])
|
||||
def test_generate_noise_tensor_honors_use_cpu_false_for_flux_variants(noise_type: str, expected_shape):
|
||||
from invokeai.app.invocations.latent_noise import generate_noise_tensor
|
||||
|
||||
noise = generate_noise_tensor(
|
||||
noise_type=noise_type,
|
||||
width=64,
|
||||
height=64,
|
||||
seed=0,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.float32,
|
||||
use_cpu=False,
|
||||
)
|
||||
|
||||
assert noise.shape == expected_shape
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Tests for the Qwen Image denoise invocation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.invocations.qwen_image_denoise import QwenImageDenoiseInvocation
|
||||
|
||||
|
||||
class TestPrepareCfgScale:
|
||||
"""Test _prepare_cfg_scale utility method."""
|
||||
|
||||
def test_scalar_cfg_scale(self):
|
||||
inv = QwenImageDenoiseInvocation.model_construct(cfg_scale=4.0)
|
||||
result = inv._prepare_cfg_scale(5)
|
||||
assert result == [4.0, 4.0, 4.0, 4.0, 4.0]
|
||||
|
||||
def test_list_cfg_scale(self):
|
||||
inv = QwenImageDenoiseInvocation.model_construct(cfg_scale=[1.0, 2.0, 3.0])
|
||||
result = inv._prepare_cfg_scale(3)
|
||||
assert result == [1.0, 2.0, 3.0]
|
||||
|
||||
def test_list_cfg_scale_length_mismatch(self):
|
||||
inv = QwenImageDenoiseInvocation.model_construct(cfg_scale=[1.0, 2.0])
|
||||
with pytest.raises(AssertionError):
|
||||
inv._prepare_cfg_scale(3)
|
||||
|
||||
def test_invalid_cfg_scale_type(self):
|
||||
inv = QwenImageDenoiseInvocation.model_construct(cfg_scale="invalid")
|
||||
with pytest.raises(ValueError, match="Invalid CFG scale type"):
|
||||
inv._prepare_cfg_scale(3)
|
||||
|
||||
|
||||
class TestPackUnpackLatents:
|
||||
"""Test latent packing and unpacking roundtrip."""
|
||||
|
||||
def test_pack_unpack_roundtrip(self):
|
||||
"""Packing then unpacking should restore the original tensor."""
|
||||
import torch
|
||||
|
||||
latents = torch.randn(1, 16, 128, 128)
|
||||
packed = QwenImageDenoiseInvocation._pack_latents(latents, 1, 16, 128, 128)
|
||||
assert packed.shape == (1, 64 * 64, 64) # (B, H/2*W/2, C*4)
|
||||
|
||||
unpacked = QwenImageDenoiseInvocation._unpack_latents(packed, 128, 128)
|
||||
assert unpacked.shape == (1, 16, 128, 128)
|
||||
assert torch.allclose(latents, unpacked)
|
||||
|
||||
def test_pack_shape(self):
|
||||
"""Pack should produce the correct shape."""
|
||||
import torch
|
||||
|
||||
latents = torch.randn(1, 16, 140, 118)
|
||||
packed = QwenImageDenoiseInvocation._pack_latents(latents, 1, 16, 140, 118)
|
||||
assert packed.shape == (1, 70 * 59, 64)
|
||||
|
||||
def test_unpack_shape(self):
|
||||
"""Unpack should produce the correct shape."""
|
||||
import torch
|
||||
|
||||
packed = torch.randn(1, 70 * 59, 64)
|
||||
unpacked = QwenImageDenoiseInvocation._unpack_latents(packed, 140, 118)
|
||||
assert unpacked.shape == (1, 16, 140, 118)
|
||||
|
||||
|
||||
class TestAlignRefLatentDims:
|
||||
"""Test reference latent dim alignment for 2x2 packing."""
|
||||
|
||||
def test_even_dims_unchanged(self):
|
||||
assert QwenImageDenoiseInvocation._align_ref_latent_dims(96, 64) == (96, 64)
|
||||
|
||||
def test_odd_dims_trimmed_to_even(self):
|
||||
assert QwenImageDenoiseInvocation._align_ref_latent_dims(97, 65) == (96, 64)
|
||||
assert QwenImageDenoiseInvocation._align_ref_latent_dims(150, 151) == (150, 150)
|
||||
|
||||
def test_minimum_aligned_dims(self):
|
||||
assert QwenImageDenoiseInvocation._align_ref_latent_dims(2, 2) == (2, 2)
|
||||
assert QwenImageDenoiseInvocation._align_ref_latent_dims(3, 2) == (2, 2)
|
||||
|
||||
def test_raises_on_zero_dim(self):
|
||||
with pytest.raises(ValueError, match="spatial dims must be >= 2"):
|
||||
QwenImageDenoiseInvocation._align_ref_latent_dims(0, 64)
|
||||
with pytest.raises(ValueError, match="spatial dims must be >= 2"):
|
||||
QwenImageDenoiseInvocation._align_ref_latent_dims(64, 0)
|
||||
|
||||
def test_raises_on_one_dim(self):
|
||||
"""A 1-pixel latent aligns to 0 and must be rejected."""
|
||||
with pytest.raises(ValueError, match="spatial dims must be >= 2"):
|
||||
QwenImageDenoiseInvocation._align_ref_latent_dims(1, 64)
|
||||
with pytest.raises(ValueError, match="spatial dims must be >= 2"):
|
||||
QwenImageDenoiseInvocation._align_ref_latent_dims(64, 1)
|
||||
|
||||
|
||||
class TestMaybeClampRefLatentSize:
|
||||
"""Test the diffusers-style VAE_IMAGE_SIZE clamp applied to reference latents
|
||||
before packing. This is defense-in-depth for backend callers (direct API,
|
||||
older graph JSON) that wire qwen_image_i2l without explicit width/height —
|
||||
without the clamp, the transformer receives an out-of-distribution sequence
|
||||
length and VRAM usage spikes on large reference images."""
|
||||
|
||||
def test_in_budget_latent_unchanged(self):
|
||||
"""A 1024² ref image → 128x128 latent → exactly the budget. Pass through."""
|
||||
import torch
|
||||
|
||||
ref = torch.randn(1, 16, 128, 128)
|
||||
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
|
||||
assert result.shape == (1, 16, 128, 128)
|
||||
assert result is ref # identity, no copy
|
||||
|
||||
def test_small_latent_unchanged(self):
|
||||
"""A 512² ref → 64x64 latent (4x under budget). Pass through unchanged."""
|
||||
import torch
|
||||
|
||||
ref = torch.randn(1, 16, 64, 64)
|
||||
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
|
||||
assert result.shape == (1, 16, 64, 64)
|
||||
assert result is ref
|
||||
|
||||
def test_native_resolution_landscape_clamped(self):
|
||||
"""A native 1600x1200 image → 200x150 latents. Should clamp to the same
|
||||
dims diffusers produces (1184x896 pixels → 148x112 latents)."""
|
||||
import torch
|
||||
|
||||
ref = torch.randn(1, 16, 150, 200)
|
||||
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
|
||||
assert result.shape == (1, 16, 112, 148)
|
||||
|
||||
def test_native_resolution_portrait_clamped(self):
|
||||
"""1200x1600 → 150x200 latents → diffusers target 896x1184 → 112x148."""
|
||||
import torch
|
||||
|
||||
ref = torch.randn(1, 16, 200, 150)
|
||||
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
|
||||
assert result.shape == (1, 16, 148, 112)
|
||||
|
||||
def test_huge_latent_clamped(self):
|
||||
"""A 4096x4096 image → 512x512 latents (16x budget). Clamp to 128x128
|
||||
latents (= 1024² pixels), well within model's trained distribution."""
|
||||
import torch
|
||||
|
||||
ref = torch.randn(1, 16, 512, 512)
|
||||
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
|
||||
assert result.shape == (1, 16, 128, 128)
|
||||
|
||||
def test_clamp_preserves_aspect_ratio_within_rounding(self):
|
||||
"""Aspect ratio of the clamped latent should match the input to within
|
||||
the 32-pixel snapping granularity used by diffusers."""
|
||||
import torch
|
||||
|
||||
# 1920x1080 (16:9, ~2M pixels)
|
||||
ref = torch.randn(1, 16, 135, 240)
|
||||
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
|
||||
# diffusers: calculate_dimensions(1024², 16/9) → (1376, 768) px → (172, 96) latent
|
||||
assert result.shape == (1, 16, 96, 172)
|
||||
|
||||
def test_clamp_output_is_packable(self):
|
||||
"""The clamped latent must have even spatial dims (required by 2x2 packing)
|
||||
before _align_ref_latent_dims is called. Because the clamp snaps to 32px
|
||||
in pixel space and vae_scale_factor=8, every clamp output is a multiple
|
||||
of 4 in latent space (and therefore even)."""
|
||||
import torch
|
||||
|
||||
for h, w in [(150, 200), (200, 150), (135, 240), (512, 512)]:
|
||||
ref = torch.randn(1, 16, h, w)
|
||||
result = QwenImageDenoiseInvocation._maybe_clamp_ref_latent_size(ref)
|
||||
_, _, rh, rw = result.shape
|
||||
assert rh % 2 == 0, f"clamp produced odd height {rh} for input ({h},{w})"
|
||||
assert rw % 2 == 0, f"clamp produced odd width {rw} for input ({h},{w})"
|
||||
|
||||
|
||||
class TestBuildImgShapes:
|
||||
"""Test img_shapes construction. Regression test for the ghosting/doubling bug
|
||||
where ref and noisy segments shared identical spatial RoPE positions."""
|
||||
|
||||
def test_txt2img_single_segment(self):
|
||||
"""No reference latent → single segment for the noisy latent only."""
|
||||
result = QwenImageDenoiseInvocation._build_img_shapes(64, 64)
|
||||
assert result == [[(1, 32, 32)]]
|
||||
|
||||
def test_edit_uses_distinct_ref_dims(self):
|
||||
"""Edit-mode img_shapes must place ref segment at the ref's OWN dims, not
|
||||
the noisy dims. Identical dims caused the ghosting artifact."""
|
||||
noisy_h, noisy_w = 64, 64
|
||||
ref_h, ref_w = 96, 64
|
||||
result = QwenImageDenoiseInvocation._build_img_shapes(noisy_h, noisy_w, ref_h, ref_w)
|
||||
assert result == [[(1, 32, 32), (1, 48, 32)]]
|
||||
# The bug was that both segments had the same shape:
|
||||
assert result[0][0] != result[0][1]
|
||||
|
||||
def test_edit_matches_diffusers_layout(self):
|
||||
"""Structure must match diffusers QwenImageEditPipeline (single batch,
|
||||
nested list of (frame, h//2, w//2) tuples)."""
|
||||
result = QwenImageDenoiseInvocation._build_img_shapes(80, 112, 128, 96)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], list)
|
||||
assert len(result[0]) == 2
|
||||
assert result[0][0] == (1, 40, 56)
|
||||
assert result[0][1] == (1, 64, 48)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for the Qwen Image model loader invocation."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.invocations.model import ModelIdentifierField
|
||||
from invokeai.app.invocations.qwen_image_model_loader import QwenImageModelLoaderInvocation
|
||||
from invokeai.backend.model_manager.taxonomy import ModelFormat, SubModelType
|
||||
|
||||
|
||||
def _make_model_id(**kwargs) -> ModelIdentifierField:
|
||||
defaults = {"key": "test-key", "hash": "test-hash", "name": "test", "base": "qwen-image", "type": "main"}
|
||||
defaults.update(kwargs)
|
||||
return ModelIdentifierField(**defaults)
|
||||
|
||||
|
||||
def _make_mock_context(
|
||||
main_format: ModelFormat = ModelFormat.Diffusers, source_format: ModelFormat = ModelFormat.Diffusers
|
||||
):
|
||||
"""Create a mock InvocationContext that returns configs with the given formats."""
|
||||
context = MagicMock()
|
||||
|
||||
def get_config(model_id):
|
||||
config = MagicMock()
|
||||
if model_id.key == "main-key":
|
||||
config.format = main_format
|
||||
config.name = "Main Model"
|
||||
elif model_id.key == "source-key":
|
||||
config.format = source_format
|
||||
config.name = "Source Model"
|
||||
return config
|
||||
|
||||
context.models.get_config = get_config
|
||||
context.models.exists = MagicMock(return_value=True)
|
||||
return context
|
||||
|
||||
|
||||
class TestDiffusersModel:
|
||||
"""Tests for loading a Diffusers-format Qwen Image model."""
|
||||
|
||||
def test_diffusers_model_extracts_all_components(self):
|
||||
"""A Diffusers model should extract transformer, VAE, tokenizer, and text encoder from itself."""
|
||||
model_id = _make_model_id(key="main-key")
|
||||
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=None)
|
||||
context = _make_mock_context(main_format=ModelFormat.Diffusers)
|
||||
|
||||
result = inv.invoke(context)
|
||||
|
||||
assert result.transformer.transformer.submodel_type == SubModelType.Transformer
|
||||
assert result.vae.vae.submodel_type == SubModelType.VAE
|
||||
assert result.qwen_vl_encoder.tokenizer.submodel_type == SubModelType.Tokenizer
|
||||
assert result.qwen_vl_encoder.text_encoder.submodel_type == SubModelType.TextEncoder
|
||||
|
||||
# All should reference the main model key
|
||||
assert result.transformer.transformer.key == "main-key"
|
||||
assert result.vae.vae.key == "main-key"
|
||||
assert result.qwen_vl_encoder.tokenizer.key == "main-key"
|
||||
assert result.qwen_vl_encoder.text_encoder.key == "main-key"
|
||||
|
||||
def test_diffusers_model_ignores_component_source(self):
|
||||
"""A Diffusers model should ignore the component_source even if provided."""
|
||||
model_id = _make_model_id(key="main-key")
|
||||
source_id = _make_model_id(key="source-key")
|
||||
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=source_id)
|
||||
context = _make_mock_context(main_format=ModelFormat.Diffusers)
|
||||
|
||||
result = inv.invoke(context)
|
||||
|
||||
# All components should come from main, not source
|
||||
assert result.vae.vae.key == "main-key"
|
||||
assert result.qwen_vl_encoder.tokenizer.key == "main-key"
|
||||
|
||||
|
||||
class TestGGUFModel:
|
||||
"""Tests for loading a GGUF-format Qwen Image model."""
|
||||
|
||||
def test_gguf_with_component_source_succeeds(self):
|
||||
"""A GGUF model with a Diffusers component source should load successfully."""
|
||||
model_id = _make_model_id(key="main-key")
|
||||
source_id = _make_model_id(key="source-key")
|
||||
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=source_id)
|
||||
context = _make_mock_context(main_format=ModelFormat.GGUFQuantized, source_format=ModelFormat.Diffusers)
|
||||
|
||||
result = inv.invoke(context)
|
||||
|
||||
# Transformer from main model
|
||||
assert result.transformer.transformer.key == "main-key"
|
||||
assert result.transformer.transformer.submodel_type == SubModelType.Transformer
|
||||
|
||||
# VAE and encoder from component source
|
||||
assert result.vae.vae.key == "source-key"
|
||||
assert result.qwen_vl_encoder.tokenizer.key == "source-key"
|
||||
assert result.qwen_vl_encoder.text_encoder.key == "source-key"
|
||||
|
||||
def test_gguf_without_component_source_raises(self):
|
||||
"""A GGUF model without a component source should raise ValueError."""
|
||||
model_id = _make_model_id(key="main-key")
|
||||
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=None)
|
||||
context = _make_mock_context(main_format=ModelFormat.GGUFQuantized)
|
||||
|
||||
with pytest.raises(ValueError, match="No source for VAE"):
|
||||
inv.invoke(context)
|
||||
|
||||
def test_gguf_with_non_diffusers_source_raises(self):
|
||||
"""A GGUF model with a non-Diffusers component source should raise ValueError."""
|
||||
model_id = _make_model_id(key="main-key")
|
||||
source_id = _make_model_id(key="source-key")
|
||||
inv = QwenImageModelLoaderInvocation.model_construct(model=model_id, component_source=source_id)
|
||||
context = _make_mock_context(main_format=ModelFormat.GGUFQuantized, source_format=ModelFormat.GGUFQuantized)
|
||||
|
||||
with pytest.raises(ValueError, match="Component Source model must be in Diffusers format"):
|
||||
inv.invoke(context)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for the Qwen Image text encoder prompt building and image resizing."""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.invocations.qwen_image_text_encoder import (
|
||||
QwenImageTextEncoderInvocation,
|
||||
_build_prompt,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildPrompt:
|
||||
"""Test the _build_prompt function for edit vs generate modes."""
|
||||
|
||||
def test_no_images_uses_generate_template(self):
|
||||
"""With 0 images, should use the generate (txt2img) template with no vision placeholder."""
|
||||
prompt = _build_prompt("a beautiful sunset", 0)
|
||||
assert "a beautiful sunset" in prompt
|
||||
assert "<|im_start|>assistant" in prompt
|
||||
# Generate mode: no vision placeholders, uses the "describe the image" system prompt
|
||||
assert "<|vision_start|>" not in prompt
|
||||
assert "Describe the image by detailing" in prompt
|
||||
|
||||
def test_no_images_does_not_use_edit_template(self):
|
||||
"""With 0 images, should NOT use the edit system prompt."""
|
||||
prompt = _build_prompt("a beautiful sunset", 0)
|
||||
assert "Describe the key features of the input image" not in prompt
|
||||
|
||||
def test_edit_mode_one_image(self):
|
||||
"""With 1 image, should use the edit template with one vision placeholder."""
|
||||
prompt = _build_prompt("change hair to red", 1)
|
||||
assert "Describe the key features of the input image" in prompt
|
||||
assert prompt.count("<|vision_start|><|image_pad|><|vision_end|>") == 1
|
||||
assert "change hair to red" in prompt
|
||||
# Should NOT use the generate system prompt
|
||||
assert "Describe the image by detailing" not in prompt
|
||||
|
||||
def test_edit_mode_multiple_images(self):
|
||||
"""With multiple images, should include one placeholder per image."""
|
||||
prompt = _build_prompt("combine these images", 3)
|
||||
assert prompt.count("<|vision_start|><|image_pad|><|vision_end|>") == 3
|
||||
assert "combine these images" in prompt
|
||||
|
||||
def test_generate_template_has_correct_structure(self):
|
||||
"""Generate template should have system + user + assistant roles."""
|
||||
prompt = _build_prompt("test prompt", 0)
|
||||
assert prompt.startswith("<|im_start|>system\n")
|
||||
assert "<|im_end|>\n<|im_start|>user\n" in prompt
|
||||
assert prompt.endswith("<|im_start|>assistant\n")
|
||||
|
||||
def test_edit_template_has_correct_structure(self):
|
||||
"""Edit template should have system + user (with image) + assistant roles."""
|
||||
prompt = _build_prompt("test prompt", 1)
|
||||
assert prompt.startswith("<|im_start|>system\n")
|
||||
assert "<|im_end|>\n<|im_start|>user\n" in prompt
|
||||
assert "<|vision_start|>" in prompt
|
||||
assert prompt.endswith("<|im_start|>assistant\n")
|
||||
|
||||
def test_prompt_special_characters(self):
|
||||
"""Prompt with special characters should be included verbatim."""
|
||||
prompt = _build_prompt("add {curly} braces & <angle> brackets", 0)
|
||||
assert "add {curly} braces & <angle> brackets" in prompt
|
||||
|
||||
|
||||
class TestResizeForVLEncoder:
|
||||
"""Test the image resizing logic for the VL encoder."""
|
||||
|
||||
def test_large_image_is_resized(self):
|
||||
"""A large image should be resized to ~target_pixels."""
|
||||
img = Image.new("RGB", (2048, 2048))
|
||||
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
|
||||
w, h = resized.size
|
||||
# Should be much smaller than original
|
||||
assert w < 2048
|
||||
assert h < 2048
|
||||
# Total pixels should be approximately target
|
||||
assert abs(w * h - 512 * 512) < 10000 # within ~10k pixels
|
||||
|
||||
def test_small_image_is_resized(self):
|
||||
"""A small image should also be resized to ~target_pixels."""
|
||||
img = Image.new("RGB", (64, 64))
|
||||
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
|
||||
w, h = resized.size
|
||||
# Should be larger than original
|
||||
assert w > 64
|
||||
assert h > 64
|
||||
|
||||
def test_aspect_ratio_preserved(self):
|
||||
"""Aspect ratio should be approximately preserved."""
|
||||
img = Image.new("RGB", (800, 400)) # 2:1 aspect ratio
|
||||
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
|
||||
w, h = resized.size
|
||||
original_ratio = 800 / 400 # 2.0
|
||||
new_ratio = w / h
|
||||
# Allow some deviation due to rounding to multiples of 32
|
||||
assert abs(new_ratio - original_ratio) < 0.3
|
||||
|
||||
def test_dimensions_are_multiples_of_32(self):
|
||||
"""Output dimensions should be multiples of 32."""
|
||||
img = Image.new("RGB", (1000, 750))
|
||||
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
|
||||
w, h = resized.size
|
||||
assert w % 32 == 0
|
||||
assert h % 32 == 0
|
||||
|
||||
def test_square_image(self):
|
||||
"""A square image should produce approximately square output."""
|
||||
img = Image.new("RGB", (1024, 1024))
|
||||
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
|
||||
w, h = resized.size
|
||||
assert abs(w - h) <= 32 # within one grid step
|
||||
|
||||
def test_portrait_image(self):
|
||||
"""A portrait image should produce portrait output."""
|
||||
img = Image.new("RGB", (600, 1200))
|
||||
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
|
||||
w, h = resized.size
|
||||
assert h > w # should remain portrait
|
||||
|
||||
def test_landscape_image(self):
|
||||
"""A landscape image should produce landscape output."""
|
||||
img = Image.new("RGB", (1200, 600))
|
||||
resized = QwenImageTextEncoderInvocation._resize_for_vl_encoder(img, target_pixels=512 * 512)
|
||||
w, h = resized.size
|
||||
assert w > h # should remain landscape
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Test that Qwen Image VAE invocations properly estimate and request working memory."""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage
|
||||
|
||||
from invokeai.app.invocations.qwen_image_image_to_latents import QwenImageImageToLatentsInvocation
|
||||
from invokeai.app.invocations.qwen_image_latents_to_image import QwenImageLatentsToImageInvocation
|
||||
from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_qwen_image
|
||||
|
||||
|
||||
class TestQwenImageWorkingMemoryEstimate:
|
||||
"""Lock in the per-backend scaling constants calibrated in scripts/calibrate_qwen_vae_working_memory.py.
|
||||
|
||||
These differ by backend because the Qwen VAE is attention-heavy: ROCm falls back to math attention
|
||||
(O(area^2), much higher memory) while CUDA uses Flash/efficient attention. A regression that swaps
|
||||
the constants would reintroduce the ROCm OOM (under-estimate) or needlessly over-budget CUDA.
|
||||
"""
|
||||
|
||||
# (operation, latent_h, latent_w) -> the estimator scales pixel area (latent * 8 for decode,
|
||||
# raw for encode) by element_size and the constant.
|
||||
@pytest.mark.parametrize(
|
||||
"operation, is_rocm, expected_constant",
|
||||
[
|
||||
("decode", True, 5500),
|
||||
("decode", False, 2900),
|
||||
("encode", True, 6300),
|
||||
("encode", False, 1600),
|
||||
],
|
||||
)
|
||||
def test_constant_selected_per_backend(self, operation, is_rocm, expected_constant):
|
||||
mock_vae = MagicMock(spec=AutoencoderKLQwenImage)
|
||||
mock_vae.parameters.return_value = iter([torch.zeros(1, dtype=torch.float16)]) # element_size == 2
|
||||
|
||||
# decode receives latents (pixel area = latent area * 8^2); encode receives a pixel image.
|
||||
if operation == "decode":
|
||||
image_tensor = torch.zeros(1, 16, 1, 64, 64)
|
||||
h = w = 64 * 8
|
||||
else:
|
||||
image_tensor = torch.zeros(1, 3, 1, 512, 512)
|
||||
h = w = 512
|
||||
|
||||
hip_value = "7.1.0" if is_rocm else None
|
||||
with patch("torch.version.hip", hip_value):
|
||||
result = estimate_vae_working_memory_qwen_image(
|
||||
operation=operation, image_tensor=image_tensor, vae=mock_vae
|
||||
)
|
||||
|
||||
assert result == h * w * 2 * expected_constant
|
||||
|
||||
|
||||
class TestQwenImageWorkingMemory:
|
||||
"""Test that Qwen Image VAE invocations request working memory before decode/encode."""
|
||||
|
||||
def _mock_vae_info(self):
|
||||
"""Build a mocked AutoencoderKLQwenImage and its LoadedModel wrapper."""
|
||||
mock_vae = MagicMock(spec=AutoencoderKLQwenImage)
|
||||
|
||||
# Create mock parameter for dtype detection
|
||||
mock_param = torch.zeros(1)
|
||||
mock_vae.parameters.return_value = iter([mock_param])
|
||||
|
||||
# Create mock vae_info with a model_on_device context manager yielding (None, vae)
|
||||
mock_vae_info = MagicMock()
|
||||
mock_vae_info.model = mock_vae
|
||||
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__ = MagicMock(return_value=(None, mock_vae))
|
||||
mock_cm.__exit__ = MagicMock(return_value=None)
|
||||
mock_vae_info.model_on_device = MagicMock(return_value=mock_cm)
|
||||
|
||||
return mock_vae, mock_vae_info
|
||||
|
||||
def test_qwen_latents_to_image_requests_working_memory(self):
|
||||
"""QwenImageLatentsToImageInvocation estimates decode memory and passes it to the cache."""
|
||||
mock_vae, mock_vae_info = self._mock_vae_info()
|
||||
|
||||
# Mock the context
|
||||
mock_context = MagicMock()
|
||||
mock_context.models.load.return_value = mock_vae_info
|
||||
|
||||
# Mock latents (5D: B, C, num_frames, H, W)
|
||||
mock_latents = torch.zeros(1, 16, 1, 64, 64)
|
||||
mock_context.tensors.load.return_value = mock_latents
|
||||
|
||||
estimation_path = "invokeai.app.invocations.qwen_image_latents_to_image.estimate_vae_working_memory_qwen_image"
|
||||
seamless_path = "invokeai.app.invocations.qwen_image_latents_to_image.SeamlessExt.static_patch_model"
|
||||
|
||||
with (
|
||||
patch(estimation_path) as mock_estimate,
|
||||
patch(seamless_path, return_value=nullcontext()),
|
||||
):
|
||||
expected_memory = 1024 * 1024 * 10000 # 10GB
|
||||
mock_estimate.return_value = expected_memory
|
||||
|
||||
invocation = QwenImageLatentsToImageInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="test_latents"),
|
||||
vae=MagicMock(vae=MagicMock(), seamless_axes=["x", "y"]),
|
||||
)
|
||||
|
||||
try:
|
||||
invocation.invoke(mock_context)
|
||||
except Exception:
|
||||
# Downstream decode math fails under mocking; we only care that the cache was
|
||||
# asked to reserve the estimated working memory before entering the device context.
|
||||
pass
|
||||
|
||||
mock_estimate.assert_called_once()
|
||||
assert mock_estimate.call_args.kwargs["operation"] == "decode"
|
||||
mock_vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
|
||||
|
||||
def test_qwen_image_to_latents_requests_working_memory(self):
|
||||
"""QwenImageImageToLatentsInvocation estimates encode memory and passes it to the cache."""
|
||||
mock_vae, mock_vae_info = self._mock_vae_info()
|
||||
|
||||
mock_image_tensor = torch.zeros(1, 3, 512, 512)
|
||||
|
||||
estimation_path = "invokeai.app.invocations.qwen_image_image_to_latents.estimate_vae_working_memory_qwen_image"
|
||||
|
||||
with patch(estimation_path) as mock_estimate:
|
||||
expected_memory = 1024 * 1024 * 5000 # 5GB
|
||||
mock_estimate.return_value = expected_memory
|
||||
|
||||
try:
|
||||
QwenImageImageToLatentsInvocation.vae_encode(mock_vae_info, mock_image_tensor)
|
||||
except Exception:
|
||||
# Downstream encode math fails under mocking; we only care that the cache was
|
||||
# asked to reserve the estimated working memory before entering the device context.
|
||||
pass
|
||||
|
||||
mock_estimate.assert_called_once()
|
||||
assert mock_estimate.call_args.kwargs["operation"] == "encode"
|
||||
mock_vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Tests for SaveImageToFileInvocation."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from pydantic import ValidationError
|
||||
|
||||
from invokeai.app.invocations.image import SaveImageToFileInvocation
|
||||
|
||||
|
||||
def _make_context(tmp_path: Path, pil_image: Image.Image, gallery_uuid: str = "abc123") -> MagicMock:
|
||||
context = MagicMock()
|
||||
context.config.get.return_value.outputs_path = tmp_path
|
||||
context.images.get_pil.return_value = pil_image
|
||||
image_dto = MagicMock()
|
||||
image_dto.image_name = f"{gallery_uuid}.png"
|
||||
image_dto.width = pil_image.width
|
||||
image_dto.height = pil_image.height
|
||||
context.images.save.return_value = image_dto
|
||||
return context
|
||||
|
||||
|
||||
def _build_node(**overrides) -> SaveImageToFileInvocation:
|
||||
defaults = {
|
||||
"id": "test",
|
||||
"image": {"image_name": "input.png"},
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return SaveImageToFileInvocation(**defaults)
|
||||
|
||||
|
||||
class TestSaveImageToFileInvocation:
|
||||
def test_saves_to_gallery(self, tmp_path):
|
||||
img = Image.new("RGB", (8, 8), (255, 0, 0))
|
||||
ctx = _make_context(tmp_path, img)
|
||||
node = _build_node()
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
ctx.images.save.assert_called_once()
|
||||
assert ctx.images.save.call_args.kwargs["image"] is img
|
||||
|
||||
def test_default_directory_is_outputs_root(self, tmp_path):
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="uuid-1")
|
||||
node = _build_node()
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
assert (tmp_path / "uuid-1.png").exists()
|
||||
|
||||
def test_relative_subdirectory_created(self, tmp_path):
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="uuid-2")
|
||||
node = _build_node(output_directory="my-exports")
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
assert (tmp_path / "my-exports" / "uuid-2.png").exists()
|
||||
|
||||
def test_nested_relative_path(self, tmp_path):
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="uuid-3")
|
||||
node = _build_node(output_directory="exports/2026/hero")
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
assert (tmp_path / "exports" / "2026" / "hero" / "uuid-3.png").exists()
|
||||
|
||||
def test_prefix_and_suffix_applied(self, tmp_path):
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="xyz")
|
||||
node = _build_node(prefix="hero_", suffix="_final")
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
assert (tmp_path / "hero_xyz_final.png").exists()
|
||||
|
||||
def test_prefix_only(self, tmp_path):
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="u")
|
||||
node = _build_node(prefix="p_")
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
assert (tmp_path / "p_u.png").exists()
|
||||
|
||||
def test_suffix_only(self, tmp_path):
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="u")
|
||||
node = _build_node(suffix="_s")
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
assert (tmp_path / "u_s.png").exists()
|
||||
|
||||
def test_filename_uses_gallery_uuid_not_input_uuid(self, tmp_path):
|
||||
"""The exported filename must use the UUID from the new gallery entry,
|
||||
not the UUID of the input image_name."""
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="new-uuid")
|
||||
node = _build_node(image={"image_name": "old-uuid.png"})
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
assert (tmp_path / "new-uuid.png").exists()
|
||||
assert not (tmp_path / "old-uuid.png").exists()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
[
|
||||
"D:/Pictures/Invoke",
|
||||
"C:/Windows",
|
||||
"/etc/passwd",
|
||||
"/tmp/foo",
|
||||
],
|
||||
)
|
||||
def test_absolute_paths_rejected(self, tmp_path, bad_path):
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img)
|
||||
node = _build_node(output_directory=bad_path)
|
||||
|
||||
with pytest.raises(ValueError, match="[Aa]bsolute"):
|
||||
node.invoke(ctx)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"traversal_path",
|
||||
[
|
||||
"../outside",
|
||||
"subdir/../../outside",
|
||||
"..",
|
||||
],
|
||||
)
|
||||
def test_path_traversal_rejected(self, tmp_path, traversal_path):
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img)
|
||||
node = _build_node(output_directory=traversal_path)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
node.invoke(ctx)
|
||||
|
||||
def test_png_format(self, tmp_path):
|
||||
img = Image.new("RGBA", (8, 8), (10, 20, 30, 128))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="u")
|
||||
node = _build_node(file_format="png")
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
path = tmp_path / "u.png"
|
||||
assert path.exists()
|
||||
with Image.open(path) as saved:
|
||||
assert saved.format == "PNG"
|
||||
assert saved.mode == "RGBA"
|
||||
|
||||
def test_jpg_format_converts_rgba_to_rgb(self, tmp_path):
|
||||
img = Image.new("RGBA", (8, 8), (10, 20, 30, 128))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="u")
|
||||
node = _build_node(file_format="jpg", quality=80)
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
path = tmp_path / "u.jpg"
|
||||
assert path.exists()
|
||||
with Image.open(path) as saved:
|
||||
assert saved.format == "JPEG"
|
||||
assert saved.mode == "RGB"
|
||||
|
||||
def test_webp_format(self, tmp_path):
|
||||
img = Image.new("RGB", (8, 8))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="u")
|
||||
node = _build_node(file_format="webp", quality=75)
|
||||
|
||||
node.invoke(ctx)
|
||||
|
||||
path = tmp_path / "u.webp"
|
||||
assert path.exists()
|
||||
with Image.open(path) as saved:
|
||||
assert saved.format == "WEBP"
|
||||
|
||||
def test_quality_bounds_enforced_by_pydantic(self):
|
||||
with pytest.raises(ValidationError):
|
||||
_build_node(quality=0)
|
||||
with pytest.raises(ValidationError):
|
||||
_build_node(quality=101)
|
||||
|
||||
def test_output_is_pass_through_of_gallery_dto(self, tmp_path):
|
||||
img = Image.new("RGB", (16, 24))
|
||||
ctx = _make_context(tmp_path, img, gallery_uuid="uuid-out")
|
||||
node = _build_node()
|
||||
|
||||
result = node.invoke(ctx)
|
||||
|
||||
assert result.image.image_name == "uuid-out.png"
|
||||
assert result.width == 16
|
||||
assert result.height == 24
|
||||
@@ -0,0 +1,153 @@
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import torch
|
||||
|
||||
from invokeai.app.invocations.sd3_text_encoder import Sd3TextEncoderInvocation
|
||||
from invokeai.backend.model_manager.taxonomy import ModelFormat
|
||||
|
||||
|
||||
class FakeSd3ClipTextEncoder(torch.nn.Module):
|
||||
def __init__(self, effective_device: torch.device):
|
||||
super().__init__()
|
||||
self.register_parameter("cpu_param", torch.nn.Parameter(torch.ones(1)))
|
||||
self.register_buffer("active_buffer", torch.ones(1, device=effective_device))
|
||||
self.dtype = torch.float32
|
||||
self.forward_input_device: torch.device | None = None
|
||||
|
||||
@property
|
||||
def device(self) -> torch.device:
|
||||
return torch.device("cpu")
|
||||
|
||||
def forward(self, input_ids: torch.Tensor, output_hidden_states: bool = False):
|
||||
assert output_hidden_states
|
||||
self.forward_input_device = input_ids.device
|
||||
hidden = input_ids.unsqueeze(-1).float()
|
||||
return SimpleNamespace(hidden_states=[hidden, hidden + 1], __getitem__=lambda self, idx: hidden)
|
||||
|
||||
|
||||
class FakeClipOutput(SimpleNamespace):
|
||||
def __getitem__(self, idx):
|
||||
del idx
|
||||
return self.hidden_states[-1]
|
||||
|
||||
|
||||
class FakeClipTokenizer:
|
||||
def __call__(self, prompt, padding, max_length=None, truncation=None, return_tensors=None):
|
||||
del prompt, padding, max_length, truncation, return_tensors
|
||||
return SimpleNamespace(input_ids=torch.tensor([[1, 2, 3]], dtype=torch.long))
|
||||
|
||||
def batch_decode(self, input_ids):
|
||||
del input_ids
|
||||
return ["decoded"]
|
||||
|
||||
|
||||
class FakeSd3T5Encoder(torch.nn.Module):
|
||||
def __init__(self, effective_device: torch.device):
|
||||
super().__init__()
|
||||
self.register_parameter("cpu_param", torch.nn.Parameter(torch.ones(1)))
|
||||
self.register_buffer("active_buffer", torch.ones(1, device=effective_device))
|
||||
self.forward_input_device: torch.device | None = None
|
||||
|
||||
@property
|
||||
def device(self) -> torch.device:
|
||||
return torch.device("cpu")
|
||||
|
||||
def forward(self, input_ids: torch.Tensor):
|
||||
self.forward_input_device = input_ids.device
|
||||
hidden = input_ids.unsqueeze(-1).float()
|
||||
return (hidden,)
|
||||
|
||||
|
||||
class FakeT5Tokenizer:
|
||||
def __call__(self, prompt, padding, max_length=None, truncation=None, add_special_tokens=None, return_tensors=None):
|
||||
del prompt, padding, max_length, truncation, add_special_tokens, return_tensors
|
||||
return SimpleNamespace(input_ids=torch.tensor([[1, 2, 3]], dtype=torch.long))
|
||||
|
||||
def batch_decode(self, input_ids):
|
||||
del input_ids
|
||||
return ["decoded"]
|
||||
|
||||
|
||||
class FakeLoadedModel:
|
||||
def __init__(self, model, config=None):
|
||||
self._model = model
|
||||
self.config = config
|
||||
|
||||
@contextmanager
|
||||
def model_on_device(self):
|
||||
yield (None, self._model)
|
||||
|
||||
def __enter__(self):
|
||||
return self._model
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
def test_sd3_clip_encode_uses_effective_device(monkeypatch):
|
||||
module_path = "invokeai.app.invocations.sd3_text_encoder"
|
||||
effective_device = torch.device("meta")
|
||||
text_encoder = FakeSd3ClipTextEncoder(effective_device)
|
||||
tokenizer = FakeClipTokenizer()
|
||||
|
||||
def forward(input_ids: torch.Tensor, output_hidden_states: bool = False):
|
||||
assert output_hidden_states
|
||||
text_encoder.forward_input_device = input_ids.device
|
||||
hidden = input_ids.unsqueeze(-1).float()
|
||||
return FakeClipOutput(hidden_states=[hidden, hidden + 1])
|
||||
|
||||
text_encoder.forward = forward # type: ignore[method-assign]
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.models.load.side_effect = [
|
||||
FakeLoadedModel(text_encoder, config=SimpleNamespace(format=ModelFormat.Diffusers)),
|
||||
FakeLoadedModel(tokenizer),
|
||||
]
|
||||
mock_context.util.signal_progress = MagicMock()
|
||||
|
||||
monkeypatch.setattr(f"{module_path}.CLIPTextModel", FakeSd3ClipTextEncoder)
|
||||
monkeypatch.setattr(f"{module_path}.CLIPTextModelWithProjection", FakeSd3ClipTextEncoder)
|
||||
monkeypatch.setattr(f"{module_path}.CLIPTokenizer", FakeClipTokenizer)
|
||||
monkeypatch.setattr(f"{module_path}.LayerPatcher.apply_smart_model_patches", lambda **kwargs: nullcontext())
|
||||
|
||||
invocation = Sd3TextEncoderInvocation.model_construct(
|
||||
clip_l=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
|
||||
clip_g=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
|
||||
t5_encoder=None,
|
||||
prompt="test prompt",
|
||||
)
|
||||
|
||||
invocation._clip_encode(
|
||||
context=mock_context,
|
||||
clip_model=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
|
||||
)
|
||||
|
||||
assert text_encoder.forward_input_device == effective_device
|
||||
|
||||
|
||||
def test_sd3_t5_encode_uses_effective_device(monkeypatch):
|
||||
module_path = "invokeai.app.invocations.sd3_text_encoder"
|
||||
effective_device = torch.device("meta")
|
||||
text_encoder = FakeSd3T5Encoder(effective_device)
|
||||
tokenizer = FakeT5Tokenizer()
|
||||
|
||||
mock_context = MagicMock()
|
||||
mock_context.models.load.side_effect = [FakeLoadedModel(text_encoder), FakeLoadedModel(tokenizer)]
|
||||
mock_context.util.signal_progress = MagicMock()
|
||||
mock_context.logger.warning = MagicMock()
|
||||
|
||||
monkeypatch.setattr(f"{module_path}.T5EncoderModel", FakeSd3T5Encoder)
|
||||
monkeypatch.setattr(f"{module_path}.T5Tokenizer", FakeT5Tokenizer)
|
||||
|
||||
invocation = Sd3TextEncoderInvocation.model_construct(
|
||||
clip_l=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
|
||||
clip_g=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace(), loras=[]),
|
||||
t5_encoder=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace()),
|
||||
prompt="test prompt",
|
||||
)
|
||||
|
||||
invocation._t5_encode(mock_context, max_seq_len=16)
|
||||
|
||||
assert text_encoder.forward_input_device == effective_device
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Test that Z-Image VAE invocations properly estimate and request working memory."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL
|
||||
|
||||
from invokeai.app.invocations.z_image_image_to_latents import ZImageImageToLatentsInvocation
|
||||
from invokeai.backend.flux.modules.autoencoder import AutoEncoder as FluxAutoEncoder
|
||||
|
||||
|
||||
class TestZImageWorkingMemory:
|
||||
"""Test that Z-Image VAE invocations request working memory."""
|
||||
|
||||
@pytest.mark.parametrize("vae_type", [AutoencoderKL, FluxAutoEncoder])
|
||||
def test_z_image_latents_to_image_requests_working_memory(self, vae_type):
|
||||
"""Test that ZImageLatentsToImageInvocation estimates and requests working memory."""
|
||||
# Create mock VAE
|
||||
mock_vae = MagicMock(spec=vae_type)
|
||||
|
||||
# Only set config for AutoencoderKL (FluxAutoEncoder doesn't use config)
|
||||
if vae_type == AutoencoderKL:
|
||||
mock_vae.config.scaling_factor = 1.0
|
||||
mock_vae.config.shift_factor = None
|
||||
|
||||
# Create mock parameter for dtype detection
|
||||
mock_param = torch.zeros(1)
|
||||
mock_vae.parameters.return_value = iter([mock_param])
|
||||
|
||||
# Create mock vae_info
|
||||
mock_vae_info = MagicMock()
|
||||
mock_vae_info.model = mock_vae
|
||||
|
||||
# Create mock context manager return value
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__ = MagicMock(return_value=(None, mock_vae))
|
||||
mock_cm.__exit__ = MagicMock(return_value=None)
|
||||
mock_vae_info.model_on_device = MagicMock(return_value=mock_cm)
|
||||
|
||||
# Mock the context
|
||||
mock_context = MagicMock()
|
||||
mock_context.models.load.return_value = mock_vae_info
|
||||
|
||||
# Mock latents
|
||||
mock_latents = torch.zeros(1, 16, 64, 64)
|
||||
mock_context.tensors.load.return_value = mock_latents
|
||||
|
||||
estimation_path = "invokeai.app.invocations.z_image_latents_to_image.estimate_vae_working_memory_flux"
|
||||
|
||||
with patch(estimation_path) as mock_estimate:
|
||||
expected_memory = 1024 * 1024 * 500 # 500MB
|
||||
mock_estimate.return_value = expected_memory
|
||||
|
||||
# Mock VAE decode to avoid actual computation
|
||||
if vae_type == FluxAutoEncoder:
|
||||
mock_vae.decode.return_value = torch.zeros(1, 3, 512, 512)
|
||||
else:
|
||||
mock_vae.decode.return_value = (torch.zeros(1, 3, 512, 512),)
|
||||
|
||||
# Mock image save
|
||||
mock_image_dto = MagicMock()
|
||||
mock_context.images.save.return_value = mock_image_dto
|
||||
|
||||
# Import and create invocation using model_construct to bypass validation
|
||||
from invokeai.app.invocations.z_image_latents_to_image import ZImageLatentsToImageInvocation
|
||||
|
||||
invocation = ZImageLatentsToImageInvocation.model_construct(
|
||||
latents=MagicMock(latents_name="test_latents"),
|
||||
vae=MagicMock(vae=MagicMock(), seamless_axes=["x", "y"]),
|
||||
)
|
||||
|
||||
try:
|
||||
invocation.invoke(mock_context)
|
||||
except Exception:
|
||||
# We expect some errors due to mocking, but we just want to verify the working memory was requested
|
||||
pass
|
||||
|
||||
# Verify that working memory estimation was called
|
||||
mock_estimate.assert_called_once()
|
||||
# Verify that model_on_device was called with the estimated working memory
|
||||
mock_vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
|
||||
|
||||
@pytest.mark.parametrize("vae_type", [AutoencoderKL, FluxAutoEncoder])
|
||||
def test_z_image_image_to_latents_requests_working_memory(self, vae_type):
|
||||
"""Test that ZImageImageToLatentsInvocation estimates and requests working memory."""
|
||||
# Create mock VAE
|
||||
mock_vae = MagicMock(spec=vae_type)
|
||||
|
||||
# Only set config for AutoencoderKL (FluxAutoEncoder doesn't use config)
|
||||
if vae_type == AutoencoderKL:
|
||||
mock_vae.config.scaling_factor = 1.0
|
||||
mock_vae.config.shift_factor = None
|
||||
|
||||
# Create mock parameter for dtype detection
|
||||
mock_param = torch.zeros(1)
|
||||
mock_vae.parameters.return_value = iter([mock_param])
|
||||
|
||||
# Create mock vae_info
|
||||
mock_vae_info = MagicMock()
|
||||
mock_vae_info.model = mock_vae
|
||||
|
||||
# Create mock context manager return value
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__ = MagicMock(return_value=(None, mock_vae))
|
||||
mock_cm.__exit__ = MagicMock(return_value=None)
|
||||
mock_vae_info.model_on_device = MagicMock(return_value=mock_cm)
|
||||
|
||||
# Mock image tensor
|
||||
mock_image_tensor = torch.zeros(1, 3, 512, 512)
|
||||
|
||||
# Mock the estimation function
|
||||
estimation_path = "invokeai.app.invocations.z_image_image_to_latents.estimate_vae_working_memory_flux"
|
||||
|
||||
with patch(estimation_path) as mock_estimate:
|
||||
expected_memory = 1024 * 1024 * 250 # 250MB
|
||||
mock_estimate.return_value = expected_memory
|
||||
|
||||
# Mock VAE encode to avoid actual computation
|
||||
if vae_type == FluxAutoEncoder:
|
||||
mock_vae.encode.return_value = torch.zeros(1, 16, 64, 64)
|
||||
else:
|
||||
mock_latent_dist = MagicMock()
|
||||
mock_latent_dist.sample.return_value = torch.zeros(1, 16, 64, 64)
|
||||
mock_encode_result = MagicMock()
|
||||
mock_encode_result.latent_dist = mock_latent_dist
|
||||
mock_vae.encode.return_value = mock_encode_result
|
||||
|
||||
# Call the static method directly
|
||||
try:
|
||||
ZImageImageToLatentsInvocation.vae_encode(mock_vae_info, mock_image_tensor)
|
||||
except Exception:
|
||||
# We expect some errors due to mocking, but we just want to verify the working memory was requested
|
||||
pass
|
||||
|
||||
# Verify that working memory estimation was called
|
||||
mock_estimate.assert_called_once()
|
||||
# Verify that model_on_device was called with the estimated working memory
|
||||
mock_vae_info.model_on_device.assert_called_once_with(working_mem_bytes=expected_memory)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Shared fixtures and helpers for router-level multiuser/auth tests.
|
||||
|
||||
Note: This conftest intentionally does NOT redefine `mock_services` / `mock_invoker`
|
||||
to avoid shadowing the project-level fixtures in `tests/conftest.py`. Instead, the
|
||||
`enable_multiuser` fixture below injects MagicMock services for the routers that
|
||||
have no real backing service in the default mock_services (download_queue,
|
||||
style_preset_image_files, model_relationships, model_manager).
|
||||
|
||||
WARNING for future authors: `enable_multiuser` *unconditionally* replaces several
|
||||
services on `mock_invoker.services` with MagicMocks. If you add a router test that
|
||||
expects the real service (e.g. a SQLite-backed implementation), you must restore
|
||||
that service in your own fixture before the request — otherwise your test will
|
||||
pass against a MagicMock that accepts every call. `style_preset_records` is wired
|
||||
up to a real SQLite storage here because cross-user filtering needs real SQL.
|
||||
|
||||
WARNING for new routers: every router that calls `ApiDependencies.invoker.services`
|
||||
must be added to `_PATCHED_API_DEPENDENCIES_MODULES` below, or its routes will hit
|
||||
the un-patched singleton and fail with `AttributeError: type object
|
||||
'ApiDependencies' has no attribute 'invoker'`.
|
||||
|
||||
Existing test files that define their own `enable_multiuser` / `admin_token` / etc.
|
||||
fixtures locally are NOT affected — pytest's local-shadows-conftest rule applies.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
"""Minimal stand-in that lets tests inject their own Invoker."""
|
||||
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
# Every module that reads `ApiDependencies.invoker.services` must be patched here
|
||||
# so the in-test invoker is reachable. Add new routers / shared helpers to this
|
||||
# list when they start touching ApiDependencies.
|
||||
_PATCHED_API_DEPENDENCIES_MODULES = (
|
||||
"invokeai.app.api.auth_dependencies",
|
||||
"invokeai.app.api.routers.auth",
|
||||
"invokeai.app.api.routers.download_queue",
|
||||
"invokeai.app.api.routers.style_presets",
|
||||
"invokeai.app.api.routers.model_relationships",
|
||||
"invokeai.app.api.routers.utilities",
|
||||
"invokeai.app.api.routers.virtual_boards",
|
||||
"invokeai.app.api.routers.images",
|
||||
"invokeai.app.api.routers.workflows",
|
||||
"invokeai.app.api.routers._access",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_jwt_secret():
|
||||
from invokeai.app.services.auth.token_service import set_jwt_secret
|
||||
|
||||
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _create_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
|
||||
user = mock_invoker.services.users.create(
|
||||
UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
|
||||
)
|
||||
return user.user_id
|
||||
|
||||
|
||||
def _login(client: TestClient, email: str) -> str:
|
||||
r = client.post("/api/v1/auth/login", json={"email": email, "password": "TestPass123", "remember_me": False})
|
||||
assert r.status_code == 200, f"Login failed for {email}: {r.text}"
|
||||
return r.json()["token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
|
||||
"""Enable multiuser mode and patch ApiDependencies across the routers covered by router-level tests.
|
||||
|
||||
Replaces None-valued services with MagicMocks so that routes can run end-to-end.
|
||||
"""
|
||||
from invokeai.app.services.style_preset_records.style_preset_records_sqlite import (
|
||||
SqliteStylePresetRecordsStorage,
|
||||
)
|
||||
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
|
||||
# Replace services that are None in the default mock_services with MagicMocks.
|
||||
mock_invoker.services.download_queue = MagicMock()
|
||||
mock_invoker.services.style_preset_image_files = MagicMock()
|
||||
mock_invoker.services.model_relationships = MagicMock()
|
||||
mock_invoker.services.model_manager = MagicMock()
|
||||
mock_invoker.services.workflow_thumbnails = MagicMock()
|
||||
|
||||
# Style preset records uses a real SQLite-backed storage on the same in-memory
|
||||
# database that image_records was wired up against. This lets cross-user tests
|
||||
# exercise the actual filter SQL instead of asserting on MagicMock calls.
|
||||
mock_invoker.services.style_preset_records = SqliteStylePresetRecordsStorage(
|
||||
db=mock_invoker.services.image_records._db
|
||||
)
|
||||
|
||||
# Required by board_image_records-touching helpers in some tests.
|
||||
if mock_invoker.services.board_images is None:
|
||||
mock_board_images = MagicMock()
|
||||
mock_board_images.get_all_board_image_names_for_board.return_value = []
|
||||
mock_invoker.services.board_images = mock_board_images
|
||||
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
for module_path in _PATCHED_API_DEPENDENCIES_MODULES:
|
||||
monkeypatch.setattr(f"{module_path}.ApiDependencies", mock_deps)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient) -> str:
|
||||
_create_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
|
||||
return _login(client, "admin@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str) -> str:
|
||||
_create_user(mock_invoker, "user1@test.com", "User One")
|
||||
return _login(client, "user1@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str) -> str:
|
||||
_create_user(mock_invoker, "user2@test.com", "User Two")
|
||||
return _login(client, "user2@test.com")
|
||||
@@ -0,0 +1,336 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api.routers import app_info
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.auth.token_service import TokenData
|
||||
from invokeai.app.services.config.config_default import get_config, load_and_migrate_config, load_external_api_keys
|
||||
from invokeai.app.services.external_generation.external_generation_common import ExternalProviderStatus
|
||||
from invokeai.app.services.image_files.image_subfolder_strategy import DateStrategy, create_subfolder_strategy
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
|
||||
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def client(invokeai_root_dir: Path) -> TestClient:
|
||||
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
def test_get_external_provider_statuses(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
statuses = {
|
||||
"gemini": ExternalProviderStatus(provider_id="gemini", configured=True, message=None),
|
||||
"openai": ExternalProviderStatus(provider_id="openai", configured=False, message="Missing key"),
|
||||
}
|
||||
|
||||
monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr(mock_invoker.services.external_generation, "get_provider_statuses", lambda: statuses)
|
||||
|
||||
response = client.get("/api/v1/app/external_providers/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = sorted(response.json(), key=lambda item: item["provider_id"])
|
||||
assert payload == [
|
||||
{"provider_id": "gemini", "configured": True, "message": None},
|
||||
{"provider_id": "openai", "configured": False, "message": "Missing key"},
|
||||
]
|
||||
|
||||
|
||||
def test_external_provider_config_update_and_reset(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
mock_store = Mock()
|
||||
mock_store.search_by_attr.return_value = []
|
||||
mock_install = Mock()
|
||||
mock_model_manager = Mock()
|
||||
mock_model_manager.store = mock_store
|
||||
mock_model_manager.install = mock_install
|
||||
mock_invoker.services.model_manager = mock_model_manager
|
||||
monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
for provider_id in ("gemini", "openai"):
|
||||
response = client.delete(f"/api/v1/app/external_providers/config/{provider_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get("/api/v1/app/external_providers/config")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
openai_config = _get_provider_config(payload, "openai")
|
||||
assert openai_config["api_key_configured"] is False
|
||||
assert openai_config["base_url"] is None
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/app/external_providers/config/openai",
|
||||
json={"api_key": "openai-key", "base_url": "https://api.openai.test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["api_key_configured"] is True
|
||||
assert payload["base_url"] == "https://api.openai.test"
|
||||
|
||||
response = client.get("/api/v1/app/external_providers/config")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
openai_config = _get_provider_config(payload, "openai")
|
||||
assert openai_config["api_key_configured"] is True
|
||||
assert openai_config["base_url"] == "https://api.openai.test"
|
||||
|
||||
config_path = get_config().config_file_path
|
||||
api_keys_path = get_config().api_keys_file_path
|
||||
file_config = load_and_migrate_config(config_path)
|
||||
assert file_config.external_openai_api_key is None
|
||||
assert file_config.external_openai_base_url is None
|
||||
assert "external_openai_api_key" not in config_path.read_text()
|
||||
assert "external_openai_base_url" not in config_path.read_text()
|
||||
api_keys = load_external_api_keys(api_keys_path)
|
||||
assert api_keys["external_openai_api_key"] == "openai-key"
|
||||
assert api_keys["external_openai_base_url"] == "https://api.openai.test"
|
||||
|
||||
response = client.delete("/api/v1/app/external_providers/config/openai")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["api_key_configured"] is False
|
||||
assert payload["base_url"] is None
|
||||
|
||||
file_config = load_and_migrate_config(config_path)
|
||||
api_keys = load_external_api_keys(api_keys_path)
|
||||
assert file_config.external_openai_api_key is None
|
||||
assert file_config.external_openai_base_url is None
|
||||
assert "external_openai_api_key" not in config_path.read_text()
|
||||
assert "external_openai_api_key" not in api_keys
|
||||
assert "external_openai_base_url" not in api_keys
|
||||
|
||||
|
||||
def test_reset_external_provider_config_removes_provider_models(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
openai_model = ExternalApiModelConfig(
|
||||
key="openai_model",
|
||||
name="OpenAI Model",
|
||||
provider_id="openai",
|
||||
provider_model_id="gpt-image-1",
|
||||
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
|
||||
)
|
||||
gemini_model = ExternalApiModelConfig(
|
||||
key="gemini_model",
|
||||
name="Gemini Model",
|
||||
provider_id="gemini",
|
||||
provider_model_id="gemini-2.5-flash-image",
|
||||
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
|
||||
)
|
||||
mock_store = Mock()
|
||||
mock_store.search_by_attr.return_value = [openai_model, gemini_model]
|
||||
mock_install = Mock()
|
||||
mock_model_manager = Mock()
|
||||
mock_model_manager.store = mock_store
|
||||
mock_model_manager.install = mock_install
|
||||
mock_invoker.services.model_manager = mock_model_manager
|
||||
|
||||
monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
response = client.delete("/api/v1/app/external_providers/config/openai")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_store.search_by_attr.assert_called_once_with(
|
||||
base_model=BaseModelType.External,
|
||||
model_type=ModelType.ExternalImageGenerator,
|
||||
)
|
||||
mock_install.delete.assert_called_once_with("openai_model")
|
||||
|
||||
|
||||
def test_set_external_provider_config_clears_provider_models_when_api_key_removed(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
openai_model = ExternalApiModelConfig(
|
||||
key="openai_model",
|
||||
name="OpenAI Model",
|
||||
provider_id="openai",
|
||||
provider_model_id="gpt-image-1",
|
||||
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
|
||||
)
|
||||
mock_store = Mock()
|
||||
mock_store.search_by_attr.return_value = [openai_model]
|
||||
mock_install = Mock()
|
||||
mock_model_manager = Mock()
|
||||
mock_model_manager.store = mock_store
|
||||
mock_model_manager.install = mock_install
|
||||
mock_invoker.services.model_manager = mock_model_manager
|
||||
|
||||
monkeypatch.setattr("invokeai.app.api.routers.app_info.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
response = client.post("/api/v1/app/external_providers/config/openai", json={"api_key": " "})
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_store.search_by_attr.assert_called_once_with(
|
||||
base_model=BaseModelType.External,
|
||||
model_type=ModelType.ExternalImageGenerator,
|
||||
)
|
||||
mock_install.delete.assert_called_once_with("openai_model")
|
||||
|
||||
|
||||
def test_update_runtime_config_persists_image_subfolder_strategy(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
response = client.patch("/api/v1/app/runtime_config", json={"image_subfolder_strategy": "date"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["config"]["image_subfolder_strategy"] == "date"
|
||||
|
||||
config_path = get_config().config_file_path
|
||||
file_config = load_and_migrate_config(config_path)
|
||||
assert file_config.image_subfolder_strategy == "date"
|
||||
assert "image_subfolder_strategy: date" in config_path.read_text()
|
||||
assert get_config().image_subfolder_strategy == "date"
|
||||
assert isinstance(create_subfolder_strategy(get_config().image_subfolder_strategy), DateStrategy)
|
||||
|
||||
|
||||
def test_update_runtime_config_rejects_null_image_subfolder_strategy(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
response = client.patch("/api/v1/app/runtime_config", json={"image_subfolder_strategy": None})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_update_runtime_config_image_subfolder_strategy_schema() -> None:
|
||||
app.openapi_schema = None
|
||||
property_schema = app.openapi()["components"]["schemas"]["UpdateAppGenerationSettingsRequest"]["properties"][
|
||||
"image_subfolder_strategy"
|
||||
]
|
||||
|
||||
assert property_schema == {
|
||||
"description": "Strategy for organizing images into subfolders.",
|
||||
"enum": ["flat", "date", "type", "hash"],
|
||||
"title": "Image Subfolder Strategy",
|
||||
"type": "string",
|
||||
}
|
||||
|
||||
|
||||
def test_update_runtime_config_reads_and_writes_yaml_under_config_lock(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
class TrackingLock:
|
||||
is_locked = False
|
||||
load_seen = False
|
||||
write_seen = False
|
||||
|
||||
def __enter__(self) -> None:
|
||||
self.is_locked = True
|
||||
|
||||
def __exit__(self, *_: Any) -> None:
|
||||
self.is_locked = False
|
||||
|
||||
tracking_lock = TrackingLock()
|
||||
original_load_and_migrate_config = app_info.load_and_migrate_config
|
||||
original_write_file = app_info.InvokeAIAppConfig.write_file
|
||||
|
||||
def load_and_migrate_config_with_lock_assertion(config_path: Path) -> Any:
|
||||
assert tracking_lock.is_locked
|
||||
tracking_lock.load_seen = True
|
||||
return original_load_and_migrate_config(config_path)
|
||||
|
||||
def write_file_with_lock_assertion(
|
||||
config: app_info.InvokeAIAppConfig, dest_path: Path, as_example: bool = False
|
||||
) -> None:
|
||||
assert tracking_lock.is_locked
|
||||
tracking_lock.write_seen = True
|
||||
return original_write_file(config, dest_path, as_example)
|
||||
|
||||
monkeypatch.setattr(app_info, "_EXTERNAL_PROVIDER_CONFIG_LOCK", tracking_lock)
|
||||
monkeypatch.setattr(app_info, "load_and_migrate_config", load_and_migrate_config_with_lock_assertion)
|
||||
monkeypatch.setattr(app_info.InvokeAIAppConfig, "write_file", write_file_with_lock_assertion)
|
||||
|
||||
response = client.patch("/api/v1/app/runtime_config", json={"max_queue_history": 10})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert tracking_lock.load_seen
|
||||
assert tracking_lock.write_seen
|
||||
|
||||
|
||||
def test_update_runtime_config_rejects_non_admin_users(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr(mock_invoker.services.configuration, "multiuser", True)
|
||||
monkeypatch.setattr(
|
||||
"invokeai.app.api.auth_dependencies.verify_token",
|
||||
lambda _: TokenData(user_id="user-1", email="user@example.com", is_admin=False),
|
||||
)
|
||||
monkeypatch.setattr(mock_invoker.services.users, "get", Mock(return_value=Mock(is_active=True)))
|
||||
|
||||
response = client.patch(
|
||||
"/api/v1/app/runtime_config",
|
||||
json={"image_subfolder_strategy": "date"},
|
||||
headers={"Authorization": "Bearer non-admin-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Admin privileges required"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_id", ["alibabacloud", "gemini", "openai", "seedream"])
|
||||
def test_set_external_provider_config_rejects_non_admin_users(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient, provider_id: str
|
||||
) -> None:
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr(mock_invoker.services.configuration, "multiuser", True)
|
||||
monkeypatch.setattr(
|
||||
"invokeai.app.api.auth_dependencies.verify_token",
|
||||
lambda _: TokenData(user_id="user-1", email="user@example.com", is_admin=False),
|
||||
)
|
||||
monkeypatch.setattr(mock_invoker.services.users, "get", Mock(return_value=Mock(is_active=True)))
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/app/external_providers/config/{provider_id}",
|
||||
json={"api_key": "non-admin-attempt"},
|
||||
headers={"Authorization": "Bearer non-admin-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Admin privileges required"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("provider_id", ["alibabacloud", "gemini", "openai", "seedream"])
|
||||
def test_reset_external_provider_config_rejects_non_admin_users(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient, provider_id: str
|
||||
) -> None:
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr(mock_invoker.services.configuration, "multiuser", True)
|
||||
monkeypatch.setattr(
|
||||
"invokeai.app.api.auth_dependencies.verify_token",
|
||||
lambda _: TokenData(user_id="user-1", email="user@example.com", is_admin=False),
|
||||
)
|
||||
monkeypatch.setattr(mock_invoker.services.users, "get", Mock(return_value=Mock(is_active=True)))
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/app/external_providers/config/{provider_id}",
|
||||
headers={"Authorization": "Bearer non-admin-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == "Admin privileges required"
|
||||
|
||||
|
||||
def _get_provider_config(payload: list[dict[str, Any]], provider_id: str) -> dict[str, Any]:
|
||||
return next(item for item in payload if item["provider_id"] == provider_id)
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Integration tests for authentication router endpoints."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.auth.token_service import set_jwt_secret
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def setup_jwt_secret():
|
||||
"""Set up JWT secret for all tests in this module."""
|
||||
# Use a test secret key
|
||||
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def client(invokeai_root_dir: Path) -> TestClient:
|
||||
"""Create a test client for the FastAPI app."""
|
||||
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None:
|
||||
"""Enable multiuser mode for auth tests.
|
||||
|
||||
Auth tests need multiuser mode enabled since the login/setup endpoints
|
||||
return 403 when multiuser is disabled.
|
||||
"""
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
"""Mock API dependencies for testing."""
|
||||
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
def setup_test_user(mock_invoker: Invoker, email: str = "test@example.com", password: str = "TestPass123") -> str:
|
||||
"""Helper to create a test user and return user_id."""
|
||||
user_service = mock_invoker.services.users
|
||||
user_data = UserCreateRequest(
|
||||
email=email,
|
||||
display_name="Test User",
|
||||
password=password,
|
||||
is_admin=False,
|
||||
)
|
||||
user = user_service.create(user_data)
|
||||
return user.user_id
|
||||
|
||||
|
||||
def setup_test_admin(mock_invoker: Invoker, email: str = "admin@example.com", password: str = "AdminPass123") -> str:
|
||||
"""Helper to create a test admin user and return user_id."""
|
||||
user_service = mock_invoker.services.users
|
||||
user_data = UserCreateRequest(
|
||||
email=email,
|
||||
display_name="Admin User",
|
||||
password=password,
|
||||
is_admin=True,
|
||||
)
|
||||
user = user_service.create(user_data)
|
||||
return user.user_id
|
||||
|
||||
|
||||
def test_login_success(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test successful login with valid credentials."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Create a test user
|
||||
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
|
||||
|
||||
# Attempt login
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"password": "TestPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
json_response = response.json()
|
||||
assert "token" in json_response
|
||||
assert "user" in json_response
|
||||
assert "expires_in" in json_response
|
||||
assert json_response["user"]["email"] == "test@example.com"
|
||||
assert json_response["user"]["is_admin"] is False
|
||||
|
||||
|
||||
def test_login_with_remember_me(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test login with remember_me flag sets longer expiration."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
setup_test_user(mock_invoker, "test2@example.com", "TestPass123")
|
||||
|
||||
# Login with remember_me=True
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "test2@example.com",
|
||||
"password": "TestPass123",
|
||||
"remember_me": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
json_response = response.json()
|
||||
# Remember me should give 7 days = 604800 seconds
|
||||
assert json_response["expires_in"] == 604800
|
||||
|
||||
|
||||
def test_login_invalid_password(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test login fails with invalid password."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
setup_test_user(mock_invoker, "test3@example.com", "TestPass123")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "test3@example.com",
|
||||
"password": "WrongPassword",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Incorrect email or password" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_login_nonexistent_user(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test login fails with nonexistent user."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "nonexistent@example.com",
|
||||
"password": "TestPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Incorrect email or password" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_login_inactive_user(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test login fails with inactive user."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
user_id = setup_test_user(mock_invoker, "inactive@example.com", "TestPass123")
|
||||
|
||||
# Deactivate the user
|
||||
user_service = mock_invoker.services.users
|
||||
from invokeai.app.services.users.users_common import UserUpdateRequest
|
||||
|
||||
user_service.update(user_id, UserUpdateRequest(is_active=False))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "inactive@example.com",
|
||||
"password": "TestPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "disabled" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_logout(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test logout endpoint."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
setup_test_user(mock_invoker, "test4@example.com", "TestPass123")
|
||||
|
||||
# Login first to get token
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "test4@example.com",
|
||||
"password": "TestPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
token = login_response.json()["token"]
|
||||
|
||||
# Logout with token
|
||||
response = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
|
||||
|
||||
def test_logout_without_token(client: TestClient) -> None:
|
||||
"""Test logout fails without authentication token."""
|
||||
response = client.post("/api/v1/auth/logout")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_get_current_user_info(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test getting current user info with valid token."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
setup_test_user(mock_invoker, "test5@example.com", "TestPass123")
|
||||
|
||||
# Login to get token
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "test5@example.com",
|
||||
"password": "TestPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
token = login_response.json()["token"]
|
||||
|
||||
# Get user info
|
||||
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert response.status_code == 200
|
||||
json_response = response.json()
|
||||
assert json_response["email"] == "test5@example.com"
|
||||
assert json_response["display_name"] == "Test User"
|
||||
assert json_response["is_admin"] is False
|
||||
|
||||
|
||||
def test_get_current_user_info_without_token(client: TestClient) -> None:
|
||||
"""Test getting user info fails without token."""
|
||||
response = client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_get_current_user_info_invalid_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test getting user info fails with invalid token."""
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
response = client.get("/api/v1/auth/me", headers={"Authorization": "Bearer invalid_token"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_setup_admin_first_time(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test setting up first admin user."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"email": "admin@example.com",
|
||||
"display_name": "Admin User",
|
||||
"password": "AdminPass123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
json_response = response.json()
|
||||
assert json_response["success"] is True
|
||||
assert json_response["user"]["email"] == "admin@example.com"
|
||||
assert json_response["user"]["is_admin"] is True
|
||||
|
||||
|
||||
def test_setup_admin_already_exists(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test setup fails when admin already exists."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Create first admin
|
||||
setup_test_admin(mock_invoker, "admin1@example.com", "AdminPass123")
|
||||
|
||||
# Try to setup another admin
|
||||
response = client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"email": "admin2@example.com",
|
||||
"display_name": "Second Admin",
|
||||
"password": "AdminPass123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "already configured" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_setup_admin_weak_password(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test setup fails with weak password when strict password checking is enabled."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
mock_invoker.services.configuration.strict_password_checking = True
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"email": "admin3@example.com",
|
||||
"display_name": "Admin User",
|
||||
"password": "weak",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Password" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_setup_admin_weak_password_non_strict(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test setup succeeds with weak password when strict password checking is disabled (the default)."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
mock_invoker.services.configuration.strict_password_checking = False
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"email": "admin3b@example.com",
|
||||
"display_name": "Admin User",
|
||||
"password": "weak",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
json_response = response.json()
|
||||
assert json_response["success"] is True
|
||||
|
||||
|
||||
def test_admin_user_token_has_admin_flag(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
"""Test that admin user login returns token with admin flag."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
setup_test_admin(mock_invoker, "admin4@example.com", "AdminPass123")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "admin4@example.com",
|
||||
"password": "AdminPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
json_response = response.json()
|
||||
assert json_response["user"]["is_admin"] is True
|
||||
@@ -0,0 +1,154 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.auth_dependencies import get_current_user_or_default
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.auth.token_service import TokenData
|
||||
from invokeai.app.services.board_records.board_records_common import BoardVisibility
|
||||
from invokeai.app.services.boards.boards_common import BoardDTO
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "json_body"),
|
||||
[
|
||||
("post", "/api/v1/board_images/", {"board_id": "board-id", "image_name": "image.png"}),
|
||||
("delete", "/api/v1/board_images/", {"image_name": "image.png"}),
|
||||
("post", "/api/v1/board_images/batch", {"board_id": "board-id", "image_names": ["image.png"]}),
|
||||
("post", "/api/v1/board_images/batch/delete", {"image_names": ["image.png"]}),
|
||||
],
|
||||
)
|
||||
def test_board_image_mutations_are_blocked_during_image_move_maintenance(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
mock_invoker: Invoker,
|
||||
client: TestClient,
|
||||
method: str,
|
||||
path: str,
|
||||
json_body: dict,
|
||||
) -> None:
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
mock_invoker.services.image_moves = MagicMock()
|
||||
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
|
||||
monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="system"))
|
||||
monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=MagicMock(board_id=None)))
|
||||
monkeypatch.setattr(
|
||||
mock_invoker.services.boards,
|
||||
"get_dto",
|
||||
MagicMock(
|
||||
return_value=BoardDTO(
|
||||
board_id="board-id",
|
||||
board_name="Board",
|
||||
user_id="system",
|
||||
created_at="2024-01-01 00:00:00.000",
|
||||
updated_at="2024-01-01 00:00:00.000",
|
||||
archived=False,
|
||||
board_visibility=BoardVisibility.Private,
|
||||
cover_image_name=None,
|
||||
image_count=0,
|
||||
asset_count=0,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
|
||||
response = client.request(method, path, json=json_body)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "Image storage maintenance is active"
|
||||
|
||||
|
||||
def test_board_image_mutation_checks_access_before_image_move_maintenance(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
mock_invoker: Invoker,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
mock_invoker.services.image_moves = MagicMock()
|
||||
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
|
||||
monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="other-user"))
|
||||
monkeypatch.setattr(
|
||||
mock_invoker.services.boards,
|
||||
"get_dto",
|
||||
MagicMock(
|
||||
return_value=BoardDTO(
|
||||
board_id="board-id",
|
||||
board_name="Board",
|
||||
user_id="system",
|
||||
created_at="2024-01-01 00:00:00.000",
|
||||
updated_at="2024-01-01 00:00:00.000",
|
||||
archived=False,
|
||||
board_visibility=BoardVisibility.Private,
|
||||
cover_image_name=None,
|
||||
image_count=0,
|
||||
asset_count=0,
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
|
||||
async def current_user_override() -> TokenData:
|
||||
return TokenData(user_id="request-user", email="request-user@example.com", is_admin=False)
|
||||
|
||||
app.dependency_overrides[get_current_user_or_default] = current_user_override
|
||||
try:
|
||||
response = client.post("/api/v1/board_images/", json={"board_id": "board-id", "image_name": "image.png"})
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_invoker.services.image_moves.is_maintenance_active.assert_not_called()
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_current_user_or_default, None)
|
||||
|
||||
|
||||
def test_delete_board_with_images_is_blocked_during_image_move_maintenance(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
mock_invoker: Invoker,
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
mock_invoker.services.image_moves = MagicMock()
|
||||
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
|
||||
mock_invoker.services.images.delete_images_on_board = MagicMock()
|
||||
mock_invoker.services.boards.get_dto = MagicMock(
|
||||
return_value=BoardDTO(
|
||||
board_id="board-id",
|
||||
board_name="Board",
|
||||
user_id="system",
|
||||
created_at="2024-01-01 00:00:00.000",
|
||||
updated_at="2024-01-01 00:00:00.000",
|
||||
archived=False,
|
||||
board_visibility=BoardVisibility.Private,
|
||||
cover_image_name=None,
|
||||
image_count=0,
|
||||
asset_count=0,
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
|
||||
response = client.delete("/api/v1/boards/board-id?include_images=true")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "Image storage maintenance is active"
|
||||
mock_invoker.services.images.delete_images_on_board.assert_not_called()
|
||||
@@ -0,0 +1,677 @@
|
||||
"""Tests for multiuser boards functionality."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
"""Mock API dependencies for testing."""
|
||||
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_jwt_secret():
|
||||
"""Initialize JWT secret for token generation."""
|
||||
from invokeai.app.services.auth.token_service import set_jwt_secret
|
||||
|
||||
# Use a test secret key
|
||||
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def setup_test_user(
|
||||
mock_invoker: Invoker,
|
||||
email: str,
|
||||
display_name: str,
|
||||
password: str = "TestPass123",
|
||||
is_admin: bool = False,
|
||||
) -> str:
|
||||
"""Helper to create a test user and return user_id."""
|
||||
user_service = mock_invoker.services.users
|
||||
user_data = UserCreateRequest(
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
password=password,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
user = user_service.create(user_data)
|
||||
return user.user_id
|
||||
|
||||
|
||||
def get_user_token(client: TestClient, email: str, password: str = "TestPass123") -> str:
|
||||
"""Helper to login and get a user token."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": password, "remember_me": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_multiuser_for_tests(monkeypatch: Any, mock_invoker: Invoker):
|
||||
"""Enable multiuser mode and patch ApiDependencies for all relevant routers."""
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
# Provide a mock board_images service so delete/image_names endpoints don't 500
|
||||
mock_board_images = MagicMock()
|
||||
mock_board_images.get_all_board_image_names_for_board.return_value = []
|
||||
mock_invoker.services.board_images = mock_board_images
|
||||
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", mock_deps)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_token(setup_jwt_secret: None, enable_multiuser_for_tests: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Create an admin user and return a login token."""
|
||||
setup_test_user(mock_invoker, "admin@test.com", "Test Admin", is_admin=True)
|
||||
return get_user_token(client, "admin@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user1_token(enable_multiuser_for_tests: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
"""Create a regular user and return a login token."""
|
||||
setup_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
|
||||
return get_user_token(client, "user1@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user2_token(enable_multiuser_for_tests: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
"""Create a second regular user and return a login token."""
|
||||
setup_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
|
||||
return get_user_token(client, "user2@test.com")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic auth requirement tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
|
||||
"""Test that creating a board requires authentication."""
|
||||
response = client.post("/api/v1/boards/?board_name=Test+Board")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_list_boards_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
|
||||
"""Test that listing boards requires authentication."""
|
||||
response = client.get("/api/v1/boards/?all=true")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_get_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
|
||||
"""Test that getting a board requires authentication."""
|
||||
response = client.get("/api/v1/boards/some-board-id")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_update_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
|
||||
"""Test that updating a board requires authentication."""
|
||||
response = client.patch("/api/v1/boards/some-board-id", json={"board_name": "New Name"})
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_delete_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
|
||||
"""Test that deleting a board requires authentication."""
|
||||
response = client.delete("/api/v1/boards/some-board-id")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_list_board_image_names_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
|
||||
"""Test that listing board image names requires authentication."""
|
||||
response = client.get("/api/v1/boards/some-board-id/image_names")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Basic create / list tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_board_with_auth(client: TestClient, admin_token: str):
|
||||
"""Test that authenticated users can create boards."""
|
||||
response = client.post(
|
||||
"/api/v1/boards/?board_name=My+Test+Board",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["board_name"] == "My Test Board"
|
||||
assert "board_id" in data
|
||||
|
||||
|
||||
def test_list_boards_with_auth(client: TestClient, admin_token: str):
|
||||
"""Test that authenticated users can list their boards."""
|
||||
# First create a board
|
||||
client.post(
|
||||
"/api/v1/boards/?board_name=Listed+Board",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
# Now list boards
|
||||
response = client.get(
|
||||
"/api/v1/boards/?all=true",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
boards = response.json()
|
||||
assert isinstance(boards, list)
|
||||
board_names = [b["board_name"] for b in boards]
|
||||
assert "Listed Board" in board_names
|
||||
|
||||
|
||||
def test_user_boards_are_isolated(client: TestClient, admin_token: str, user1_token: str):
|
||||
"""Test that boards are isolated between users."""
|
||||
# Admin creates a board
|
||||
admin_response = client.post(
|
||||
"/api/v1/boards/?board_name=Admin+Board",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert admin_response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Admin can see their own board
|
||||
list_response = client.get(
|
||||
"/api/v1/boards/?all=true",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert list_response.status_code == status.HTTP_200_OK
|
||||
boards = list_response.json()
|
||||
board_names = [b["board_name"] for b in boards]
|
||||
assert "Admin Board" in board_names
|
||||
|
||||
# user1 should not see admin's board in their own listing
|
||||
user1_list = client.get(
|
||||
"/api/v1/boards/?all=true",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert user1_list.status_code == status.HTTP_200_OK
|
||||
user1_board_names = [b["board_name"] for b in user1_list.json()]
|
||||
assert "Admin Board" not in user1_board_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ownership enforcement: get_board
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_board_owner_succeeds(client: TestClient, user1_token: str):
|
||||
"""Test that the board owner can retrieve their own board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["board_id"] == board_id
|
||||
|
||||
|
||||
def test_get_board_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that a non-owner cannot retrieve another user's board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Private+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_get_board_admin_can_access_any_board(client: TestClient, admin_token: str, user1_token: str):
|
||||
"""Test that an admin can retrieve any user's board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Board+For+Admin",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ownership enforcement: update_board
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_board_owner_succeeds(client: TestClient, user1_token: str):
|
||||
"""Test that the board owner can update their own board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=Original+Name",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_name": "Updated Name"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["board_name"] == "Updated Name"
|
||||
|
||||
|
||||
def test_update_board_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that a non-owner cannot update another user's board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Board+To+Update",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_name": "Hijacked Name"},
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_update_board_admin_can_update_any_board(client: TestClient, admin_token: str, user1_token: str):
|
||||
"""Test that an admin can update any user's board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Board+Admin+Update",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_name": "Admin Updated Name"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["board_name"] == "Admin Updated Name"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ownership enforcement: delete_board
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_board_owner_succeeds(client: TestClient, user1_token: str):
|
||||
"""Test that the board owner can delete their own board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=Board+To+Delete",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["board_id"] == board_id
|
||||
|
||||
|
||||
def test_delete_board_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that a non-owner cannot delete another user's board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Board+To+Delete",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_delete_board_admin_can_delete_any_board(client: TestClient, admin_token: str, user1_token: str):
|
||||
"""Test that an admin can delete any user's board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Board+Admin+Delete",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ownership enforcement: list_all_board_image_names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_board_image_names_owner_succeeds(client: TestClient, user1_token: str):
|
||||
"""Test that the board owner can list image names for their board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Images+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}/image_names",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
|
||||
def test_list_board_image_names_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that a non-owner cannot list image names for another user's board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Private+Images+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}/image_names",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_list_board_image_names_admin_can_access_any_board(client: TestClient, admin_token: str, user1_token: str):
|
||||
"""Test that an admin can list image names for any user's board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Board+Admin+Images",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}/image_names",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
def test_list_board_image_names_none_board_no_auth_check(enable_multiuser_for_tests: Any, client: TestClient):
|
||||
"""Test that listing image names for the 'none' board requires auth but no ownership check."""
|
||||
# The 'none' board is the uncategorized images board — no ownership check needed,
|
||||
# but auth is still required in multiuser mode.
|
||||
response = client.get("/api/v1/boards/none/image_names")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Misc tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_enqueue_batch_requires_auth(enable_multiuser_for_tests: Any, client: TestClient):
|
||||
"""Test that enqueuing a batch requires authentication."""
|
||||
response = client.post(
|
||||
"/api/v1/queue/default/enqueue_batch",
|
||||
json={
|
||||
"batch": {
|
||||
"batch_id": "test-batch",
|
||||
"data": [],
|
||||
"graph": {"nodes": {}, "edges": []},
|
||||
},
|
||||
"prepend": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Board visibility tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_board_created_with_private_visibility(client: TestClient, user1_token: str):
|
||||
"""Test that newly created boards default to private visibility."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=Visibility+Default+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
data = create.json()
|
||||
assert data["board_visibility"] == "private"
|
||||
|
||||
|
||||
def test_set_board_visibility_shared(client: TestClient, user1_token: str):
|
||||
"""Test that the board owner can set their board to shared."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=Shared+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "shared"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["board_visibility"] == "shared"
|
||||
|
||||
|
||||
def test_set_board_visibility_public(client: TestClient, user1_token: str):
|
||||
"""Test that the board owner can set their board to public."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=Public+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "public"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["board_visibility"] == "public"
|
||||
|
||||
|
||||
def test_shared_board_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that a shared board is accessible to other authenticated users."""
|
||||
# user1 creates a board and sets it to shared
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Shared+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "shared"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# user2 should be able to access the shared board
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["board_id"] == board_id
|
||||
|
||||
|
||||
def test_public_board_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that a public board is accessible to other authenticated users."""
|
||||
# user1 creates a board and sets it to public
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Public+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "public"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# user2 should be able to access the public board
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["board_id"] == board_id
|
||||
|
||||
|
||||
def test_shared_board_appears_in_other_user_list(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that shared boards appear in other users' board listings."""
|
||||
# user1 creates and shares a board
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Listed+Shared+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "shared"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# user2 should see the shared board in their listing
|
||||
response = client.get(
|
||||
"/api/v1/boards/?all=true",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
board_ids = [b["board_id"] for b in response.json()]
|
||||
assert board_id in board_ids
|
||||
|
||||
|
||||
def test_private_board_not_visible_after_privacy_change(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that reverting a board from shared to private hides it from other users."""
|
||||
# user1 creates a board, makes it shared, then reverts to private
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=Reverted+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "shared"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "private"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# user2 should not be able to access the now-private board
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_non_owner_cannot_change_board_visibility(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that a non-owner cannot change a board's visibility."""
|
||||
# user1 creates a board
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Private+Locked+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
# user2 tries to make it public - should be forbidden
|
||||
response = client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "public"},
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_shared_board_image_names_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that image names for shared boards are accessible to other users."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Shared+Images+Board",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "shared"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# user2 can access image names for a shared board
|
||||
response = client.get(
|
||||
f"/api/v1/boards/{board_id}/image_names",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
def test_admin_can_change_any_board_visibility(client: TestClient, admin_token: str, user1_token: str):
|
||||
"""Test that an admin can change the visibility of any user's board."""
|
||||
create = client.post(
|
||||
"/api/v1/boards/?board_name=User1+Board+For+Admin+Visibility",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert create.status_code == status.HTTP_201_CREATED
|
||||
board_id = create.json()["board_id"]
|
||||
|
||||
# Admin sets it to public
|
||||
response = client.patch(
|
||||
f"/api/v1/boards/{board_id}",
|
||||
json={"board_visibility": "public"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["board_visibility"] == "public"
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Tests for multiuser client state functionality."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
"""Mock API dependencies for testing."""
|
||||
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
def setup_test_user(
|
||||
mock_invoker: Invoker, email: str, display_name: str, password: str = "TestPass123", is_admin: bool = False
|
||||
) -> str:
|
||||
"""Helper to create a test user and return user_id."""
|
||||
user_service = mock_invoker.services.users
|
||||
user_data = UserCreateRequest(
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
password=password,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
user = user_service.create(user_data)
|
||||
return user.user_id
|
||||
|
||||
|
||||
def get_user_token(client: TestClient, email: str, password: str = "TestPass123") -> str:
|
||||
"""Helper to login and get a user token."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": email,
|
||||
"password": password,
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Get an admin token for testing."""
|
||||
# Enable multiuser mode for auth endpoints
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
|
||||
# Mock ApiDependencies for auth and client_state routers
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Create admin user
|
||||
setup_test_user(mock_invoker, "admin@test.com", "Admin User", is_admin=True)
|
||||
|
||||
return get_user_token(client, "admin@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user1_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
"""Get a token for test user 1."""
|
||||
# Create a regular user
|
||||
setup_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
|
||||
|
||||
return get_user_token(client, "user1@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user2_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
"""Get a token for test user 2."""
|
||||
# Create another regular user
|
||||
setup_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
|
||||
|
||||
return get_user_token(client, "user2@test.com")
|
||||
|
||||
|
||||
def test_get_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker):
|
||||
"""Test that getting client state without authentication uses the system user."""
|
||||
# Mock ApiDependencies
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Set a value for the system user directly
|
||||
mock_invoker.services.client_state_persistence.set_by_key("system", "test_key", "system_value")
|
||||
|
||||
# Get without authentication - should return system user's value
|
||||
response = client.get("/api/v1/client_state/default/get_by_key?key=test_key")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == "system_value"
|
||||
|
||||
|
||||
def test_set_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker):
|
||||
"""Test that setting client state without authentication uses the system user."""
|
||||
# Mock ApiDependencies
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Set without authentication - should set for system user
|
||||
response = client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=test_key",
|
||||
json="unauthenticated_value",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify it was set for system user
|
||||
value = mock_invoker.services.client_state_persistence.get_by_key("system", "test_key")
|
||||
assert value == "unauthenticated_value"
|
||||
|
||||
|
||||
def test_delete_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker):
|
||||
"""Test that deleting client state without authentication uses the system user."""
|
||||
# Mock ApiDependencies
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Set a value for system user
|
||||
mock_invoker.services.client_state_persistence.set_by_key("system", "test_key", "system_value")
|
||||
|
||||
# Delete without authentication - should delete system user's data
|
||||
response = client.post("/api/v1/client_state/default/delete")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify it was deleted for system user
|
||||
value = mock_invoker.services.client_state_persistence.get_by_key("system", "test_key")
|
||||
assert value is None
|
||||
|
||||
|
||||
def test_set_and_get_client_state(client: TestClient, admin_token: str):
|
||||
"""Test that authenticated users can set and get their client state."""
|
||||
# Set a value
|
||||
set_response = client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=test_key",
|
||||
json="test_value",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert set_response.status_code == status.HTTP_200_OK
|
||||
assert set_response.json() == "test_value"
|
||||
|
||||
# Get the value back
|
||||
get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=test_key",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_response.status_code == status.HTTP_200_OK
|
||||
assert get_response.json() == "test_value"
|
||||
|
||||
|
||||
def test_client_state_isolation_between_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that client state is isolated between different users."""
|
||||
# User 1 sets a value
|
||||
user1_set_response = client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=shared_key",
|
||||
json="user1_value",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert user1_set_response.status_code == status.HTTP_200_OK
|
||||
|
||||
# User 2 sets a different value for the same key
|
||||
user2_set_response = client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=shared_key",
|
||||
json="user2_value",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert user2_set_response.status_code == status.HTTP_200_OK
|
||||
|
||||
# User 1 should still see their own value
|
||||
user1_get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=shared_key",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert user1_get_response.status_code == status.HTTP_200_OK
|
||||
assert user1_get_response.json() == "user1_value"
|
||||
|
||||
# User 2 should see their own value
|
||||
user2_get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=shared_key",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert user2_get_response.status_code == status.HTTP_200_OK
|
||||
assert user2_get_response.json() == "user2_value"
|
||||
|
||||
|
||||
def test_get_nonexistent_key_returns_null(client: TestClient, admin_token: str):
|
||||
"""Test that getting a nonexistent key returns null."""
|
||||
response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=nonexistent_key",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() is None
|
||||
|
||||
|
||||
def test_delete_client_state(client: TestClient, admin_token: str):
|
||||
"""Test that users can delete their own client state."""
|
||||
# Set some values
|
||||
client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=key1",
|
||||
json="value1",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=key2",
|
||||
json="value2",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
# Verify values exist
|
||||
get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=key1",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_response.json() == "value1"
|
||||
|
||||
# Delete all client state
|
||||
delete_response = client.post(
|
||||
"/api/v1/client_state/default/delete",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert delete_response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify values are gone
|
||||
get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=key1",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_response.json() is None
|
||||
|
||||
get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=key2",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_response.json() is None
|
||||
|
||||
|
||||
def test_update_existing_key(client: TestClient, admin_token: str):
|
||||
"""Test that updating an existing key works correctly."""
|
||||
# Set initial value
|
||||
client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=update_key",
|
||||
json="initial_value",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
# Update the value
|
||||
update_response = client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=update_key",
|
||||
json="updated_value",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert update_response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify the updated value
|
||||
get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=update_key",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_response.status_code == status.HTTP_200_OK
|
||||
assert get_response.json() == "updated_value"
|
||||
|
||||
|
||||
def test_complex_json_values(client: TestClient, admin_token: str):
|
||||
"""Test that complex JSON values can be stored and retrieved."""
|
||||
import json
|
||||
|
||||
complex_dict = {"params": {"model": "test-model", "steps": 50}, "prompt": "a beautiful landscape"}
|
||||
complex_value = json.dumps(complex_dict)
|
||||
|
||||
# Set complex value
|
||||
set_response = client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=complex_key",
|
||||
json=complex_value,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert set_response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Get it back
|
||||
get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=complex_key",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_response.status_code == status.HTTP_200_OK
|
||||
assert get_response.json() == complex_value
|
||||
|
||||
|
||||
def test_get_keys_by_prefix_without_auth(client: TestClient, monkeypatch, mock_invoker: Invoker):
|
||||
"""Test that keys can be retrieved by prefix without authentication."""
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Set several keys with a common prefix directly
|
||||
for i in range(3):
|
||||
mock_invoker.services.client_state_persistence.set_by_key("system", f"canvas_snapshot:snap{i}", f"value{i}")
|
||||
mock_invoker.services.client_state_persistence.set_by_key("system", "other_key", "other_value")
|
||||
|
||||
# Get keys by prefix
|
||||
response = client.get("/api/v1/client_state/default/get_keys_by_prefix?prefix=canvas_snapshot:")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
keys = response.json()
|
||||
assert len(keys) == 3
|
||||
assert "canvas_snapshot:snap0" in keys
|
||||
assert "canvas_snapshot:snap1" in keys
|
||||
assert "canvas_snapshot:snap2" in keys
|
||||
assert "other_key" not in keys
|
||||
|
||||
|
||||
def test_get_keys_by_prefix_empty_without_auth(client: TestClient, monkeypatch, mock_invoker: Invoker):
|
||||
"""Test that an empty list is returned when no keys match the prefix."""
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
response = client.get("/api/v1/client_state/default/get_keys_by_prefix?prefix=nonexistent_prefix:")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def test_delete_by_key_without_auth(client: TestClient, monkeypatch, mock_invoker: Invoker):
|
||||
"""Test that a specific key can be deleted without affecting other keys."""
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Set two keys directly
|
||||
mock_invoker.services.client_state_persistence.set_by_key("system", "keep_key", "keep_value")
|
||||
mock_invoker.services.client_state_persistence.set_by_key("system", "delete_key", "delete_value")
|
||||
|
||||
# Delete only one key via endpoint
|
||||
delete_response = client.post("/api/v1/client_state/default/delete_by_key?key=delete_key")
|
||||
assert delete_response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify deleted key is gone
|
||||
value = mock_invoker.services.client_state_persistence.get_by_key("system", "delete_key")
|
||||
assert value is None
|
||||
|
||||
# Verify other key still exists
|
||||
value = mock_invoker.services.client_state_persistence.get_by_key("system", "keep_key")
|
||||
assert value == "keep_value"
|
||||
|
||||
|
||||
def test_get_keys_by_prefix(client: TestClient, admin_token: str):
|
||||
"""Test that keys can be retrieved by prefix with authentication."""
|
||||
# Set several keys with a common prefix
|
||||
for i in range(3):
|
||||
client.post(
|
||||
f"/api/v1/client_state/default/set_by_key?key=canvas_snapshot:snap{i}",
|
||||
json=f"value{i}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
# Set a key without the prefix
|
||||
client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=other_key",
|
||||
json="other_value",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
# Get keys by prefix
|
||||
response = client.get(
|
||||
"/api/v1/client_state/default/get_keys_by_prefix?prefix=canvas_snapshot:",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
keys = response.json()
|
||||
assert len(keys) == 3
|
||||
assert "canvas_snapshot:snap0" in keys
|
||||
assert "canvas_snapshot:snap1" in keys
|
||||
assert "canvas_snapshot:snap2" in keys
|
||||
assert "other_key" not in keys
|
||||
|
||||
|
||||
def test_delete_by_key(client: TestClient, admin_token: str):
|
||||
"""Test that a specific key can be deleted without affecting other keys."""
|
||||
# Set two keys
|
||||
client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=keep_key",
|
||||
json="keep_value",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=delete_key",
|
||||
json="delete_value",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
|
||||
# Delete only one key
|
||||
delete_response = client.post(
|
||||
"/api/v1/client_state/default/delete_by_key?key=delete_key",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert delete_response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Verify deleted key is gone
|
||||
get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=delete_key",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_response.json() is None
|
||||
|
||||
# Verify other key still exists
|
||||
get_response = client.get(
|
||||
"/api/v1/client_state/default/get_by_key?key=keep_key",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_response.json() == "keep_value"
|
||||
|
||||
|
||||
def test_get_keys_by_prefix_isolation_between_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Test that get_keys_by_prefix is isolated between users."""
|
||||
# User 1 sets keys
|
||||
client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=snapshot:u1",
|
||||
json="user1_data",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# User 2 sets keys
|
||||
client.post(
|
||||
"/api/v1/client_state/default/set_by_key?key=snapshot:u2",
|
||||
json="user2_data",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
|
||||
# User 1 should only see their own keys
|
||||
response = client.get(
|
||||
"/api/v1/client_state/default/get_keys_by_prefix?prefix=snapshot:",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
keys = response.json()
|
||||
assert "snapshot:u1" in keys
|
||||
assert "snapshot:u2" not in keys
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Tests for the custom nodes router."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from invokeai.app.api.routers.custom_nodes import (
|
||||
PACK_MANIFEST_FILENAME,
|
||||
_get_installed_packs,
|
||||
_import_workflows_from_pack,
|
||||
_load_node_pack,
|
||||
_purge_pack_modules,
|
||||
_read_pack_manifest,
|
||||
_remove_workflows_by_ids,
|
||||
_write_pack_manifest,
|
||||
)
|
||||
from invokeai.app.invocations.baseinvocation import (
|
||||
BaseInvocation,
|
||||
InvocationRegistry,
|
||||
)
|
||||
|
||||
|
||||
class TestGetInstalledPacks:
|
||||
"""Tests for _get_installed_packs()."""
|
||||
|
||||
def test_returns_empty_when_dir_not_exists(self, tmp_path: Path) -> None:
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=nonexistent):
|
||||
packs = _get_installed_packs()
|
||||
assert packs == []
|
||||
|
||||
def test_returns_empty_when_dir_empty(self, tmp_path: Path) -> None:
|
||||
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
|
||||
packs = _get_installed_packs()
|
||||
assert packs == []
|
||||
|
||||
def test_skips_files(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "some_file.py").touch()
|
||||
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
|
||||
packs = _get_installed_packs()
|
||||
assert packs == []
|
||||
|
||||
def test_skips_hidden_dirs(self, tmp_path: Path) -> None:
|
||||
hidden = tmp_path / ".hidden_pack"
|
||||
hidden.mkdir()
|
||||
(hidden / "__init__.py").touch()
|
||||
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
|
||||
packs = _get_installed_packs()
|
||||
assert packs == []
|
||||
|
||||
def test_skips_dirs_without_init(self, tmp_path: Path) -> None:
|
||||
no_init = tmp_path / "no_init_pack"
|
||||
no_init.mkdir()
|
||||
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
|
||||
packs = _get_installed_packs()
|
||||
assert packs == []
|
||||
|
||||
def test_finds_valid_pack(self, tmp_path: Path) -> None:
|
||||
pack = tmp_path / "my_pack"
|
||||
pack.mkdir()
|
||||
(pack / "__init__.py").touch()
|
||||
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
|
||||
packs = _get_installed_packs()
|
||||
assert len(packs) == 1
|
||||
assert packs[0].name == "my_pack"
|
||||
assert packs[0].path == str(pack)
|
||||
|
||||
def test_finds_multiple_packs_sorted(self, tmp_path: Path) -> None:
|
||||
for name in ["zebra_pack", "alpha_pack", "middle_pack"]:
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
(d / "__init__.py").touch()
|
||||
with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path):
|
||||
packs = _get_installed_packs()
|
||||
assert len(packs) == 3
|
||||
assert [p.name for p in packs] == ["alpha_pack", "middle_pack", "zebra_pack"]
|
||||
|
||||
|
||||
class TestImportWorkflowsFromPack:
|
||||
"""Tests for _import_workflows_from_pack()."""
|
||||
|
||||
@staticmethod
|
||||
def _mock_service_with_id(workflow_id: str = "new-id") -> MagicMock:
|
||||
"""Returns a mock workflow_records service whose create() yields a DTO with the given id."""
|
||||
mock_service = MagicMock()
|
||||
mock_service.create.return_value = MagicMock(workflow_id=workflow_id)
|
||||
return mock_service
|
||||
|
||||
def test_no_json_files(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "__init__.py").touch()
|
||||
(tmp_path / "node.py").write_text("# node code")
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
|
||||
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
|
||||
assert ids == []
|
||||
|
||||
def test_skips_non_workflow_json(self, tmp_path: Path) -> None:
|
||||
# JSON without nodes/edges should be skipped
|
||||
config = {"setting": "value"}
|
||||
(tmp_path / "config.json").write_text(json.dumps(config))
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
|
||||
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
|
||||
assert ids == []
|
||||
|
||||
def test_imports_valid_workflow(self, tmp_path: Path) -> None:
|
||||
workflow = {
|
||||
"name": "Test Workflow",
|
||||
"author": "Test",
|
||||
"description": "A test workflow",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "test",
|
||||
"notes": "",
|
||||
"exposedFields": [],
|
||||
"meta": {"version": "3.0.0", "category": "user"},
|
||||
"nodes": [{"id": "1", "type": "test_node"}],
|
||||
"edges": [],
|
||||
}
|
||||
workflows_dir = tmp_path / "workflows"
|
||||
workflows_dir.mkdir()
|
||||
(workflows_dir / "test_workflow.json").write_text(json.dumps(workflow))
|
||||
|
||||
mock_service = self._mock_service_with_id("wf-new-1")
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
|
||||
mock_deps.invoker.services.workflow_records = mock_service
|
||||
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
|
||||
|
||||
assert ids == ["wf-new-1"]
|
||||
mock_service.create.assert_called_once()
|
||||
# Verify the workflow was tagged
|
||||
create_kwargs = mock_service.create.call_args.kwargs
|
||||
assert "node-pack:test_pack" in create_kwargs["workflow"].tags
|
||||
assert create_kwargs["user_id"] == "admin"
|
||||
assert create_kwargs["is_public"] is True
|
||||
|
||||
def test_adds_pack_tag_to_existing_tags(self, tmp_path: Path) -> None:
|
||||
workflow = {
|
||||
"name": "Tagged Workflow",
|
||||
"author": "Test",
|
||||
"description": "",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "existing, tags",
|
||||
"notes": "",
|
||||
"exposedFields": [],
|
||||
"meta": {"version": "3.0.0", "category": "user"},
|
||||
"nodes": [{"id": "1"}],
|
||||
"edges": [],
|
||||
}
|
||||
(tmp_path / "workflow.json").write_text(json.dumps(workflow))
|
||||
|
||||
mock_service = self._mock_service_with_id()
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
|
||||
mock_deps.invoker.services.workflow_records = mock_service
|
||||
ids = _import_workflows_from_pack(tmp_path, "my_pack", owner_user_id="admin")
|
||||
|
||||
assert len(ids) == 1
|
||||
created_workflow = mock_service.create.call_args.kwargs["workflow"]
|
||||
assert "existing, tags" in created_workflow.tags
|
||||
assert "node-pack:my_pack" in created_workflow.tags
|
||||
|
||||
def test_removes_id_before_import(self, tmp_path: Path) -> None:
|
||||
workflow = {
|
||||
"id": "should-be-removed",
|
||||
"name": "Workflow With ID",
|
||||
"author": "Test",
|
||||
"description": "",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "",
|
||||
"notes": "",
|
||||
"exposedFields": [],
|
||||
"meta": {"version": "3.0.0", "category": "user"},
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
}
|
||||
(tmp_path / "workflow.json").write_text(json.dumps(workflow))
|
||||
|
||||
mock_service = self._mock_service_with_id()
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
|
||||
mock_deps.invoker.services.workflow_records = mock_service
|
||||
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
|
||||
|
||||
assert len(ids) == 1
|
||||
|
||||
def test_sets_category_to_user(self, tmp_path: Path) -> None:
|
||||
workflow = {
|
||||
"name": "Default-like Workflow",
|
||||
"author": "Test",
|
||||
"description": "",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "",
|
||||
"notes": "",
|
||||
"exposedFields": [],
|
||||
"meta": {"version": "3.0.0", "category": "default"},
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
}
|
||||
(tmp_path / "workflow.json").write_text(json.dumps(workflow))
|
||||
|
||||
mock_service = self._mock_service_with_id()
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
|
||||
mock_deps.invoker.services.workflow_records = mock_service
|
||||
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
|
||||
|
||||
assert len(ids) == 1
|
||||
created_workflow = mock_service.create.call_args.kwargs["workflow"]
|
||||
assert created_workflow.meta.category.value == "user"
|
||||
|
||||
def test_skips_invalid_json(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "broken.json").write_text("{invalid json")
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
|
||||
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
|
||||
assert ids == []
|
||||
|
||||
def test_finds_workflows_recursively(self, tmp_path: Path) -> None:
|
||||
workflow = {
|
||||
"name": "Nested Workflow",
|
||||
"author": "Test",
|
||||
"description": "",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "",
|
||||
"notes": "",
|
||||
"exposedFields": [],
|
||||
"meta": {"version": "3.0.0", "category": "user"},
|
||||
"nodes": [{"id": "1"}],
|
||||
"edges": [],
|
||||
}
|
||||
nested = tmp_path / "sub" / "dir"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "deep_workflow.json").write_text(json.dumps(workflow))
|
||||
|
||||
mock_service = self._mock_service_with_id()
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
|
||||
mock_deps.invoker.services.workflow_records = mock_service
|
||||
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
|
||||
|
||||
assert len(ids) == 1
|
||||
|
||||
def test_skips_manifest_file(self, tmp_path: Path) -> None:
|
||||
# A manifest inside the pack must not be mistaken for a workflow during import
|
||||
(tmp_path / PACK_MANIFEST_FILENAME).write_text(json.dumps({"workflow_ids": ["wf-old"]}))
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"):
|
||||
ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin")
|
||||
assert ids == []
|
||||
|
||||
|
||||
class TestPackManifest:
|
||||
"""Tests for _write_pack_manifest() and _read_pack_manifest()."""
|
||||
|
||||
def test_write_then_read_roundtrip(self, tmp_path: Path) -> None:
|
||||
_write_pack_manifest(tmp_path, ["wf-1", "wf-2"])
|
||||
assert _read_pack_manifest(tmp_path) == ["wf-1", "wf-2"]
|
||||
|
||||
def test_read_returns_empty_when_manifest_missing(self, tmp_path: Path) -> None:
|
||||
assert _read_pack_manifest(tmp_path) == []
|
||||
|
||||
def test_read_returns_empty_when_manifest_malformed(self, tmp_path: Path) -> None:
|
||||
(tmp_path / PACK_MANIFEST_FILENAME).write_text("{not valid json")
|
||||
assert _read_pack_manifest(tmp_path) == []
|
||||
|
||||
def test_read_returns_empty_when_workflow_ids_not_a_list(self, tmp_path: Path) -> None:
|
||||
(tmp_path / PACK_MANIFEST_FILENAME).write_text(json.dumps({"workflow_ids": "oops"}))
|
||||
assert _read_pack_manifest(tmp_path) == []
|
||||
|
||||
|
||||
class TestRemoveWorkflowsByIds:
|
||||
"""Tests for _remove_workflows_by_ids()."""
|
||||
|
||||
def test_deletes_only_given_ids(self) -> None:
|
||||
mock_service = MagicMock()
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
|
||||
mock_deps.invoker.services.workflow_records = mock_service
|
||||
count = _remove_workflows_by_ids(["wf-1", "wf-2"], "test_pack")
|
||||
|
||||
assert count == 2
|
||||
assert mock_service.delete.call_count == 2
|
||||
deleted_ids = [
|
||||
call.args[0] if call.args else call.kwargs.get("workflow_id") for call in mock_service.delete.call_args_list
|
||||
]
|
||||
assert deleted_ids == ["wf-1", "wf-2"]
|
||||
|
||||
def test_returns_zero_when_no_ids(self) -> None:
|
||||
mock_service = MagicMock()
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
|
||||
mock_deps.invoker.services.workflow_records = mock_service
|
||||
count = _remove_workflows_by_ids([], "empty_pack")
|
||||
|
||||
assert count == 0
|
||||
mock_service.delete.assert_not_called()
|
||||
|
||||
def test_continues_on_individual_delete_error(self) -> None:
|
||||
# One workflow is already gone; the helper still removes the others
|
||||
mock_service = MagicMock()
|
||||
mock_service.delete.side_effect = [Exception("not found"), None]
|
||||
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
|
||||
mock_deps.invoker.services.workflow_records = mock_service
|
||||
count = _remove_workflows_by_ids(["wf-gone", "wf-still-here"], "test_pack")
|
||||
|
||||
assert count == 1
|
||||
|
||||
def test_preserves_user_workflow_with_colliding_tag(self, tmp_path: Path) -> None:
|
||||
# Regression test for the data-destruction risk the reviewer raised:
|
||||
# If a user-authored workflow reuses the 'node-pack:<name>' tag, uninstall
|
||||
# must NOT delete it. The full flow is exercised here: a manifest records
|
||||
# only the pack's own workflow IDs, and _remove_workflows_by_ids operates
|
||||
# only on those — so the user's workflow (whose id is NOT in the manifest)
|
||||
# is never touched.
|
||||
pack_wf_id = "pack-wf-1"
|
||||
user_wf_id = "user-owned-wf-with-same-tag"
|
||||
|
||||
_write_pack_manifest(tmp_path, [pack_wf_id])
|
||||
manifest_ids = _read_pack_manifest(tmp_path)
|
||||
|
||||
mock_service = MagicMock()
|
||||
with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps:
|
||||
mock_deps.invoker.services.workflow_records = mock_service
|
||||
_remove_workflows_by_ids(manifest_ids, "test_pack")
|
||||
|
||||
assert mock_service.delete.call_count == 1
|
||||
deleted_id = (
|
||||
mock_service.delete.call_args.args[0]
|
||||
if mock_service.delete.call_args.args
|
||||
else mock_service.delete.call_args.kwargs.get("workflow_id")
|
||||
)
|
||||
assert deleted_id == pack_wf_id
|
||||
# The user-owned workflow id is never passed to delete()
|
||||
all_delete_args = [
|
||||
(call.args[0] if call.args else call.kwargs.get("workflow_id"))
|
||||
for call in mock_service.delete.call_args_list
|
||||
]
|
||||
assert user_wf_id not in all_delete_args
|
||||
|
||||
|
||||
class TestUnregisterPack:
|
||||
"""Tests for InvocationRegistry.unregister_pack()."""
|
||||
|
||||
def test_unregister_removes_invocations(self) -> None:
|
||||
# Save original state
|
||||
original_invocations = InvocationRegistry._invocation_classes.copy()
|
||||
original_outputs = InvocationRegistry._output_classes.copy()
|
||||
|
||||
try:
|
||||
# Create a mock invocation class
|
||||
mock_inv = MagicMock(spec=BaseInvocation)
|
||||
mock_inv.UIConfig = MagicMock()
|
||||
mock_inv.UIConfig.node_pack = "test_removable_pack"
|
||||
mock_inv.get_type.return_value = "test_removable_node"
|
||||
|
||||
InvocationRegistry._invocation_classes.add(mock_inv)
|
||||
|
||||
# Verify it's registered
|
||||
assert mock_inv in InvocationRegistry._invocation_classes
|
||||
|
||||
# Unregister
|
||||
removed = InvocationRegistry.unregister_pack("test_removable_pack")
|
||||
|
||||
assert "test_removable_node" in removed
|
||||
assert mock_inv not in InvocationRegistry._invocation_classes
|
||||
finally:
|
||||
# Restore original state
|
||||
InvocationRegistry._invocation_classes = original_invocations
|
||||
InvocationRegistry._output_classes = original_outputs
|
||||
|
||||
def test_unregister_returns_empty_for_unknown_pack(self) -> None:
|
||||
removed = InvocationRegistry.unregister_pack("nonexistent_pack_xyz")
|
||||
assert removed == []
|
||||
|
||||
def test_unregister_removes_multiple_invocations(self) -> None:
|
||||
original_invocations = InvocationRegistry._invocation_classes.copy()
|
||||
original_outputs = InvocationRegistry._output_classes.copy()
|
||||
|
||||
try:
|
||||
mock_inv_1 = MagicMock(spec=BaseInvocation)
|
||||
mock_inv_1.UIConfig = MagicMock()
|
||||
mock_inv_1.UIConfig.node_pack = "multi_pack"
|
||||
mock_inv_1.get_type.return_value = "multi_node_1"
|
||||
|
||||
mock_inv_2 = MagicMock(spec=BaseInvocation)
|
||||
mock_inv_2.UIConfig = MagicMock()
|
||||
mock_inv_2.UIConfig.node_pack = "multi_pack"
|
||||
mock_inv_2.get_type.return_value = "multi_node_2"
|
||||
|
||||
mock_inv_other = MagicMock(spec=BaseInvocation)
|
||||
mock_inv_other.UIConfig = MagicMock()
|
||||
mock_inv_other.UIConfig.node_pack = "other_pack"
|
||||
mock_inv_other.get_type.return_value = "other_node"
|
||||
|
||||
InvocationRegistry._invocation_classes.update({mock_inv_1, mock_inv_2, mock_inv_other})
|
||||
|
||||
removed = InvocationRegistry.unregister_pack("multi_pack")
|
||||
|
||||
assert len(removed) == 2
|
||||
assert "multi_node_1" in removed
|
||||
assert "multi_node_2" in removed
|
||||
# Other pack's node should remain
|
||||
assert mock_inv_other in InvocationRegistry._invocation_classes
|
||||
finally:
|
||||
InvocationRegistry._invocation_classes = original_invocations
|
||||
InvocationRegistry._output_classes = original_outputs
|
||||
|
||||
|
||||
class TestPurgePackModules:
|
||||
"""Tests for _purge_pack_modules() — clears the pack subtree from sys.modules."""
|
||||
|
||||
def test_removes_root_module(self) -> None:
|
||||
sys.modules["purge_test_root"] = MagicMock()
|
||||
try:
|
||||
removed = _purge_pack_modules("purge_test_root")
|
||||
assert "purge_test_root" in removed
|
||||
assert "purge_test_root" not in sys.modules
|
||||
finally:
|
||||
sys.modules.pop("purge_test_root", None)
|
||||
|
||||
def test_removes_submodules(self) -> None:
|
||||
sys.modules["purge_test_pack"] = MagicMock()
|
||||
sys.modules["purge_test_pack.nodes"] = MagicMock()
|
||||
sys.modules["purge_test_pack.utils.helpers"] = MagicMock()
|
||||
try:
|
||||
removed = _purge_pack_modules("purge_test_pack")
|
||||
assert set(removed) == {
|
||||
"purge_test_pack",
|
||||
"purge_test_pack.nodes",
|
||||
"purge_test_pack.utils.helpers",
|
||||
}
|
||||
assert "purge_test_pack" not in sys.modules
|
||||
assert "purge_test_pack.nodes" not in sys.modules
|
||||
assert "purge_test_pack.utils.helpers" not in sys.modules
|
||||
finally:
|
||||
for key in ("purge_test_pack", "purge_test_pack.nodes", "purge_test_pack.utils.helpers"):
|
||||
sys.modules.pop(key, None)
|
||||
|
||||
def test_does_not_remove_unrelated_modules_with_prefix_collision(self) -> None:
|
||||
# "foo_pack_extra" must NOT be removed when purging "foo_pack"
|
||||
sys.modules["foo_pack"] = MagicMock()
|
||||
sys.modules["foo_pack_extra"] = MagicMock()
|
||||
sys.modules["foo_pack.sub"] = MagicMock()
|
||||
try:
|
||||
removed = _purge_pack_modules("foo_pack")
|
||||
assert set(removed) == {"foo_pack", "foo_pack.sub"}
|
||||
assert "foo_pack_extra" in sys.modules
|
||||
finally:
|
||||
for key in ("foo_pack", "foo_pack_extra", "foo_pack.sub"):
|
||||
sys.modules.pop(key, None)
|
||||
|
||||
def test_noop_when_pack_not_loaded(self) -> None:
|
||||
removed = _purge_pack_modules("never_loaded_pack_xyz")
|
||||
assert removed == []
|
||||
|
||||
|
||||
class TestUninstallReinstallReloadsSubmodules:
|
||||
"""Regression test for the uninstall -> reinstall cache bug.
|
||||
|
||||
Before the fix, uninstall only cleared sys.modules[pack_name] and left
|
||||
submodules cached. On reinstall, Python reused the cached submodules,
|
||||
their @invocation decorators never re-ran, and the pack loaded with
|
||||
zero registered nodes until a full process restart.
|
||||
"""
|
||||
|
||||
def test_reinstall_re_executes_submodule(self, tmp_path: Path) -> None:
|
||||
pack_name = "reinstall_regression_pack"
|
||||
pack_dir = tmp_path / pack_name
|
||||
pack_dir.mkdir()
|
||||
|
||||
# __init__.py imports from a submodule — this is the shape that triggered the bug
|
||||
(pack_dir / "__init__.py").write_text("from .nodes import * # noqa: F401,F403\n")
|
||||
submodule = pack_dir / "nodes.py"
|
||||
|
||||
# Each import of the submodule must append a marker to this file.
|
||||
# If the submodule gets reused from sys.modules instead of re-executed,
|
||||
# the second install won't produce a second marker.
|
||||
marker_file = tmp_path / "exec_markers.txt"
|
||||
submodule.write_text(
|
||||
f"from pathlib import Path\nPath(r'{marker_file.as_posix()}').open('a').write('exec\\n')\n"
|
||||
)
|
||||
|
||||
try:
|
||||
# First install
|
||||
_load_node_pack(pack_name, pack_dir)
|
||||
assert pack_name in sys.modules
|
||||
assert f"{pack_name}.nodes" in sys.modules
|
||||
assert marker_file.read_text().count("exec") == 1
|
||||
|
||||
# Simulate uninstall's module cleanup
|
||||
_purge_pack_modules(pack_name)
|
||||
assert pack_name not in sys.modules
|
||||
assert f"{pack_name}.nodes" not in sys.modules
|
||||
|
||||
# Reinstall — submodule MUST re-execute
|
||||
_load_node_pack(pack_name, pack_dir)
|
||||
assert marker_file.read_text().count("exec") == 2, (
|
||||
"Submodule was not re-executed on reinstall — the @invocation "
|
||||
"decorators would not have re-registered the pack's nodes."
|
||||
)
|
||||
finally:
|
||||
_purge_pack_modules(pack_name)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Router-level tests for /api/v1/download_queue.
|
||||
|
||||
Covers:
|
||||
- Auth gating (CurrentUserOrDefault on read/per-job, AdminUserOrDefault on prune & cancel-all).
|
||||
- Bug regression: `dest` path validation must reject absolute paths and '..' segments
|
||||
BEFORE the queue service is invoked.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.services.download import DownloadJob
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
|
||||
|
||||
def _make_job(id: int = 1) -> DownloadJob:
|
||||
from pathlib import Path
|
||||
|
||||
return DownloadJob(id=id, source="http://example.com/file.bin", dest=Path("models/file.bin"))
|
||||
|
||||
|
||||
# ----------------------------- Auth gating -----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("GET", "/api/v1/download_queue/"),
|
||||
("PATCH", "/api/v1/download_queue/"),
|
||||
("POST", "/api/v1/download_queue/i/"),
|
||||
("GET", "/api/v1/download_queue/i/1"),
|
||||
("DELETE", "/api/v1/download_queue/i/1"),
|
||||
("DELETE", "/api/v1/download_queue/i"),
|
||||
],
|
||||
)
|
||||
def test_routes_require_auth_in_multiuser_mode(enable_multiuser: Any, client: TestClient, method: str, path: str):
|
||||
response = client.request(method, path, json={"source": "http://x/y", "dest": "models/x"})
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_list_downloads_as_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
mock_invoker.services.download_queue.list_jobs = MagicMock(return_value=[])
|
||||
r = client.get("/api/v1/download_queue/", headers={"Authorization": f"Bearer {user1_token}"})
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
def test_prune_downloads_forbidden_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
r = client.patch("/api/v1/download_queue/", headers={"Authorization": f"Bearer {user1_token}"})
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_invoker.services.download_queue.prune_jobs.assert_not_called()
|
||||
|
||||
|
||||
def test_prune_downloads_allowed_for_admin(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
r = client.patch("/api/v1/download_queue/", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert r.status_code == status.HTTP_204_NO_CONTENT
|
||||
mock_invoker.services.download_queue.prune_jobs.assert_called_once()
|
||||
|
||||
|
||||
def test_cancel_all_forbidden_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
r = client.delete("/api/v1/download_queue/i", headers={"Authorization": f"Bearer {user1_token}"})
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_invoker.services.download_queue.cancel_all_jobs.assert_not_called()
|
||||
|
||||
|
||||
def test_cancel_all_allowed_for_admin(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
r = client.delete("/api/v1/download_queue/i", headers={"Authorization": f"Bearer {admin_token}"})
|
||||
assert r.status_code == status.HTTP_204_NO_CONTENT
|
||||
mock_invoker.services.download_queue.cancel_all_jobs.assert_called_once()
|
||||
|
||||
|
||||
# ----------------------------- Bug D regression: dest validation -----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_dest",
|
||||
[
|
||||
"/etc/passwd",
|
||||
"C:/Windows/System32",
|
||||
"models/../../etc/passwd",
|
||||
"..",
|
||||
"",
|
||||
" ",
|
||||
],
|
||||
)
|
||||
def test_download_rejects_unsafe_dest_before_service_call(
|
||||
client: TestClient, user1_token: str, mock_invoker: Invoker, bad_dest: str
|
||||
):
|
||||
"""Absolute paths, '..' segments, and empty strings must produce 400 and
|
||||
must NOT invoke the download_queue service."""
|
||||
r = client.post(
|
||||
"/api/v1/download_queue/i/",
|
||||
json={"source": "http://example.com/file.bin", "dest": bad_dest},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_invoker.services.download_queue.download.assert_not_called()
|
||||
|
||||
|
||||
def test_download_accepts_relative_dest(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
mock_invoker.services.download_queue.download = MagicMock(return_value=_make_job())
|
||||
r = client.post(
|
||||
"/api/v1/download_queue/i/",
|
||||
json={"source": "http://example.com/file.bin", "dest": "models/sd15.safetensors"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
mock_invoker.services.download_queue.download.assert_called_once()
|
||||
@@ -0,0 +1,199 @@
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.auth.token_service import set_jwt_secret
|
||||
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
|
||||
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
||||
from invokeai.app.services.boards.boards_default import BoardService
|
||||
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
|
||||
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.image_moves.image_moves_default import ImageMoveJobAlreadyRunning, ImageMoveQueueActive
|
||||
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
|
||||
from invokeai.app.services.images.images_default import ImageService
|
||||
from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
|
||||
from invokeai.app.services.invocation_services import InvocationServices
|
||||
from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
from invokeai.app.services.users.users_default import UserService
|
||||
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
from tests.fixtures.sqlite_database import create_mock_sqlite_database
|
||||
from tests.test_nodes import TestEventService
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_services() -> InvocationServices:
|
||||
configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
|
||||
logger = InvokeAILogger.get_logger()
|
||||
db = create_mock_sqlite_database(configuration, logger)
|
||||
image_moves = MagicMock()
|
||||
return InvocationServices(
|
||||
board_image_records=SqliteBoardImageRecordStorage(db=db),
|
||||
board_images=None, # type: ignore
|
||||
board_records=SqliteBoardRecordStorage(db=db),
|
||||
boards=BoardService(),
|
||||
bulk_download=BulkDownloadService(),
|
||||
configuration=configuration,
|
||||
events=TestEventService(),
|
||||
image_files=None, # type: ignore
|
||||
image_records=SqliteImageRecordStorage(db=db),
|
||||
images=ImageService(),
|
||||
invocation_cache=MemoryInvocationCache(max_cache_size=0),
|
||||
logger=logging, # type: ignore
|
||||
model_images=None, # type: ignore
|
||||
model_manager=None, # type: ignore
|
||||
download_queue=None, # type: ignore
|
||||
external_generation=None, # type: ignore
|
||||
names=None, # type: ignore
|
||||
performance_statistics=InvocationStatsService(),
|
||||
session_processor=None, # type: ignore
|
||||
session_queue=None, # type: ignore
|
||||
urls=None, # type: ignore
|
||||
workflow_records=SqliteWorkflowRecordsStorage(db=db),
|
||||
tensors=None, # type: ignore
|
||||
conditioning=None, # type: ignore
|
||||
style_preset_records=None, # type: ignore
|
||||
style_preset_image_files=None, # type: ignore
|
||||
workflow_thumbnails=None, # type: ignore
|
||||
model_relationship_records=None, # type: ignore
|
||||
model_relationships=None, # type: ignore
|
||||
client_state_persistence=ClientStatePersistenceSqlite(db=db),
|
||||
users=UserService(db),
|
||||
image_moves=image_moves,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_invoker(mock_services: InvocationServices, monkeypatch: pytest.MonkeyPatch) -> Invoker:
|
||||
invoker = Invoker(services=mock_services)
|
||||
mock_deps = MockApiDependencies(invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.image_moves.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
|
||||
return invoker
|
||||
|
||||
|
||||
def _status_payload(is_running: bool = True, operation: str = "move_all") -> dict:
|
||||
return {
|
||||
"is_running": is_running,
|
||||
"operation": operation,
|
||||
"active_job_id": None,
|
||||
"latest_job": None,
|
||||
"last_error": None,
|
||||
"needs_move_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def _create_user(invoker: Invoker, email: str, is_admin: bool) -> None:
|
||||
invoker.services.users.create(
|
||||
UserCreateRequest(
|
||||
email=email,
|
||||
display_name=email,
|
||||
password="TestPass123",
|
||||
is_admin=is_admin,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _login(client: TestClient, email: str) -> str:
|
||||
response = client.post("/api/v1/auth/login", json={"email": email, "password": "TestPass123"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
def test_start_image_move_returns_accepted_without_running_job_inline(
|
||||
client: TestClient, mock_invoker: Invoker
|
||||
) -> None:
|
||||
image_moves = mock_invoker.services.image_moves
|
||||
image_moves.start_background_move_all.return_value = _status_payload()
|
||||
|
||||
response = client.post("/api/v1/image_moves/start")
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert response.json()["is_running"] is True
|
||||
image_moves.start_background_move_all.assert_called_once_with()
|
||||
image_moves.move_all_images.assert_not_called()
|
||||
|
||||
|
||||
def test_start_image_move_rejects_overlapping_background_job(client: TestClient, mock_invoker: Invoker) -> None:
|
||||
image_moves = mock_invoker.services.image_moves
|
||||
image_moves.start_background_move_all.side_effect = ImageMoveJobAlreadyRunning("already running")
|
||||
|
||||
response = client.post("/api/v1/image_moves/start")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert response.json()["detail"] == "already running"
|
||||
|
||||
|
||||
def test_start_image_move_rejects_active_queue_work(client: TestClient, mock_invoker: Invoker) -> None:
|
||||
image_moves = mock_invoker.services.image_moves
|
||||
image_moves.start_background_move_all.side_effect = ImageMoveQueueActive("queue work is active")
|
||||
|
||||
response = client.post("/api/v1/image_moves/start")
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert response.json()["detail"] == "queue work is active"
|
||||
|
||||
|
||||
def test_force_recovery_returns_accepted(client: TestClient, mock_invoker: Invoker) -> None:
|
||||
image_moves = mock_invoker.services.image_moves
|
||||
image_moves.start_background_recovery.return_value = _status_payload(operation="recovery")
|
||||
|
||||
response = client.post("/api/v1/image_moves/recover")
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert response.json()["operation"] == "recovery"
|
||||
image_moves.start_background_recovery.assert_called_once_with()
|
||||
|
||||
|
||||
def test_image_move_status_uses_service_status(client: TestClient, mock_invoker: Invoker) -> None:
|
||||
image_moves = mock_invoker.services.image_moves
|
||||
image_moves.get_background_status.return_value = _status_payload(is_running=False)
|
||||
|
||||
response = client.get("/api/v1/image_moves/status")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["is_running"] is False
|
||||
assert response.json()["needs_move_count"] == 0
|
||||
image_moves.get_background_status.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("post", "/api/v1/image_moves/start"),
|
||||
("post", "/api/v1/image_moves/recover"),
|
||||
("get", "/api/v1/image_moves/status"),
|
||||
],
|
||||
)
|
||||
def test_image_move_endpoints_require_admin_in_multiuser_mode(
|
||||
client: TestClient, mock_invoker: Invoker, method: str, path: str
|
||||
) -> None:
|
||||
set_jwt_secret("test-secret")
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
_create_user(mock_invoker, "user@test.com", is_admin=False)
|
||||
token = _login(client, "user@test.com")
|
||||
|
||||
response = getattr(client, method)(path, headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -0,0 +1,222 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.auth_dependencies import get_current_user_or_default
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.auth.token_service import TokenData
|
||||
from invokeai.app.services.board_records.board_records_common import BoardRecord
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def client(invokeai_root_dir: Path) -> TestClient:
|
||||
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
def test_download_images_from_list(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
prepare_download_images_test(monkeypatch, mock_invoker)
|
||||
|
||||
response = client.post("/api/v1/images/download", json={"image_names": ["test.png"]})
|
||||
json_response = response.json()
|
||||
assert response.status_code == 202
|
||||
assert json_response["bulk_download_item_name"] == "test.zip"
|
||||
|
||||
|
||||
def test_download_images_from_board_id_empty_image_name_list(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
expected_board_name = "test"
|
||||
|
||||
def mock_get(*args, **kwargs):
|
||||
return BoardRecord(board_id="12345", board_name=expected_board_name, created_at="None", updated_at="None")
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_get)
|
||||
prepare_download_images_test(monkeypatch, mock_invoker)
|
||||
|
||||
response = client.post("/api/v1/images/download", json={"board_id": "test"})
|
||||
json_response = response.json()
|
||||
assert response.status_code == 202
|
||||
assert json_response["bulk_download_item_name"] == "test.zip"
|
||||
|
||||
|
||||
def prepare_download_images_test(monkeypatch: Any, mock_invoker: Invoker) -> None:
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr(
|
||||
"invokeai.app.api.routers.images.ApiDependencies.invoker.services.bulk_download.generate_item_id",
|
||||
lambda arg: "test",
|
||||
)
|
||||
|
||||
def mock_add_task(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(BackgroundTasks, "add_task", mock_add_task)
|
||||
|
||||
|
||||
def prepare_image_maintenance_test(monkeypatch: Any, mock_invoker: Invoker) -> None:
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
mock_invoker.services.image_moves = MagicMock()
|
||||
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
|
||||
monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="system"))
|
||||
monkeypatch.setattr(mock_invoker.services.board_image_records, "get_board_for_image", MagicMock(return_value=None))
|
||||
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers._access.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "json_body"),
|
||||
[
|
||||
("get", "/api/v1/images/i/test.png/full", None),
|
||||
("head", "/api/v1/images/i/test.png/full", None),
|
||||
("get", "/api/v1/images/i/test.png/thumbnail", None),
|
||||
("get", "/api/v1/images/i/test.png/workflow", None),
|
||||
("delete", "/api/v1/images/i/test.png", None),
|
||||
("delete", "/api/v1/images/intermediates", None),
|
||||
("delete", "/api/v1/images/uncategorized", None),
|
||||
("patch", "/api/v1/images/i/test.png", {"starred": True}),
|
||||
("post", "/api/v1/images/delete", {"image_names": ["test.png"]}),
|
||||
("post", "/api/v1/images/star", {"image_names": ["test.png"]}),
|
||||
("post", "/api/v1/images/unstar", {"image_names": ["test.png"]}),
|
||||
("post", "/api/v1/images/download", {"image_names": ["test.png"]}),
|
||||
],
|
||||
)
|
||||
def test_image_operations_are_blocked_during_image_move_maintenance(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient, method: str, path: str, json_body: dict | None
|
||||
) -> None:
|
||||
prepare_image_maintenance_test(monkeypatch, mock_invoker)
|
||||
|
||||
if json_body is not None:
|
||||
response = getattr(client, method)(path, json=json_body)
|
||||
else:
|
||||
response = getattr(client, method)(path)
|
||||
|
||||
assert response.status_code == 409
|
||||
if method != "head":
|
||||
assert response.json()["detail"] == "Image storage maintenance is active"
|
||||
|
||||
|
||||
def test_image_mutation_checks_access_before_image_move_maintenance(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
prepare_image_maintenance_test(monkeypatch, mock_invoker)
|
||||
monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="other-user"))
|
||||
|
||||
async def current_user_override() -> TokenData:
|
||||
return TokenData(user_id="request-user", email="request-user@example.com", is_admin=False)
|
||||
|
||||
app.dependency_overrides[get_current_user_or_default] = current_user_override
|
||||
try:
|
||||
response = client.delete("/api/v1/images/i/test.png")
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_invoker.services.image_moves.is_maintenance_active.assert_not_called()
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_current_user_or_default, None)
|
||||
|
||||
|
||||
def test_image_upload_is_blocked_during_image_move_maintenance(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
prepare_image_maintenance_test(monkeypatch, mock_invoker)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/images/upload",
|
||||
params={"image_category": "general", "is_intermediate": False},
|
||||
files={"file": ("test.png", b"not-read-during-maintenance", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "Image storage maintenance is active"
|
||||
|
||||
|
||||
def test_image_to_prompt_is_blocked_during_image_move_maintenance(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
prepare_image_maintenance_test(monkeypatch, mock_invoker)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/utilities/image-to-prompt",
|
||||
json={"image_name": "test.png", "model_key": "model-key", "instruction": "describe"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "Image storage maintenance is active"
|
||||
|
||||
|
||||
def test_download_images_with_empty_image_list_and_no_board_id(
|
||||
monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
prepare_download_images_test(monkeypatch, mock_invoker)
|
||||
|
||||
response = client.post("/api/v1/images/download", json={"image_names": []})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_get_bulk_download_image(tmp_path: Path, monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
mock_file: Path = tmp_path / "test.zip"
|
||||
mock_file.write_text("contents")
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.bulk_download, "get_path", lambda x: str(mock_file))
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
|
||||
def mock_add_task(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(BackgroundTasks, "add_task", mock_add_task)
|
||||
|
||||
response = client.get("/api/v1/images/download/test.zip")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"contents"
|
||||
|
||||
|
||||
def test_get_bulk_download_image_not_found(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None:
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
|
||||
def mock_add_task(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(BackgroundTasks, "add_task", mock_add_task)
|
||||
|
||||
response = client.get("/api/v1/images/download/test.zip")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_get_bulk_download_image_image_deleted_after_response(
|
||||
monkeypatch: Any, mock_invoker: Invoker, tmp_path: Path, client: TestClient
|
||||
) -> None:
|
||||
mock_file: Path = tmp_path / "test.zip"
|
||||
mock_file.write_text("contents")
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.bulk_download, "get_path", lambda x: str(mock_file))
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
|
||||
client.get("/api/v1/images/download/test.zip")
|
||||
|
||||
assert not (tmp_path / "test.zip").exists()
|
||||
@@ -0,0 +1,185 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.backend.model_manager.configs.external_api import (
|
||||
ExternalApiModelConfig,
|
||||
ExternalModelCapabilities,
|
||||
ExternalModelPanelSchema,
|
||||
)
|
||||
from invokeai.backend.model_manager.taxonomy import ModelType
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def client(invokeai_root_dir: Path) -> TestClient:
|
||||
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class DummyModelImages:
|
||||
def get_url(self, key: str) -> str:
|
||||
return f"https://example.com/models/{key}.png"
|
||||
|
||||
|
||||
class DummyInvoker:
|
||||
def __init__(self, services: Any) -> None:
|
||||
self.services = services
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
invoker: DummyInvoker
|
||||
|
||||
def __init__(self, invoker: DummyInvoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
def test_model_manager_external_config_round_trip(
|
||||
monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any
|
||||
) -> None:
|
||||
config = ExternalApiModelConfig(
|
||||
key="external_test",
|
||||
name="External Test",
|
||||
provider_id="openai",
|
||||
provider_model_id="gpt-image-1",
|
||||
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
|
||||
)
|
||||
mm2_model_manager.store.add_model(config)
|
||||
|
||||
services = type("Services", (), {})()
|
||||
services.model_manager = mm2_model_manager
|
||||
services.model_images = DummyModelImages()
|
||||
services.configuration = mm2_app_config
|
||||
|
||||
invoker = DummyInvoker(services)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker))
|
||||
|
||||
response = client.get("/api/v2/models/", params={"model_type": ModelType.ExternalImageGenerator.value})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload["models"]) == 1
|
||||
assert payload["models"][0]["key"] == "external_test"
|
||||
assert payload["models"][0]["provider_id"] == "openai"
|
||||
assert payload["models"][0]["cover_image"] == "https://example.com/models/external_test.png"
|
||||
|
||||
get_response = client.get("/api/v2/models/i/external_test")
|
||||
|
||||
assert get_response.status_code == 200
|
||||
model_payload = get_response.json()
|
||||
assert model_payload["provider_model_id"] == "gpt-image-1"
|
||||
assert model_payload["cover_image"] == "https://example.com/models/external_test.png"
|
||||
|
||||
|
||||
def test_model_manager_external_config_preserves_custom_panel_schema(
|
||||
monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any
|
||||
) -> None:
|
||||
config = ExternalApiModelConfig(
|
||||
key="external_custom_schema",
|
||||
name="External Custom Schema",
|
||||
provider_id="custom",
|
||||
provider_model_id="custom-model",
|
||||
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
|
||||
panel_schema=ExternalModelPanelSchema(
|
||||
prompts=[{"name": "reference_images"}],
|
||||
image=[{"name": "dimensions"}],
|
||||
),
|
||||
source="external://custom/custom-model",
|
||||
)
|
||||
mm2_model_manager.store.add_model(config)
|
||||
|
||||
services = type("Services", (), {})()
|
||||
services.model_manager = mm2_model_manager
|
||||
services.model_images = DummyModelImages()
|
||||
services.configuration = mm2_app_config
|
||||
|
||||
invoker = DummyInvoker(services)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker))
|
||||
|
||||
response = client.get("/api/v2/models/i/external_custom_schema")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [control["name"] for control in payload["panel_schema"]["prompts"]] == ["reference_images"]
|
||||
assert [control["name"] for control in payload["panel_schema"]["image"]] == ["dimensions"]
|
||||
|
||||
|
||||
def test_model_manager_external_starter_model_applies_panel_schema_overrides(
|
||||
monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any
|
||||
) -> None:
|
||||
config = ExternalApiModelConfig(
|
||||
key="external_starter_schema",
|
||||
name="Starter Schema Test",
|
||||
provider_id="openai",
|
||||
provider_model_id="gpt-image-1",
|
||||
capabilities=ExternalModelCapabilities(
|
||||
modes=["txt2img"],
|
||||
supports_reference_images=False,
|
||||
),
|
||||
)
|
||||
mm2_model_manager.store.add_model(config)
|
||||
|
||||
services = type("Services", (), {})()
|
||||
services.model_manager = mm2_model_manager
|
||||
services.model_images = DummyModelImages()
|
||||
services.configuration = mm2_app_config
|
||||
|
||||
invoker = DummyInvoker(services)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker))
|
||||
|
||||
response = client.get("/api/v2/models/i/external_starter_schema")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [control["name"] for control in payload["panel_schema"]["prompts"]] == ["reference_images"]
|
||||
assert [control["name"] for control in payload["panel_schema"]["image"]] == ["dimensions"]
|
||||
assert payload["panel_schema"]["generation"] == []
|
||||
|
||||
|
||||
def test_model_manager_gemini_starter_model_applies_reference_and_resolution_overrides(
|
||||
monkeypatch: Any, client: TestClient, mm2_model_manager: Any, mm2_app_config: Any
|
||||
) -> None:
|
||||
config = ExternalApiModelConfig(
|
||||
key="external_gemini_schema",
|
||||
name="Gemini Starter Schema Test",
|
||||
provider_id="gemini",
|
||||
provider_model_id="gemini-3.1-flash-image-preview",
|
||||
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
|
||||
source="external://gemini/gemini-3.1-flash-image-preview",
|
||||
)
|
||||
mm2_model_manager.store.add_model(config)
|
||||
|
||||
services = type("Services", (), {})()
|
||||
services.model_manager = mm2_model_manager
|
||||
services.model_images = DummyModelImages()
|
||||
services.configuration = mm2_app_config
|
||||
|
||||
invoker = DummyInvoker(services)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", MockApiDependencies(invoker))
|
||||
|
||||
response = client.get("/api/v2/models/i/external_gemini_schema")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["capabilities"]["max_reference_images"] == 14
|
||||
assert payload["capabilities"]["max_image_size"] == {"width": 4096, "height": 4096}
|
||||
assert payload["capabilities"]["allowed_aspect_ratios"] == [
|
||||
"1:1",
|
||||
"1:4",
|
||||
"1:8",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"3:4",
|
||||
"4:1",
|
||||
"4:3",
|
||||
"4:5",
|
||||
"5:4",
|
||||
"8:1",
|
||||
"9:16",
|
||||
"16:9",
|
||||
"21:9",
|
||||
]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Router-level tests for /api/v1/model_relationships.
|
||||
|
||||
Covers:
|
||||
- Auth gating (CurrentUserOrDefault on read/batch, AdminUserOrDefault on add/remove).
|
||||
- Bug regression: self-relationship checks must return 400 (not 500 — the previous
|
||||
broad `except Exception` swallowed the HTTPException and converted it).
|
||||
- Service exception mapping: ValueError → 409 on add, 404 on remove.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
|
||||
REQ_BODY = {
|
||||
"model_key_1": "aa3b247f-90c9-4416-bfcd-aeaa57a5339e",
|
||||
"model_key_2": "ac32b914-10ab-496e-a24a-3068724b9c35",
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------- Auth gating -----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "body"),
|
||||
[
|
||||
("GET", "/api/v1/model_relationships/i/some-key", None),
|
||||
("POST", "/api/v1/model_relationships/", REQ_BODY),
|
||||
("DELETE", "/api/v1/model_relationships/", REQ_BODY),
|
||||
("POST", "/api/v1/model_relationships/batch", {"model_keys": ["a", "b"]}),
|
||||
],
|
||||
)
|
||||
def test_routes_require_auth(enable_multiuser: Any, client: TestClient, method: str, path: str, body: dict | None):
|
||||
r = client.request(method, path, json=body)
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_get_related_models_allowed_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
mock_invoker.services.model_relationships.get_related_model_keys = MagicMock(return_value=["k1"])
|
||||
r = client.get(
|
||||
"/api/v1/model_relationships/i/some-key",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
assert r.json() == ["k1"]
|
||||
|
||||
|
||||
def test_batch_allowed_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
mock_invoker.services.model_relationships.get_related_model_keys = MagicMock(side_effect=lambda k: [f"r-{k}"])
|
||||
r = client.post(
|
||||
"/api/v1/model_relationships/batch",
|
||||
json={"model_keys": ["a", "b"]},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
assert set(r.json()) == {"r-a", "r-b"}
|
||||
|
||||
|
||||
def test_add_forbidden_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
r = client.post(
|
||||
"/api/v1/model_relationships/",
|
||||
json=REQ_BODY,
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_invoker.services.model_relationships.add_model_relationship.assert_not_called()
|
||||
|
||||
|
||||
def test_remove_forbidden_for_regular_user(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
r = client.request(
|
||||
"DELETE",
|
||||
"/api/v1/model_relationships/",
|
||||
json=REQ_BODY,
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_invoker.services.model_relationships.remove_model_relationship.assert_not_called()
|
||||
|
||||
|
||||
def test_add_allowed_for_admin(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
r = client.post(
|
||||
"/api/v1/model_relationships/",
|
||||
json=REQ_BODY,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_204_NO_CONTENT
|
||||
mock_invoker.services.model_relationships.add_model_relationship.assert_called_once()
|
||||
|
||||
|
||||
def test_remove_allowed_for_admin(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
r = client.request(
|
||||
"DELETE",
|
||||
"/api/v1/model_relationships/",
|
||||
json=REQ_BODY,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_204_NO_CONTENT
|
||||
mock_invoker.services.model_relationships.remove_model_relationship.assert_called_once()
|
||||
|
||||
|
||||
# ----------------------------- Bug A regression: self-relationship → 400 -----------------------------
|
||||
|
||||
|
||||
def test_add_self_relationship_returns_400_not_500(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Before the fix, the inner HTTPException(400) was caught by `except Exception`
|
||||
and re-raised as 500."""
|
||||
r = client.post(
|
||||
"/api/v1/model_relationships/",
|
||||
json={"model_key_1": "same-key", "model_key_2": "same-key"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_invoker.services.model_relationships.add_model_relationship.assert_not_called()
|
||||
|
||||
|
||||
def test_remove_self_relationship_returns_400_not_500(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
r = client.request(
|
||||
"DELETE",
|
||||
"/api/v1/model_relationships/",
|
||||
json={"model_key_1": "same-key", "model_key_2": "same-key"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_invoker.services.model_relationships.remove_model_relationship.assert_not_called()
|
||||
|
||||
|
||||
# ----------------------------- Service exception mapping -----------------------------
|
||||
|
||||
|
||||
def test_add_value_error_returns_409(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
mock_invoker.services.model_relationships.add_model_relationship = MagicMock(
|
||||
side_effect=ValueError("relationship already exists")
|
||||
)
|
||||
r = client.post(
|
||||
"/api/v1/model_relationships/",
|
||||
json=REQ_BODY,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_409_CONFLICT
|
||||
|
||||
|
||||
def test_remove_value_error_returns_404(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
mock_invoker.services.model_relationships.remove_model_relationship = MagicMock(
|
||||
side_effect=ValueError("relationship not found")
|
||||
)
|
||||
r = client.request(
|
||||
"DELETE",
|
||||
"/api/v1/model_relationships/",
|
||||
json=REQ_BODY,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_404_NOT_FOUND
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,834 @@
|
||||
"""Tests for the recall parameters router.
|
||||
|
||||
These tests monkey-patch the heavy-weight lookup helpers
|
||||
(``resolve_model_name_to_key``, ``load_image_file``,
|
||||
``process_controlnet_image``) rather than wiring up a real model manager
|
||||
or image-files service. This keeps each test focused on the router's
|
||||
request-validation, resolver sequencing, and broadcast payload shape.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api.routers import recall_parameters as recall_module
|
||||
from invokeai.app.api.routers.recall_parameters import load_image_file
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.backend.model_manager.taxonomy import ModelType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
"""Minimal ApiDependencies stand-in that only wires up an invoker."""
|
||||
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_dependencies(monkeypatch: Any, mock_invoker: Invoker) -> MockApiDependencies:
|
||||
"""Install a mock ApiDependencies for the recall_parameters router.
|
||||
|
||||
The router persists each parameter via ``client_state_persistence.set_by_key``,
|
||||
whose ``user_id`` column has a FOREIGN KEY constraint back to the users
|
||||
table. The mock invoker uses an in-memory SQLite database that is not
|
||||
pre-populated with any users, so persistence would fail with "FOREIGN
|
||||
KEY constraint failed" — that's an orthogonal concern to the reference-
|
||||
images resolver under test, so we stub it out.
|
||||
"""
|
||||
dependencies = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.recall_parameters.ApiDependencies", dependencies)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", dependencies)
|
||||
monkeypatch.setattr(
|
||||
mock_invoker.services.client_state_persistence,
|
||||
"set_by_key",
|
||||
lambda user_id, key, value: value,
|
||||
)
|
||||
return dependencies
|
||||
|
||||
|
||||
def make_name_to_key_stub(
|
||||
mapping: dict[tuple[str, ModelType], str],
|
||||
) -> Callable[[str, ModelType], Optional[str]]:
|
||||
"""Build a ``resolve_model_name_to_key`` stand-in from a (name, type) dict.
|
||||
|
||||
Any lookup that is not present in ``mapping`` returns ``None``, mirroring
|
||||
what the real resolver does when the model manager cannot find a match.
|
||||
"""
|
||||
|
||||
def _lookup(model_name: str, model_type: ModelType = ModelType.Main) -> Optional[str]:
|
||||
return mapping.get((model_name, model_type))
|
||||
|
||||
return _lookup
|
||||
|
||||
|
||||
def make_load_image_file_stub(
|
||||
known_images: dict[str, tuple[int, int]],
|
||||
) -> Callable[[str], Optional[dict[str, Any]]]:
|
||||
"""Build a ``load_image_file`` stand-in from a name → (width, height) dict."""
|
||||
|
||||
def _load(image_name: str) -> Optional[dict[str, Any]]:
|
||||
dims = known_images.get(image_name)
|
||||
if dims is None:
|
||||
return None
|
||||
width, height = dims
|
||||
return {"image_name": image_name, "width": width, "height": height}
|
||||
|
||||
return _load
|
||||
|
||||
|
||||
def test_recall_parameters_is_blocked_during_image_move_maintenance(
|
||||
monkeypatch: Any, patched_dependencies: MockApiDependencies, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
mock_invoker.services.image_moves = MagicMock()
|
||||
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
|
||||
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", patched_dependencies)
|
||||
|
||||
response = client.post("/api/v1/recall/default", json={"positive_prompt": "hello"})
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"] == "Image storage maintenance is active"
|
||||
|
||||
|
||||
class TestReferenceImagesRecall:
|
||||
def test_reference_images_forwarded_when_image_exists(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Reference images whose files exist should flow through to the event payload."""
|
||||
|
||||
# Stub load_image_file so we don't need a real outputs/images directory.
|
||||
def fake_load_image_file(image_name: str) -> dict[str, Any] | None:
|
||||
return {"image_name": image_name, "width": 1024, "height": 768}
|
||||
|
||||
monkeypatch.setattr(recall_module, "load_image_file", fake_load_image_file)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"reference_images": [
|
||||
{"image_name": "cat.png"},
|
||||
{"image_name": "dog.png"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "success"
|
||||
assert body["queue_id"] == "default"
|
||||
# Both references came through, in order.
|
||||
resolved = body["parameters"]["reference_images"]
|
||||
assert len(resolved) == 2
|
||||
assert resolved[0]["image"]["image_name"] == "cat.png"
|
||||
assert resolved[1]["image"]["image_name"] == "dog.png"
|
||||
assert resolved[0]["image"]["width"] == 1024
|
||||
|
||||
def test_missing_reference_images_are_dropped_without_failing(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""An image that can't be loaded should be skipped — never 500."""
|
||||
|
||||
def fake_load_image_file(image_name: str) -> dict[str, Any] | None:
|
||||
if image_name == "present.png":
|
||||
return {"image_name": image_name, "width": 512, "height": 512}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(recall_module, "load_image_file", fake_load_image_file)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"reference_images": [
|
||||
{"image_name": "missing.png"},
|
||||
{"image_name": "present.png"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resolved = response.json()["parameters"]["reference_images"]
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0]["image"]["image_name"] == "present.png"
|
||||
|
||||
def test_reference_images_do_not_require_model_name(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""The schema must accept a reference image entry with only ``image_name``.
|
||||
|
||||
This pins down the "model-free" contract: unlike ``ip_adapters``,
|
||||
these entries are for FLUX.2 Klein / FLUX Kontext / Qwen Image Edit,
|
||||
where the reference image feeds the main model directly and there is
|
||||
no adapter model to name. Callers should be able to omit every
|
||||
field except ``image_name``.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
lambda image_name: {"image_name": image_name, "width": 64, "height": 64},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"reference_images": [{"image_name": "ok.png"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resolved = response.json()["parameters"]["reference_images"]
|
||||
assert resolved == [{"image": {"image_name": "ok.png", "width": 64, "height": 64}}]
|
||||
|
||||
def test_empty_reference_images_is_noop_for_other_fields(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Sending an empty reference_images list should not break other fields."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
lambda image_name: {"image_name": image_name, "width": 1, "height": 1},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"positive_prompt": "hello",
|
||||
"reference_images": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["positive_prompt"] == "hello"
|
||||
assert params["reference_images"] == []
|
||||
|
||||
|
||||
class TestLorasRecall:
|
||||
def test_multiple_loras_resolved_with_weights_and_is_enabled(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Each LoRA's model name is resolved to a key and weight/is_enabled pass through."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub(
|
||||
{
|
||||
("detail-lora", ModelType.LoRA): "key-detail",
|
||||
("style-lora", ModelType.LoRA): "key-style",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"loras": [
|
||||
{"model_name": "detail-lora", "weight": 0.8, "is_enabled": True},
|
||||
{"model_name": "style-lora", "weight": 0.5, "is_enabled": False},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
loras = response.json()["parameters"]["loras"]
|
||||
assert loras == [
|
||||
{"model_key": "key-detail", "weight": 0.8, "is_enabled": True},
|
||||
{"model_key": "key-style", "weight": 0.5, "is_enabled": False},
|
||||
]
|
||||
|
||||
def test_unresolvable_loras_are_dropped(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""LoRAs whose names do not resolve are silently skipped — not an error."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("keeper", ModelType.LoRA): "key-keeper"}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"loras": [
|
||||
{"model_name": "keeper", "weight": 0.7},
|
||||
{"model_name": "ghost-lora"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
loras = response.json()["parameters"]["loras"]
|
||||
assert len(loras) == 1
|
||||
assert loras[0]["model_key"] == "key-keeper"
|
||||
|
||||
def test_is_enabled_defaults_to_true(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Omitting is_enabled should default to True per the pydantic schema."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("x", ModelType.LoRA): "key-x"}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"loras": [{"model_name": "x"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["loras"][0]["is_enabled"] is True
|
||||
|
||||
|
||||
class TestControlLayersRecall:
|
||||
def test_controlnet_resolution_takes_precedence(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""A name that matches a ControlNet model should resolve to it directly."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub({"ctl.png": (512, 512)}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"control_layers": [
|
||||
{
|
||||
"model_name": "canny",
|
||||
"image_name": "ctl.png",
|
||||
"weight": 0.75,
|
||||
"begin_step_percent": 0.1,
|
||||
"end_step_percent": 0.9,
|
||||
"control_mode": "balanced",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
layer = response.json()["parameters"]["control_layers"][0]
|
||||
assert layer["model_key"] == "key-canny"
|
||||
assert layer["weight"] == 0.75
|
||||
assert layer["begin_step_percent"] == 0.1
|
||||
assert layer["end_step_percent"] == 0.9
|
||||
assert layer["control_mode"] == "balanced"
|
||||
assert layer["image"] == {"image_name": "ctl.png", "width": 512, "height": 512}
|
||||
# processor returned None → no processed_image field
|
||||
assert "processed_image" not in layer
|
||||
|
||||
def test_falls_back_to_t2i_adapter(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""When no ControlNet match exists, T2I Adapter is tried next."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("sketchy", ModelType.T2IAdapter): "key-t2i"}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"control_layers": [{"model_name": "sketchy", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["control_layers"][0]["model_key"] == "key-t2i"
|
||||
|
||||
def test_falls_back_to_control_lora(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""When neither ControlNet nor T2I Adapter matches, Control LoRA is tried last."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("clora", ModelType.LoRA): "key-clora"}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"control_layers": [{"model_name": "clora", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["control_layers"][0]["model_key"] == "key-clora"
|
||||
|
||||
def test_missing_image_still_resolves_config(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""A missing control image is warned about but does not block the rest of the config."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"control_layers": [
|
||||
{
|
||||
"model_name": "canny",
|
||||
"image_name": "missing.png",
|
||||
"weight": 0.75,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
layer = response.json()["parameters"]["control_layers"][0]
|
||||
assert layer["model_key"] == "key-canny"
|
||||
assert layer["weight"] == 0.75
|
||||
assert "image" not in layer
|
||||
assert "processed_image" not in layer
|
||||
|
||||
def test_processed_image_included_when_processor_returns_data(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""When the processor produces a derived image, it is attached to the resolved layer."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub({"ctl.png": (768, 768)}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"process_controlnet_image",
|
||||
lambda image_name, model_key, services: {
|
||||
"image_name": f"processed-{image_name}",
|
||||
"width": 768,
|
||||
"height": 768,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"control_layers": [{"model_name": "canny", "image_name": "ctl.png", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
layer = response.json()["parameters"]["control_layers"][0]
|
||||
assert layer["processed_image"]["image_name"] == "processed-ctl.png"
|
||||
|
||||
def test_unresolvable_control_layers_are_dropped(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Control entries whose model doesn't resolve by any type are skipped."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"control_layers": [{"model_name": "unknown", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["control_layers"] == []
|
||||
|
||||
|
||||
class TestIPAdaptersRecall:
|
||||
def test_ip_adapter_resolved_with_image_and_method(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""IPAdapter lookup is tried first and all config fields pass through."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("ipa-face", ModelType.IPAdapter): "key-ipa"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub({"ref.png": (1024, 1024)}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_name": "ipa-face",
|
||||
"image_name": "ref.png",
|
||||
"weight": 0.7,
|
||||
"begin_step_percent": 0.0,
|
||||
"end_step_percent": 0.8,
|
||||
"method": "style",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
adapter = response.json()["parameters"]["ip_adapters"][0]
|
||||
assert adapter["model_key"] == "key-ipa"
|
||||
assert adapter["weight"] == 0.7
|
||||
assert adapter["begin_step_percent"] == 0.0
|
||||
assert adapter["end_step_percent"] == 0.8
|
||||
assert adapter["method"] == "style"
|
||||
assert adapter["image"] == {"image_name": "ref.png", "width": 1024, "height": 1024}
|
||||
# image_influence was not sent, so it must not appear in the resolved config
|
||||
assert "image_influence" not in adapter
|
||||
|
||||
def test_falls_back_to_flux_redux(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""When the name doesn't match an IPAdapter, FluxRedux is tried next."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("redux-1", ModelType.FluxRedux): "key-redux"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub({"ref.png": (512, 512)}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_name": "redux-1",
|
||||
"image_name": "ref.png",
|
||||
"weight": 1.0,
|
||||
"image_influence": "high",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
adapter = response.json()["parameters"]["ip_adapters"][0]
|
||||
assert adapter["model_key"] == "key-redux"
|
||||
assert adapter["image_influence"] == "high"
|
||||
|
||||
def test_missing_image_still_resolves_config(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""A missing reference image is warned about but the adapter still lands."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("ipa", ModelType.IPAdapter): "key-ipa"}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"ip_adapters": [{"model_name": "ipa", "image_name": "missing.png", "weight": 0.5}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
adapter = response.json()["parameters"]["ip_adapters"][0]
|
||||
assert adapter["model_key"] == "key-ipa"
|
||||
assert adapter["weight"] == 0.5
|
||||
assert "image" not in adapter
|
||||
|
||||
def test_unresolvable_ip_adapters_are_dropped(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Adapters whose model can't be resolved (neither IPAdapter nor FluxRedux) are skipped."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"ip_adapters": [{"model_name": "unknown", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["ip_adapters"] == []
|
||||
|
||||
|
||||
class TestCombinedRecall:
|
||||
def test_all_collection_fields_together(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Exercise the full happy path: prompts, model, loras, control_layers, ip_adapters, reference_images."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub(
|
||||
{
|
||||
("my-model", ModelType.Main): "key-main",
|
||||
("detail-lora", ModelType.LoRA): "key-lora",
|
||||
("canny", ModelType.ControlNet): "key-canny",
|
||||
("ipa-face", ModelType.IPAdapter): "key-ipa",
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub(
|
||||
{
|
||||
"ctl.png": (512, 512),
|
||||
"face.png": (768, 768),
|
||||
"ref.png": (1024, 1024),
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"positive_prompt": "a cat",
|
||||
"negative_prompt": "blurry",
|
||||
"model": "my-model",
|
||||
"steps": 30,
|
||||
"cfg_scale": 7.5,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"seed": 42,
|
||||
"loras": [{"model_name": "detail-lora", "weight": 0.6}],
|
||||
"control_layers": [{"model_name": "canny", "image_name": "ctl.png", "weight": 0.75}],
|
||||
"ip_adapters": [
|
||||
{"model_name": "ipa-face", "image_name": "face.png", "weight": 0.5, "method": "composition"}
|
||||
],
|
||||
"reference_images": [{"image_name": "ref.png"}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
|
||||
# Core fields
|
||||
assert params["positive_prompt"] == "a cat"
|
||||
assert params["negative_prompt"] == "blurry"
|
||||
assert params["model"] == "key-main"
|
||||
assert params["steps"] == 30
|
||||
assert params["seed"] == 42
|
||||
|
||||
# Collections
|
||||
assert params["loras"] == [{"model_key": "key-lora", "weight": 0.6, "is_enabled": True}]
|
||||
assert params["control_layers"][0]["model_key"] == "key-canny"
|
||||
assert params["control_layers"][0]["image"]["image_name"] == "ctl.png"
|
||||
assert params["ip_adapters"][0]["model_key"] == "key-ipa"
|
||||
assert params["ip_adapters"][0]["method"] == "composition"
|
||||
assert params["reference_images"] == [{"image": {"image_name": "ref.png", "width": 1024, "height": 1024}}]
|
||||
|
||||
def test_unresolvable_main_model_drops_from_payload(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""A model name that doesn't resolve should be scrubbed from the broadcast payload."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"positive_prompt": "x", "model": "ghost-model"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["positive_prompt"] == "x"
|
||||
assert "model" not in params
|
||||
|
||||
|
||||
class TestStrictMode:
|
||||
"""Regression tests for the ``strict`` query parameter.
|
||||
|
||||
When ``strict=True``, parameters not included in the request body must
|
||||
be reset — list-typed fields to ``[]`` and scalar fields to ``None``.
|
||||
"""
|
||||
|
||||
def test_strict_clears_list_fields(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""List fields (loras, control_layers, ip_adapters, reference_images) are
|
||||
sent as empty lists when omitted in strict mode."""
|
||||
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default?strict=true",
|
||||
json={"positive_prompt": "hello"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["positive_prompt"] == "hello"
|
||||
assert params["loras"] == []
|
||||
assert params["control_layers"] == []
|
||||
assert params["ip_adapters"] == []
|
||||
assert params["reference_images"] == []
|
||||
|
||||
def test_strict_clears_scalar_fields(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Scalar fields not in the request are sent as None in strict mode."""
|
||||
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default?strict=true",
|
||||
json={"steps": 20},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["steps"] == 20
|
||||
assert params["positive_prompt"] is None
|
||||
assert params["seed"] is None
|
||||
assert params["loras"] == []
|
||||
|
||||
def test_non_strict_omits_unset_fields(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Default (non-strict) behaviour: unset fields are absent from the response."""
|
||||
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"positive_prompt": "hello"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["positive_prompt"] == "hello"
|
||||
assert "loras" not in params
|
||||
assert "reference_images" not in params
|
||||
assert "seed" not in params
|
||||
|
||||
|
||||
class TestAppendMode:
|
||||
"""Tests for the ``append`` query parameter.
|
||||
|
||||
``append=true`` asks the frontend to add the recalled reference images to
|
||||
its existing list instead of replacing it. The flag travels inside the
|
||||
event's ``parameters`` dict (so the generated client schema needs no
|
||||
change) and must never be persisted as a recall parameter.
|
||||
"""
|
||||
|
||||
def test_append_flag_rides_in_parameters(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({"cat.png": (1024, 768)}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default?append=true",
|
||||
json={"reference_images": [{"image_name": "cat.png"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["append"] is True
|
||||
assert params["reference_images"][0]["image"]["image_name"] == "cat.png"
|
||||
|
||||
def test_append_flag_absent_by_default(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({"cat.png": (1024, 768)}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"reference_images": [{"image_name": "cat.png"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "append" not in response.json()["parameters"]
|
||||
|
||||
def test_append_flag_not_persisted(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, mock_invoker: Invoker, client: TestClient
|
||||
) -> None:
|
||||
"""The flag is injected after the persistence loop — only real recall
|
||||
parameters may be written to client state."""
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({"cat.png": (1024, 768)}))
|
||||
persisted_keys: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
mock_invoker.services.client_state_persistence,
|
||||
"set_by_key",
|
||||
lambda user_id, key, value: persisted_keys.append(key) or value,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default?append=true",
|
||||
json={"reference_images": [{"image_name": "cat.png"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert persisted_keys == ["recall_reference_images"]
|
||||
|
||||
def test_append_and_strict_are_mutually_exclusive(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/api/v1/recall/default?strict=true&append=true",
|
||||
json={"reference_images": [{"image_name": "cat.png"}]},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "mutually exclusive" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_deps():
|
||||
"""Patch ApiDependencies.invoker with a mock that simulates subfolder-aware image service."""
|
||||
with patch("invokeai.app.api.routers.recall_parameters.ApiDependencies") as mock_deps:
|
||||
invoker = MagicMock()
|
||||
mock_deps.invoker = invoker
|
||||
|
||||
images_service = invoker.services.images
|
||||
images_service.get_path.return_value = "/outputs/images/2026/04/05/test.png"
|
||||
images_service.validate_path.return_value = True
|
||||
|
||||
pil_image = Image.new("RGB", (512, 768))
|
||||
images_service.get_pil_image.return_value = pil_image
|
||||
|
||||
yield invoker
|
||||
|
||||
|
||||
class TestLoadImageFile:
|
||||
"""Unit tests for ``load_image_file`` — verifies it goes through the
|
||||
subfolder-aware images service rather than the flat image_files service.
|
||||
"""
|
||||
|
||||
def test_returns_image_info_for_subfolder_image(self, mock_api_deps: MagicMock):
|
||||
"""load_image_file should work for images stored in subfolders."""
|
||||
result = load_image_file("test.png")
|
||||
|
||||
assert result is not None
|
||||
assert result["image_name"] == "test.png"
|
||||
assert result["width"] == 512
|
||||
assert result["height"] == 768
|
||||
|
||||
mock_api_deps.services.images.get_path.assert_called_once_with("test.png")
|
||||
mock_api_deps.services.images.get_pil_image.assert_called_once_with("test.png")
|
||||
|
||||
def test_returns_none_when_file_not_found(self, mock_api_deps: MagicMock):
|
||||
"""load_image_file should return None if the resolved path doesn't exist."""
|
||||
mock_api_deps.services.images.validate_path.return_value = False
|
||||
|
||||
result = load_image_file("missing.png")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_on_service_exception(self, mock_api_deps: MagicMock):
|
||||
"""load_image_file should return None if the images service raises."""
|
||||
mock_api_deps.services.images.get_path.side_effect = Exception("DB error")
|
||||
|
||||
result = load_image_file("broken.png")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_uses_images_service_not_image_files(self, mock_api_deps: MagicMock):
|
||||
"""Regression: load_image_file must go through images service (subfolder-aware),
|
||||
not image_files (flat-only)."""
|
||||
load_image_file("test.png")
|
||||
|
||||
mock_api_deps.services.image_files.get.assert_not_called()
|
||||
mock_api_deps.services.image_files.get_path.assert_not_called()
|
||||
@@ -0,0 +1,40 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api.routers.session_queue import enqueue_batch
|
||||
from invokeai.app.services.session_queue.session_queue_common import DEFAULT_QUEUE_ID, Batch
|
||||
from invokeai.app.services.shared.graph import Graph
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
def __init__(self, invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enqueue_batch_is_blocked_during_image_move_maintenance(
|
||||
monkeypatch: pytest.MonkeyPatch, mock_invoker
|
||||
) -> None:
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
mock_invoker.services.image_moves = MagicMock()
|
||||
mock_invoker.services.image_moves.is_maintenance_active.return_value = True
|
||||
monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await enqueue_batch(
|
||||
current_user=MagicMock(user_id="user-id"),
|
||||
queue_id=DEFAULT_QUEUE_ID,
|
||||
batch=Batch(graph=Graph()),
|
||||
prepend=False,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 409
|
||||
assert exc.value.detail == "Image storage maintenance is active"
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for session queue item sanitization in multiuser mode."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user
|
||||
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output
|
||||
from invokeai.app.invocations.fields import InputField, OutputField
|
||||
from invokeai.app.services.session_queue.session_queue_common import NodeFieldValue, SessionQueueItem
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from invokeai.app.services.shared.invocation_context import InvocationContext
|
||||
|
||||
|
||||
# Define a minimal test invocation for the test
|
||||
@invocation_output("test_sanitization_output")
|
||||
class TestSanitizationInvocationOutput(BaseInvocationOutput):
|
||||
value: str = OutputField(default="")
|
||||
|
||||
|
||||
@invocation("test_sanitization", version="1.0.0")
|
||||
class TestSanitizationInvocation(BaseInvocation):
|
||||
test_field: str = InputField(default="")
|
||||
|
||||
def invoke(self, context: InvocationContext) -> TestSanitizationInvocationOutput:
|
||||
return TestSanitizationInvocationOutput(value=self.test_field)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_session_queue_item() -> SessionQueueItem:
|
||||
"""Create a sample queue item with full data for testing."""
|
||||
graph = Graph()
|
||||
# Add a simple node to the graph
|
||||
graph.add_node(TestSanitizationInvocation(id="test_node", test_field="test value"))
|
||||
|
||||
session = GraphExecutionState(id="test_session", graph=graph)
|
||||
|
||||
# Create timestamps for the queue item
|
||||
now = datetime.now()
|
||||
|
||||
return SessionQueueItem(
|
||||
item_id=1,
|
||||
status="pending",
|
||||
batch_id="batch_123",
|
||||
session_id="session_123",
|
||||
queue_id="default",
|
||||
user_id="user_123",
|
||||
user_display_name="Test User",
|
||||
user_email="test@example.com",
|
||||
field_values=[
|
||||
NodeFieldValue(node_path="test_node", field_name="test_field", value="sensitive prompt data"),
|
||||
],
|
||||
session=session,
|
||||
workflow=None,
|
||||
workflow_call_id="workflow-call-1",
|
||||
parent_item_id=99,
|
||||
parent_session_id="parent-session-1",
|
||||
root_item_id=1,
|
||||
workflow_call_depth=2,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_queue_item_for_admin(sample_session_queue_item):
|
||||
"""Test that admins can see all data regardless of user_id."""
|
||||
result = sanitize_queue_item_for_user(
|
||||
queue_item=sample_session_queue_item,
|
||||
current_user_id="different_user",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
# Admin should see everything
|
||||
assert result.field_values is not None
|
||||
assert len(result.field_values) == 1
|
||||
assert result.session.graph.nodes is not None
|
||||
assert len(result.session.graph.nodes) == 1
|
||||
|
||||
|
||||
def test_sanitize_queue_item_for_owner(sample_session_queue_item):
|
||||
"""Test that queue item owners can see their own data."""
|
||||
result = sanitize_queue_item_for_user(
|
||||
queue_item=sample_session_queue_item,
|
||||
current_user_id="user_123", # Same as queue item user_id
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# Owner should see everything
|
||||
assert result.field_values is not None
|
||||
assert len(result.field_values) == 1
|
||||
assert result.session.graph.nodes is not None
|
||||
assert len(result.session.graph.nodes) == 1
|
||||
|
||||
|
||||
def test_sanitize_queue_item_for_different_user(sample_session_queue_item):
|
||||
"""Test that non-admin users cannot see other users' sensitive data."""
|
||||
result = sanitize_queue_item_for_user(
|
||||
queue_item=sample_session_queue_item,
|
||||
current_user_id="different_user",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# Non-admin viewing another user's item should have sanitized data
|
||||
assert result.field_values is None
|
||||
assert result.workflow is None
|
||||
# Session should be replaced with empty/redacted graph
|
||||
assert result.session.graph.nodes is not None
|
||||
assert len(result.session.graph.nodes) == 0
|
||||
assert result.session.id == "redacted"
|
||||
# Identity and batch fields should be redacted
|
||||
assert result.user_id == "redacted"
|
||||
assert result.batch_id == "redacted"
|
||||
assert result.session_id == "redacted"
|
||||
assert result.user_display_name is None
|
||||
assert result.user_email is None
|
||||
assert result.origin is None
|
||||
assert result.destination is None
|
||||
assert result.error_type is None
|
||||
assert result.error_message is None
|
||||
assert result.error_traceback is None
|
||||
|
||||
|
||||
def test_sanitize_preserves_non_sensitive_fields(sample_session_queue_item):
|
||||
"""Test that sanitization preserves non-sensitive fields."""
|
||||
result = sanitize_queue_item_for_user(
|
||||
queue_item=sample_session_queue_item,
|
||||
current_user_id="different_user",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# Non-sensitive fields should be preserved
|
||||
assert result.item_id == 1
|
||||
assert result.status == "pending"
|
||||
assert result.queue_id == "default"
|
||||
assert result.created_at is not None
|
||||
assert result.updated_at is not None
|
||||
# Sensitive fields should be redacted for non-owner non-admin
|
||||
assert result.batch_id == "redacted"
|
||||
assert result.session_id == "redacted"
|
||||
assert result.user_id == "redacted"
|
||||
assert result.user_display_name is None
|
||||
assert result.user_email is None
|
||||
|
||||
|
||||
def test_sanitize_redacts_workflow_call_metadata_for_different_user(sample_session_queue_item):
|
||||
result = sanitize_queue_item_for_user(
|
||||
queue_item=sample_session_queue_item,
|
||||
current_user_id="different_user",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
assert result.workflow_call_id is None
|
||||
assert result.parent_item_id is None
|
||||
assert result.parent_session_id is None
|
||||
assert result.root_item_id is None
|
||||
assert result.workflow_call_depth is None
|
||||
|
||||
|
||||
def test_sanitize_system_user_item_for_non_admin(sample_session_queue_item):
|
||||
"""Test that non-admin users cannot see sensitive data from System user's queue items."""
|
||||
# Simulate a legacy System user queue item
|
||||
system_item = sample_session_queue_item.model_copy(update={"user_id": "system"})
|
||||
|
||||
result = sanitize_queue_item_for_user(
|
||||
queue_item=system_item,
|
||||
current_user_id="non_admin_user",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# System user's sensitive fields should be sanitized for non-admin users
|
||||
assert result.field_values is None
|
||||
assert result.workflow is None
|
||||
assert len(result.session.graph.nodes) == 0
|
||||
|
||||
|
||||
def test_sanitize_system_user_item_for_admin(sample_session_queue_item):
|
||||
"""Test that admin users can see full data from System user's queue items."""
|
||||
system_item = sample_session_queue_item.model_copy(update={"user_id": "system"})
|
||||
|
||||
result = sanitize_queue_item_for_user(
|
||||
queue_item=system_item,
|
||||
current_user_id="admin_user",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
# Admin should see everything including System user's data
|
||||
assert result.field_values is not None
|
||||
assert len(result.field_values) == 1
|
||||
assert len(result.session.graph.nodes) == 1
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Tests for session queue API behavior with workflow-call queue items."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.invocation_services import InvocationServices
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
from tests.fixtures.sqlite_database import create_mock_sqlite_database
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_jwt_secret():
|
||||
from invokeai.app.services.auth.token_service import set_jwt_secret
|
||||
|
||||
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_services() -> InvocationServices:
|
||||
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
|
||||
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
||||
from invokeai.app.services.boards.boards_default import BoardService
|
||||
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
|
||||
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import (
|
||||
ClientStatePersistenceSqlite,
|
||||
)
|
||||
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
|
||||
from invokeai.app.services.images.images_default import ImageService
|
||||
from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
|
||||
from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
|
||||
from invokeai.app.services.users.users_default import UserService
|
||||
from tests.test_nodes import TestEventService
|
||||
|
||||
configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
|
||||
logger = InvokeAILogger.get_logger()
|
||||
db = create_mock_sqlite_database(configuration, logger)
|
||||
|
||||
return InvocationServices(
|
||||
board_image_records=SqliteBoardImageRecordStorage(db=db),
|
||||
board_images=None, # type: ignore
|
||||
board_records=SqliteBoardRecordStorage(db=db),
|
||||
boards=BoardService(),
|
||||
bulk_download=BulkDownloadService(),
|
||||
configuration=configuration,
|
||||
events=TestEventService(),
|
||||
image_files=None, # type: ignore
|
||||
image_records=SqliteImageRecordStorage(db=db),
|
||||
images=ImageService(),
|
||||
invocation_cache=MemoryInvocationCache(max_cache_size=0),
|
||||
logger=logging, # type: ignore
|
||||
model_images=None, # type: ignore
|
||||
model_manager=None, # type: ignore
|
||||
download_queue=None, # type: ignore
|
||||
names=None, # type: ignore
|
||||
performance_statistics=InvocationStatsService(),
|
||||
session_processor=None, # type: ignore
|
||||
session_queue=None, # type: ignore
|
||||
urls=None, # type: ignore
|
||||
workflow_records=SqliteWorkflowRecordsStorage(db=db),
|
||||
tensors=None, # type: ignore
|
||||
conditioning=None, # type: ignore
|
||||
style_preset_records=None, # type: ignore
|
||||
style_preset_image_files=None, # type: ignore
|
||||
workflow_thumbnails=None, # type: ignore
|
||||
model_relationship_records=None, # type: ignore
|
||||
model_relationships=None, # type: ignore
|
||||
client_state_persistence=ClientStatePersistenceSqlite(db=db),
|
||||
users=UserService(db),
|
||||
external_generation=None, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_invoker(mock_services: InvocationServices) -> Invoker:
|
||||
invoker = Invoker(services=mock_services)
|
||||
queue = SqliteSessionQueue(db=mock_services.board_records._db)
|
||||
mock_services.session_queue = queue
|
||||
mock_services.session_processor = MagicMock()
|
||||
mock_services.session_processor.get_status.return_value = SessionProcessorStatus(
|
||||
is_started=True, is_processing=False
|
||||
)
|
||||
queue.start(invoker)
|
||||
return invoker
|
||||
|
||||
|
||||
def _create_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
|
||||
user = mock_invoker.services.users.create(
|
||||
UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
|
||||
)
|
||||
return user.user_id
|
||||
|
||||
|
||||
def _login(client: TestClient, email: str) -> str:
|
||||
response = client.post("/api/v1/auth/login", json={"email": email, "password": "TestPass123", "remember_me": False})
|
||||
assert response.status_code == 200
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.session_queue.ApiDependencies", mock_deps)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient):
|
||||
_create_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
|
||||
return _login(client, "admin@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
_create_user(mock_invoker, "user1@test.com", "User One")
|
||||
return _login(client, "user1@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
_create_user(mock_invoker, "user2@test.com", "User Two")
|
||||
return _login(client, "user2@test.com")
|
||||
|
||||
|
||||
def _insert_queue_item(
|
||||
session_queue: SqliteSessionQueue,
|
||||
*,
|
||||
queue_id: str = "default",
|
||||
user_id: str,
|
||||
status: str,
|
||||
session: GraphExecutionState | None = None,
|
||||
workflow_call_id: str | None = None,
|
||||
parent_item_id: int | None = None,
|
||||
parent_session_id: str | None = None,
|
||||
root_item_id: int | None = None,
|
||||
workflow_call_depth: int | None = None,
|
||||
) -> int:
|
||||
session = session or GraphExecutionState(graph=Graph())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (
|
||||
queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination,
|
||||
retried_from_item_id, user_id, status, workflow_call_id, parent_item_id, parent_session_id,
|
||||
root_item_id, workflow_call_depth
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
queue_id,
|
||||
session.model_dump_json(warnings=False),
|
||||
session.id,
|
||||
str(uuid.uuid4()),
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
user_id,
|
||||
status,
|
||||
workflow_call_id,
|
||||
parent_item_id,
|
||||
parent_session_id,
|
||||
root_item_id,
|
||||
workflow_call_depth,
|
||||
),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def test_get_queue_status_reports_waiting_for_owner(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
|
||||
assert user1 is not None and user2 is not None
|
||||
|
||||
_insert_queue_item(mock_invoker.services.session_queue, user_id=user1.user_id, status="waiting")
|
||||
_insert_queue_item(mock_invoker.services.session_queue, user_id=user2.user_id, status="pending")
|
||||
|
||||
response = client.get("/api/v1/queue/default/status", headers=_auth(user1_token))
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["queue"]["waiting"] == 1
|
||||
assert payload["queue"]["pending"] == 1
|
||||
assert payload["queue"]["in_progress"] == 0
|
||||
assert payload["queue"]["total"] == 2
|
||||
assert payload["queue"]["user_pending"] == 0
|
||||
assert payload["queue"]["user_in_progress"] == 0
|
||||
assert payload["queue"]["item_id"] is None
|
||||
|
||||
|
||||
def test_get_queue_item_sanitizes_workflow_call_metadata_for_non_owner(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
|
||||
item_id = _insert_queue_item(
|
||||
mock_invoker.services.session_queue,
|
||||
user_id=user1.user_id,
|
||||
status="waiting",
|
||||
workflow_call_id="workflow-call-1",
|
||||
parent_item_id=41,
|
||||
parent_session_id="parent-session-1",
|
||||
root_item_id=17,
|
||||
workflow_call_depth=2,
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1/queue/default/i/{item_id}", headers=_auth(user2_token))
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "waiting"
|
||||
assert payload["item_id"] == item_id
|
||||
assert payload["user_id"] == "redacted"
|
||||
assert payload["batch_id"] == "redacted"
|
||||
assert payload["session_id"] == "redacted"
|
||||
assert payload.get("workflow_call_id") is None
|
||||
assert payload.get("parent_item_id") is None
|
||||
assert payload.get("parent_session_id") is None
|
||||
assert payload.get("root_item_id") is None
|
||||
assert payload.get("workflow_call_depth") is None
|
||||
|
||||
|
||||
def test_retry_items_by_id_normalizes_child_to_root_at_router(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
|
||||
root_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user1.user_id, status="failed")
|
||||
child_item_id = _insert_queue_item(
|
||||
mock_invoker.services.session_queue,
|
||||
user_id=user1.user_id,
|
||||
status="failed",
|
||||
workflow_call_id="workflow-call-1",
|
||||
parent_item_id=root_item_id,
|
||||
parent_session_id="parent-session-1",
|
||||
root_item_id=root_item_id,
|
||||
workflow_call_depth=1,
|
||||
)
|
||||
|
||||
response = client.put("/api/v1/queue/default/retry_items_by_id", headers=_auth(user1_token), json=[child_item_id])
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["retried_item_ids"] == [root_item_id]
|
||||
|
||||
|
||||
def test_retry_items_by_id_rejects_items_from_other_queue(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
|
||||
item_id = _insert_queue_item(
|
||||
mock_invoker.services.session_queue,
|
||||
queue_id="secondary",
|
||||
user_id=user1.user_id,
|
||||
status="failed",
|
||||
)
|
||||
|
||||
response = client.put("/api/v1/queue/default/retry_items_by_id", headers=_auth(user1_token), json=[item_id])
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_retry_items_by_id_authorizes_workflow_call_root_owner(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
|
||||
assert user1 is not None and user2 is not None
|
||||
|
||||
root_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user2.user_id, status="failed")
|
||||
child_item_id = _insert_queue_item(
|
||||
mock_invoker.services.session_queue,
|
||||
user_id=user1.user_id,
|
||||
status="failed",
|
||||
workflow_call_id="workflow-call-1",
|
||||
parent_item_id=root_item_id,
|
||||
parent_session_id="parent-session-1",
|
||||
root_item_id=root_item_id,
|
||||
workflow_call_depth=1,
|
||||
)
|
||||
|
||||
response = client.put("/api/v1/queue/default/retry_items_by_id", headers=_auth(user1_token), json=[child_item_id])
|
||||
|
||||
assert response.status_code == 403
|
||||
assert mock_invoker.services.session_queue.get_queue_item(root_item_id).status == "failed"
|
||||
|
||||
|
||||
def test_retry_items_by_id_skips_missing_items_and_retries_valid_items(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
|
||||
root_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user1.user_id, status="failed")
|
||||
|
||||
response = client.put(
|
||||
"/api/v1/queue/default/retry_items_by_id",
|
||||
headers=_auth(user1_token),
|
||||
json=[root_item_id + 9999, root_item_id],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["retried_item_ids"] == [root_item_id]
|
||||
|
||||
|
||||
def test_cancel_queue_item_cascades_to_waiting_parent_via_router(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
|
||||
parent_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user1.user_id, status="waiting")
|
||||
child_item_id = _insert_queue_item(
|
||||
mock_invoker.services.session_queue,
|
||||
user_id=user1.user_id,
|
||||
status="pending",
|
||||
workflow_call_id="workflow-call-1",
|
||||
parent_item_id=parent_item_id,
|
||||
parent_session_id="parent-session-1",
|
||||
root_item_id=parent_item_id,
|
||||
workflow_call_depth=1,
|
||||
)
|
||||
|
||||
response = client.put(f"/api/v1/queue/default/i/{child_item_id}/cancel", headers=_auth(user1_token))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "canceled"
|
||||
assert mock_invoker.services.session_queue.get_queue_item(parent_item_id).status == "canceled"
|
||||
assert mock_invoker.services.session_queue.get_queue_item(child_item_id).status == "canceled"
|
||||
|
||||
|
||||
def test_cancel_queue_item_rejects_item_from_other_queue(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
|
||||
item_id = _insert_queue_item(
|
||||
mock_invoker.services.session_queue,
|
||||
queue_id="secondary",
|
||||
user_id=user1.user_id,
|
||||
status="pending",
|
||||
)
|
||||
|
||||
response = client.put(f"/api/v1/queue/default/i/{item_id}/cancel", headers=_auth(user1_token))
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_queue_item_rejects_item_from_other_queue(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
|
||||
item_id = _insert_queue_item(
|
||||
mock_invoker.services.session_queue,
|
||||
queue_id="secondary",
|
||||
user_id=user1.user_id,
|
||||
status="failed",
|
||||
)
|
||||
|
||||
response = client.delete(f"/api/v1/queue/default/i/{item_id}", headers=_auth(user1_token))
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_queue_item_authorizes_workflow_call_root_owner(
|
||||
client: TestClient, mock_invoker: Invoker, user1_token: str, user2_token: str
|
||||
) -> None:
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
|
||||
assert user1 is not None and user2 is not None
|
||||
|
||||
root_item_id = _insert_queue_item(mock_invoker.services.session_queue, user_id=user2.user_id, status="failed")
|
||||
child_item_id = _insert_queue_item(
|
||||
mock_invoker.services.session_queue,
|
||||
user_id=user1.user_id,
|
||||
status="failed",
|
||||
workflow_call_id="workflow-call-1",
|
||||
parent_item_id=root_item_id,
|
||||
parent_session_id="parent-session-1",
|
||||
root_item_id=root_item_id,
|
||||
workflow_call_depth=1,
|
||||
)
|
||||
|
||||
response = client.delete(f"/api/v1/queue/default/i/{child_item_id}", headers=_auth(user1_token))
|
||||
|
||||
assert response.status_code == 403
|
||||
assert mock_invoker.services.session_queue.get_queue_item(root_item_id).status == "failed"
|
||||
assert mock_invoker.services.session_queue.get_queue_item(child_item_id).status == "failed"
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Router-level tests for /api/v1/style_presets.
|
||||
|
||||
Backed by a real SqliteStylePresetRecordsStorage from the shared conftest, so SQL
|
||||
filtering and ownership rules are exercised end-to-end. style_preset_image_files
|
||||
remains a MagicMock — file IO is not under test here.
|
||||
|
||||
Covers:
|
||||
- Auth gating (CurrentUserOrDefault on CRUD/list/image, AdminUserOrDefault on export/import).
|
||||
- Bug regression: json.JSONDecodeError must surface as 400 (not 500).
|
||||
- Bug regression: malformed `data` payload after a valid image upload must NOT have
|
||||
persisted the image (validation runs before image mutation).
|
||||
- Cross-user isolation: owner / non-owner / admin / default / public matrix on
|
||||
get, list, update, delete, and image fetch.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.style_preset_records.style_preset_records_common import (
|
||||
PresetData,
|
||||
PresetType,
|
||||
StylePresetRecordDTO,
|
||||
StylePresetWithoutId,
|
||||
)
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (4, 4), color="red").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _user_id(mock_invoker: Invoker, email: str) -> str:
|
||||
user = mock_invoker.services.users.get_by_email(email)
|
||||
assert user is not None, f"user {email} not seeded"
|
||||
return user.user_id
|
||||
|
||||
|
||||
def _seed(
|
||||
mock_invoker: Invoker,
|
||||
user_id: str,
|
||||
name: str = "P",
|
||||
is_public: bool = False,
|
||||
preset_type: PresetType = PresetType.User,
|
||||
) -> StylePresetRecordDTO:
|
||||
return mock_invoker.services.style_preset_records.create(
|
||||
StylePresetWithoutId(
|
||||
name=name,
|
||||
preset_data=PresetData(positive_prompt="p", negative_prompt="n"),
|
||||
type=preset_type,
|
||||
is_public=is_public,
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _form(name: str = "X", preset_type: str = "user", is_public: bool = False) -> dict[str, str]:
|
||||
return {
|
||||
"data": json.dumps(
|
||||
{
|
||||
"name": name,
|
||||
"positive_prompt": "p",
|
||||
"negative_prompt": "n",
|
||||
"type": preset_type,
|
||||
"is_public": is_public,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------- Auth gating -----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("GET", "/api/v1/style_presets/i/preset-1"),
|
||||
("DELETE", "/api/v1/style_presets/i/preset-1"),
|
||||
("GET", "/api/v1/style_presets/"),
|
||||
("GET", "/api/v1/style_presets/i/preset-1/image"),
|
||||
("GET", "/api/v1/style_presets/export"),
|
||||
],
|
||||
)
|
||||
def test_simple_routes_require_auth(enable_multiuser: Any, client: TestClient, method: str, path: str):
|
||||
r = client.request(method, path)
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_create_requires_auth(enable_multiuser: Any, client: TestClient):
|
||||
r = client.post("/api/v1/style_presets/", data=_form())
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_update_requires_auth(enable_multiuser: Any, client: TestClient):
|
||||
r = client.patch("/api/v1/style_presets/i/preset-1", data=_form())
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_import_requires_auth(enable_multiuser: Any, client: TestClient):
|
||||
r = client.post(
|
||||
"/api/v1/style_presets/import",
|
||||
files={"file": ("x.csv", b"name,prompt,negative_prompt\n", "text/csv")},
|
||||
)
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_export_forbidden_for_regular_user(client: TestClient, user1_token: str):
|
||||
r = client.get("/api/v1/style_presets/export", headers=_auth(user1_token))
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_export_allowed_for_admin(client: TestClient, admin_token: str):
|
||||
r = client.get("/api/v1/style_presets/export", headers=_auth(admin_token))
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
|
||||
|
||||
def test_import_forbidden_for_regular_user(client: TestClient, user1_token: str):
|
||||
r = client.post(
|
||||
"/api/v1/style_presets/import",
|
||||
files={"file": ("x.csv", b"name,prompt,negative_prompt\n", "text/csv")},
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# ----------------------------- Bug B regression: JSONDecodeError → 400 -----------------------------
|
||||
|
||||
|
||||
def test_create_malformed_json_returns_400(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
r = client.post("/api/v1/style_presets/", data={"data": "not-valid-json"}, headers=_auth(user1_token))
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
# Nothing persisted.
|
||||
assert mock_invoker.services.style_preset_records.get_many(user_id=_user_id(mock_invoker, "user1@test.com")) == []
|
||||
|
||||
|
||||
def test_update_malformed_json_returns_400(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
# No need for a real record — JSON validation happens before the record is loaded.
|
||||
r = client.patch("/api/v1/style_presets/i/some-id", data={"data": "not-valid-json"}, headers=_auth(user1_token))
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
# ----------------------------- Bug C regression: validation before image mutation -----------------------------
|
||||
|
||||
|
||||
def test_update_with_invalid_data_does_not_save_image(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""A valid image plus malformed `data` must reject (400) AND must not have
|
||||
persisted or deleted the preset image — the validation has to run first."""
|
||||
r = client.patch(
|
||||
"/api/v1/style_presets/i/preset-1",
|
||||
data={"data": "not-valid-json"},
|
||||
files={"image": ("x.png", _png_bytes(), "image/png")},
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_invoker.services.style_preset_image_files.save.assert_not_called()
|
||||
mock_invoker.services.style_preset_image_files.delete.assert_not_called()
|
||||
|
||||
|
||||
def test_update_without_image_and_invalid_data_does_not_delete(
|
||||
client: TestClient, user1_token: str, mock_invoker: Invoker
|
||||
):
|
||||
"""The pre-fix code would call `delete` on the image even though `data` is invalid."""
|
||||
r = client.patch("/api/v1/style_presets/i/preset-1", data={"data": "not-valid-json"}, headers=_auth(user1_token))
|
||||
assert r.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_invoker.services.style_preset_image_files.delete.assert_not_called()
|
||||
|
||||
|
||||
# ----------------------------- Happy path -----------------------------
|
||||
|
||||
|
||||
def test_create_with_valid_data_persists_record(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.post("/api/v1/style_presets/", data=_form(name="Mine"), headers=_auth(user1_token))
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
user1 = _user_id(mock_invoker, "user1@test.com")
|
||||
presets = mock_invoker.services.style_preset_records.get_many(user_id=user1)
|
||||
names = [p.name for p in presets]
|
||||
assert "Mine" in names
|
||||
# New record is owned by user1.
|
||||
owned = [p for p in presets if p.name == "Mine"]
|
||||
assert owned[0].user_id == user1
|
||||
assert owned[0].is_public is False
|
||||
|
||||
|
||||
def test_update_with_valid_data_changes_record(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
user1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, user1, name="Before")
|
||||
|
||||
r = client.patch(
|
||||
f"/api/v1/style_presets/i/{seeded.id}",
|
||||
data=_form(name="After"),
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
refreshed = mock_invoker.services.style_preset_records.get(seeded.id)
|
||||
assert refreshed.name == "After"
|
||||
|
||||
|
||||
def test_update_with_non_image_returns_415(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
user1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, user1, name="X")
|
||||
r = client.patch(
|
||||
f"/api/v1/style_presets/i/{seeded.id}",
|
||||
data=_form(name="X"),
|
||||
files={"image": ("x.txt", b"not an image", "text/plain")},
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
mock_invoker.services.style_preset_image_files.save.assert_not_called()
|
||||
|
||||
|
||||
# ----------------------------- Cross-user ownership policy -----------------------------
|
||||
|
||||
|
||||
class TestOwnership:
|
||||
def test_list_returns_only_own_plus_defaults_plus_public(
|
||||
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
u2 = _user_id(mock_invoker, "user2@test.com")
|
||||
_seed(mock_invoker, u1, name="u1-private")
|
||||
_seed(mock_invoker, u1, name="u1-public", is_public=True)
|
||||
_seed(mock_invoker, u2, name="u2-private")
|
||||
_seed(mock_invoker, "system", name="default-A", preset_type=PresetType.Default)
|
||||
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.get("/api/v1/style_presets/", headers=_auth(user1_token))
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
names = {p["name"] for p in r.json()}
|
||||
assert "u1-private" in names
|
||||
assert "u1-public" in names
|
||||
assert "default-A" in names
|
||||
assert "u2-private" not in names
|
||||
|
||||
def test_list_includes_other_users_public_preset(
|
||||
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
_seed(mock_invoker, u1, name="u1-public", is_public=True)
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.get("/api/v1/style_presets/", headers=_auth(user2_token))
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
names = {p["name"] for p in r.json()}
|
||||
assert "u1-public" in names
|
||||
|
||||
def test_get_other_users_private_is_forbidden(
|
||||
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, u1, name="private")
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.get(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user2_token))
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_get_other_users_public_is_allowed(
|
||||
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, u1, name="pub", is_public=True)
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.get(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user2_token))
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
assert r.json()["name"] == "pub"
|
||||
|
||||
def test_get_default_is_allowed_for_any_user(self, client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
seeded = _seed(mock_invoker, "system", name="builtin", preset_type=PresetType.Default)
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.get(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user1_token))
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_update_other_users_preset_is_forbidden(
|
||||
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, u1, name="orig")
|
||||
r = client.patch(
|
||||
f"/api/v1/style_presets/i/{seeded.id}",
|
||||
data=_form(name="hijack"),
|
||||
headers=_auth(user2_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
# Even a public preset cannot be modified by a non-owner.
|
||||
seeded_public = _seed(mock_invoker, u1, name="orig-pub", is_public=True)
|
||||
r = client.patch(
|
||||
f"/api/v1/style_presets/i/{seeded_public.id}",
|
||||
data=_form(name="hijack-pub"),
|
||||
headers=_auth(user2_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_delete_other_users_preset_is_forbidden(
|
||||
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, u1, name="keep")
|
||||
r = client.delete(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user2_token))
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
# Record is still there.
|
||||
still = mock_invoker.services.style_preset_records.get(seeded.id)
|
||||
assert still.id == seeded.id
|
||||
|
||||
def test_update_default_preset_forbidden_for_non_admin(
|
||||
self, client: TestClient, user1_token: str, mock_invoker: Invoker
|
||||
):
|
||||
seeded = _seed(mock_invoker, "system", name="builtin", preset_type=PresetType.Default)
|
||||
r = client.patch(
|
||||
f"/api/v1/style_presets/i/{seeded.id}",
|
||||
data=_form(name="hijacked", preset_type="default"),
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_delete_default_preset_forbidden_for_non_admin(
|
||||
self, client: TestClient, user1_token: str, mock_invoker: Invoker
|
||||
):
|
||||
seeded = _seed(mock_invoker, "system", name="builtin", preset_type=PresetType.Default)
|
||||
r = client.delete(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(user1_token))
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_create_default_preset_forbidden_for_non_admin(
|
||||
self, client: TestClient, user1_token: str, mock_invoker: Invoker
|
||||
):
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.post(
|
||||
"/api/v1/style_presets/",
|
||||
data=_form(name="Sneaky", preset_type="default"),
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_admin_can_get_any_preset(
|
||||
self, client: TestClient, admin_token: str, user1_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, u1, name="private")
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.get(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(admin_token))
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_admin_can_update_any_preset(
|
||||
self, client: TestClient, admin_token: str, user1_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, u1, name="orig")
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.patch(
|
||||
f"/api/v1/style_presets/i/{seeded.id}",
|
||||
data=_form(name="admin-edit"),
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
assert mock_invoker.services.style_preset_records.get(seeded.id).name == "admin-edit"
|
||||
|
||||
def test_admin_can_delete_default_preset(self, client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
seeded = _seed(mock_invoker, "system", name="del-default", preset_type=PresetType.Default)
|
||||
r = client.delete(f"/api/v1/style_presets/i/{seeded.id}", headers=_auth(admin_token))
|
||||
# delete returns 200 with no body (operation_id has no explicit status_code)
|
||||
assert r.status_code in (status.HTTP_200_OK, status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def test_admin_list_returns_everything(
|
||||
self, client: TestClient, admin_token: str, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
u2 = _user_id(mock_invoker, "user2@test.com")
|
||||
_seed(mock_invoker, u1, name="u1-only")
|
||||
_seed(mock_invoker, u2, name="u2-only")
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
r = client.get("/api/v1/style_presets/", headers=_auth(admin_token))
|
||||
names = {p["name"] for p in r.json()}
|
||||
assert {"u1-only", "u2-only"}.issubset(names)
|
||||
|
||||
def test_owner_can_flip_to_public(
|
||||
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, u1, name="will-be-public")
|
||||
mock_invoker.services.style_preset_image_files.get_url.return_value = None
|
||||
# user2 can't see it yet
|
||||
r = client.get("/api/v1/style_presets/", headers=_auth(user2_token))
|
||||
assert "will-be-public" not in {p["name"] for p in r.json()}
|
||||
|
||||
# user1 flips is_public=True
|
||||
r = client.patch(
|
||||
f"/api/v1/style_presets/i/{seeded.id}",
|
||||
data=_form(name="will-be-public", is_public=True),
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
assert r.json()["is_public"] is True
|
||||
|
||||
# user2 now sees it
|
||||
r = client.get("/api/v1/style_presets/", headers=_auth(user2_token))
|
||||
assert "will-be-public" in {p["name"] for p in r.json()}
|
||||
|
||||
def test_image_fetch_enforces_same_policy_as_get(
|
||||
self, client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
u1 = _user_id(mock_invoker, "user1@test.com")
|
||||
seeded = _seed(mock_invoker, u1, name="img-private")
|
||||
r = client.get(f"/api/v1/style_presets/i/{seeded.id}/image", headers=_auth(user2_token))
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_invoker.services.style_preset_image_files.get_path.assert_not_called()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests for `_load_settings_changed` — the predicate that decides whether to evict cached
|
||||
model entries after an `update_model_record` call. Settings like `fp8_storage` and `cpu_only`
|
||||
are baked into the loaded nn.Module at load time, so toggling them silently has no effect
|
||||
until the cached entry is evicted. The predicate must catch changes to those settings while
|
||||
ignoring changes that don't affect how the model loads (e.g. name, description).
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from invokeai.app.api.routers.model_manager import _load_settings_changed
|
||||
|
||||
|
||||
def _config(*, fp8: bool | None = None, cpu_only: bool | None = None):
|
||||
return SimpleNamespace(
|
||||
cpu_only=cpu_only,
|
||||
default_settings=SimpleNamespace(fp8_storage=fp8),
|
||||
)
|
||||
|
||||
|
||||
def test_no_change_returns_false():
|
||||
assert _load_settings_changed(_config(fp8=True), _config(fp8=True)) is False
|
||||
assert _load_settings_changed(_config(fp8=None), _config(fp8=None)) is False
|
||||
|
||||
|
||||
def test_fp8_storage_toggle_returns_true():
|
||||
"""The primary motivating case: a user toggling FP8 storage in the Model Manager must
|
||||
drop the cached entry, otherwise inference keeps using the old (non-FP8) module."""
|
||||
assert _load_settings_changed(_config(fp8=False), _config(fp8=True)) is True
|
||||
assert _load_settings_changed(_config(fp8=True), _config(fp8=False)) is True
|
||||
assert _load_settings_changed(_config(fp8=None), _config(fp8=True)) is True
|
||||
assert _load_settings_changed(_config(fp8=True), _config(fp8=None)) is True
|
||||
|
||||
|
||||
def test_cpu_only_toggle_returns_true():
|
||||
"""`cpu_only` is also read by the loader (in `_get_execution_device`) and baked into the
|
||||
cache entry's execution device — toggling it after load is a silent no-op without eviction."""
|
||||
assert _load_settings_changed(_config(cpu_only=False), _config(cpu_only=True)) is True
|
||||
assert _load_settings_changed(_config(cpu_only=True), _config(cpu_only=None)) is True
|
||||
|
||||
|
||||
def test_missing_default_settings_is_handled():
|
||||
"""default_settings can legitimately be None (e.g. a freshly identified config)."""
|
||||
no_settings = SimpleNamespace(cpu_only=None, default_settings=None)
|
||||
assert _load_settings_changed(no_settings, no_settings) is False
|
||||
assert _load_settings_changed(no_settings, _config(fp8=True)) is True
|
||||
|
||||
|
||||
def test_unrelated_field_does_not_trigger_invalidation():
|
||||
"""A config missing the fp8/cpu_only attributes entirely (e.g. a model type with no such
|
||||
fields) must not falsely report a change."""
|
||||
bare_a = SimpleNamespace()
|
||||
bare_b = SimpleNamespace()
|
||||
assert _load_settings_changed(bare_a, bare_b) is False
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Router-level tests for /api/v1/utilities.
|
||||
|
||||
Covers:
|
||||
- Auth gating (CurrentUserOrDefault on all three utility routes).
|
||||
- image-to-prompt: image read-access check must fire BEFORE the model is loaded,
|
||||
so non-owners can't probe stored images.
|
||||
- image-to-prompt: a missing image surfaces as 404, not 500.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
|
||||
|
||||
def _save_image(mock_invoker: Invoker, image_name: str, user_id: str) -> None:
|
||||
mock_invoker.services.image_records.save(
|
||||
image_name=image_name,
|
||||
image_origin=ResourceOrigin.INTERNAL,
|
||||
image_category=ImageCategory.GENERAL,
|
||||
width=100,
|
||||
height=100,
|
||||
has_workflow=False,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def _create_extra_user(mock_invoker: Invoker, email: str) -> str:
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
|
||||
user = mock_invoker.services.users.create(
|
||||
UserCreateRequest(email=email, display_name=email, password="TestPass123", is_admin=False)
|
||||
)
|
||||
return user.user_id
|
||||
|
||||
|
||||
# ----------------------------- Auth gating -----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,body",
|
||||
[
|
||||
("/api/v1/utilities/dynamicprompts", {"prompt": "hi"}),
|
||||
("/api/v1/utilities/expand-prompt", {"prompt": "hi", "model_key": "m"}),
|
||||
("/api/v1/utilities/image-to-prompt", {"image_name": "img-1", "model_key": "m"}),
|
||||
],
|
||||
)
|
||||
def test_routes_require_auth(enable_multiuser: Any, client: TestClient, mock_invoker: Invoker, path: str, body: dict):
|
||||
r = client.post(path, json=body)
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
mock_invoker.services.model_manager.store.get_model.assert_not_called()
|
||||
|
||||
|
||||
def test_dynamicprompts_works_for_user(client: TestClient, user1_token: str):
|
||||
r = client.post(
|
||||
"/api/v1/utilities/dynamicprompts",
|
||||
json={"prompt": "a {b|c}"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
body = r.json()
|
||||
assert "prompts" in body
|
||||
|
||||
|
||||
def test_dynamicprompts_unknown_wildcard_returns_error_without_hanging(client: TestClient, user1_token: str):
|
||||
"""An unknown wildcard used as a variant value would otherwise loop forever in the combinatorial generator.
|
||||
|
||||
The endpoint must instead return promptly with a clear error and the original prompt echoed back.
|
||||
"""
|
||||
r = client.post(
|
||||
"/api/v1/utilities/dynamicprompts",
|
||||
json={"prompt": "{__random__8chan|fenster|stuff}"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
body = r.json()
|
||||
assert body["error"] is not None
|
||||
assert "random" in body["error"]
|
||||
assert body["prompts"] == ["{__random__8chan|fenster|stuff}"]
|
||||
|
||||
|
||||
def test_dynamicprompts_bare_unknown_wildcard_still_generates(client: TestClient, user1_token: str):
|
||||
"""A wildcard used as plain literal text (not a variant value) does not hang and must not error."""
|
||||
r = client.post(
|
||||
"/api/v1/utilities/dynamicprompts",
|
||||
json={"prompt": "a photo, __my_style__"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
body = r.json()
|
||||
assert body["error"] is None
|
||||
assert body["prompts"] # non-empty
|
||||
assert all(p == "a photo, __my_style__" for p in body["prompts"])
|
||||
|
||||
|
||||
def test_dynamicprompts_random_generator_ignores_unknown_wildcard(client: TestClient, user1_token: str):
|
||||
"""The random generator handles unknown wildcards gracefully, so the guard must not fire for it."""
|
||||
r = client.post(
|
||||
"/api/v1/utilities/dynamicprompts",
|
||||
json={"prompt": "{__random__8chan|fenster|stuff}", "combinatorial": False, "seed": 0},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
body = r.json()
|
||||
assert body["error"] is None
|
||||
assert body["prompts"] # non-empty; the random generator expanded the variant
|
||||
|
||||
|
||||
# ----------------------------- image_to_prompt: ownership / read-access -----------------------------
|
||||
|
||||
|
||||
def test_image_to_prompt_forbidden_for_non_owner(
|
||||
client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
"""A second user must not be able to read a private image via image-to-prompt."""
|
||||
# Need to discover user1's id, then save an image under that id.
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
_save_image(mock_invoker, "private-img.png", user1.user_id)
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/utilities/image-to-prompt",
|
||||
json={"image_name": "private-img.png", "model_key": "some-key"},
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_403_FORBIDDEN
|
||||
# The model must not have been loaded — ownership must fire BEFORE inference.
|
||||
mock_invoker.services.model_manager.store.get_model.assert_not_called()
|
||||
|
||||
|
||||
def test_image_to_prompt_owner_reaches_model_load(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""The owner passes the read-access check and the model load is attempted.
|
||||
We force an UnknownModelException to keep the test light and assert 404."""
|
||||
from invokeai.app.services.model_records.model_records_base import UnknownModelException
|
||||
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
_save_image(mock_invoker, "owned-img.png", user1.user_id)
|
||||
|
||||
mock_invoker.services.model_manager.store.get_model = MagicMock(side_effect=UnknownModelException("no such model"))
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/utilities/image-to-prompt",
|
||||
json={"image_name": "owned-img.png", "model_key": "missing-model"},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_invoker.services.model_manager.store.get_model.assert_called_once()
|
||||
|
||||
|
||||
def test_image_to_prompt_admin_can_access_any_image(
|
||||
client: TestClient, admin_token: str, user1_token: str, mock_invoker: Invoker
|
||||
):
|
||||
from invokeai.app.services.model_records.model_records_base import UnknownModelException
|
||||
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
assert user1 is not None
|
||||
_save_image(mock_invoker, "user1-img.png", user1.user_id)
|
||||
|
||||
mock_invoker.services.model_manager.store.get_model = MagicMock(side_effect=UnknownModelException("no model"))
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/utilities/image-to-prompt",
|
||||
json={"image_name": "user1-img.png", "model_key": "x"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
# Admin passes the read-access check; model loading then fails with 404.
|
||||
assert r.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Router-level tests for /api/v1/virtual_boards.
|
||||
|
||||
These routes already use CurrentUserOrDefault, but until now had no tests pinning
|
||||
the anonymous-rejection + per-user filtering behavior.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
|
||||
|
||||
def _save_image(mock_invoker: Invoker, image_name: str, user_id: str) -> None:
|
||||
mock_invoker.services.image_records.save(
|
||||
image_name=image_name,
|
||||
image_origin=ResourceOrigin.INTERNAL,
|
||||
image_category=ImageCategory.GENERAL,
|
||||
width=10,
|
||||
height=10,
|
||||
has_workflow=False,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def test_list_by_date_requires_auth(enable_multiuser: Any, client: TestClient):
|
||||
r = client.get("/api/v1/virtual_boards/by_date")
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_image_names_by_date_requires_auth(enable_multiuser: Any, client: TestClient):
|
||||
r = client.get("/api/v1/virtual_boards/by_date/2026-05-18/image_names")
|
||||
assert r.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_user_sees_only_own_dates(client: TestClient, user1_token: str, user2_token: str, mock_invoker: Invoker):
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
|
||||
assert user1 is not None and user2 is not None
|
||||
|
||||
_save_image(mock_invoker, "u1-img-a.png", user1.user_id)
|
||||
_save_image(mock_invoker, "u2-img-a.png", user2.user_id)
|
||||
|
||||
r1 = client.get(
|
||||
"/api/v1/virtual_boards/by_date",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert r1.status_code == status.HTTP_200_OK
|
||||
user1_counts = sum(b.get("image_count", 0) for b in r1.json())
|
||||
|
||||
r2 = client.get(
|
||||
"/api/v1/virtual_boards/by_date",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert r2.status_code == status.HTTP_200_OK
|
||||
user2_counts = sum(b.get("image_count", 0) for b in r2.json())
|
||||
|
||||
# Each user sees only their single image — not the other user's.
|
||||
assert user1_counts == 1
|
||||
assert user2_counts == 1
|
||||
|
||||
|
||||
def test_admin_sees_all_dates(
|
||||
client: TestClient, admin_token: str, user1_token: str, user2_token: str, mock_invoker: Invoker
|
||||
):
|
||||
user1 = mock_invoker.services.users.get_by_email("user1@test.com")
|
||||
user2 = mock_invoker.services.users.get_by_email("user2@test.com")
|
||||
assert user1 is not None and user2 is not None
|
||||
|
||||
_save_image(mock_invoker, "u1-shared.png", user1.user_id)
|
||||
_save_image(mock_invoker, "u2-shared.png", user2.user_id)
|
||||
|
||||
r = client.get(
|
||||
"/api/v1/virtual_boards/by_date",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert r.status_code == status.HTTP_200_OK
|
||||
total = sum(b.get("image_count", 0) for b in r.json())
|
||||
assert total >= 2 # admin sees images from both users
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for workflow CRUD live-update events with multiuser visibility rules."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.app.routers.test_workflows_multiuser import WORKFLOW_BODY
|
||||
|
||||
pytest_plugins = ("tests.app.routers.test_workflows_multiuser",)
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _event_names(events: list[Any]) -> list[str]:
|
||||
return [event.__event_name__ for event in events]
|
||||
|
||||
|
||||
def _get_last_event(events: list[Any], event_name: str) -> Any:
|
||||
matching = [event for event in events if event.__event_name__ == event_name]
|
||||
assert matching, f"Expected event '{event_name}' to be emitted"
|
||||
return matching[-1]
|
||||
|
||||
|
||||
def test_create_private_workflow_emits_owner_scoped_created_event(
|
||||
client: TestClient, user1_token: str, mock_invoker: Any
|
||||
) -> None:
|
||||
response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
event = _get_last_event(mock_invoker.services.events.events, "workflow_created")
|
||||
assert event.workflow_id == response.json()["workflow_id"]
|
||||
assert event.user_id == response.json()["user_id"]
|
||||
assert event.is_public is False
|
||||
|
||||
|
||||
def test_update_workflow_emits_updated_event_with_previous_visibility(
|
||||
client: TestClient, user1_token: str, mock_invoker: Any
|
||||
) -> None:
|
||||
create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
|
||||
workflow_id = create_response.json()["workflow_id"]
|
||||
|
||||
update_response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
json={"workflow": {**WORKFLOW_BODY, "id": workflow_id, "name": "Renamed Workflow"}},
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
|
||||
event = _get_last_event(mock_invoker.services.events.events, "workflow_updated")
|
||||
assert event.workflow_id == workflow_id
|
||||
assert event.user_id == create_response.json()["user_id"]
|
||||
assert event.old_is_public is False
|
||||
assert event.new_is_public is False
|
||||
|
||||
|
||||
def test_update_workflow_is_public_emits_visibility_transition_event(
|
||||
client: TestClient, user1_token: str, mock_invoker: Any
|
||||
) -> None:
|
||||
create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
|
||||
workflow_id = create_response.json()["workflow_id"]
|
||||
|
||||
update_response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
|
||||
event = _get_last_event(mock_invoker.services.events.events, "workflow_updated")
|
||||
assert event.workflow_id == workflow_id
|
||||
assert event.user_id == create_response.json()["user_id"]
|
||||
assert event.old_is_public is False
|
||||
assert event.new_is_public is True
|
||||
|
||||
|
||||
def test_delete_workflow_emits_deleted_event_with_last_known_visibility(
|
||||
client: TestClient, user1_token: str, mock_invoker: Any
|
||||
) -> None:
|
||||
create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
|
||||
workflow_id = create_response.json()["workflow_id"]
|
||||
|
||||
share_response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers=_auth(user1_token),
|
||||
)
|
||||
assert share_response.status_code == 200
|
||||
|
||||
delete_response = client.delete(f"/api/v1/workflows/i/{workflow_id}", headers=_auth(user1_token))
|
||||
|
||||
assert delete_response.status_code == 200
|
||||
|
||||
event = _get_last_event(mock_invoker.services.events.events, "workflow_deleted")
|
||||
assert event.workflow_id == workflow_id
|
||||
assert event.user_id == create_response.json()["user_id"]
|
||||
assert event.is_public is True
|
||||
|
||||
|
||||
def test_failed_update_does_not_emit_workflow_live_update_event(
|
||||
client: TestClient, user1_token: str, user2_token: str, mock_invoker: Any
|
||||
) -> None:
|
||||
create_response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY}, headers=_auth(user1_token))
|
||||
workflow_id = create_response.json()["workflow_id"]
|
||||
before_event_names = _event_names(mock_invoker.services.events.events)
|
||||
|
||||
update_response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
json={"workflow": {**WORKFLOW_BODY, "id": workflow_id, "name": "Hijacked"}},
|
||||
headers=_auth(user2_token),
|
||||
)
|
||||
|
||||
assert update_response.status_code == 403
|
||||
assert _event_names(mock_invoker.services.events.events) == before_event_names
|
||||
@@ -0,0 +1,672 @@
|
||||
"""Tests for multiuser workflow library functionality."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.invocation_services import InvocationServices
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
from tests.fixtures.sqlite_database import create_mock_sqlite_database
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
WORKFLOW_BODY = {
|
||||
"name": "Test Workflow",
|
||||
"author": "",
|
||||
"description": "A test workflow",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "",
|
||||
"notes": "",
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"exposedFields": [],
|
||||
"meta": {"version": "3.0.0", "category": "user"},
|
||||
"id": None,
|
||||
"form_fields": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_jwt_secret():
|
||||
from invokeai.app.services.auth.token_service import set_jwt_secret
|
||||
|
||||
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_services() -> InvocationServices:
|
||||
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
|
||||
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
||||
from invokeai.app.services.boards.boards_default import BoardService
|
||||
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
|
||||
from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import (
|
||||
ClientStatePersistenceSqlite,
|
||||
)
|
||||
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
|
||||
from invokeai.app.services.images.images_default import ImageService
|
||||
from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
|
||||
from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
|
||||
from invokeai.app.services.users.users_default import UserService
|
||||
from tests.test_nodes import TestEventService
|
||||
|
||||
configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
|
||||
logger = InvokeAILogger.get_logger()
|
||||
db = create_mock_sqlite_database(configuration, logger)
|
||||
|
||||
return InvocationServices(
|
||||
board_image_records=SqliteBoardImageRecordStorage(db=db),
|
||||
board_images=None, # type: ignore
|
||||
board_records=SqliteBoardRecordStorage(db=db),
|
||||
boards=BoardService(),
|
||||
bulk_download=BulkDownloadService(),
|
||||
configuration=configuration,
|
||||
events=TestEventService(),
|
||||
image_files=None, # type: ignore
|
||||
image_records=SqliteImageRecordStorage(db=db),
|
||||
images=ImageService(),
|
||||
invocation_cache=MemoryInvocationCache(max_cache_size=0),
|
||||
logger=logging, # type: ignore
|
||||
model_images=None, # type: ignore
|
||||
model_manager=None, # type: ignore
|
||||
download_queue=None, # type: ignore
|
||||
names=None, # type: ignore
|
||||
performance_statistics=InvocationStatsService(),
|
||||
session_processor=None, # type: ignore
|
||||
session_queue=None, # type: ignore
|
||||
urls=None, # type: ignore
|
||||
workflow_records=SqliteWorkflowRecordsStorage(db=db),
|
||||
tensors=None, # type: ignore
|
||||
conditioning=None, # type: ignore
|
||||
style_preset_records=None, # type: ignore
|
||||
style_preset_image_files=None, # type: ignore
|
||||
workflow_thumbnails=None, # type: ignore
|
||||
model_relationship_records=None, # type: ignore
|
||||
model_relationships=None, # type: ignore
|
||||
client_state_persistence=ClientStatePersistenceSqlite(db=db),
|
||||
users=UserService(db),
|
||||
external_generation=None, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def create_test_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
|
||||
user_service = mock_invoker.services.users
|
||||
user_data = UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
|
||||
user = user_service.create(user_data)
|
||||
return user.user_id
|
||||
|
||||
|
||||
def get_user_token(client: TestClient, email: str) -> str:
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": "TestPass123", "remember_me": False},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
mock_workflow_thumbnails = MagicMock()
|
||||
mock_workflow_thumbnails.get_url.return_value = None
|
||||
mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
|
||||
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient):
|
||||
create_test_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
|
||||
return get_user_token(client, "admin@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
create_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
|
||||
return get_user_token(client, "user1@test.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
|
||||
create_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
|
||||
return get_user_token(client, "user2@test.com")
|
||||
|
||||
|
||||
def create_workflow(client: TestClient, token: str, workflow_body: dict[str, Any] | None = None) -> str:
|
||||
response = client.post(
|
||||
"/api/v1/workflows/",
|
||||
json={"workflow": workflow_body or WORKFLOW_BODY},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["workflow_id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_workflows_requires_auth(enable_multiuser: Any, client: TestClient):
|
||||
response = client.get("/api/v1/workflows/")
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def test_create_workflow_requires_auth(enable_multiuser: Any, client: TestClient):
|
||||
response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY})
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ownership isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_workflows_are_isolated_between_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""Users should only see their own workflows in list."""
|
||||
# user1 creates a workflow
|
||||
create_workflow(client, user1_token)
|
||||
|
||||
# user1 can see it
|
||||
r1 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user1_token}"})
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["total"] == 1
|
||||
|
||||
# user2 cannot see user1's workflow
|
||||
r2 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user2_token}"})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["total"] == 0
|
||||
|
||||
|
||||
def test_user_cannot_delete_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_user_cannot_update_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
updated = {**WORKFLOW_BODY, "id": workflow_id, "name": "Hijacked"}
|
||||
response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
json={"workflow": updated},
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_owner_can_delete_own_workflow(client: TestClient, user1_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_admin_can_delete_any_workflow(client: TestClient, admin_token: str, user1_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_list_workflows_skips_stale_workflow_rows(
|
||||
client: TestClient, user1_token: str, mock_invoker: Invoker, monkeypatch: Any
|
||||
):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
stale_id = "stale-workflow"
|
||||
workflow_records = mock_invoker.services.workflow_records
|
||||
existing = workflow_records.get(workflow_id)
|
||||
|
||||
original_get_many = workflow_records.get_many
|
||||
original_get = workflow_records.get
|
||||
|
||||
def fake_get_many(*args, **kwargs):
|
||||
results = original_get_many(*args, **kwargs)
|
||||
return results.model_copy(
|
||||
update={"items": [*results.items, results.items[0].model_copy(update={"workflow_id": stale_id})]}
|
||||
)
|
||||
|
||||
def fake_get(requested_workflow_id: str):
|
||||
if requested_workflow_id == stale_id:
|
||||
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowNotFoundError
|
||||
|
||||
raise WorkflowNotFoundError("stale")
|
||||
return original_get(requested_workflow_id)
|
||||
|
||||
monkeypatch.setattr(workflow_records, "get_many", fake_get_many)
|
||||
monkeypatch.setattr(workflow_records, "get", fake_get)
|
||||
|
||||
response = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user1_token}"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["total"] == 1
|
||||
assert [item["workflow_id"] for item in payload["items"]] == [existing.workflow_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared workflow (is_public)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_is_public_owner_succeeds(client: TestClient, user1_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_public"] is True
|
||||
|
||||
|
||||
def test_update_is_public_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
response = client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_public_workflow_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""A shared (is_public=True) workflow should appear when filtering with is_public=true."""
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
# Make it public
|
||||
client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# user2 can see it through is_public=true filter
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user&is_public=true",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert workflow_id in ids
|
||||
|
||||
|
||||
def test_private_workflow_not_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
|
||||
"""A private (is_public=False) user workflow should NOT appear for another user."""
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
|
||||
# user2 lists 'yours' style (their own workflows)
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {user2_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert workflow_id not in ids
|
||||
|
||||
|
||||
def test_public_workflow_still_in_owners_list(client: TestClient, user1_token: str):
|
||||
"""A shared workflow should still appear in the owner's own workflow list."""
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
client.patch(
|
||||
f"/api/v1/workflows/i/{workflow_id}/is_public",
|
||||
json={"is_public": True},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
# owner's 'yours' list (no is_public filter)
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert workflow_id in ids
|
||||
|
||||
|
||||
def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_token: str):
|
||||
"""Created workflow should return user_id and is_public fields."""
|
||||
response = client.post(
|
||||
"/api/v1/workflows/",
|
||||
json={"workflow": WORKFLOW_BODY},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "user_id" in data
|
||||
assert "is_public" in data
|
||||
assert data["is_public"] is False
|
||||
|
||||
|
||||
def test_list_workflows_includes_call_saved_workflow_compatibility(client: TestClient, user1_token: str):
|
||||
compatible_workflow_id = create_workflow(
|
||||
client,
|
||||
user1_token,
|
||||
{
|
||||
**WORKFLOW_BODY,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "return",
|
||||
"type": "invocation",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"id": "return",
|
||||
"type": "workflow_return",
|
||||
"version": "1.0.0",
|
||||
"nodePack": "invokeai",
|
||||
"label": "",
|
||||
"notes": "",
|
||||
"isOpen": True,
|
||||
"isIntermediate": False,
|
||||
"useCache": True,
|
||||
"dynamicInputTemplates": {},
|
||||
"inputs": {"collection": {"value": []}},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
incompatible_workflow_id = create_workflow(client, user1_token)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
items_by_id = {item["workflow_id"]: item for item in response.json()["items"]}
|
||||
assert items_by_id[compatible_workflow_id]["call_saved_workflow_compatibility"] == {
|
||||
"is_callable": True,
|
||||
"reason": "ok",
|
||||
"message": None,
|
||||
}
|
||||
assert items_by_id[incompatible_workflow_id]["call_saved_workflow_compatibility"] == {
|
||||
"is_callable": False,
|
||||
"reason": "missing_workflow_return",
|
||||
"message": "The workflow must contain exactly one workflow_return node.",
|
||||
}
|
||||
|
||||
|
||||
def _create_callable_workflow(client: TestClient, token: str, name: str) -> str:
|
||||
return create_workflow(
|
||||
client,
|
||||
token,
|
||||
{
|
||||
**WORKFLOW_BODY,
|
||||
"name": name,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "return",
|
||||
"type": "invocation",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"id": "return",
|
||||
"type": "workflow_return",
|
||||
"version": "1.0.0",
|
||||
"nodePack": "invokeai",
|
||||
"label": "",
|
||||
"notes": "",
|
||||
"isOpen": True,
|
||||
"isIntermediate": False,
|
||||
"useCache": True,
|
||||
"dynamicInputTemplates": {},
|
||||
"inputs": {"values": {"value": []}},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_list_workflows_callable_filter_counts_only_callable_workflows(client: TestClient, user1_token: str):
|
||||
callable_a = _create_callable_workflow(client, user1_token, "Callable A")
|
||||
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable A"})
|
||||
callable_b = _create_callable_workflow(client, user1_token, "Callable B")
|
||||
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable B"})
|
||||
callable_c = _create_callable_workflow(client, user1_token, "Callable C")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user&callable=true&per_page=2&page=0&order_by=name&direction=ASC",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["total"] == 3
|
||||
assert payload["pages"] == 2
|
||||
assert payload["per_page"] == 2
|
||||
assert [item["workflow_id"] for item in payload["items"]] == [callable_a, callable_b]
|
||||
assert {item["workflow_id"] for item in payload["items"]}.isdisjoint({callable_c})
|
||||
assert all(item["call_saved_workflow_compatibility"]["is_callable"] for item in payload["items"])
|
||||
|
||||
|
||||
def test_list_workflows_callable_filter_paginates_callable_workflows_after_filtering(
|
||||
client: TestClient, user1_token: str
|
||||
):
|
||||
_create_callable_workflow(client, user1_token, "Callable A")
|
||||
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable A"})
|
||||
_create_callable_workflow(client, user1_token, "Callable B")
|
||||
create_workflow(client, user1_token, {**WORKFLOW_BODY, "name": "Not Callable B"})
|
||||
callable_c = _create_callable_workflow(client, user1_token, "Callable C")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user&callable=true&per_page=2&page=1&order_by=name&direction=ASC",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["total"] == 3
|
||||
assert payload["pages"] == 2
|
||||
assert payload["per_page"] == 2
|
||||
assert [item["workflow_id"] for item in payload["items"]] == [callable_c]
|
||||
assert all(item["call_saved_workflow_compatibility"]["is_callable"] for item in payload["items"])
|
||||
|
||||
|
||||
def test_get_workflow_includes_call_saved_workflow_compatibility(client: TestClient, user1_token: str):
|
||||
workflow_id = create_workflow(client, user1_token)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1/workflows/i/{workflow_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["call_saved_workflow_compatibility"] == {
|
||||
"is_callable": False,
|
||||
"reason": "missing_workflow_return",
|
||||
"message": "The workflow must contain exactly one workflow_return node.",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System-owned workflow visibility (regression tests for migration 30 fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _insert_system_workflow(mock_invoker: Invoker, name: str = "Legacy Workflow", is_public: bool = True) -> str:
|
||||
"""Insert a workflow owned by 'system' directly via the service layer, then set is_public."""
|
||||
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutID
|
||||
|
||||
wf = WorkflowWithoutID(**{**WORKFLOW_BODY, "name": name})
|
||||
record = mock_invoker.services.workflow_records.create(workflow=wf, user_id="system")
|
||||
if is_public:
|
||||
mock_invoker.services.workflow_records.update_is_public(workflow_id=record.workflow_id, is_public=True)
|
||||
return record.workflow_id
|
||||
|
||||
|
||||
def test_system_public_workflow_visible_in_shared_listing(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""After migration 30, system-owned public workflows should appear in the shared workflows listing."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user&is_public=true",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert wf_id in ids
|
||||
|
||||
|
||||
def test_system_public_workflow_not_in_your_workflows(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""System-owned public workflows should NOT appear in 'Your Workflows' listing."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Legacy Workflow")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert wf_id not in ids
|
||||
|
||||
|
||||
def test_admin_can_list_system_workflows(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Admins should see system-owned workflows in their listing."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Admin Visible Workflow")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows/?categories=user",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
ids = [w["workflow_id"] for w in response.json()["items"]]
|
||||
assert wf_id in ids
|
||||
|
||||
|
||||
def test_admin_can_update_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Admins should be able to update a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Editable Legacy")
|
||||
|
||||
# Get the full workflow to update it
|
||||
get_resp = client.get(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert get_resp.status_code == 200
|
||||
workflow_data = get_resp.json()["workflow"]
|
||||
workflow_data["name"] = "Updated by Admin"
|
||||
|
||||
update_resp = client.patch(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
json={"workflow": workflow_data},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert update_resp.status_code == 200
|
||||
assert update_resp.json()["workflow"]["name"] == "Updated by Admin"
|
||||
|
||||
|
||||
def test_admin_can_delete_system_workflow(client: TestClient, admin_token: str, mock_invoker: Invoker):
|
||||
"""Admins should be able to delete a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Deletable Legacy")
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_regular_user_cannot_update_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""Regular users should NOT be able to update a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Protected Legacy")
|
||||
|
||||
get_resp = client.get(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert get_resp.status_code == 200
|
||||
workflow_data = get_resp.json()["workflow"]
|
||||
workflow_data["name"] = "Hijacked"
|
||||
|
||||
update_resp = client.patch(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
json={"workflow": workflow_data},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert update_resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_regular_user_cannot_delete_system_workflow(client: TestClient, user1_token: str, mock_invoker: Invoker):
|
||||
"""Regular users should NOT be able to delete a system-owned workflow."""
|
||||
wf_id = _insert_system_workflow(mock_invoker, "Undeletable Legacy")
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/workflows/i/{wf_id}",
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-user mode: default ownership + sharing on create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def single_user_mode(monkeypatch: Any, mock_invoker: Invoker):
|
||||
"""Configure the app for single-user (legacy) mode."""
|
||||
mock_invoker.services.configuration.multiuser = False
|
||||
mock_workflow_thumbnails = MagicMock()
|
||||
mock_workflow_thumbnails.get_url.return_value = None
|
||||
mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
|
||||
|
||||
mock_deps = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
|
||||
yield
|
||||
|
||||
|
||||
def test_single_user_create_workflow_owned_by_system_and_public(single_user_mode: Any, client: TestClient):
|
||||
"""In single-user mode, newly created workflows should be owned by 'system' and shared (is_public=True)."""
|
||||
response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY})
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["user_id"] == "system"
|
||||
assert payload["is_public"] is True
|
||||
|
||||
|
||||
def test_multiuser_create_workflow_owned_by_user_and_private(client: TestClient, user1_token: str):
|
||||
"""In multiuser mode, newly created workflows should be owned by the creator and private (is_public=False)."""
|
||||
response = client.post(
|
||||
"/api/v1/workflows/",
|
||||
json={"workflow": WORKFLOW_BODY},
|
||||
headers={"Authorization": f"Bearer {user1_token}"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["user_id"] != "system"
|
||||
assert payload["is_public"] is False
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for authentication services."""
|
||||
@@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.auth.token_service import set_jwt_secret
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_jwt_secret() -> None:
|
||||
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Integration tests for multi-user data isolation.
|
||||
|
||||
Tests to ensure users can only access their own data and cannot access
|
||||
other users' data unless explicitly shared.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def client(invokeai_root_dir: Path) -> TestClient:
|
||||
"""Create a test client for the FastAPI app."""
|
||||
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None:
|
||||
"""Enable multiuser mode for auth tests.
|
||||
|
||||
Auth tests need multiuser mode enabled since the login/setup endpoints
|
||||
return 403 when multiuser is disabled.
|
||||
"""
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
"""Mock API dependencies for testing."""
|
||||
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
def create_user_and_login(
|
||||
mock_invoker: Invoker, client: TestClient, monkeypatch: Any, email: str, password: str, is_admin: bool = False
|
||||
) -> tuple[str, str]:
|
||||
"""Helper to create a user, login, and return (user_id, token)."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
user_service = mock_invoker.services.users
|
||||
user_data = UserCreateRequest(
|
||||
email=email,
|
||||
display_name=f"User {email}",
|
||||
password=password,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
user = user_service.create(user_data)
|
||||
|
||||
# Login to get token
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": email,
|
||||
"password": password,
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
token = response.json()["token"]
|
||||
|
||||
return user.user_id, token
|
||||
|
||||
|
||||
class TestBoardDataIsolation:
|
||||
"""Tests for board data isolation between users."""
|
||||
|
||||
def test_user_can_only_see_own_boards(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Test that users can only see their own boards."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Create two users
|
||||
user1_id, user1_token = create_user_and_login(
|
||||
mock_invoker, client, monkeypatch, "user1@example.com", "TestPass123"
|
||||
)
|
||||
user2_id, user2_token = create_user_and_login(
|
||||
mock_invoker, client, monkeypatch, "user2@example.com", "TestPass123"
|
||||
)
|
||||
|
||||
# Create board for user1
|
||||
board_service = mock_invoker.services.boards
|
||||
user1_board = board_service.create(board_name="User 1 Board", user_id=user1_id)
|
||||
|
||||
# Create board for user2
|
||||
user2_board = board_service.create(board_name="User 2 Board", user_id=user2_id)
|
||||
|
||||
# User1 should only see their board
|
||||
user1_boards = board_service.get_many(
|
||||
user_id=user1_id,
|
||||
is_admin=False,
|
||||
order_by=BoardRecordOrderBy.CreatedAt,
|
||||
direction=SQLiteDirection.Ascending,
|
||||
)
|
||||
|
||||
user1_board_ids = [b.board_id for b in user1_boards.items]
|
||||
assert user1_board.board_id in user1_board_ids
|
||||
assert user2_board.board_id not in user1_board_ids
|
||||
|
||||
# User2 should only see their board
|
||||
user2_boards = board_service.get_many(
|
||||
user_id=user2_id,
|
||||
is_admin=False,
|
||||
order_by=BoardRecordOrderBy.CreatedAt,
|
||||
direction=SQLiteDirection.Ascending,
|
||||
)
|
||||
|
||||
user2_board_ids = [b.board_id for b in user2_boards.items]
|
||||
assert user2_board.board_id in user2_board_ids
|
||||
assert user1_board.board_id not in user2_board_ids
|
||||
|
||||
def test_user_cannot_access_other_user_board_directly(self, mock_invoker: Invoker):
|
||||
"""Test that users cannot access other users' boards by ID."""
|
||||
board_service = mock_invoker.services.boards
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create two users
|
||||
user1_data = UserCreateRequest(
|
||||
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
|
||||
)
|
||||
user1 = user_service.create(user1_data)
|
||||
|
||||
user2_data = UserCreateRequest(
|
||||
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
|
||||
)
|
||||
user2 = user_service.create(user2_data)
|
||||
|
||||
# User1 creates a board
|
||||
user1_board = board_service.create(board_name="User 1 Private Board", user_id=user1.user_id)
|
||||
|
||||
# User2 tries to access user1's board
|
||||
# The get method should check ownership
|
||||
try:
|
||||
retrieved_board = board_service.get(board_id=user1_board.board_id, user_id=user2.user_id)
|
||||
# If get doesn't check ownership, this test needs to be updated
|
||||
# or the implementation needs to be fixed
|
||||
if retrieved_board is not None:
|
||||
# Board was retrieved - check if it's because of missing authorization check
|
||||
# This would be a security issue that needs fixing
|
||||
pytest.fail("User was able to access another user's board without authorization")
|
||||
except Exception:
|
||||
# Expected - user2 should not be able to access user1's board
|
||||
pass
|
||||
|
||||
def test_admin_can_see_all_boards(self, mock_invoker: Invoker):
|
||||
"""Test that admin users can see all boards."""
|
||||
board_service = mock_invoker.services.boards
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create admin user
|
||||
admin_data = UserCreateRequest(
|
||||
email="admin@example.com", display_name="Admin", password="AdminPass123", is_admin=True
|
||||
)
|
||||
admin = user_service.create(admin_data)
|
||||
|
||||
# Create regular user
|
||||
user_data = UserCreateRequest(
|
||||
email="user@example.com", display_name="User", password="TestPass123", is_admin=False
|
||||
)
|
||||
user = user_service.create(user_data)
|
||||
|
||||
# User creates a board
|
||||
board_service.create(board_name="User Board", user_id=user.user_id)
|
||||
|
||||
# Admin creates a board
|
||||
board_service.create(board_name="Admin Board", user_id=admin.user_id)
|
||||
|
||||
# Admin should be able to get all boards (implementation dependent)
|
||||
# Note: Current implementation may not have admin override for board listing
|
||||
# This test documents expected behavior
|
||||
|
||||
|
||||
class TestImageDataIsolation:
|
||||
"""Tests for image data isolation between users."""
|
||||
|
||||
def test_user_images_isolated_from_other_users(self, mock_invoker: Invoker):
|
||||
"""Test that users cannot see other users' images."""
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create two users
|
||||
user1_data = UserCreateRequest(
|
||||
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
|
||||
)
|
||||
user_service.create(user1_data)
|
||||
|
||||
user2_data = UserCreateRequest(
|
||||
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
|
||||
)
|
||||
user_service.create(user2_data)
|
||||
|
||||
# Note: Image service tests would require actual image creation
|
||||
# which is beyond the scope of basic security testing
|
||||
# This test documents expected behavior:
|
||||
# - Images should have user_id field
|
||||
# - Image queries should filter by user_id
|
||||
# - Users should not be able to access images by knowing the image_name
|
||||
|
||||
|
||||
class TestWorkflowDataIsolation:
|
||||
"""Tests for workflow data isolation between users."""
|
||||
|
||||
def test_user_workflows_isolated_from_other_users(self, mock_invoker: Invoker):
|
||||
"""Test that users cannot see other users' private workflows."""
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create two users
|
||||
user1_data = UserCreateRequest(
|
||||
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
|
||||
)
|
||||
user_service.create(user1_data)
|
||||
|
||||
user2_data = UserCreateRequest(
|
||||
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
|
||||
)
|
||||
user_service.create(user2_data)
|
||||
|
||||
# Note: Workflow service tests would require workflow creation
|
||||
# This test documents expected behavior:
|
||||
# - Workflows should have user_id and is_public fields
|
||||
# - Private workflows should only be visible to owner
|
||||
# - Public workflows should be visible to all users
|
||||
|
||||
|
||||
class TestQueueDataIsolation:
|
||||
"""Tests for session queue data isolation between users."""
|
||||
|
||||
def test_user_queue_items_isolated_from_other_users(self, mock_invoker: Invoker):
|
||||
"""Test that users cannot see other users' queue items."""
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create two users
|
||||
user1_data = UserCreateRequest(
|
||||
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
|
||||
)
|
||||
user_service.create(user1_data)
|
||||
|
||||
user2_data = UserCreateRequest(
|
||||
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
|
||||
)
|
||||
user_service.create(user2_data)
|
||||
|
||||
# Note: Queue service tests would require session creation
|
||||
# This test documents expected behavior:
|
||||
# - Queue items should have user_id field
|
||||
# - Users should only see their own queue items
|
||||
# - Admin should see all queue items
|
||||
|
||||
|
||||
class TestSharedBoardAccess:
|
||||
"""Tests for shared board functionality."""
|
||||
|
||||
@pytest.mark.skip(reason="Shared board functionality not yet fully implemented")
|
||||
def test_shared_board_access(self, mock_invoker: Invoker):
|
||||
"""Test that users can access boards shared with them."""
|
||||
board_service = mock_invoker.services.boards
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create two users
|
||||
user1_data = UserCreateRequest(
|
||||
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
|
||||
)
|
||||
user1 = user_service.create(user1_data)
|
||||
|
||||
user2_data = UserCreateRequest(
|
||||
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
|
||||
)
|
||||
user_service.create(user2_data)
|
||||
|
||||
# User1 creates a board
|
||||
board_service.create(board_name="Shared Board", user_id=user1.user_id)
|
||||
|
||||
# User1 shares the board with user2
|
||||
# (This functionality is not yet implemented)
|
||||
|
||||
# User2 should be able to see the shared board
|
||||
# Expected behavior documented for future implementation
|
||||
|
||||
|
||||
class TestAdminAuthorization:
|
||||
"""Tests for admin-only functionality."""
|
||||
|
||||
def test_regular_user_cannot_create_admin(self, mock_invoker: Invoker):
|
||||
"""Test that regular users cannot create admin accounts."""
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create first admin
|
||||
admin_data = UserCreateRequest(
|
||||
email="admin@example.com", display_name="Admin", password="AdminPass123", is_admin=True
|
||||
)
|
||||
user_service.create(admin_data)
|
||||
|
||||
# Try to create another admin (should fail)
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
another_admin_data = UserCreateRequest(
|
||||
email="another@example.com", display_name="Another Admin", password="AdminPass123"
|
||||
)
|
||||
user_service.create_admin(another_admin_data)
|
||||
|
||||
def test_regular_user_cannot_list_all_users(self, mock_invoker: Invoker):
|
||||
"""Test that regular users cannot list all users.
|
||||
|
||||
Note: This depends on API endpoint implementation.
|
||||
At the service level, list_users is available to all callers.
|
||||
Authorization should be enforced at the API level.
|
||||
"""
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create users
|
||||
user1_data = UserCreateRequest(
|
||||
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
|
||||
)
|
||||
user_service.create(user1_data)
|
||||
|
||||
# Service level does not enforce authorization
|
||||
# API level should check if caller is admin before allowing user listing
|
||||
user_service.list_users()
|
||||
# This will succeed at service level - API must enforce auth
|
||||
|
||||
|
||||
class TestDataIntegrity:
|
||||
"""Tests for data integrity in multi-user scenarios."""
|
||||
|
||||
def test_user_deletion_cascades_to_owned_data(self, mock_invoker: Invoker):
|
||||
"""Test that deleting a user also deletes their owned data."""
|
||||
user_service = mock_invoker.services.users
|
||||
board_service = mock_invoker.services.boards
|
||||
|
||||
# Create user
|
||||
user_data = UserCreateRequest(
|
||||
email="deleteme@example.com", display_name="Delete Me", password="TestPass123", is_admin=False
|
||||
)
|
||||
user = user_service.create(user_data)
|
||||
|
||||
# User creates a board
|
||||
board = board_service.create(board_name="My Board", user_id=user.user_id)
|
||||
|
||||
# Delete user
|
||||
user_service.delete(user.user_id)
|
||||
|
||||
# Board should be deleted too (CASCADE in database)
|
||||
# Note: get_dto doesn't take user_id parameter, it gets the board by ID only
|
||||
# We'll check that it raises an exception or returns None after cascade delete
|
||||
try:
|
||||
board_service.get_dto(board_id=board.board_id)
|
||||
# If we get here, the board wasn't deleted - this is a failure
|
||||
raise AssertionError("Board should have been deleted by CASCADE")
|
||||
except Exception:
|
||||
# Expected - board was deleted by CASCADE
|
||||
pass
|
||||
|
||||
def test_concurrent_user_operations_maintain_isolation(self, mock_invoker: Invoker):
|
||||
"""Test that concurrent operations from different users maintain data isolation.
|
||||
|
||||
This is a basic test - comprehensive concurrency testing would require
|
||||
multiple threads/processes and more complex scenarios.
|
||||
"""
|
||||
user_service = mock_invoker.services.users
|
||||
board_service = mock_invoker.services.boards
|
||||
|
||||
# Create two users
|
||||
user1_data = UserCreateRequest(
|
||||
email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False
|
||||
)
|
||||
user1 = user_service.create(user1_data)
|
||||
|
||||
user2_data = UserCreateRequest(
|
||||
email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False
|
||||
)
|
||||
user2 = user_service.create(user2_data)
|
||||
|
||||
# Both users create boards
|
||||
user1_board = board_service.create(board_name="User 1 Board", user_id=user1.user_id)
|
||||
user2_board = board_service.create(board_name="User 2 Board", user_id=user2.user_id)
|
||||
|
||||
# Verify isolation is maintained
|
||||
user1_boards = board_service.get_many(
|
||||
user_id=user1.user_id,
|
||||
is_admin=False,
|
||||
order_by=BoardRecordOrderBy.CreatedAt,
|
||||
direction=SQLiteDirection.Ascending,
|
||||
)
|
||||
user2_boards = board_service.get_many(
|
||||
user_id=user2.user_id,
|
||||
is_admin=False,
|
||||
order_by=BoardRecordOrderBy.CreatedAt,
|
||||
direction=SQLiteDirection.Ascending,
|
||||
)
|
||||
|
||||
user1_board_ids = [b.board_id for b in user1_boards.items]
|
||||
user2_board_ids = [b.board_id for b in user2_boards.items]
|
||||
|
||||
# Each user should only see their own board
|
||||
assert user1_board.board_id in user1_board_ids
|
||||
assert user2_board.board_id not in user1_board_ids
|
||||
|
||||
assert user2_board.board_id in user2_board_ids
|
||||
assert user1_board.board_id not in user2_board_ids
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Unit tests for password utilities."""
|
||||
|
||||
from invokeai.app.services.auth.password_utils import (
|
||||
get_password_strength,
|
||||
hash_password,
|
||||
validate_password_strength,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
"""Tests for password hashing functionality."""
|
||||
|
||||
def test_hash_password_returns_different_hash_each_time(self):
|
||||
"""Test that hashing the same password twice produces different hashes (due to salt)."""
|
||||
password = "TestPassword123"
|
||||
hash1 = hash_password(password)
|
||||
hash2 = hash_password(password)
|
||||
|
||||
assert hash1 != hash2
|
||||
assert hash1 != password
|
||||
assert hash2 != password
|
||||
|
||||
def test_hash_password_with_special_characters(self):
|
||||
"""Test hashing passwords with special characters."""
|
||||
password = "Test!@#$%^&*()_+{}[]|:;<>?,./~`"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert hashed is not None
|
||||
assert verify_password(password, hashed)
|
||||
|
||||
def test_hash_password_with_unicode(self):
|
||||
"""Test hashing passwords with Unicode characters."""
|
||||
password = "Test密码123パスワード"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert hashed is not None
|
||||
assert verify_password(password, hashed)
|
||||
|
||||
def test_hash_password_empty_string(self):
|
||||
"""Test hashing empty password (should work but fail validation)."""
|
||||
password = ""
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert hashed is not None
|
||||
assert verify_password(password, hashed)
|
||||
|
||||
def test_hash_password_very_long(self):
|
||||
"""Test hashing very long passwords (bcrypt has 72 byte limit)."""
|
||||
# Create a password longer than 72 bytes
|
||||
password = "A" * 100
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert hashed is not None
|
||||
# Verify with original password
|
||||
assert verify_password(password, hashed)
|
||||
# Should also match the truncated version
|
||||
assert verify_password("A" * 72, hashed)
|
||||
|
||||
def test_hash_password_with_newlines(self):
|
||||
"""Test hashing passwords containing newlines."""
|
||||
password = "Test\nPassword\n123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert hashed is not None
|
||||
assert verify_password(password, hashed)
|
||||
|
||||
|
||||
class TestPasswordVerification:
|
||||
"""Tests for password verification functionality."""
|
||||
|
||||
def test_verify_password_correct(self):
|
||||
"""Test verifying correct password."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password(password, hashed) is True
|
||||
|
||||
def test_verify_password_incorrect(self):
|
||||
"""Test verifying incorrect password."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password("WrongPassword123", hashed) is False
|
||||
|
||||
def test_verify_password_case_sensitive(self):
|
||||
"""Test that password verification is case-sensitive."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password("testpassword123", hashed) is False
|
||||
assert verify_password("TESTPASSWORD123", hashed) is False
|
||||
|
||||
def test_verify_password_whitespace_sensitive(self):
|
||||
"""Test that whitespace matters in password verification."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password(" TestPassword123", hashed) is False
|
||||
assert verify_password("TestPassword123 ", hashed) is False
|
||||
assert verify_password("Test Password123", hashed) is False
|
||||
|
||||
def test_verify_password_with_special_characters(self):
|
||||
"""Test verifying passwords with special characters."""
|
||||
password = "Test!@#$%^&*()_+"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password(password, hashed) is True
|
||||
assert verify_password("Test!@#$%^&*()_+X", hashed) is False
|
||||
|
||||
def test_verify_password_with_unicode(self):
|
||||
"""Test verifying passwords with Unicode."""
|
||||
password = "Test密码123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password(password, hashed) is True
|
||||
assert verify_password("Test密码124", hashed) is False
|
||||
|
||||
def test_verify_password_empty_against_hashed(self):
|
||||
"""Test verifying empty password."""
|
||||
password = ""
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password("", hashed) is True
|
||||
assert verify_password("notEmpty", hashed) is False
|
||||
|
||||
def test_verify_password_invalid_hash_format(self):
|
||||
"""Test verifying password against invalid hash format."""
|
||||
password = "TestPassword123"
|
||||
|
||||
# Should return False for invalid hash, not raise exception
|
||||
assert verify_password(password, "not_a_valid_hash") is False
|
||||
assert verify_password(password, "") is False
|
||||
|
||||
|
||||
class TestPasswordStrengthValidation:
|
||||
"""Tests for password strength validation."""
|
||||
|
||||
def test_validate_strong_password(self):
|
||||
"""Test validating a strong password."""
|
||||
valid, message = validate_password_strength("StrongPass123")
|
||||
|
||||
assert valid is True
|
||||
assert message == ""
|
||||
|
||||
def test_validate_password_too_short(self):
|
||||
"""Test validating password shorter than 8 characters."""
|
||||
valid, message = validate_password_strength("Short1")
|
||||
|
||||
assert valid is False
|
||||
assert "at least 8 characters" in message
|
||||
|
||||
def test_validate_password_minimum_length(self):
|
||||
"""Test validating password with exactly 8 characters."""
|
||||
valid, message = validate_password_strength("Pass123A")
|
||||
|
||||
assert valid is True
|
||||
assert message == ""
|
||||
|
||||
def test_validate_password_no_uppercase(self):
|
||||
"""Test validating password without uppercase letters."""
|
||||
valid, message = validate_password_strength("lowercase123")
|
||||
|
||||
assert valid is False
|
||||
assert "uppercase" in message.lower()
|
||||
|
||||
def test_validate_password_no_lowercase(self):
|
||||
"""Test validating password without lowercase letters."""
|
||||
valid, message = validate_password_strength("UPPERCASE123")
|
||||
|
||||
assert valid is False
|
||||
assert "lowercase" in message.lower()
|
||||
|
||||
def test_validate_password_no_digits(self):
|
||||
"""Test validating password without digits."""
|
||||
valid, message = validate_password_strength("NoDigitsHere")
|
||||
|
||||
assert valid is False
|
||||
assert "number" in message.lower()
|
||||
|
||||
def test_validate_password_with_special_characters(self):
|
||||
"""Test that special characters are allowed but not required."""
|
||||
# With special characters
|
||||
valid, message = validate_password_strength("Pass!@#$123")
|
||||
assert valid is True
|
||||
|
||||
# Without special characters (but meets other requirements)
|
||||
valid, message = validate_password_strength("Password123")
|
||||
assert valid is True
|
||||
|
||||
def test_validate_password_with_spaces(self):
|
||||
"""Test validating password with spaces."""
|
||||
# Password with spaces that meets requirements
|
||||
valid, message = validate_password_strength("Pass Word 123")
|
||||
|
||||
assert valid is True
|
||||
assert message == ""
|
||||
|
||||
def test_validate_password_unicode(self):
|
||||
"""Test validating password with Unicode characters."""
|
||||
# Unicode with uppercase, lowercase, and digits
|
||||
valid, message = validate_password_strength("密码Pass123")
|
||||
|
||||
assert valid is True
|
||||
|
||||
def test_validate_password_empty(self):
|
||||
"""Test validating empty password."""
|
||||
valid, message = validate_password_strength("")
|
||||
|
||||
assert valid is False
|
||||
assert "at least 8 characters" in message
|
||||
|
||||
def test_validate_password_all_requirements_barely_met(self):
|
||||
"""Test password that barely meets all requirements."""
|
||||
# 8 chars, 1 upper, 1 lower, 1 digit
|
||||
valid, message = validate_password_strength("Passwor1")
|
||||
|
||||
assert valid is True
|
||||
assert message == ""
|
||||
|
||||
def test_validate_password_very_long(self):
|
||||
"""Test validating very long password."""
|
||||
# Very long password that meets requirements
|
||||
password = "A" * 50 + "a" * 50 + "1" * 50
|
||||
valid, message = validate_password_strength(password)
|
||||
|
||||
assert valid is True
|
||||
assert message == ""
|
||||
|
||||
|
||||
class TestGetPasswordStrength:
|
||||
"""Tests for get_password_strength function."""
|
||||
|
||||
def test_weak_password_too_short(self):
|
||||
"""Test that passwords shorter than 8 characters are 'weak'."""
|
||||
assert get_password_strength("Ab1") == "weak"
|
||||
assert get_password_strength("Ab1defg") == "weak" # 7 chars
|
||||
assert get_password_strength("") == "weak"
|
||||
|
||||
def test_moderate_password_missing_uppercase(self):
|
||||
"""Test that 8+ char passwords missing uppercase are 'moderate'."""
|
||||
assert get_password_strength("lowercase1") == "moderate"
|
||||
|
||||
def test_moderate_password_missing_lowercase(self):
|
||||
"""Test that 8+ char passwords missing lowercase are 'moderate'."""
|
||||
assert get_password_strength("UPPERCASE1") == "moderate"
|
||||
|
||||
def test_moderate_password_missing_digit(self):
|
||||
"""Test that 8+ char passwords missing digits are 'moderate'."""
|
||||
assert get_password_strength("NoDigitsHere") == "moderate"
|
||||
|
||||
def test_moderate_password_only_lowercase_and_digit(self):
|
||||
"""Test that 8+ char passwords with only lowercase and digit are 'moderate'."""
|
||||
assert get_password_strength("lowercase1") == "moderate"
|
||||
|
||||
def test_strong_password(self):
|
||||
"""Test that 8+ char passwords with upper, lower, and digit are 'strong'."""
|
||||
assert get_password_strength("StrongPass1") == "strong"
|
||||
assert get_password_strength("Pass123A") == "strong"
|
||||
|
||||
def test_strong_password_with_special_chars(self):
|
||||
"""Test that passwords meeting all requirements plus special chars are 'strong'."""
|
||||
assert get_password_strength("Pass!@#$123") == "strong"
|
||||
|
||||
def test_exactly_8_characters_meeting_requirements(self):
|
||||
"""Test that exactly 8 characters meeting requirements is 'strong'."""
|
||||
assert get_password_strength("Pass123A") == "strong"
|
||||
|
||||
def test_exactly_8_characters_missing_uppercase(self):
|
||||
"""Test that exactly 8 characters missing uppercase is 'moderate'."""
|
||||
assert get_password_strength("pass123a") == "moderate"
|
||||
|
||||
def test_strength_progression(self):
|
||||
"""Test that strength improves as requirements are met."""
|
||||
# Too short - weak
|
||||
assert get_password_strength("Abc1") == "weak"
|
||||
# Long enough but only lowercase - moderate
|
||||
assert get_password_strength("abcdefgh") == "moderate"
|
||||
# Meets all requirements - strong
|
||||
assert get_password_strength("Abcdefg1") == "strong"
|
||||
|
||||
|
||||
class TestPasswordSecurityProperties:
|
||||
"""Tests for security properties of password handling."""
|
||||
|
||||
def test_timing_attack_resistance_same_length(self):
|
||||
"""Test that password verification takes similar time for correct and incorrect passwords.
|
||||
|
||||
Note: This is a basic check. Real timing attack resistance requires more sophisticated testing.
|
||||
"""
|
||||
import time
|
||||
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
# Measure time for correct password
|
||||
start = time.perf_counter()
|
||||
for _ in range(100):
|
||||
verify_password(password, hashed)
|
||||
correct_time = time.perf_counter() - start
|
||||
|
||||
# Measure time for incorrect password of same length
|
||||
start = time.perf_counter()
|
||||
for _ in range(100):
|
||||
verify_password("WrongPassword12", hashed)
|
||||
incorrect_time = time.perf_counter() - start
|
||||
|
||||
# Times should be relatively similar (within 50% difference)
|
||||
# This is a loose check as bcrypt is designed to be slow and timing-resistant
|
||||
ratio = max(correct_time, incorrect_time) / min(correct_time, incorrect_time)
|
||||
assert ratio < 1.5, "Timing difference too large, potential timing attack vulnerability"
|
||||
|
||||
def test_different_hashes_for_same_password(self):
|
||||
"""Test that the same password produces different hashes (salt randomization)."""
|
||||
password = "TestPassword123"
|
||||
hashes = {hash_password(password) for _ in range(10)}
|
||||
|
||||
# All hashes should be unique due to random salt
|
||||
assert len(hashes) == 10
|
||||
|
||||
def test_hash_output_format(self):
|
||||
"""Test that hash output follows bcrypt format."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
# Bcrypt hashes start with $2b$ (or other valid bcrypt identifiers)
|
||||
assert hashed.startswith("$2")
|
||||
# Bcrypt hashes are 60 characters long
|
||||
assert len(hashed) == 60
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Performance tests for multiuser authentication system.
|
||||
|
||||
These tests measure the performance overhead of authentication and
|
||||
ensure the system performs acceptably under load.
|
||||
"""
|
||||
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from logging import Logger
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.auth.password_utils import hash_password, verify_password
|
||||
from invokeai.app.services.auth.token_service import TokenData, create_access_token, verify_token
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
from invokeai.app.services.users.users_default import UserService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logger() -> Logger:
|
||||
"""Create a logger for testing."""
|
||||
return Logger("test_performance")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_service(logger: Logger) -> UserService:
|
||||
"""Create a user service with in-memory database for testing."""
|
||||
db = SqliteDatabase(db_path=None, logger=logger, verbose=False)
|
||||
|
||||
# Create users table
|
||||
db._conn.execute("""
|
||||
CREATE TABLE users (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
|
||||
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
|
||||
last_login_at DATETIME
|
||||
);
|
||||
""")
|
||||
db._conn.commit()
|
||||
|
||||
return UserService(db)
|
||||
|
||||
|
||||
class TestPasswordPerformance:
|
||||
"""Tests for password hashing and verification performance."""
|
||||
|
||||
def test_password_hashing_performance(self):
|
||||
"""Test that password hashing completes in reasonable time.
|
||||
|
||||
bcrypt is intentionally slow for security. Each hash should take
|
||||
approximately 50-100ms on modern hardware.
|
||||
"""
|
||||
password = "TestPassword123"
|
||||
iterations = 10
|
||||
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
hash_password(password)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
avg_time_ms = (elapsed_time / iterations) * 1000
|
||||
|
||||
# Each hash should take between 10ms and 500ms
|
||||
# (bcrypt is designed to be slow, 50-100ms is typical)
|
||||
assert 10 < avg_time_ms < 500, f"Password hashing took {avg_time_ms:.2f}ms per hash"
|
||||
|
||||
# Log performance for reference
|
||||
print(f"\nPassword hashing performance: {avg_time_ms:.2f}ms per hash")
|
||||
|
||||
def test_password_verification_performance(self):
|
||||
"""Test that password verification completes in reasonable time."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
iterations = 10
|
||||
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
verify_password(password, hashed)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
avg_time_ms = (elapsed_time / iterations) * 1000
|
||||
|
||||
# Verification should take similar time to hashing
|
||||
assert 10 < avg_time_ms < 500, f"Password verification took {avg_time_ms:.2f}ms per verification"
|
||||
|
||||
print(f"Password verification performance: {avg_time_ms:.2f}ms per verification")
|
||||
|
||||
def test_concurrent_password_operations(self):
|
||||
"""Test password operations under concurrent load."""
|
||||
password = "TestPassword123"
|
||||
num_operations = 20
|
||||
|
||||
def hash_and_verify():
|
||||
hashed = hash_password(password)
|
||||
return verify_password(password, hashed)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
futures = [executor.submit(hash_and_verify) for _ in range(num_operations)]
|
||||
|
||||
results = [future.result() for future in as_completed(futures)]
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# All operations should succeed
|
||||
assert all(results)
|
||||
|
||||
# Total time should be less than sequential time due to parallelization
|
||||
print(f"Concurrent password operations ({num_operations}): {elapsed_time:.2f}s total")
|
||||
|
||||
|
||||
class TestTokenPerformance:
|
||||
"""Tests for JWT token performance."""
|
||||
|
||||
def test_token_creation_performance(self):
|
||||
"""Test that token creation is fast."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
iterations = 1000
|
||||
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
create_access_token(token_data)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
avg_time_ms = (elapsed_time / iterations) * 1000
|
||||
|
||||
# Token creation should be very fast (< 1ms per token)
|
||||
assert avg_time_ms < 1.0, f"Token creation took {avg_time_ms:.3f}ms per token"
|
||||
|
||||
print(f"\nToken creation performance: {avg_time_ms:.3f}ms per token")
|
||||
|
||||
def test_token_verification_performance(self):
|
||||
"""Test that token verification is fast."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
iterations = 1000
|
||||
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
verify_token(token)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
avg_time_ms = (elapsed_time / iterations) * 1000
|
||||
|
||||
# Token verification should be very fast (< 1ms per verification)
|
||||
assert avg_time_ms < 1.0, f"Token verification took {avg_time_ms:.3f}ms per verification"
|
||||
|
||||
print(f"Token verification performance: {avg_time_ms:.3f}ms per verification")
|
||||
|
||||
def test_concurrent_token_operations(self):
|
||||
"""Test token operations under concurrent load."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
num_operations = 1000
|
||||
|
||||
def create_and_verify():
|
||||
token = create_access_token(token_data)
|
||||
verified = verify_token(token)
|
||||
return verified is not None
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
futures = [executor.submit(create_and_verify) for _ in range(num_operations)]
|
||||
|
||||
results = [future.result() for future in as_completed(futures)]
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# All operations should succeed
|
||||
assert all(results)
|
||||
|
||||
ops_per_second = num_operations / elapsed_time
|
||||
print(f"Concurrent token operations: {ops_per_second:.0f} ops/second")
|
||||
|
||||
# Should handle at least 1000 operations per second
|
||||
assert ops_per_second > 1000, f"Only {ops_per_second:.0f} ops/second"
|
||||
|
||||
|
||||
class TestAuthenticationOverhead:
|
||||
"""Tests for overall authentication system overhead."""
|
||||
|
||||
def test_login_flow_performance(self, user_service: UserService):
|
||||
"""Test complete login flow performance."""
|
||||
# Create a user
|
||||
user_data = UserCreateRequest(
|
||||
email="perf@example.com",
|
||||
display_name="Performance Test",
|
||||
password="TestPass123",
|
||||
is_admin=False,
|
||||
)
|
||||
user_service.create(user_data)
|
||||
|
||||
iterations = 10
|
||||
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
# Simulate login flow
|
||||
user = user_service.authenticate("perf@example.com", "TestPass123")
|
||||
assert user is not None
|
||||
|
||||
# Create token
|
||||
token_data = TokenData(
|
||||
user_id=user.user_id,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
token = create_access_token(token_data)
|
||||
|
||||
# Verify token
|
||||
verified = verify_token(token)
|
||||
assert verified is not None
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
avg_time_ms = (elapsed_time / iterations) * 1000
|
||||
|
||||
# Complete login flow should complete in reasonable time
|
||||
# Most of the time is spent on password verification (50-100ms)
|
||||
assert avg_time_ms < 500, f"Login flow took {avg_time_ms:.2f}ms"
|
||||
|
||||
print(f"\nComplete login flow performance: {avg_time_ms:.2f}ms per login")
|
||||
|
||||
def test_token_verification_overhead(self):
|
||||
"""Measure overhead of token verification vs no auth."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
iterations = 10000
|
||||
|
||||
# Measure token verification time
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
verify_token(token)
|
||||
verification_time = time.time() - start_time
|
||||
|
||||
# Measure baseline (minimal operation)
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
# Simulate minimal auth check
|
||||
_ = token is not None
|
||||
baseline_time = time.time() - start_time
|
||||
|
||||
overhead_ms = ((verification_time - baseline_time) / iterations) * 1000
|
||||
|
||||
# Overhead should be minimal (< 0.1ms per request)
|
||||
assert overhead_ms < 0.1, f"Token verification adds {overhead_ms:.4f}ms overhead per request"
|
||||
|
||||
print(f"Token verification overhead: {overhead_ms:.4f}ms per request")
|
||||
|
||||
|
||||
class TestUserServicePerformance:
|
||||
"""Tests for user service performance."""
|
||||
|
||||
def test_user_creation_performance(self, user_service: UserService):
|
||||
"""Test user creation performance."""
|
||||
iterations = 10
|
||||
|
||||
start_time = time.time()
|
||||
for i in range(iterations):
|
||||
user_data = UserCreateRequest(
|
||||
email=f"user{i}@example.com",
|
||||
display_name=f"User {i}",
|
||||
password="TestPass123",
|
||||
is_admin=False,
|
||||
)
|
||||
user_service.create(user_data)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
avg_time_ms = (elapsed_time / iterations) * 1000
|
||||
|
||||
# User creation includes password hashing, so should be ~50-150ms
|
||||
assert avg_time_ms < 500, f"User creation took {avg_time_ms:.2f}ms per user"
|
||||
|
||||
print(f"\nUser creation performance: {avg_time_ms:.2f}ms per user")
|
||||
|
||||
def test_user_lookup_performance(self, user_service: UserService):
|
||||
"""Test user lookup performance."""
|
||||
# Create some users
|
||||
for i in range(10):
|
||||
user_data = UserCreateRequest(
|
||||
email=f"lookup{i}@example.com",
|
||||
display_name=f"Lookup User {i}",
|
||||
password="TestPass123",
|
||||
is_admin=False,
|
||||
)
|
||||
user_service.create(user_data)
|
||||
|
||||
iterations = 1000
|
||||
|
||||
# Test lookup by email
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
user_service.get_by_email("lookup5@example.com")
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
avg_time_ms = (elapsed_time / iterations) * 1000
|
||||
|
||||
# Lookup should be fast (< 1ms with proper indexing)
|
||||
assert avg_time_ms < 5.0, f"User lookup took {avg_time_ms:.3f}ms per lookup"
|
||||
|
||||
print(f"User lookup by email performance: {avg_time_ms:.3f}ms per lookup")
|
||||
|
||||
def test_user_list_performance(self, user_service: UserService):
|
||||
"""Test user list performance with many users."""
|
||||
# Create many users
|
||||
num_users = 100
|
||||
|
||||
for i in range(num_users):
|
||||
user_data = UserCreateRequest(
|
||||
email=f"listuser{i}@example.com",
|
||||
display_name=f"List User {i}",
|
||||
password="TestPass123",
|
||||
is_admin=False,
|
||||
)
|
||||
user_service.create(user_data)
|
||||
|
||||
# Test listing users
|
||||
iterations = 10
|
||||
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
user_service.list_users(limit=50)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
avg_time_ms = (elapsed_time / iterations) * 1000
|
||||
|
||||
# Listing users should be fast (< 10ms for reasonable page size)
|
||||
assert avg_time_ms < 50.0, f"User listing took {avg_time_ms:.2f}ms"
|
||||
|
||||
print(f"User listing performance (50 users): {avg_time_ms:.2f}ms per query")
|
||||
|
||||
|
||||
class TestConcurrentUserSessions:
|
||||
"""Tests for concurrent user session handling."""
|
||||
|
||||
def test_multiple_concurrent_logins(self, user_service: UserService):
|
||||
"""Test handling multiple concurrent user logins."""
|
||||
# Create test users
|
||||
num_users = 20
|
||||
for i in range(num_users):
|
||||
user_data = UserCreateRequest(
|
||||
email=f"concurrent{i}@example.com",
|
||||
display_name=f"Concurrent User {i}",
|
||||
password="TestPass123",
|
||||
is_admin=False,
|
||||
)
|
||||
user_service.create(user_data)
|
||||
|
||||
def authenticate_user(user_index: int):
|
||||
# Authenticate
|
||||
user = user_service.authenticate(f"concurrent{user_index}@example.com", "TestPass123")
|
||||
if user is None:
|
||||
return False
|
||||
|
||||
# Create token
|
||||
token_data = TokenData(
|
||||
user_id=user.user_id,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
token = create_access_token(token_data)
|
||||
|
||||
# Verify token
|
||||
verified = verify_token(token)
|
||||
return verified is not None
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Simulate concurrent logins
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
futures = [executor.submit(authenticate_user, i) for i in range(num_users)]
|
||||
|
||||
results = [future.result() for future in as_completed(futures)]
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
# All logins should succeed
|
||||
assert all(results), "Some concurrent logins failed"
|
||||
|
||||
print(f"\nConcurrent logins ({num_users} users): {elapsed_time:.2f}s total")
|
||||
|
||||
# Should complete in reasonable time
|
||||
assert elapsed_time < 10.0, f"Concurrent logins took {elapsed_time:.2f}s"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
class TestScalabilityBenchmarks:
|
||||
"""Scalability benchmarks (marked as slow tests)."""
|
||||
|
||||
def test_authentication_under_load(self, user_service: UserService):
|
||||
"""Test authentication system under sustained load."""
|
||||
# Create test users
|
||||
num_users = 50
|
||||
for i in range(num_users):
|
||||
user_data = UserCreateRequest(
|
||||
email=f"load{i}@example.com",
|
||||
display_name=f"Load User {i}",
|
||||
password="TestPass123",
|
||||
is_admin=False,
|
||||
)
|
||||
user_service.create(user_data)
|
||||
|
||||
def simulate_user_activity(user_index: int, num_requests: int):
|
||||
success_count = 0
|
||||
for _ in range(num_requests):
|
||||
# Authenticate
|
||||
user = user_service.authenticate(f"load{user_index}@example.com", "TestPass123")
|
||||
if user is None:
|
||||
continue
|
||||
|
||||
# Create and verify token
|
||||
token_data = TokenData(user_id=user.user_id, email=user.email, is_admin=user.is_admin)
|
||||
token = create_access_token(token_data)
|
||||
verified = verify_token(token)
|
||||
|
||||
if verified is not None:
|
||||
success_count += 1
|
||||
|
||||
return success_count
|
||||
|
||||
# Simulate sustained load
|
||||
requests_per_user = 5
|
||||
total_requests = num_users * requests_per_user
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
futures = [executor.submit(simulate_user_activity, i, requests_per_user) for i in range(num_users)]
|
||||
|
||||
success_counts = [future.result() for future in as_completed(futures)]
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
total_success = sum(success_counts)
|
||||
success_rate = (total_success / total_requests) * 100
|
||||
requests_per_second = total_requests / elapsed_time
|
||||
|
||||
print("\nLoad test results:")
|
||||
print(f" Total requests: {total_requests}")
|
||||
print(f" Success rate: {success_rate:.1f}%")
|
||||
print(f" Requests/second: {requests_per_second:.0f}")
|
||||
print(f" Total time: {elapsed_time:.2f}s")
|
||||
|
||||
# Should maintain high success rate under load
|
||||
assert success_rate > 95.0, f"Success rate only {success_rate:.1f}%"
|
||||
|
||||
# Should handle reasonable throughput
|
||||
# Note: This is limited by bcrypt hashing speed
|
||||
assert requests_per_second > 5.0, f"Only {requests_per_second:.1f} req/s"
|
||||
@@ -0,0 +1,459 @@
|
||||
"""Security tests for multiuser authentication system.
|
||||
|
||||
This module tests various security aspects including:
|
||||
- SQL injection prevention
|
||||
- Authorization bypass attempts
|
||||
- Session security
|
||||
- Input validation
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def client(invokeai_root_dir: Path) -> TestClient:
|
||||
"""Create a test client for the FastAPI app."""
|
||||
os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None:
|
||||
"""Enable multiuser mode for auth tests.
|
||||
|
||||
Auth tests need multiuser mode enabled since the login/setup endpoints
|
||||
return 403 when multiuser is disabled.
|
||||
"""
|
||||
mock_invoker.services.configuration.multiuser = True
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
"""Mock API dependencies for testing."""
|
||||
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
def setup_test_user(mock_invoker: Invoker, email: str = "test@example.com", password: str = "TestPass123") -> str:
|
||||
"""Helper to create a test user and return user_id."""
|
||||
user_service = mock_invoker.services.users
|
||||
user_data = UserCreateRequest(
|
||||
email=email,
|
||||
display_name="Test User",
|
||||
password=password,
|
||||
is_admin=False,
|
||||
)
|
||||
user = user_service.create(user_data)
|
||||
return user.user_id
|
||||
|
||||
|
||||
def setup_test_admin(mock_invoker: Invoker, email: str = "admin@example.com", password: str = "AdminPass123") -> str:
|
||||
"""Helper to create a test admin user and return user_id."""
|
||||
user_service = mock_invoker.services.users
|
||||
user_data = UserCreateRequest(
|
||||
email=email,
|
||||
display_name="Admin User",
|
||||
password=password,
|
||||
is_admin=True,
|
||||
)
|
||||
user = user_service.create(user_data)
|
||||
return user.user_id
|
||||
|
||||
|
||||
class TestSQLInjectionPrevention:
|
||||
"""Tests to ensure SQL injection attacks are prevented."""
|
||||
|
||||
def test_login_sql_injection_in_email(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Test that SQL injection in email field is prevented."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Create a legitimate user first
|
||||
setup_test_user(mock_invoker, "legitimate@example.com", "TestPass123")
|
||||
|
||||
# Try SQL injection in email field
|
||||
sql_injection_attempts = [
|
||||
"' OR '1'='1",
|
||||
"admin' --",
|
||||
"' OR 1=1 --",
|
||||
"'; DROP TABLE users; --",
|
||||
"' UNION SELECT * FROM users --",
|
||||
]
|
||||
|
||||
for injection_attempt in sql_injection_attempts:
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": injection_attempt,
|
||||
"password": "TestPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Should return 401 (invalid credentials) or 422 (validation error)
|
||||
# Both are acceptable - the important thing is no SQL injection occurs
|
||||
assert response.status_code in [401, 422], f"SQL injection attempt should be rejected: {injection_attempt}"
|
||||
# Should NOT return 200 (success) or 500 (server error)
|
||||
assert response.status_code != 200, f"SQL injection should not succeed: {injection_attempt}"
|
||||
assert response.status_code != 500, f"SQL injection should not cause server error: {injection_attempt}"
|
||||
|
||||
def test_login_sql_injection_in_password(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Test that SQL injection in password field is prevented."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Create a legitimate user
|
||||
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
|
||||
|
||||
# Try SQL injection in password field
|
||||
sql_injection_attempts = [
|
||||
"' OR '1'='1",
|
||||
"anything' OR '1'='1' --",
|
||||
"' OR 1=1; DROP TABLE users; --",
|
||||
]
|
||||
|
||||
for injection_attempt in sql_injection_attempts:
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"password": injection_attempt,
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Should fail authentication
|
||||
assert response.status_code == 401, f"SQL injection attempt should be rejected: {injection_attempt}"
|
||||
|
||||
def test_user_service_sql_injection_in_email(self, mock_invoker: Invoker):
|
||||
"""Test that user service prevents SQL injection in email lookups."""
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create a test user
|
||||
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
|
||||
|
||||
# Try SQL injection in get_by_email
|
||||
sql_injection_attempts = [
|
||||
"test@example.com' OR '1'='1",
|
||||
"' OR 1=1 --",
|
||||
"test@example.com'; DROP TABLE users; --",
|
||||
]
|
||||
|
||||
for injection_attempt in sql_injection_attempts:
|
||||
# Should return None (not found), not raise an error or return wrong user
|
||||
user = user_service.get_by_email(injection_attempt)
|
||||
assert user is None, f"SQL injection should not return a user: {injection_attempt}"
|
||||
|
||||
|
||||
class TestAuthorizationBypass:
|
||||
"""Tests to ensure authorization cannot be bypassed."""
|
||||
|
||||
def test_cannot_access_protected_endpoint_without_token(self, client: TestClient):
|
||||
"""Test that protected endpoints require authentication."""
|
||||
# Try to access protected endpoint without token
|
||||
response = client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_cannot_access_protected_endpoint_with_invalid_token(
|
||||
self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient
|
||||
):
|
||||
"""Test that invalid tokens are rejected."""
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
invalid_tokens = [
|
||||
"invalid_token",
|
||||
"Bearer invalid_token",
|
||||
"",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature",
|
||||
]
|
||||
|
||||
for token in invalid_tokens:
|
||||
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert response.status_code == 401, f"Invalid token should be rejected: {token}"
|
||||
|
||||
def test_cannot_forge_admin_token(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Test that admin privileges cannot be forged by modifying tokens."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Create a regular user and login
|
||||
setup_test_user(mock_invoker, "regular@example.com", "TestPass123")
|
||||
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "regular@example.com",
|
||||
"password": "TestPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
token = login_response.json()["token"]
|
||||
|
||||
# Try to modify the token to gain admin privileges
|
||||
# (In practice, this should fail signature verification)
|
||||
parts = token.split(".")
|
||||
if len(parts) == 3:
|
||||
# Decode the payload, modify it, and re-encode
|
||||
import base64
|
||||
import json
|
||||
|
||||
# Add padding if necessary
|
||||
payload_b64 = parts[1]
|
||||
padding = 4 - len(payload_b64) % 4
|
||||
if padding != 4:
|
||||
payload_b64 += "=" * padding
|
||||
|
||||
# Decode payload
|
||||
try:
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
||||
payload_data = json.loads(payload_bytes)
|
||||
|
||||
# Modify is_admin to true
|
||||
payload_data["is_admin"] = True
|
||||
|
||||
# Re-encode
|
||||
modified_payload_bytes = json.dumps(payload_data).encode()
|
||||
modified_payload_b64 = base64.urlsafe_b64encode(modified_payload_bytes).decode().rstrip("=")
|
||||
|
||||
# Create forged token with modified payload but original signature
|
||||
modified_token = f"{parts[0]}.{modified_payload_b64}.{parts[2]}"
|
||||
|
||||
# Attempt to use modified token
|
||||
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {modified_token}"})
|
||||
|
||||
# Should be rejected (invalid signature)
|
||||
assert response.status_code == 401
|
||||
except Exception:
|
||||
# If we can't decode/modify the token, that's fine - just skip this part of the test
|
||||
pass
|
||||
|
||||
def test_regular_user_cannot_create_admin(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Test that regular users cannot create admin users."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# This test would require user management endpoints to be implemented
|
||||
# For now, we test at the service level
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Create a regular user
|
||||
regular_user_data = UserCreateRequest(
|
||||
email="regular@example.com",
|
||||
display_name="Regular User",
|
||||
password="TestPass123",
|
||||
is_admin=False,
|
||||
)
|
||||
user_service.create(regular_user_data)
|
||||
|
||||
# Try to create an admin user (should only be possible through setup or by existing admin)
|
||||
# The create_admin method checks if an admin already exists
|
||||
admin_data = UserCreateRequest(
|
||||
email="sneaky@example.com",
|
||||
display_name="Sneaky Admin",
|
||||
password="TestPass123",
|
||||
)
|
||||
|
||||
# First create an actual admin
|
||||
setup_test_admin(mock_invoker, "realadmin@example.com", "AdminPass123")
|
||||
|
||||
# Now trying to create another admin should fail
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
user_service.create_admin(admin_data)
|
||||
|
||||
|
||||
class TestSessionSecurity:
|
||||
"""Tests for session and token security."""
|
||||
|
||||
def test_token_expires_after_time(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Test that tokens expire after their validity period."""
|
||||
from datetime import timedelta
|
||||
|
||||
from invokeai.app.services.auth.token_service import TokenData, create_access_token
|
||||
|
||||
# Create a token that expires quickly
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# Create token with 10 millisecond expiration
|
||||
expired_token = create_access_token(token_data, expires_delta=timedelta(milliseconds=10))
|
||||
|
||||
# Wait for expiration (wait longer than expiration time)
|
||||
import time
|
||||
|
||||
time.sleep(0.02)
|
||||
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Try to use expired token
|
||||
response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {expired_token}"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_logout_invalidates_session(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Test that logout invalidates the session.
|
||||
|
||||
Note: Current implementation uses JWT which is stateless.
|
||||
This test documents expected behavior for future server-side session tracking.
|
||||
"""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Create user and login
|
||||
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
|
||||
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"password": "TestPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
token = login_response.json()["token"]
|
||||
|
||||
# Logout
|
||||
logout_response = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert logout_response.status_code == 200
|
||||
|
||||
# Note: With JWT, the token is still technically valid until expiration
|
||||
# For true session invalidation, server-side session tracking would be needed
|
||||
|
||||
|
||||
class TestInputValidation:
|
||||
"""Tests for input validation and sanitization."""
|
||||
|
||||
def test_email_validation_on_login(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Test that email validation is enforced on login."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
# Invalid email formats should be rejected by pydantic validation
|
||||
invalid_emails = [
|
||||
"not_an_email",
|
||||
"@example.com",
|
||||
"user@",
|
||||
"user @example.com", # space in email
|
||||
"../../../etc/passwd", # path traversal attempt
|
||||
]
|
||||
|
||||
for invalid_email in invalid_emails:
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": invalid_email,
|
||||
"password": "TestPass123",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Should return 422 (validation error) or 401 (invalid credentials)
|
||||
assert response.status_code in [401, 422], f"Invalid email should be rejected: {invalid_email}"
|
||||
|
||||
def test_xss_prevention_in_user_data(self, mock_invoker: Invoker):
|
||||
"""Test that XSS attempts in user data are handled safely.
|
||||
|
||||
Note: Database storage uses parameterized queries which prevent XSS.
|
||||
This test ensures data is stored and retrieved without executing scripts.
|
||||
"""
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Try to create user with XSS payload in display name
|
||||
xss_payloads = [
|
||||
"<script>alert('xss')</script>",
|
||||
"'; alert('xss'); //",
|
||||
"<img src=x onerror=alert('xss')>",
|
||||
]
|
||||
|
||||
for payload in xss_payloads:
|
||||
user_data = UserCreateRequest(
|
||||
email=f"xss{hash(payload)}@example.com", # unique email
|
||||
display_name=payload,
|
||||
password="TestPass123",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# Should not raise an error - data is stored as-is
|
||||
user = user_service.create(user_data)
|
||||
|
||||
# Verify data is stored exactly as provided (not executed or modified)
|
||||
assert user.display_name == payload
|
||||
|
||||
# Cleanup
|
||||
user_service.delete(user.user_id)
|
||||
|
||||
def test_path_traversal_prevention(self, mock_invoker: Invoker):
|
||||
"""Test that path traversal attempts in user input are handled."""
|
||||
user_service = mock_invoker.services.users
|
||||
|
||||
# Path traversal attempts
|
||||
path_traversal_attempts = [
|
||||
"../../../etc/passwd",
|
||||
"..\\..\\..\\windows\\system32",
|
||||
"user/../../../secret",
|
||||
]
|
||||
|
||||
for attempt in path_traversal_attempts:
|
||||
# These should be stored as literal strings, not interpreted as paths
|
||||
user_data = UserCreateRequest(
|
||||
email=f"path{hash(attempt)}@example.com",
|
||||
display_name=attempt,
|
||||
password="TestPass123",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
user = user_service.create(user_data)
|
||||
assert user.display_name == attempt
|
||||
|
||||
# Cleanup
|
||||
user_service.delete(user.user_id)
|
||||
|
||||
|
||||
class TestRateLimiting:
|
||||
"""Tests for rate limiting and brute force protection.
|
||||
|
||||
Note: Rate limiting is not currently implemented in the codebase.
|
||||
These tests document expected behavior for future implementation.
|
||||
"""
|
||||
|
||||
@pytest.mark.skip(reason="Rate limiting not yet implemented")
|
||||
def test_login_rate_limiting(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient):
|
||||
"""Test that excessive login attempts are rate limited."""
|
||||
monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker))
|
||||
|
||||
setup_test_user(mock_invoker, "test@example.com", "TestPass123")
|
||||
|
||||
# Try many login attempts with wrong password
|
||||
for i in range(20):
|
||||
response = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "test@example.com",
|
||||
"password": "WrongPassword",
|
||||
"remember_me": False,
|
||||
},
|
||||
)
|
||||
|
||||
if i < 10:
|
||||
# First attempts should return 401
|
||||
assert response.status_code == 401
|
||||
else:
|
||||
# After many attempts, should be rate limited (429)
|
||||
# This is expected behavior for future implementation
|
||||
pass
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Unit tests for JWT token service."""
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.auth.token_service import TokenData, create_access_token, set_jwt_secret, verify_token
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def setup_jwt_secret():
|
||||
"""Set up JWT secret for all tests in this module."""
|
||||
# Use a test secret key
|
||||
set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
|
||||
|
||||
|
||||
# Minimum token length to safely modify middle characters for testing
|
||||
# JWT tokens have format header.payload.signature and are typically >180 characters
|
||||
MIN_TOKEN_LENGTH_FOR_MODIFICATION = 50
|
||||
|
||||
# Minimum signature length to safely modify middle characters for testing
|
||||
# JWT signatures are typically 43 characters (base64-encoded HMAC-SHA256)
|
||||
MIN_SIGNATURE_LENGTH_FOR_MODIFICATION = 10
|
||||
|
||||
|
||||
class TestTokenCreation:
|
||||
"""Tests for JWT token creation."""
|
||||
|
||||
def test_create_access_token_basic(self):
|
||||
"""Test creating a basic access token."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
|
||||
assert token is not None
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_create_access_token_with_expiration(self):
|
||||
"""Test creating token with custom expiration."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data, expires_delta=timedelta(hours=1))
|
||||
|
||||
assert token is not None
|
||||
# Verify token is valid
|
||||
verified_data = verify_token(token)
|
||||
assert verified_data is not None
|
||||
assert verified_data.user_id == "user123"
|
||||
|
||||
def test_create_access_token_admin_user(self):
|
||||
"""Test creating token for admin user."""
|
||||
token_data = TokenData(
|
||||
user_id="admin123",
|
||||
email="admin@example.com",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
verified_data = verify_token(token)
|
||||
|
||||
assert verified_data is not None
|
||||
assert verified_data.is_admin is True
|
||||
|
||||
def test_create_access_token_preserves_all_data(self):
|
||||
"""Test that all token data is preserved."""
|
||||
token_data = TokenData(
|
||||
user_id="user_with_complex_id_12345",
|
||||
email="complex.email+tag@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
verified_data = verify_token(token)
|
||||
|
||||
assert verified_data is not None
|
||||
assert verified_data.user_id == token_data.user_id
|
||||
assert verified_data.email == token_data.email
|
||||
assert verified_data.is_admin == token_data.is_admin
|
||||
|
||||
def test_create_access_token_different_each_time(self):
|
||||
"""Test that creating token with same data produces different tokens (due to timestamps)."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# Create tokens with different expiration times to ensure uniqueness
|
||||
token1 = create_access_token(token_data, expires_delta=timedelta(hours=1))
|
||||
token2 = create_access_token(token_data, expires_delta=timedelta(hours=2))
|
||||
|
||||
# Tokens should be different due to different exp timestamps
|
||||
assert token1 != token2
|
||||
|
||||
|
||||
class TestTokenVerification:
|
||||
"""Tests for JWT token verification."""
|
||||
|
||||
def test_verify_valid_token(self):
|
||||
"""Test verifying a valid token."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
verified_data = verify_token(token)
|
||||
|
||||
assert verified_data is not None
|
||||
assert verified_data.user_id == "user123"
|
||||
assert verified_data.email == "test@example.com"
|
||||
assert verified_data.is_admin is False
|
||||
|
||||
def test_verify_invalid_token(self):
|
||||
"""Test verifying an invalid token."""
|
||||
verified_data = verify_token("invalid_token_string")
|
||||
|
||||
assert verified_data is None
|
||||
|
||||
def test_verify_malformed_token(self):
|
||||
"""Test verifying malformed tokens."""
|
||||
malformed_tokens = [
|
||||
"",
|
||||
"not.a.token",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid",
|
||||
"header.payload", # Missing signature
|
||||
]
|
||||
|
||||
for token in malformed_tokens:
|
||||
verified_data = verify_token(token)
|
||||
assert verified_data is None, f"Should reject malformed token: {token}"
|
||||
|
||||
def test_verify_expired_token(self):
|
||||
"""Test verifying an expired token."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# Create token that expires in 100 milliseconds (0.1 seconds)
|
||||
token = create_access_token(token_data, expires_delta=timedelta(milliseconds=100))
|
||||
|
||||
# Wait for token to expire (wait longer than expiration - 200ms to be safe)
|
||||
time.sleep(0.2)
|
||||
|
||||
# Token should be invalid now
|
||||
verified_data = verify_token(token)
|
||||
assert verified_data is None
|
||||
|
||||
def test_verify_token_with_modified_payload(self):
|
||||
"""Test that tokens with modified payloads are rejected."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
|
||||
# Try to modify the token by changing a character in the middle
|
||||
# JWT tokens are base64 encoded, so changing any character should invalidate the signature
|
||||
# Note: We change a character in the middle to avoid Base64 padding issues where
|
||||
# the last character might not affect the decoded value
|
||||
if len(token) > MIN_TOKEN_LENGTH_FOR_MODIFICATION:
|
||||
mid = len(token) // 2
|
||||
modified_token = token[:mid] + ("X" if token[mid] != "X" else "Y") + token[mid + 1 :]
|
||||
verified_data = verify_token(modified_token)
|
||||
assert verified_data is None
|
||||
|
||||
def test_verify_token_preserves_admin_status(self):
|
||||
"""Test that admin status is correctly preserved through token lifecycle."""
|
||||
# Test with regular user
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="user@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
token = create_access_token(token_data)
|
||||
verified = verify_token(token)
|
||||
assert verified is not None
|
||||
assert verified.is_admin is False
|
||||
|
||||
# Test with admin user
|
||||
admin_token_data = TokenData(
|
||||
user_id="admin123",
|
||||
email="admin@example.com",
|
||||
is_admin=True,
|
||||
)
|
||||
admin_token = create_access_token(admin_token_data)
|
||||
admin_verified = verify_token(admin_token)
|
||||
assert admin_verified is not None
|
||||
assert admin_verified.is_admin is True
|
||||
|
||||
|
||||
class TestTokenExpiration:
|
||||
"""Tests for token expiration handling."""
|
||||
|
||||
def test_token_not_expired_immediately(self):
|
||||
"""Test that freshly created token is not expired."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data, expires_delta=timedelta(hours=1))
|
||||
verified_data = verify_token(token)
|
||||
|
||||
assert verified_data is not None
|
||||
|
||||
def test_token_with_long_expiration(self):
|
||||
"""Test token with long expiration time."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# Create token that expires in 7 days
|
||||
token = create_access_token(token_data, expires_delta=timedelta(days=7))
|
||||
verified_data = verify_token(token)
|
||||
|
||||
assert verified_data is not None
|
||||
assert verified_data.user_id == "user123"
|
||||
|
||||
def test_token_with_short_expiration_not_expired(self):
|
||||
"""Test token with short but not yet expired time."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
# Create token that expires in 1 second
|
||||
token = create_access_token(token_data, expires_delta=timedelta(seconds=1))
|
||||
|
||||
# Immediately verify - should still be valid
|
||||
verified_data = verify_token(token)
|
||||
assert verified_data is not None
|
||||
|
||||
|
||||
class TestTokenDataModel:
|
||||
"""Tests for TokenData model."""
|
||||
|
||||
def test_token_data_creation(self):
|
||||
"""Test creating TokenData instance."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
assert token_data.user_id == "user123"
|
||||
assert token_data.email == "test@example.com"
|
||||
assert token_data.is_admin is False
|
||||
|
||||
def test_token_data_with_admin(self):
|
||||
"""Test TokenData for admin user."""
|
||||
token_data = TokenData(
|
||||
user_id="admin123",
|
||||
email="admin@example.com",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
assert token_data.is_admin is True
|
||||
|
||||
def test_token_data_model_dump(self):
|
||||
"""Test that TokenData can be serialized."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
data_dict = token_data.model_dump()
|
||||
|
||||
assert isinstance(data_dict, dict)
|
||||
assert data_dict["user_id"] == "user123"
|
||||
assert data_dict["email"] == "test@example.com"
|
||||
assert data_dict["is_admin"] is False
|
||||
|
||||
|
||||
class TestTokenSecurity:
|
||||
"""Tests for token security properties."""
|
||||
|
||||
def test_token_signature_verification(self):
|
||||
"""Test that token signature is verified."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
|
||||
# Token should verify correctly
|
||||
assert verify_token(token) is not None
|
||||
|
||||
# Modified token should fail verification
|
||||
if len(token) > MIN_TOKEN_LENGTH_FOR_MODIFICATION:
|
||||
# Change a character in the signature part (last part of JWT)
|
||||
parts = token.split(".")
|
||||
if len(parts) == 3 and len(parts[2]) > MIN_SIGNATURE_LENGTH_FOR_MODIFICATION:
|
||||
# Modify a character in the middle of the signature to avoid Base64 padding issues
|
||||
# where the last few characters might not affect the decoded value
|
||||
mid = len(parts[2]) // 2
|
||||
modified_signature = parts[2][:mid] + ("X" if parts[2][mid] != "X" else "Y") + parts[2][mid + 1 :]
|
||||
modified_token = f"{parts[0]}.{parts[1]}.{modified_signature}"
|
||||
assert verify_token(modified_token) is None
|
||||
|
||||
def test_cannot_forge_admin_token(self):
|
||||
"""Test that admin status cannot be forged by modifying token."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
|
||||
# Any modification to the token should invalidate it
|
||||
# This prevents attackers from changing is_admin=false to is_admin=true
|
||||
parts = token.split(".")
|
||||
if len(parts) == 3:
|
||||
# Try to modify the payload
|
||||
modified_payload = parts[1][:-1] + ("X" if parts[1][-1] != "X" else "Y")
|
||||
modified_token = f"{parts[0]}.{modified_payload}.{parts[2]}"
|
||||
|
||||
verified_data = verify_token(modified_token)
|
||||
# Modified token should be rejected
|
||||
assert verified_data is None
|
||||
|
||||
def test_token_uses_strong_algorithm(self):
|
||||
"""Test that token uses secure algorithm (HS256)."""
|
||||
token_data = TokenData(
|
||||
user_id="user123",
|
||||
email="test@example.com",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
token = create_access_token(token_data)
|
||||
|
||||
# JWT tokens have format: header.payload.signature
|
||||
# Header contains algorithm information
|
||||
import base64
|
||||
import json
|
||||
|
||||
parts = token.split(".")
|
||||
if len(parts) >= 1:
|
||||
# Decode header (add padding if necessary)
|
||||
header_b64 = parts[0]
|
||||
# Add padding if necessary
|
||||
padding = 4 - len(header_b64) % 4
|
||||
if padding != 4:
|
||||
header_b64 += "=" * padding
|
||||
|
||||
header = json.loads(base64.urlsafe_b64decode(header_b64))
|
||||
# Should use HS256 algorithm
|
||||
assert header.get("alg") == "HS256"
|
||||
@@ -0,0 +1,398 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.board_records.board_records_common import BoardRecord, BoardRecordNotFoundException
|
||||
from invokeai.app.services.bulk_download.bulk_download_common import BulkDownloadTargetException
|
||||
from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
|
||||
from invokeai.app.services.events.events_common import (
|
||||
BulkDownloadCompleteEvent,
|
||||
BulkDownloadErrorEvent,
|
||||
BulkDownloadStartedEvent,
|
||||
)
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageCategory,
|
||||
ImageRecordNotFoundException,
|
||||
ResourceOrigin,
|
||||
)
|
||||
from invokeai.app.services.images.images_common import ImageDTO
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.shared.pagination import OffsetPaginatedResults
|
||||
from tests.test_nodes import TestEventService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_image_dto() -> ImageDTO:
|
||||
"""Create a mock ImageDTO."""
|
||||
return ImageDTO(
|
||||
image_name="mock_image.png",
|
||||
board_id="12345",
|
||||
image_url="None",
|
||||
width=100,
|
||||
height=100,
|
||||
thumbnail_url="None",
|
||||
image_origin=ResourceOrigin.INTERNAL,
|
||||
image_category=ImageCategory.GENERAL,
|
||||
created_at="None",
|
||||
updated_at="None",
|
||||
starred=False,
|
||||
has_workflow=False,
|
||||
is_intermediate=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_temporary_directory(monkeypatch: Any, tmp_path: Path):
|
||||
"""Mock the TemporaryDirectory class so that it uses the tmp_path fixture."""
|
||||
|
||||
class MockTemporaryDirectory(TemporaryDirectory):
|
||||
def __init__(self):
|
||||
super().__init__(dir=tmp_path)
|
||||
self.name = tmp_path
|
||||
|
||||
def mock_TemporaryDirectory(*args, **kwargs):
|
||||
return MockTemporaryDirectory()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"invokeai.app.services.bulk_download.bulk_download_default.TemporaryDirectory", mock_TemporaryDirectory
|
||||
)
|
||||
|
||||
|
||||
def test_get_path_when_file_exists(tmp_path: Path) -> None:
|
||||
"""Test get_path when the file exists."""
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
|
||||
# Create a directory at tmp_path/bulk_downloads
|
||||
test_bulk_downloads_dir: Path = tmp_path / "bulk_downloads"
|
||||
test_bulk_downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create a file at tmp_path/bulk_downloads/test.zip
|
||||
test_file_path: Path = test_bulk_downloads_dir / "test.zip"
|
||||
test_file_path.touch()
|
||||
|
||||
assert bulk_download_service.get_path("test.zip") == str(test_file_path)
|
||||
|
||||
|
||||
def test_get_path_when_file_does_not_exist(tmp_path: Path) -> None:
|
||||
"""Test get_path when the file does not exist."""
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
with pytest.raises(BulkDownloadTargetException):
|
||||
bulk_download_service.get_path("test")
|
||||
|
||||
|
||||
def test_bulk_downloads_dir_created_at_start(tmp_path: Path) -> None:
|
||||
"""Test that the bulk_downloads directory is created at start."""
|
||||
|
||||
BulkDownloadService()
|
||||
assert (tmp_path / "bulk_downloads").exists()
|
||||
|
||||
|
||||
def test_handler_image_names(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
|
||||
"""Test that the handler creates the zip file correctly when given a list of image names."""
|
||||
|
||||
expected_zip_path, expected_image_path, mock_image_contents = prepare_handler_test(
|
||||
tmp_path, monkeypatch, mock_image_dto, mock_invoker
|
||||
)
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
bulk_download_service.start(mock_invoker)
|
||||
bulk_download_service.handler([mock_image_dto.image_name], None, None)
|
||||
|
||||
assert_handler_success(
|
||||
expected_zip_path, expected_image_path, mock_image_contents, tmp_path, mock_invoker.services.events
|
||||
)
|
||||
|
||||
|
||||
def test_generate_id(monkeypatch: Any):
|
||||
"""Test that the generate_id method generates a unique id."""
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
|
||||
monkeypatch.setattr("invokeai.app.services.bulk_download.bulk_download_default.uuid_string", lambda: "test")
|
||||
|
||||
assert bulk_download_service.generate_item_id(None) == "test"
|
||||
|
||||
|
||||
def test_generate_id_with_board_id(monkeypatch: Any, mock_invoker: Invoker):
|
||||
"""Test that the generate_id method generates a unique id with a board id."""
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
bulk_download_service.start(mock_invoker)
|
||||
|
||||
def mock_board_get(*args, **kwargs):
|
||||
return BoardRecord(
|
||||
board_id="12345",
|
||||
board_name="test_board_name",
|
||||
user_id="test_user",
|
||||
created_at="None",
|
||||
updated_at="None",
|
||||
archived=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get)
|
||||
|
||||
monkeypatch.setattr("invokeai.app.services.bulk_download.bulk_download_default.uuid_string", lambda: "test")
|
||||
|
||||
assert bulk_download_service.generate_item_id("12345") == "test_board_name_test"
|
||||
|
||||
|
||||
def test_generate_id_with_default_board_id(monkeypatch: Any):
|
||||
"""Test that the generate_id method generates a unique id with a board id."""
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
|
||||
monkeypatch.setattr("invokeai.app.services.bulk_download.bulk_download_default.uuid_string", lambda: "test")
|
||||
|
||||
assert bulk_download_service.generate_item_id("none") == "Uncategorized_test"
|
||||
|
||||
|
||||
def test_handler_board_id(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
|
||||
"""Test that the handler creates the zip file correctly when given a board id."""
|
||||
|
||||
expected_zip_path, expected_image_path, mock_image_contents = prepare_handler_test(
|
||||
tmp_path, monkeypatch, mock_image_dto, mock_invoker
|
||||
)
|
||||
|
||||
def mock_board_get(*args, **kwargs):
|
||||
return BoardRecord(
|
||||
board_id="12345",
|
||||
board_name="test_board_name",
|
||||
user_id="test_user",
|
||||
created_at="None",
|
||||
updated_at="None",
|
||||
archived=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get)
|
||||
|
||||
def mock_get_many(*args, **kwargs):
|
||||
return OffsetPaginatedResults(limit=-1, total=1, offset=0, items=[mock_image_dto])
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.images, "get_many", mock_get_many)
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
bulk_download_service.start(mock_invoker)
|
||||
bulk_download_service.handler([], "test", None)
|
||||
|
||||
assert_handler_success(
|
||||
expected_zip_path, expected_image_path, mock_image_contents, tmp_path, mock_invoker.services.events
|
||||
)
|
||||
|
||||
|
||||
def test_handler_board_id_default(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
|
||||
"""Test that the handler creates the zip file correctly when given a board id."""
|
||||
|
||||
_, expected_image_path, mock_image_contents = prepare_handler_test(
|
||||
tmp_path, monkeypatch, mock_image_dto, mock_invoker
|
||||
)
|
||||
|
||||
def mock_get_many(*args, **kwargs):
|
||||
return OffsetPaginatedResults(limit=-1, total=1, offset=0, items=[mock_image_dto])
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.images, "get_many", mock_get_many)
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
bulk_download_service.start(mock_invoker)
|
||||
bulk_download_service.handler([], "none", None)
|
||||
|
||||
expected_zip_path: Path = tmp_path / "bulk_downloads" / "test.zip"
|
||||
|
||||
assert_handler_success(
|
||||
expected_zip_path, expected_image_path, mock_image_contents, tmp_path, mock_invoker.services.events
|
||||
)
|
||||
|
||||
|
||||
def test_handler_bulk_download_item_id_given(
|
||||
tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker
|
||||
):
|
||||
"""Test that the handler creates the zip file correctly when given a pregenerated bulk download item id."""
|
||||
|
||||
_, expected_image_path, mock_image_contents = prepare_handler_test(
|
||||
tmp_path, monkeypatch, mock_image_dto, mock_invoker
|
||||
)
|
||||
|
||||
def mock_get_many(*args, **kwargs):
|
||||
return OffsetPaginatedResults(limit=-1, total=1, offset=0, items=[mock_image_dto])
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.images, "get_many", mock_get_many)
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
bulk_download_service.start(mock_invoker)
|
||||
bulk_download_service.handler([mock_image_dto.image_name], None, "test_id")
|
||||
|
||||
expected_zip_path: Path = tmp_path / "bulk_downloads" / "test_id.zip"
|
||||
|
||||
assert_handler_success(
|
||||
expected_zip_path, expected_image_path, mock_image_contents, tmp_path, mock_invoker.services.events
|
||||
)
|
||||
|
||||
|
||||
def prepare_handler_test(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
|
||||
"""Prepare the test for the handler tests."""
|
||||
|
||||
def mock_uuid_string():
|
||||
return "test"
|
||||
|
||||
# You have to patch the function within the module it's being imported into. This is strange, but it works.
|
||||
# See http://www.gregreda.com/2021/06/28/mocking-imported-module-function-python/
|
||||
monkeypatch.setattr("invokeai.app.services.bulk_download.bulk_download_default.uuid_string", mock_uuid_string)
|
||||
|
||||
expected_zip_path: Path = tmp_path / "bulk_downloads" / "test.zip"
|
||||
expected_image_path: Path = (
|
||||
tmp_path / "bulk_downloads" / mock_image_dto.image_category.value / mock_image_dto.image_name
|
||||
)
|
||||
|
||||
# Mock the get_dto method so that when the image dto needs to be retrieved it is returned
|
||||
def mock_get_dto(*args, **kwargs):
|
||||
return mock_image_dto
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.images, "get_dto", mock_get_dto)
|
||||
|
||||
# This is used when preparing all images for a given board
|
||||
def mock_get_all_board_image_names_for_board(*args, **kwargs):
|
||||
return [mock_image_dto.image_name]
|
||||
|
||||
monkeypatch.setattr(
|
||||
mock_invoker.services.board_image_records,
|
||||
"get_all_board_image_names_for_board",
|
||||
mock_get_all_board_image_names_for_board,
|
||||
)
|
||||
|
||||
# Create a mock image file so that the contents of the zip file are not empty
|
||||
mock_image_path: Path = tmp_path / mock_image_dto.image_name
|
||||
mock_image_contents: str = "Totally an image"
|
||||
mock_image_path.write_text(mock_image_contents)
|
||||
|
||||
def mock_get_path(*args, **kwargs):
|
||||
return str(mock_image_path)
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.images, "get_path", mock_get_path)
|
||||
|
||||
return expected_zip_path, expected_image_path, mock_image_contents
|
||||
|
||||
|
||||
def assert_handler_success(
|
||||
expected_zip_path: Path,
|
||||
expected_image_path: Path,
|
||||
mock_image_contents: str,
|
||||
tmp_path: Path,
|
||||
event_bus: TestEventService,
|
||||
):
|
||||
"""Assert that the handler was successful."""
|
||||
# Check that the zip file was created
|
||||
assert expected_zip_path.exists()
|
||||
assert expected_zip_path.is_file()
|
||||
assert expected_zip_path.stat().st_size > 0
|
||||
|
||||
# Check that the zip contents are expected
|
||||
with ZipFile(expected_zip_path, "r") as zip_file:
|
||||
zip_file.extractall(tmp_path / "bulk_downloads")
|
||||
assert expected_image_path.exists()
|
||||
assert expected_image_path.is_file()
|
||||
assert expected_image_path.stat().st_size > 0
|
||||
assert expected_image_path.read_text() == mock_image_contents
|
||||
|
||||
# Check that the correct events were emitted
|
||||
assert len(event_bus.events) == 2
|
||||
assert isinstance(event_bus.events[0], BulkDownloadStartedEvent)
|
||||
assert isinstance(event_bus.events[1], BulkDownloadCompleteEvent)
|
||||
assert event_bus.events[1].bulk_download_item_name == os.path.basename(expected_zip_path)
|
||||
|
||||
|
||||
def test_handler_on_image_not_found(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
|
||||
"""Test that the handler emits an error event when the image is not found."""
|
||||
exception: Exception = ImageRecordNotFoundException("Image not found")
|
||||
|
||||
def mock_get_dto(*args, **kwargs):
|
||||
raise exception
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.images, "get_dto", mock_get_dto)
|
||||
|
||||
execute_handler_test_on_error(tmp_path, monkeypatch, mock_image_dto, mock_invoker, exception)
|
||||
|
||||
|
||||
def test_handler_on_board_not_found(tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker):
|
||||
"""Test that the handler emits an error event when the image is not found."""
|
||||
|
||||
exception: Exception = BoardRecordNotFoundException("Image not found")
|
||||
|
||||
def mock_get_board_name(*args, **kwargs):
|
||||
raise exception
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.images, "get_dto", mock_get_board_name)
|
||||
|
||||
execute_handler_test_on_error(tmp_path, monkeypatch, mock_image_dto, mock_invoker, exception)
|
||||
|
||||
|
||||
def test_handler_on_generic_exception(
|
||||
tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker
|
||||
):
|
||||
"""Test that the handler emits an error event when the image is not found."""
|
||||
|
||||
exception: Exception = Exception("Generic exception")
|
||||
|
||||
def mock_get_board_name(*args, **kwargs):
|
||||
raise exception
|
||||
|
||||
monkeypatch.setattr(mock_invoker.services.images, "get_dto", mock_get_board_name)
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
execute_handler_test_on_error(tmp_path, monkeypatch, mock_image_dto, mock_invoker, exception)
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
|
||||
assert len(event_bus.events) == 2
|
||||
assert isinstance(event_bus.events[0], BulkDownloadStartedEvent)
|
||||
assert isinstance(event_bus.events[1], BulkDownloadErrorEvent)
|
||||
assert event_bus.events[1].error == exception.__str__()
|
||||
|
||||
|
||||
def execute_handler_test_on_error(
|
||||
tmp_path: Path, monkeypatch: Any, mock_image_dto: ImageDTO, mock_invoker: Invoker, error: Exception
|
||||
):
|
||||
bulk_download_service = BulkDownloadService()
|
||||
bulk_download_service.start(mock_invoker)
|
||||
bulk_download_service.handler([mock_image_dto.image_name], None, None)
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
|
||||
assert len(event_bus.events) == 2
|
||||
assert isinstance(event_bus.events[0], BulkDownloadStartedEvent)
|
||||
assert isinstance(event_bus.events[1], BulkDownloadErrorEvent)
|
||||
assert event_bus.events[1].error == error.__str__()
|
||||
|
||||
|
||||
def test_delete(tmp_path: Path):
|
||||
"""Test that the delete method removes the bulk download file."""
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
|
||||
mock_file: Path = tmp_path / "bulk_downloads" / "test.zip"
|
||||
mock_file.write_text("contents")
|
||||
|
||||
bulk_download_service.delete("test.zip")
|
||||
|
||||
assert (tmp_path / "bulk_downloads").exists()
|
||||
assert len(os.listdir(tmp_path / "bulk_downloads")) == 0
|
||||
|
||||
|
||||
def test_stop(tmp_path: Path):
|
||||
"""Test that the stop method removes the bulk download file and not any directories."""
|
||||
|
||||
bulk_download_service = BulkDownloadService()
|
||||
|
||||
mock_file: Path = tmp_path / "bulk_downloads" / "test.zip"
|
||||
mock_file.write_text("contents")
|
||||
|
||||
mock_dir: Path = tmp_path / "bulk_downloads" / "test"
|
||||
mock_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
bulk_download_service.stop()
|
||||
|
||||
assert not (tmp_path / "bulk_downloads").exists()
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Test the queued download facility"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Generator, Optional
|
||||
|
||||
import pytest
|
||||
from pydantic.networks import AnyHttpUrl
|
||||
from requests.sessions import Session
|
||||
from requests_testadapter import TestAdapter
|
||||
|
||||
from invokeai.app.services.config import get_config
|
||||
from invokeai.app.services.config.config_default import URLRegexTokenPair
|
||||
from invokeai.app.services.download import DownloadJob, DownloadJobStatus, DownloadQueueService, MultiFileDownloadJob
|
||||
from invokeai.app.services.events.events_common import (
|
||||
DownloadCancelledEvent,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
)
|
||||
from invokeai.backend.model_manager.metadata import HuggingFaceMetadataFetch, ModelMetadataWithFiles, RemoteModelFile
|
||||
from tests.test_nodes import TestEventService
|
||||
|
||||
# Prevent pytest deprecation warnings
|
||||
TestAdapter.__test__ = False
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_basic_queue_download(tmp_path: Path, mm2_session: Session) -> None:
|
||||
events = set()
|
||||
|
||||
def event_handler(job: DownloadJob, excp: Optional[Exception] = None) -> None:
|
||||
events.add(job.status)
|
||||
|
||||
queue = DownloadQueueService(
|
||||
requests_session=mm2_session,
|
||||
)
|
||||
queue.start()
|
||||
job = queue.download(
|
||||
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
|
||||
dest=tmp_path,
|
||||
on_start=event_handler,
|
||||
on_progress=event_handler,
|
||||
on_complete=event_handler,
|
||||
on_error=event_handler,
|
||||
)
|
||||
assert isinstance(job, DownloadJob), "expected the job to be of type DownloadJobBase"
|
||||
assert isinstance(job.id, int), "expected the job id to be numeric"
|
||||
queue.join()
|
||||
|
||||
assert job.status == DownloadJobStatus("completed"), "expected job status to be completed"
|
||||
assert job.download_path == tmp_path / "mock12345.safetensors"
|
||||
assert Path(tmp_path, "mock12345.safetensors").exists(), f"expected {tmp_path}/mock12345.safetensors to exist"
|
||||
|
||||
assert events == {DownloadJobStatus.RUNNING, DownloadJobStatus.COMPLETED}
|
||||
queue.stop()
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_errors(tmp_path: Path, mm2_session: Session) -> None:
|
||||
queue = DownloadQueueService(
|
||||
requests_session=mm2_session,
|
||||
)
|
||||
queue.start()
|
||||
|
||||
for bad_url in ["http://www.civitai.com/models/broken", "http://www.civitai.com/models/missing"]:
|
||||
queue.download(AnyHttpUrl(bad_url), dest=tmp_path)
|
||||
|
||||
queue.join()
|
||||
jobs = queue.list_jobs()
|
||||
print(jobs)
|
||||
assert len(jobs) == 2
|
||||
jobs_dict = {str(x.source): x for x in jobs}
|
||||
assert jobs_dict["http://www.civitai.com/models/broken"].status == DownloadJobStatus.ERROR
|
||||
assert jobs_dict["http://www.civitai.com/models/broken"].error_type == "HTTPError(NOT FOUND)"
|
||||
assert jobs_dict["http://www.civitai.com/models/missing"].status == DownloadJobStatus.COMPLETED
|
||||
assert jobs_dict["http://www.civitai.com/models/missing"].total_bytes == 0
|
||||
queue.stop()
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_event_bus(tmp_path: Path, mm2_session: Session) -> None:
|
||||
event_bus = TestEventService()
|
||||
|
||||
queue = DownloadQueueService(requests_session=mm2_session, event_bus=event_bus)
|
||||
queue.start()
|
||||
queue.download(
|
||||
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
|
||||
dest=tmp_path,
|
||||
)
|
||||
queue.join()
|
||||
events = event_bus.events
|
||||
assert len(events) == 3
|
||||
assert isinstance(events[0], DownloadStartedEvent)
|
||||
assert isinstance(events[1], DownloadProgressEvent)
|
||||
assert isinstance(events[2], DownloadCompleteEvent)
|
||||
assert events[0].timestamp <= events[1].timestamp
|
||||
assert events[1].timestamp <= events[2].timestamp
|
||||
assert events[1].total_bytes > 0
|
||||
assert events[1].current_bytes <= events[1].total_bytes
|
||||
assert events[2].total_bytes == 32029
|
||||
|
||||
# test a failure
|
||||
event_bus.events = [] # reset our accumulator
|
||||
queue.download(source=AnyHttpUrl("http://www.civitai.com/models/broken"), dest=tmp_path)
|
||||
queue.join()
|
||||
events = event_bus.events
|
||||
print("\n".join([x.model_dump_json() for x in events]))
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], DownloadErrorEvent)
|
||||
assert events[0].error_type == "HTTPError(NOT FOUND)"
|
||||
assert events[0].error is not None
|
||||
assert re.search(r"requests.exceptions.HTTPError: NOT FOUND", events[0].error)
|
||||
queue.stop()
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_broken_callbacks(tmp_path: Path, mm2_session: Session, capsys) -> None:
|
||||
queue = DownloadQueueService(
|
||||
requests_session=mm2_session,
|
||||
)
|
||||
queue.start()
|
||||
|
||||
callback_ran = False
|
||||
|
||||
def broken_callback(job: DownloadJob) -> None:
|
||||
nonlocal callback_ran
|
||||
callback_ran = True
|
||||
print(1 / 0) # deliberate error here
|
||||
|
||||
job = queue.download(
|
||||
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
|
||||
dest=tmp_path,
|
||||
on_progress=broken_callback,
|
||||
)
|
||||
|
||||
queue.join()
|
||||
assert job.status == DownloadJobStatus.COMPLETED # should complete even though the callback is borked
|
||||
assert Path(tmp_path, "mock12345.safetensors").exists()
|
||||
assert callback_ran
|
||||
# LS: The pytest capsys fixture does not seem to be working. I can see the
|
||||
# correct stderr message in the pytest log, but it is not appearing in
|
||||
# capsys.readouterr().
|
||||
# captured = capsys.readouterr()
|
||||
# assert re.search("division by zero", captured.err)
|
||||
queue.stop()
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_cancel(tmp_path: Path, mm2_session: Session) -> None:
|
||||
event_bus = TestEventService()
|
||||
|
||||
queue = DownloadQueueService(requests_session=mm2_session, event_bus=event_bus)
|
||||
queue.start()
|
||||
|
||||
cancelled = False
|
||||
|
||||
def slow_callback(job: DownloadJob) -> None:
|
||||
time.sleep(2)
|
||||
|
||||
def cancelled_callback(job: DownloadJob) -> None:
|
||||
nonlocal cancelled
|
||||
cancelled = True
|
||||
|
||||
job = queue.download(
|
||||
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
|
||||
dest=tmp_path,
|
||||
on_start=slow_callback,
|
||||
on_cancelled=cancelled_callback,
|
||||
)
|
||||
queue.cancel_job(job)
|
||||
queue.join()
|
||||
|
||||
assert job.status == DownloadJobStatus.CANCELLED
|
||||
assert cancelled
|
||||
events = event_bus.events
|
||||
assert isinstance(events[-1], DownloadCancelledEvent)
|
||||
assert events[-1].source == "http://www.civitai.com/models/12345"
|
||||
queue.stop()
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_multifile_download(tmp_path: Path, mm2_session: Session) -> None:
|
||||
fetcher = HuggingFaceMetadataFetch(mm2_session)
|
||||
metadata = fetcher.from_id("stabilityai/sdxl-turbo")
|
||||
assert isinstance(metadata, ModelMetadataWithFiles)
|
||||
events = set()
|
||||
|
||||
def event_handler(job: DownloadJob | MultiFileDownloadJob, excp: Optional[Exception] = None) -> None:
|
||||
events.add(job.status)
|
||||
|
||||
queue = DownloadQueueService(
|
||||
requests_session=mm2_session,
|
||||
)
|
||||
queue.start()
|
||||
job = queue.multifile_download(
|
||||
parts=metadata.download_urls(session=mm2_session),
|
||||
dest=tmp_path,
|
||||
on_start=event_handler,
|
||||
on_progress=event_handler,
|
||||
on_complete=event_handler,
|
||||
on_error=event_handler,
|
||||
)
|
||||
assert isinstance(job, MultiFileDownloadJob), "expected the job to be of type MultiFileDownloadJobBase"
|
||||
queue.join()
|
||||
|
||||
assert job.status == DownloadJobStatus("completed"), "expected job status to be completed"
|
||||
assert job.bytes > 0, "expected download bytes to be positive"
|
||||
assert job.bytes == job.total_bytes, "expected download bytes to equal total bytes"
|
||||
assert job.download_path == tmp_path / "sdxl-turbo"
|
||||
assert Path(tmp_path, "sdxl-turbo/model_index.json").exists(), (
|
||||
f"expected {tmp_path}/sdxl-turbo/model_inded.json to exist"
|
||||
)
|
||||
assert Path(tmp_path, "sdxl-turbo/text_encoder/config.json").exists(), (
|
||||
f"expected {tmp_path}/sdxl-turbo/text_encoder/config.json to exist"
|
||||
)
|
||||
|
||||
assert events == {DownloadJobStatus.RUNNING, DownloadJobStatus.COMPLETED}
|
||||
queue.stop()
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_multifile_download_error(tmp_path: Path, mm2_session: Session) -> None:
|
||||
fetcher = HuggingFaceMetadataFetch(mm2_session)
|
||||
metadata = fetcher.from_id("stabilityai/sdxl-turbo")
|
||||
assert isinstance(metadata, ModelMetadataWithFiles)
|
||||
events = set()
|
||||
|
||||
def event_handler(job: DownloadJob | MultiFileDownloadJob, excp: Optional[Exception] = None) -> None:
|
||||
events.add(job.status)
|
||||
|
||||
queue = DownloadQueueService(
|
||||
requests_session=mm2_session,
|
||||
)
|
||||
queue.start()
|
||||
files = metadata.download_urls(session=mm2_session)
|
||||
# this will give a 404 error
|
||||
files.append(RemoteModelFile(url="https://test.com/missing_model.safetensors", path=Path("sdxl-turbo/broken")))
|
||||
job = queue.multifile_download(
|
||||
parts=files,
|
||||
dest=tmp_path,
|
||||
on_start=event_handler,
|
||||
on_progress=event_handler,
|
||||
on_complete=event_handler,
|
||||
on_error=event_handler,
|
||||
)
|
||||
queue.join()
|
||||
|
||||
assert job.status == DownloadJobStatus("error"), "expected job status to be errored"
|
||||
assert job.error_type is not None
|
||||
assert "HTTPError(NOT FOUND)" in job.error_type
|
||||
assert DownloadJobStatus.ERROR in events
|
||||
queue.stop()
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_multifile_cancel(tmp_path: Path, mm2_session: Session, monkeypatch: Any) -> None:
|
||||
event_bus = TestEventService()
|
||||
|
||||
queue = DownloadQueueService(requests_session=mm2_session, event_bus=event_bus)
|
||||
queue.start()
|
||||
|
||||
cancelled = False
|
||||
|
||||
def cancelled_callback(job: DownloadJob) -> None:
|
||||
nonlocal cancelled
|
||||
cancelled = True
|
||||
|
||||
fetcher = HuggingFaceMetadataFetch(mm2_session)
|
||||
metadata = fetcher.from_id("stabilityai/sdxl-turbo")
|
||||
assert isinstance(metadata, ModelMetadataWithFiles)
|
||||
|
||||
job = queue.multifile_download(
|
||||
parts=metadata.download_urls(session=mm2_session),
|
||||
dest=tmp_path,
|
||||
on_cancelled=cancelled_callback,
|
||||
)
|
||||
queue.cancel_job(job)
|
||||
queue.join()
|
||||
|
||||
assert job.status == DownloadJobStatus.CANCELLED
|
||||
assert cancelled
|
||||
events = event_bus.events
|
||||
assert DownloadCancelledEvent in [type(x) for x in events]
|
||||
queue.stop()
|
||||
|
||||
|
||||
def test_multifile_onefile(tmp_path: Path, mm2_session: Session) -> None:
|
||||
queue = DownloadQueueService(
|
||||
requests_session=mm2_session,
|
||||
)
|
||||
queue.start()
|
||||
job = queue.multifile_download(
|
||||
parts=[
|
||||
RemoteModelFile(url=AnyHttpUrl("http://www.civitai.com/models/12345"), path=Path("mock12345.safetensors"))
|
||||
],
|
||||
dest=tmp_path,
|
||||
)
|
||||
assert isinstance(job, MultiFileDownloadJob), "expected the job to be of type MultiFileDownloadJobBase"
|
||||
queue.join()
|
||||
|
||||
assert job.status == DownloadJobStatus("completed"), "expected job status to be completed"
|
||||
assert job.bytes > 0, "expected download bytes to be positive"
|
||||
assert job.bytes == job.total_bytes, "expected download bytes to equal total bytes"
|
||||
assert job.download_path == tmp_path / "mock12345.safetensors"
|
||||
assert Path(tmp_path, "mock12345.safetensors").exists(), f"expected {tmp_path}/mock12345.safetensors to exist"
|
||||
queue.stop()
|
||||
|
||||
|
||||
def test_multifile_no_rel_paths(tmp_path: Path, mm2_session: Session) -> None:
|
||||
queue = DownloadQueueService(
|
||||
requests_session=mm2_session,
|
||||
)
|
||||
|
||||
with pytest.raises(AssertionError) as error:
|
||||
queue.multifile_download(
|
||||
parts=[RemoteModelFile(url=AnyHttpUrl("http://www.civitai.com/models/12345"), path=Path("/etc/passwd"))],
|
||||
dest=tmp_path,
|
||||
)
|
||||
assert str(error.value) == "only relative download paths accepted"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def clear_config() -> Generator[None, None, None]:
|
||||
try:
|
||||
yield None
|
||||
finally:
|
||||
get_config.cache_clear()
|
||||
|
||||
|
||||
def test_tokens(tmp_path: Path, mm2_session: Session):
|
||||
with clear_config():
|
||||
config = get_config()
|
||||
config.remote_api_tokens = [URLRegexTokenPair(url_regex="civitai", token="cv_12345")]
|
||||
queue = DownloadQueueService(requests_session=mm2_session)
|
||||
queue.start()
|
||||
# this one has an access token assigned
|
||||
job1 = queue.download(
|
||||
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
|
||||
dest=tmp_path,
|
||||
)
|
||||
# this one doesn't
|
||||
job2 = queue.download(
|
||||
source=AnyHttpUrl(
|
||||
"http://www.huggingface.co/foo.txt",
|
||||
),
|
||||
dest=tmp_path,
|
||||
)
|
||||
queue.join()
|
||||
# this token is defined in the temporary root invokeai.yaml
|
||||
# see tests/backend/model_manager/data/invokeai_root/invokeai.yaml
|
||||
assert job1.access_token == "cv_12345"
|
||||
assert job2.access_token is None
|
||||
queue.stop()
|
||||
@@ -0,0 +1,311 @@
|
||||
import io
|
||||
import logging
|
||||
from typing import Any, Iterator
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.external_generation.errors import ExternalProviderRequestError
|
||||
from invokeai.app.services.external_generation.external_generation_common import (
|
||||
ExternalGenerationRequest,
|
||||
ExternalReferenceImage,
|
||||
)
|
||||
from invokeai.app.services.external_generation.image_utils import encode_image_base64
|
||||
from invokeai.app.services.external_generation.providers import alibabacloud as alibabacloud_module
|
||||
from invokeai.app.services.external_generation.providers.alibabacloud import AlibabaCloudProvider
|
||||
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(
|
||||
self,
|
||||
ok: bool,
|
||||
status_code: int = 200,
|
||||
json_data: dict | None = None,
|
||||
text: str = "",
|
||||
content: bytes = b"",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self.ok = ok
|
||||
self.status_code = status_code
|
||||
self._json_data = json_data or {}
|
||||
self.text = text
|
||||
self.content = content
|
||||
self.headers: dict[str, str] = headers or {}
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._json_data
|
||||
|
||||
def iter_content(self, chunk_size: int = 65536) -> Iterator[bytes]:
|
||||
for i in range(0, len(self.content), chunk_size):
|
||||
yield self.content[i : i + chunk_size]
|
||||
|
||||
def __enter__(self) -> "DummyResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _make_image(color: str = "blue") -> Image.Image:
|
||||
return Image.new("RGB", (16, 16), color=color)
|
||||
|
||||
|
||||
def _png_bytes(image: Image.Image) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _build_model(provider_model_id: str) -> ExternalApiModelConfig:
|
||||
return ExternalApiModelConfig(
|
||||
key=f"alibabacloud_{provider_model_id}",
|
||||
name=provider_model_id,
|
||||
provider_id="alibabacloud",
|
||||
provider_model_id=provider_model_id,
|
||||
capabilities=ExternalModelCapabilities(
|
||||
modes=["txt2img"],
|
||||
supports_reference_images=True,
|
||||
supports_seed=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_request(
|
||||
model: ExternalApiModelConfig,
|
||||
reference_images: list[ExternalReferenceImage] | None = None,
|
||||
) -> ExternalGenerationRequest:
|
||||
return ExternalGenerationRequest(
|
||||
model=model,
|
||||
mode="txt2img", # type: ignore[arg-type]
|
||||
prompt="a cat",
|
||||
seed=42,
|
||||
num_images=1,
|
||||
width=1024,
|
||||
height=1024,
|
||||
image_size=None,
|
||||
init_image=None,
|
||||
mask_image=None,
|
||||
reference_images=reference_images or [],
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
|
||||
def _provider() -> AlibabaCloudProvider:
|
||||
config = InvokeAIAppConfig(external_alibabacloud_api_key="test-key")
|
||||
return AlibabaCloudProvider(config, logging.getLogger("test"))
|
||||
|
||||
|
||||
def test_unknown_model_id_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
provider = _provider()
|
||||
request = _build_request(_build_model("not-a-real-model"))
|
||||
|
||||
def fail_post(*_args: Any, **_kwargs: Any) -> DummyResponse: # pragma: no cover - should not be called
|
||||
raise AssertionError("network must not be touched for unknown model")
|
||||
|
||||
monkeypatch.setattr("requests.post", fail_post)
|
||||
|
||||
with pytest.raises(ExternalProviderRequestError, match="Unknown DashScope model_id"):
|
||||
provider.generate(request)
|
||||
|
||||
|
||||
def test_sync_routes_qwen_edit_max_with_reference_images(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
provider = _provider()
|
||||
ref = _make_image("red")
|
||||
request = _build_request(
|
||||
_build_model("qwen-image-edit-max"),
|
||||
reference_images=[ExternalReferenceImage(image=ref)],
|
||||
)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
image_url = "https://example.invalid/result.png"
|
||||
image_bytes = _png_bytes(_make_image("green"))
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={
|
||||
"request_id": "req-1",
|
||||
"output": {
|
||||
"choices": [
|
||||
{"message": {"content": [{"image": image_url}]}},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def fake_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
|
||||
assert url == image_url
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
content=image_bytes,
|
||||
headers={"Content-Length": str(len(image_bytes))},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
|
||||
result = provider.generate(request)
|
||||
|
||||
assert "multimodal-generation" in captured["url"]
|
||||
payload = captured["json"]
|
||||
messages = payload["input"]["messages"]
|
||||
content = messages[0]["content"]
|
||||
# Reference image first, then prompt text — and no init_image entry.
|
||||
assert content[0]["image"].startswith("data:image/png;base64,")
|
||||
assert content[0]["image"].endswith(encode_image_base64(ref))
|
||||
assert content[1] == {"text": request.prompt}
|
||||
assert len(content) == 2
|
||||
assert payload["model"] == "qwen-image-edit-max"
|
||||
assert payload["parameters"]["seed"] == request.seed
|
||||
assert result.provider_request_id == "req-1"
|
||||
assert len(result.images) == 1
|
||||
|
||||
|
||||
def test_sync_error_response_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
provider = _provider()
|
||||
request = _build_request(_build_model("qwen-image-2.0-pro"))
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
return DummyResponse(ok=False, status_code=400, text="bad request")
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
with pytest.raises(ExternalProviderRequestError, match="DashScope request failed"):
|
||||
provider.generate(request)
|
||||
|
||||
|
||||
def test_sync_retries_on_429_and_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
provider = _provider()
|
||||
request = _build_request(_build_model("qwen-image-2.0-pro"))
|
||||
image_bytes = _png_bytes(_make_image("yellow"))
|
||||
image_url = "https://example.invalid/r.png"
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return DummyResponse(ok=False, status_code=429, text="rate limited", headers={"Retry-After": "0"})
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={
|
||||
"request_id": "req-2",
|
||||
"output": {"choices": [{"message": {"content": [{"image": image_url}]}}]},
|
||||
},
|
||||
)
|
||||
|
||||
def fake_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
|
||||
return DummyResponse(ok=True, content=image_bytes, headers={"Content-Length": str(len(image_bytes))})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
monkeypatch.setattr("time.sleep", lambda _s: None)
|
||||
|
||||
result = provider.generate(request)
|
||||
assert calls["n"] == 2
|
||||
assert len(result.images) == 1
|
||||
|
||||
|
||||
def test_async_parser_does_not_double_count(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A result with both `url` and `b64_image` must yield one image, not two."""
|
||||
provider = _provider()
|
||||
request = _build_request(_build_model("qwen-image-2.0-pro"))
|
||||
image_bytes = _png_bytes(_make_image("magenta"))
|
||||
image_url = "https://example.invalid/x.png"
|
||||
|
||||
def fake_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
|
||||
return DummyResponse(ok=True, content=image_bytes, headers={"Content-Length": str(len(image_bytes))})
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
|
||||
output: dict[str, Any] = {
|
||||
"results": [
|
||||
{
|
||||
"url": image_url,
|
||||
"b64_image": encode_image_base64(_make_image("cyan")),
|
||||
}
|
||||
]
|
||||
}
|
||||
result = provider._parse_async_response(output, request, request_id="rid")
|
||||
assert len(result.images) == 1
|
||||
|
||||
|
||||
def test_async_parser_accepts_b64_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
provider = _provider()
|
||||
request = _build_request(_build_model("qwen-image-2.0-pro"))
|
||||
output: dict[str, Any] = {
|
||||
"results": [
|
||||
{"b64_image": encode_image_base64(_make_image("cyan"))},
|
||||
]
|
||||
}
|
||||
result = provider._parse_async_response(output, request, request_id="rid")
|
||||
assert len(result.images) == 1
|
||||
|
||||
|
||||
def test_download_image_size_cap(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
provider = _provider()
|
||||
too_big = alibabacloud_module._DOWNLOAD_MAX_BYTES + 1
|
||||
|
||||
def fake_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
content=b"\x00" * 16, # body itself is small; we trip the Content-Length check first
|
||||
headers={"Content-Length": str(too_big)},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
|
||||
with pytest.raises(ExternalProviderRequestError, match="exceeds"):
|
||||
provider._download_image("https://example.invalid/big.png")
|
||||
|
||||
|
||||
def test_poll_task_first_call_no_initial_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""First poll must not be preceded by a sleep — fast tasks should not pay the poll interval."""
|
||||
provider = _provider()
|
||||
request = _build_request(_build_model("qwen-image-2.0-pro"))
|
||||
image_bytes = _png_bytes(_make_image("teal"))
|
||||
image_url = "https://example.invalid/y.png"
|
||||
|
||||
sleeps: list[float] = []
|
||||
|
||||
def fake_sleep(seconds: float) -> None:
|
||||
sleeps.append(seconds)
|
||||
|
||||
def fake_get(url: str, headers: dict, timeout: int) -> DummyResponse:
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={
|
||||
"output": {
|
||||
"task_status": "SUCCEEDED",
|
||||
"results": [{"url": image_url}],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def fake_download_get(url: str, timeout: int, stream: bool = False) -> DummyResponse:
|
||||
return DummyResponse(ok=True, content=image_bytes, headers={"Content-Length": str(len(image_bytes))})
|
||||
|
||||
# Single requests.get is shared by polling (with headers kwarg) and download (no kwarg).
|
||||
def dispatch_get(*args: Any, **kwargs: Any) -> DummyResponse:
|
||||
if "headers" in kwargs and "task" in args[0]:
|
||||
return fake_get(*args, **kwargs)
|
||||
return fake_download_get(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("requests.get", dispatch_get)
|
||||
monkeypatch.setattr("time.sleep", fake_sleep)
|
||||
|
||||
result = provider._poll_task(
|
||||
base_url="https://dashscope.invalid",
|
||||
headers={"Authorization": "Bearer test", "Content-Type": "application/json"},
|
||||
task_id="task-xyz",
|
||||
request=request,
|
||||
request_id="rid",
|
||||
)
|
||||
|
||||
assert len(result.images) == 1
|
||||
# No sleep should have been recorded — task succeeded on the first poll.
|
||||
assert sleeps == []
|
||||
@@ -0,0 +1,277 @@
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.external_generation.errors import (
|
||||
ExternalProviderCapabilityError,
|
||||
ExternalProviderNotConfiguredError,
|
||||
ExternalProviderNotFoundError,
|
||||
)
|
||||
from invokeai.app.services.external_generation.external_generation_base import ExternalProvider
|
||||
from invokeai.app.services.external_generation.external_generation_common import (
|
||||
ExternalGeneratedImage,
|
||||
ExternalGenerationRequest,
|
||||
ExternalGenerationResult,
|
||||
ExternalReferenceImage,
|
||||
)
|
||||
from invokeai.app.services.external_generation.external_generation_default import ExternalGenerationService
|
||||
from invokeai.backend.model_manager.configs.external_api import (
|
||||
ExternalApiModelConfig,
|
||||
ExternalImageSize,
|
||||
ExternalModelCapabilities,
|
||||
)
|
||||
|
||||
|
||||
class DummyProvider(ExternalProvider):
|
||||
def __init__(self, provider_id: str, configured: bool, result: ExternalGenerationResult | None = None) -> None:
|
||||
super().__init__(InvokeAIAppConfig(), logging.getLogger("test"))
|
||||
self.provider_id = provider_id
|
||||
self._configured = configured
|
||||
self._result = result
|
||||
self.last_request: ExternalGenerationRequest | None = None
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return self._configured
|
||||
|
||||
def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
|
||||
self.last_request = request
|
||||
assert self._result is not None
|
||||
return self._result
|
||||
|
||||
|
||||
def _build_model(capabilities: ExternalModelCapabilities) -> ExternalApiModelConfig:
|
||||
return ExternalApiModelConfig(
|
||||
key="external_test",
|
||||
name="External Test",
|
||||
provider_id="openai",
|
||||
provider_model_id="gpt-image-1",
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
|
||||
def _build_request(
|
||||
*,
|
||||
model: ExternalApiModelConfig,
|
||||
mode: str = "txt2img",
|
||||
seed: int | None = None,
|
||||
num_images: int = 1,
|
||||
width: int = 64,
|
||||
height: int = 64,
|
||||
init_image: Image.Image | None = None,
|
||||
mask_image: Image.Image | None = None,
|
||||
reference_images: list[ExternalReferenceImage] | None = None,
|
||||
) -> ExternalGenerationRequest:
|
||||
return ExternalGenerationRequest(
|
||||
model=model,
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
prompt="A test prompt",
|
||||
seed=seed,
|
||||
num_images=num_images,
|
||||
width=width,
|
||||
height=height,
|
||||
image_size=None,
|
||||
init_image=init_image,
|
||||
mask_image=mask_image,
|
||||
reference_images=reference_images or [],
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_image() -> Image.Image:
|
||||
return Image.new("RGB", (64, 64), color="black")
|
||||
|
||||
|
||||
def test_generate_requires_registered_provider() -> None:
|
||||
model = _build_model(ExternalModelCapabilities(modes=["txt2img"]))
|
||||
request = _build_request(model=model)
|
||||
service = ExternalGenerationService({}, logging.getLogger("test"))
|
||||
|
||||
with pytest.raises(ExternalProviderNotFoundError):
|
||||
service.generate(request)
|
||||
|
||||
|
||||
def test_generate_requires_configured_provider() -> None:
|
||||
model = _build_model(ExternalModelCapabilities(modes=["txt2img"]))
|
||||
request = _build_request(model=model)
|
||||
provider = DummyProvider("openai", configured=False)
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
with pytest.raises(ExternalProviderNotConfiguredError):
|
||||
service.generate(request)
|
||||
|
||||
|
||||
def test_generate_validates_mode_support() -> None:
|
||||
model = _build_model(ExternalModelCapabilities(modes=["txt2img"]))
|
||||
request = _build_request(model=model, mode="img2img", init_image=_make_image())
|
||||
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
with pytest.raises(ExternalProviderCapabilityError, match="Mode 'img2img'"):
|
||||
service.generate(request)
|
||||
|
||||
|
||||
def test_generate_requires_init_image_for_img2img() -> None:
|
||||
model = _build_model(ExternalModelCapabilities(modes=["img2img"]))
|
||||
request = _build_request(model=model, mode="img2img")
|
||||
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
with pytest.raises(ExternalProviderCapabilityError, match="requires an init image"):
|
||||
service.generate(request)
|
||||
|
||||
|
||||
def test_generate_requires_mask_for_inpaint() -> None:
|
||||
model = _build_model(ExternalModelCapabilities(modes=["inpaint"]))
|
||||
request = _build_request(model=model, mode="inpaint", init_image=_make_image())
|
||||
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
with pytest.raises(ExternalProviderCapabilityError, match="requires a mask"):
|
||||
service.generate(request)
|
||||
|
||||
|
||||
def test_generate_validates_reference_images() -> None:
|
||||
model = _build_model(ExternalModelCapabilities(modes=["txt2img"], supports_reference_images=False))
|
||||
request = _build_request(
|
||||
model=model,
|
||||
reference_images=[ExternalReferenceImage(image=_make_image())],
|
||||
)
|
||||
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
with pytest.raises(ExternalProviderCapabilityError, match="Reference images"):
|
||||
service.generate(request)
|
||||
|
||||
|
||||
def test_generate_validates_limits() -> None:
|
||||
model = _build_model(
|
||||
ExternalModelCapabilities(
|
||||
modes=["txt2img"],
|
||||
supports_reference_images=True,
|
||||
max_reference_images=1,
|
||||
max_images_per_request=1,
|
||||
)
|
||||
)
|
||||
request = _build_request(
|
||||
model=model,
|
||||
num_images=2,
|
||||
reference_images=[
|
||||
ExternalReferenceImage(image=_make_image()),
|
||||
ExternalReferenceImage(image=_make_image()),
|
||||
],
|
||||
)
|
||||
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
with pytest.raises(ExternalProviderCapabilityError, match="supports at most"):
|
||||
service.generate(request)
|
||||
|
||||
|
||||
def test_generate_validates_allowed_aspect_ratios() -> None:
|
||||
model = _build_model(
|
||||
ExternalModelCapabilities(
|
||||
modes=["txt2img"],
|
||||
allowed_aspect_ratios=["1:1", "16:9"],
|
||||
aspect_ratio_sizes={
|
||||
"1:1": ExternalImageSize(width=1024, height=1024),
|
||||
"16:9": ExternalImageSize(width=1344, height=768),
|
||||
},
|
||||
)
|
||||
)
|
||||
request = _build_request(model=model)
|
||||
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
response = service.generate(request)
|
||||
assert response.images == []
|
||||
assert provider.last_request is not None
|
||||
assert provider.last_request.width == 1024
|
||||
assert provider.last_request.height == 1024
|
||||
|
||||
|
||||
def test_generate_validates_allowed_aspect_ratios_with_bucket_sizes() -> None:
|
||||
model = _build_model(
|
||||
ExternalModelCapabilities(
|
||||
modes=["txt2img"],
|
||||
allowed_aspect_ratios=["1:1", "16:9"],
|
||||
aspect_ratio_sizes={
|
||||
"1:1": ExternalImageSize(width=1024, height=1024),
|
||||
"16:9": ExternalImageSize(width=1344, height=768),
|
||||
},
|
||||
)
|
||||
)
|
||||
request = _build_request(model=model, width=160, height=90)
|
||||
provider = DummyProvider("openai", configured=True, result=ExternalGenerationResult(images=[]))
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
response = service.generate(request)
|
||||
|
||||
assert response.images == []
|
||||
assert provider.last_request is not None
|
||||
assert provider.last_request.width == 1344
|
||||
assert provider.last_request.height == 768
|
||||
|
||||
|
||||
def test_generate_happy_path() -> None:
|
||||
model = _build_model(ExternalModelCapabilities(modes=["txt2img"], supports_seed=True))
|
||||
request = _build_request(model=model, seed=42)
|
||||
result = ExternalGenerationResult(images=[ExternalGeneratedImage(image=_make_image(), seed=42)])
|
||||
provider = DummyProvider("openai", configured=True, result=result)
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
response = service.generate(request)
|
||||
|
||||
assert response is result
|
||||
assert provider.last_request == request
|
||||
|
||||
|
||||
def test_generate_resizes_inpaint_result_to_original_init_size() -> None:
|
||||
model = _build_model(ExternalModelCapabilities(modes=["inpaint"]))
|
||||
request = _build_request(
|
||||
model=model,
|
||||
mode="inpaint",
|
||||
width=128,
|
||||
height=128,
|
||||
init_image=_make_image(),
|
||||
mask_image=_make_image(),
|
||||
)
|
||||
generated_large = Image.new("RGB", (128, 128), color="black")
|
||||
result = ExternalGenerationResult(images=[ExternalGeneratedImage(image=generated_large, seed=1)])
|
||||
provider = DummyProvider("openai", configured=True, result=result)
|
||||
service = ExternalGenerationService({"openai": provider}, logging.getLogger("test"))
|
||||
|
||||
response = service.generate(request)
|
||||
|
||||
assert request.init_image is not None
|
||||
assert response.images[0].image.width == request.init_image.width
|
||||
assert response.images[0].image.height == request.init_image.height
|
||||
assert response.images[0].seed == 1
|
||||
|
||||
|
||||
def test_qwen_image_edit_max_enforces_three_reference_images() -> None:
|
||||
from invokeai.backend.model_manager.starter_models import alibabacloud_qwen_image_edit_max
|
||||
|
||||
capabilities = alibabacloud_qwen_image_edit_max.capabilities
|
||||
assert capabilities is not None
|
||||
assert capabilities.max_reference_images == 3
|
||||
|
||||
model = ExternalApiModelConfig(
|
||||
key="qwen_image_edit_max",
|
||||
name="Qwen Image Edit Max",
|
||||
provider_id="alibabacloud",
|
||||
provider_model_id="qwen-image-edit-max",
|
||||
capabilities=capabilities,
|
||||
)
|
||||
request = _build_request(
|
||||
model=model,
|
||||
reference_images=[ExternalReferenceImage(image=_make_image()) for _ in range(4)],
|
||||
)
|
||||
provider = DummyProvider("alibabacloud", configured=True, result=ExternalGenerationResult(images=[]))
|
||||
service = ExternalGenerationService({"alibabacloud": provider}, logging.getLogger("test"))
|
||||
|
||||
with pytest.raises(ExternalProviderCapabilityError, match="supports at most 3 reference images"):
|
||||
service.generate(request)
|
||||
|
||||
assert provider.last_request is None
|
||||
@@ -0,0 +1,393 @@
|
||||
import io
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.external_generation.errors import ExternalProviderRequestError
|
||||
from invokeai.app.services.external_generation.external_generation_common import (
|
||||
ExternalGenerationRequest,
|
||||
ExternalReferenceImage,
|
||||
)
|
||||
from invokeai.app.services.external_generation.image_utils import decode_image_base64, encode_image_base64
|
||||
from invokeai.app.services.external_generation.providers.gemini import GeminiProvider
|
||||
from invokeai.app.services.external_generation.providers.openai import OpenAIProvider
|
||||
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, ok: bool, status_code: int = 200, json_data: dict | None = None, text: str = "") -> None:
|
||||
self.ok = ok
|
||||
self.status_code = status_code
|
||||
self._json_data = json_data or {}
|
||||
self.text = text
|
||||
self.headers: dict[str, str] = {}
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._json_data
|
||||
|
||||
|
||||
def _make_image(color: str = "black") -> Image.Image:
|
||||
return Image.new("RGB", (32, 32), color=color)
|
||||
|
||||
|
||||
def _build_model(provider_id: str, provider_model_id: str) -> ExternalApiModelConfig:
|
||||
return ExternalApiModelConfig(
|
||||
key=f"{provider_id}_test",
|
||||
name=f"{provider_id.title()} Test",
|
||||
provider_id=provider_id,
|
||||
provider_model_id=provider_model_id,
|
||||
capabilities=ExternalModelCapabilities(
|
||||
modes=["txt2img", "img2img", "inpaint"],
|
||||
supports_reference_images=True,
|
||||
supports_seed=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_request(
|
||||
model: ExternalApiModelConfig,
|
||||
mode: str = "txt2img",
|
||||
init_image: Image.Image | None = None,
|
||||
mask_image: Image.Image | None = None,
|
||||
reference_images: list[ExternalReferenceImage] | None = None,
|
||||
) -> ExternalGenerationRequest:
|
||||
return ExternalGenerationRequest(
|
||||
model=model,
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
prompt="A test prompt",
|
||||
seed=123,
|
||||
num_images=1,
|
||||
width=256,
|
||||
height=256,
|
||||
image_size=None,
|
||||
init_image=init_image,
|
||||
mask_image=mask_image,
|
||||
reference_images=reference_images or [],
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
|
||||
def test_gemini_generate_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api_key = "gemini-key"
|
||||
config = InvokeAIAppConfig(external_gemini_api_key=api_key)
|
||||
provider = GeminiProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("gemini", "gemini-2.5-flash-image")
|
||||
init_image = _make_image("blue")
|
||||
ref_image = _make_image("red")
|
||||
request = _build_request(
|
||||
model,
|
||||
init_image=init_image,
|
||||
reference_images=[ExternalReferenceImage(image=ref_image)],
|
||||
)
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
captured["params"] = params
|
||||
captured["json"] = json
|
||||
captured["timeout"] = timeout
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={
|
||||
"candidates": [
|
||||
{"content": {"parts": [{"inlineData": {"data": encoded}}]}},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
result = provider.generate(request)
|
||||
|
||||
assert (
|
||||
captured["url"]
|
||||
== "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent"
|
||||
)
|
||||
assert captured["params"] == {"key": api_key}
|
||||
payload = captured["json"]
|
||||
assert isinstance(payload, dict)
|
||||
system_instruction = payload.get("systemInstruction")
|
||||
assert isinstance(system_instruction, dict)
|
||||
system_parts = system_instruction.get("parts")
|
||||
assert isinstance(system_parts, list)
|
||||
system_text = str(system_parts[0]).lower()
|
||||
assert "image" in system_text
|
||||
generation_config = payload.get("generationConfig")
|
||||
assert isinstance(generation_config, dict)
|
||||
assert generation_config["candidateCount"] == 1
|
||||
assert generation_config["responseModalities"] == ["IMAGE"]
|
||||
contents = payload.get("contents")
|
||||
assert isinstance(contents, list)
|
||||
first_content = contents[0]
|
||||
assert isinstance(first_content, dict)
|
||||
parts = first_content.get("parts")
|
||||
assert isinstance(parts, list)
|
||||
assert len(parts) >= 3
|
||||
part0 = parts[0]
|
||||
part1 = parts[1]
|
||||
part2 = parts[2]
|
||||
assert isinstance(part0, dict)
|
||||
assert isinstance(part1, dict)
|
||||
assert isinstance(part2, dict)
|
||||
inline0 = part0.get("inlineData")
|
||||
assert isinstance(inline0, dict)
|
||||
assert part1["text"] == request.prompt
|
||||
inline1 = part2.get("inlineData")
|
||||
assert isinstance(inline1, dict)
|
||||
assert inline0["data"] == encode_image_base64(init_image)
|
||||
assert inline1["data"] == encode_image_base64(ref_image)
|
||||
assert result.images[0].seed == request.seed
|
||||
assert result.provider_metadata == {"model": request.model.provider_model_id}
|
||||
|
||||
|
||||
def test_gemini_generate_error_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_gemini_api_key="gemini-key")
|
||||
provider = GeminiProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("gemini", "gemini-2.5-flash-image")
|
||||
request = _build_request(model)
|
||||
|
||||
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
return DummyResponse(ok=False, status_code=400, text="bad request")
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
with pytest.raises(ExternalProviderRequestError, match="Gemini request failed"):
|
||||
provider.generate(request)
|
||||
|
||||
|
||||
def test_gemini_generate_uses_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(
|
||||
external_gemini_api_key="gemini-key",
|
||||
external_gemini_base_url="https://proxy.gemini",
|
||||
)
|
||||
provider = GeminiProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("gemini", "gemini-2.5-flash-image")
|
||||
request = _build_request(model)
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={"candidates": [{"content": {"parts": [{"inlineData": {"data": encoded}}]}}]},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
provider.generate(request)
|
||||
|
||||
assert captured["url"] == "https://proxy.gemini/v1beta/models/gemini-2.5-flash-image:generateContent"
|
||||
|
||||
|
||||
def test_gemini_generate_keeps_base_url_version(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(
|
||||
external_gemini_api_key="gemini-key",
|
||||
external_gemini_base_url="https://proxy.gemini/v1",
|
||||
)
|
||||
provider = GeminiProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("gemini", "gemini-2.5-flash-image")
|
||||
request = _build_request(model)
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={"candidates": [{"content": {"parts": [{"inlineData": {"data": encoded}}]}}]},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
provider.generate(request)
|
||||
|
||||
assert captured["url"] == "https://proxy.gemini/v1/models/gemini-2.5-flash-image:generateContent"
|
||||
|
||||
|
||||
def test_gemini_generate_strips_models_prefix(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_gemini_api_key="gemini-key")
|
||||
provider = GeminiProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("gemini", "models/gemini-2.5-flash-image")
|
||||
request = _build_request(model)
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, params: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={"candidates": [{"content": {"parts": [{"inlineData": {"data": encoded}}]}}]},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
provider.generate(request)
|
||||
|
||||
assert (
|
||||
captured["url"]
|
||||
== "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent"
|
||||
)
|
||||
|
||||
|
||||
def test_openai_generate_txt2img_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api_key = "openai-key"
|
||||
config = InvokeAIAppConfig(external_openai_api_key=api_key)
|
||||
provider = OpenAIProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("openai", "gpt-image-1")
|
||||
request = _build_request(model)
|
||||
encoded = encode_image_base64(_make_image("purple"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
captured["json"] = json
|
||||
response = DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
|
||||
response.headers["x-request-id"] = "req-123"
|
||||
return response
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
result = provider.generate(request)
|
||||
|
||||
assert captured["url"] == "https://api.openai.com/v1/images/generations"
|
||||
headers = captured["headers"]
|
||||
assert isinstance(headers, dict)
|
||||
assert headers["Authorization"] == f"Bearer {api_key}"
|
||||
json_payload = captured["json"]
|
||||
assert isinstance(json_payload, dict)
|
||||
assert json_payload["prompt"] == request.prompt
|
||||
assert result.provider_request_id == "req-123"
|
||||
assert result.images[0].seed == request.seed
|
||||
assert decode_image_base64(encoded).size == result.images[0].image.size
|
||||
|
||||
|
||||
def test_openai_generate_uses_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(
|
||||
external_openai_api_key="openai-key",
|
||||
external_openai_base_url="https://proxy.openai/",
|
||||
)
|
||||
provider = OpenAIProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("openai", "gpt-image-1")
|
||||
request = _build_request(model)
|
||||
encoded = encode_image_base64(_make_image("purple"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
provider.generate(request)
|
||||
|
||||
assert captured["url"] == "https://proxy.openai/v1/images/generations"
|
||||
|
||||
|
||||
def test_openai_generate_txt2img_error_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_openai_api_key="openai-key")
|
||||
provider = OpenAIProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("openai", "gpt-image-1")
|
||||
request = _build_request(model)
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
return DummyResponse(ok=False, status_code=500, text="server error")
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
with pytest.raises(ExternalProviderRequestError, match="OpenAI request failed"):
|
||||
provider.generate(request)
|
||||
|
||||
|
||||
def test_openai_generate_inpaint_uses_edit_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_openai_api_key="openai-key")
|
||||
provider = OpenAIProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("openai", "gpt-image-1")
|
||||
request = _build_request(
|
||||
model,
|
||||
mode="inpaint",
|
||||
init_image=_make_image("white"),
|
||||
mask_image=_make_image("black"),
|
||||
)
|
||||
encoded = encode_image_base64(_make_image("orange"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(
|
||||
url: str,
|
||||
headers: dict,
|
||||
data: dict,
|
||||
files: list[tuple[str, tuple[str, io.BytesIO, str]]],
|
||||
timeout: int,
|
||||
) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
captured["data"] = data
|
||||
captured["files"] = files
|
||||
response = DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
|
||||
return response
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
result = provider.generate(request)
|
||||
|
||||
assert captured["url"] == "https://api.openai.com/v1/images/edits"
|
||||
data_payload = captured["data"]
|
||||
assert isinstance(data_payload, dict)
|
||||
assert data_payload["prompt"] == request.prompt
|
||||
files = captured["files"]
|
||||
assert isinstance(files, list)
|
||||
image_file = next((file for file in files if file[0] == "image"), None)
|
||||
mask_file = next((file for file in files if file[0] == "mask"), None)
|
||||
assert image_file is not None
|
||||
assert mask_file is not None
|
||||
image_tuple = image_file[1]
|
||||
assert isinstance(image_tuple, tuple)
|
||||
assert image_tuple[0] == "image_0.png"
|
||||
assert isinstance(image_tuple[1], io.BytesIO)
|
||||
assert result.images
|
||||
|
||||
|
||||
def test_openai_generate_txt2img_with_references_uses_edit_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_openai_api_key="openai-key")
|
||||
provider = OpenAIProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("openai", "gpt-image-1")
|
||||
request = _build_request(
|
||||
model,
|
||||
reference_images=[
|
||||
ExternalReferenceImage(image=_make_image("red")),
|
||||
ExternalReferenceImage(image=_make_image("blue")),
|
||||
],
|
||||
)
|
||||
encoded = encode_image_base64(_make_image("orange"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(
|
||||
url: str,
|
||||
headers: dict,
|
||||
data: dict,
|
||||
files: list[tuple[str, tuple[str, io.BytesIO, str]]],
|
||||
timeout: int,
|
||||
) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
captured["data"] = data
|
||||
captured["files"] = files
|
||||
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
result = provider.generate(request)
|
||||
|
||||
assert captured["url"] == "https://api.openai.com/v1/images/edits"
|
||||
data_payload = captured["data"]
|
||||
assert isinstance(data_payload, dict)
|
||||
assert data_payload["prompt"] == request.prompt
|
||||
files = captured["files"]
|
||||
assert isinstance(files, list)
|
||||
image_files = [file for file in files if file[0] == "image[]"]
|
||||
assert len(image_files) == 2
|
||||
assert image_files[0][1][0] == "image_0.png"
|
||||
assert image_files[1][1][0] == "image_1.png"
|
||||
assert result.images
|
||||
@@ -0,0 +1,354 @@
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.external_generation.errors import (
|
||||
ExternalProviderCapabilityError,
|
||||
ExternalProviderRequestError,
|
||||
)
|
||||
from invokeai.app.services.external_generation.external_generation_common import (
|
||||
ExternalGenerationRequest,
|
||||
ExternalReferenceImage,
|
||||
)
|
||||
from invokeai.app.services.external_generation.image_utils import encode_image_base64
|
||||
from invokeai.app.services.external_generation.providers.seedream import SeedreamProvider
|
||||
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, ok: bool, status_code: int = 200, json_data: dict | None = None, text: str = "") -> None:
|
||||
self.ok = ok
|
||||
self.status_code = status_code
|
||||
self._json_data = json_data or {}
|
||||
self.text = text
|
||||
self.headers: dict[str, str] = {}
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._json_data
|
||||
|
||||
|
||||
def _make_image(color: str = "black") -> Image.Image:
|
||||
return Image.new("RGB", (32, 32), color=color)
|
||||
|
||||
|
||||
def _build_model(provider_model_id: str, modes: list[str] | None = None) -> ExternalApiModelConfig:
|
||||
return ExternalApiModelConfig(
|
||||
key="seedream_test",
|
||||
name="Seedream Test",
|
||||
provider_id="seedream",
|
||||
provider_model_id=provider_model_id,
|
||||
capabilities=ExternalModelCapabilities(
|
||||
modes=modes or ["txt2img", "img2img"],
|
||||
supports_reference_images=True,
|
||||
supports_seed=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_request(
|
||||
model: ExternalApiModelConfig,
|
||||
mode: str = "txt2img",
|
||||
init_image: Image.Image | None = None,
|
||||
reference_images: list[ExternalReferenceImage] | None = None,
|
||||
num_images: int = 1,
|
||||
seed: int | None = 123,
|
||||
provider_options: dict | None = None,
|
||||
) -> ExternalGenerationRequest:
|
||||
return ExternalGenerationRequest(
|
||||
model=model,
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
prompt="A test prompt",
|
||||
seed=seed,
|
||||
num_images=num_images,
|
||||
width=2048,
|
||||
height=2048,
|
||||
image_size=None,
|
||||
init_image=init_image,
|
||||
mask_image=None,
|
||||
reference_images=reference_images or [],
|
||||
metadata=None,
|
||||
provider_options=provider_options,
|
||||
)
|
||||
|
||||
|
||||
def test_seedream_is_configured() -> None:
|
||||
config = InvokeAIAppConfig(external_seedream_api_key="test-key")
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
assert provider.is_configured() is True
|
||||
|
||||
|
||||
def test_seedream_not_configured() -> None:
|
||||
config = InvokeAIAppConfig()
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
assert provider.is_configured() is False
|
||||
|
||||
|
||||
def test_seedream_txt2img_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api_key = "seedream-key"
|
||||
config = InvokeAIAppConfig(external_seedream_api_key=api_key)
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-4-5-251128")
|
||||
request = _build_request(model)
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
captured["json"] = json
|
||||
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
result = provider.generate(request)
|
||||
|
||||
assert captured["url"] == "https://ark.ap-southeast.bytepluses.com/api/v3/images/generations"
|
||||
headers = captured["headers"]
|
||||
assert isinstance(headers, dict)
|
||||
assert headers["Authorization"] == f"Bearer {api_key}"
|
||||
json_payload = captured["json"]
|
||||
assert isinstance(json_payload, dict)
|
||||
assert json_payload["model"] == "seedream-4-5-251128"
|
||||
assert json_payload["prompt"] == "A test prompt"
|
||||
assert json_payload["size"] == "2048x2048"
|
||||
assert json_payload["response_format"] == "b64_json"
|
||||
assert json_payload["watermark"] is False
|
||||
assert json_payload["sequential_image_generation"] == "disabled"
|
||||
# Seed should not be sent for 4.x models
|
||||
assert "seed" not in json_payload
|
||||
# Guidance should not be sent for 4.x models
|
||||
assert "guidance_scale" not in json_payload
|
||||
assert len(result.images) == 1
|
||||
assert result.images[0].seed == 123
|
||||
|
||||
|
||||
def test_seedream_3_0_t2i_sends_seed_and_guidance(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-3-0-t2i-250415", modes=["txt2img"])
|
||||
request = _build_request(model, seed=42, provider_options={"guidance_scale": 2.5})
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["json"] = json
|
||||
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
provider.generate(request)
|
||||
|
||||
json_payload = captured["json"]
|
||||
assert isinstance(json_payload, dict)
|
||||
assert json_payload["seed"] == 42
|
||||
assert json_payload["guidance_scale"] == 2.5
|
||||
# 3.0 models should not have sequential_image_generation
|
||||
assert "sequential_image_generation" not in json_payload
|
||||
|
||||
|
||||
def test_seedream_batch_generation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-4-5-251128")
|
||||
request = _build_request(model, num_images=3)
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["json"] = json
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={"data": [{"b64_json": encoded}, {"b64_json": encoded}, {"b64_json": encoded}]},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
result = provider.generate(request)
|
||||
|
||||
json_payload = captured["json"]
|
||||
assert isinstance(json_payload, dict)
|
||||
assert json_payload["sequential_image_generation"] == "auto"
|
||||
assert json_payload["sequential_image_generation_options"] == {"max_images": 3}
|
||||
assert len(result.images) == 3
|
||||
|
||||
|
||||
def test_seedream_img2img_with_reference_images(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-4-5-251128")
|
||||
init_image = _make_image("blue")
|
||||
ref_image = _make_image("red")
|
||||
request = _build_request(
|
||||
model,
|
||||
mode="img2img",
|
||||
init_image=init_image,
|
||||
reference_images=[ExternalReferenceImage(image=ref_image)],
|
||||
)
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["json"] = json
|
||||
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
result = provider.generate(request)
|
||||
|
||||
json_payload = captured["json"]
|
||||
assert isinstance(json_payload, dict)
|
||||
images = json_payload["image"]
|
||||
assert isinstance(images, list)
|
||||
assert len(images) == 2 # init_image + reference
|
||||
assert images[0].startswith("data:image/png;base64,")
|
||||
assert images[1].startswith("data:image/png;base64,")
|
||||
assert len(result.images) == 1
|
||||
|
||||
|
||||
def test_seedream_single_image_not_array(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-3-0-t2i-250415", modes=["txt2img"])
|
||||
init_image = _make_image("blue")
|
||||
request = _build_request(model, mode="txt2img", init_image=init_image, provider_options={"guidance_scale": 5.5})
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["json"] = json
|
||||
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
provider.generate(request)
|
||||
|
||||
json_payload = captured["json"]
|
||||
assert isinstance(json_payload, dict)
|
||||
# Single image should be a string, not an array
|
||||
image = json_payload["image"]
|
||||
assert isinstance(image, str)
|
||||
assert image.startswith("data:image/png;base64,")
|
||||
|
||||
|
||||
def test_seedream_error_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-4-5-251128")
|
||||
request = _build_request(model)
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
return DummyResponse(ok=False, status_code=400, text="bad request")
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
with pytest.raises(ExternalProviderRequestError, match="Seedream request failed"):
|
||||
provider.generate(request)
|
||||
|
||||
|
||||
def test_seedream_no_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig()
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-4-5-251128")
|
||||
request = _build_request(model)
|
||||
|
||||
with pytest.raises(ExternalProviderRequestError, match="API key is not configured"):
|
||||
provider.generate(request)
|
||||
|
||||
|
||||
def test_seedream_uses_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(
|
||||
external_seedream_api_key="seedream-key",
|
||||
external_seedream_base_url="https://proxy.seedream/",
|
||||
)
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-4-5-251128")
|
||||
request = _build_request(model)
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
captured["url"] = url
|
||||
return DummyResponse(ok=True, json_data={"data": [{"b64_json": encoded}]})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
provider.generate(request)
|
||||
|
||||
assert captured["url"] == "https://proxy.seedream/api/v3/images/generations"
|
||||
|
||||
|
||||
def test_seedream_batch_surfaces_partial_failures(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-4-5-251128")
|
||||
request = _build_request(model, num_images=3)
|
||||
encoded = encode_image_base64(_make_image("green"))
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={
|
||||
"data": [
|
||||
{"b64_json": encoded},
|
||||
{"error": {"code": "content_filter", "message": "filtered"}},
|
||||
{"b64_json": encoded},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
result = provider.generate(request)
|
||||
|
||||
assert len(result.images) == 2
|
||||
assert result.provider_metadata is not None
|
||||
partial_failures = result.provider_metadata.get("partial_failures")
|
||||
assert isinstance(partial_failures, list) and len(partial_failures) == 1
|
||||
assert partial_failures[0] == {"code": "content_filter", "message": "filtered"}
|
||||
|
||||
|
||||
def test_seedream_batch_all_items_failed_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-4-5-251128")
|
||||
request = _build_request(model, num_images=2)
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
return DummyResponse(
|
||||
ok=True,
|
||||
json_data={
|
||||
"data": [
|
||||
{"error": {"code": "content_filter", "message": "filtered"}},
|
||||
{"error": {"code": "content_filter", "message": "filtered"}},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
with pytest.raises(ExternalProviderRequestError, match="filtered"):
|
||||
provider.generate(request)
|
||||
|
||||
|
||||
def test_seedream_rejects_combined_reference_and_output_count(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = InvokeAIAppConfig(external_seedream_api_key="seedream-key")
|
||||
provider = SeedreamProvider(config, logging.getLogger("test"))
|
||||
model = _build_model("seedream-4-5-251128")
|
||||
references = [ExternalReferenceImage(image=_make_image("red")) for _ in range(14)]
|
||||
request = _build_request(model, num_images=15, reference_images=references)
|
||||
|
||||
posted = False
|
||||
|
||||
def fake_post(url: str, headers: dict, json: dict, timeout: int) -> DummyResponse:
|
||||
nonlocal posted
|
||||
posted = True
|
||||
return DummyResponse(ok=True, json_data={"data": []})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
|
||||
with pytest.raises(ExternalProviderCapabilityError, match="15 images total"):
|
||||
provider.generate(request)
|
||||
|
||||
assert posted is False
|
||||
@@ -0,0 +1,56 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from invokeai.app.services.external_generation.startup import sync_configured_external_starter_models
|
||||
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
|
||||
|
||||
|
||||
def _build_installed_model(source: str) -> ExternalApiModelConfig:
|
||||
provider_id, provider_model_id = source.removeprefix("external://").split("/", 1)
|
||||
return ExternalApiModelConfig(
|
||||
key=f"{provider_id}-{provider_model_id}",
|
||||
name=provider_model_id,
|
||||
source=source,
|
||||
provider_id=provider_id,
|
||||
provider_model_id=provider_model_id,
|
||||
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
|
||||
)
|
||||
|
||||
|
||||
def test_sync_configured_external_starter_models_queues_missing_models_for_configured_providers() -> None:
|
||||
model_manager = MagicMock()
|
||||
model_manager.store.search_by_attr.return_value = [
|
||||
_build_installed_model("external://openai/gpt-image-1"),
|
||||
]
|
||||
logger = MagicMock()
|
||||
|
||||
queued_sources = sync_configured_external_starter_models(
|
||||
configured_provider_ids={"gemini", "openai"},
|
||||
model_manager=model_manager,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert "external://openai/gpt-image-1" not in queued_sources
|
||||
assert "external://gemini/gemini-2.5-flash-image" in queued_sources
|
||||
assert "external://gemini/gemini-3.1-flash-image-preview" in queued_sources
|
||||
assert "external://gemini/gemini-3-pro-image-preview" in queued_sources
|
||||
|
||||
install_calls = [call.args[0] for call in model_manager.install.heuristic_import.call_args_list]
|
||||
assert "external://openai/gpt-image-1" not in install_calls
|
||||
assert "external://gemini/gemini-2.5-flash-image" in install_calls
|
||||
assert "external://gemini/gemini-3.1-flash-image-preview" in install_calls
|
||||
assert "external://gemini/gemini-3-pro-image-preview" in install_calls
|
||||
|
||||
|
||||
def test_sync_configured_external_starter_models_skips_when_no_provider_is_configured() -> None:
|
||||
model_manager = MagicMock()
|
||||
logger = MagicMock()
|
||||
|
||||
queued_sources = sync_configured_external_starter_models(
|
||||
configured_provider_ids=set(),
|
||||
model_manager=model_manager,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
assert queued_sources == []
|
||||
model_manager.store.search_by_attr.assert_not_called()
|
||||
model_manager.install.heuristic_import.assert_not_called()
|
||||
@@ -0,0 +1,254 @@
|
||||
import hashlib
|
||||
import platform
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage, _should_use_png_rle
|
||||
from invokeai.app.util.thumbnails import get_thumbnail_name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_names() -> list[str]:
|
||||
# Determine the platform and return a path that matches its format
|
||||
if platform.system() == "Windows":
|
||||
return [
|
||||
# Relative paths
|
||||
"folder\\evil.txt",
|
||||
"folder\\..\\evil.txt",
|
||||
# Absolute paths
|
||||
"\\folder\\evil.txt",
|
||||
"C:\\folder\\..\\evil.txt",
|
||||
]
|
||||
else:
|
||||
return [
|
||||
# Relative paths
|
||||
"folder/evil.txt",
|
||||
"folder/../evil.txt",
|
||||
# Absolute paths
|
||||
"/folder/evil.txt",
|
||||
"/folder/../evil.txt",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disk_storage(tmp_path: Path) -> DiskImageFileStorage:
|
||||
storage = DiskImageFileStorage(tmp_path)
|
||||
# Mock the invoker for save() which needs compress_level
|
||||
mock_invoker = MagicMock()
|
||||
mock_invoker.services.configuration.pil_compress_level = 6
|
||||
storage._DiskImageFileStorage__invoker = mock_invoker # type: ignore
|
||||
return storage
|
||||
|
||||
|
||||
def test_directory_traversal_protection(tmp_path: Path, image_names: list[str]):
|
||||
"""Test that the image file storage prevents directory traversal attacks.
|
||||
|
||||
There are two safeguards in the `DiskImageFileStorage.get_path` method:
|
||||
1. Check if the image name contains any directory traversal characters
|
||||
2. Check if the resulting path is relative to the base folder
|
||||
|
||||
This test checks the first safeguard. I'd like to check the second but I cannot figure out a test case that would
|
||||
pass the first check but fail the second check.
|
||||
"""
|
||||
image_files_disk = DiskImageFileStorage(tmp_path)
|
||||
for name in image_names:
|
||||
with pytest.raises(ValueError, match="Invalid image name, potential directory traversal detected"):
|
||||
image_files_disk.get_path(name)
|
||||
|
||||
|
||||
def test_image_paths_relative_to_storage_dir(tmp_path: Path):
|
||||
image_files_disk = DiskImageFileStorage(tmp_path)
|
||||
path = image_files_disk.get_path("foo.png")
|
||||
assert path.is_relative_to(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("compress_level", "expected_compress_type"),
|
||||
[(0, None), (1, zlib.Z_RLE), (7, None)],
|
||||
)
|
||||
def test_save_uses_rle_only_for_compression_level_one(
|
||||
tmp_path: Path, compress_level: int, expected_compress_type: int | None
|
||||
):
|
||||
storage = DiskImageFileStorage(tmp_path)
|
||||
mock_invoker = MagicMock()
|
||||
mock_invoker.services.configuration.pil_compress_level = compress_level
|
||||
storage._DiskImageFileStorage__invoker = mock_invoker # type: ignore
|
||||
|
||||
with (
|
||||
patch("invokeai.app.services.image_files.image_files_disk._should_use_png_rle", return_value=True),
|
||||
patch.object(Image.Image, "save", autospec=True) as save_mock,
|
||||
):
|
||||
storage.save(image=Image.new("RGBA", (32, 32)), image_name="test.png")
|
||||
|
||||
png_calls = [call for call in save_mock.call_args_list if len(call.args) > 2 and call.args[2] == "PNG"]
|
||||
assert len(png_calls) == 1
|
||||
assert png_calls[0].kwargs["compress_level"] == compress_level
|
||||
if expected_compress_type is None:
|
||||
assert "compress_type" not in png_calls[0].kwargs
|
||||
else:
|
||||
assert png_calls[0].kwargs["compress_type"] == expected_compress_type
|
||||
|
||||
|
||||
def test_png_rle_probe_rejects_structured_images():
|
||||
entropy = Image.frombytes("RGB", (512, 512), hashlib.shake_256(b"png-rle-test").digest(512 * 512 * 3))
|
||||
gradient = Image.linear_gradient("L").resize((512, 512)).convert("RGB")
|
||||
|
||||
assert _should_use_png_rle(entropy)
|
||||
assert not _should_use_png_rle(gradient)
|
||||
|
||||
entropy.close()
|
||||
gradient.close()
|
||||
|
||||
|
||||
def _make_round_trip_image(mode: str) -> Image.Image:
|
||||
image = Image.new(mode, (4, 4))
|
||||
if mode == "P":
|
||||
palette = [component for index in range(256) for component in (index, 255 - index, index // 2, index)]
|
||||
image.putpalette(palette, rawmode="RGBA")
|
||||
image.putdata(range(16))
|
||||
else:
|
||||
values = {
|
||||
"1": [0, 1],
|
||||
"L": [0, 255],
|
||||
"LA": [(17, 0), (201, 255)],
|
||||
"RGB": [(1, 2, 3), (251, 252, 253)],
|
||||
"RGBA": [(1, 2, 3, 0), (251, 252, 253, 255)],
|
||||
"I;16": [0, 65535],
|
||||
}
|
||||
image.putdata(values[mode] * 8)
|
||||
return image
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["1", "L", "LA", "P", "RGB", "RGBA", "I;16"])
|
||||
def test_level_one_png_round_trip_from_disk(tmp_path: Path, mode: str):
|
||||
storage = DiskImageFileStorage(tmp_path)
|
||||
mock_invoker = MagicMock()
|
||||
mock_invoker.services.configuration.pil_compress_level = 1
|
||||
storage._DiskImageFileStorage__invoker = mock_invoker # type: ignore
|
||||
|
||||
image = _make_round_trip_image(mode)
|
||||
expected_bytes = image.tobytes()
|
||||
expected_rgba = image.convert("RGBA").tobytes() if mode == "P" else None
|
||||
metadata = f'{{"mode":"{mode}"}}'
|
||||
image_name = f"round-trip-{mode.replace(';', '-')}.png"
|
||||
|
||||
with patch("invokeai.app.services.image_files.image_files_disk._should_use_png_rle", return_value=True):
|
||||
storage.save(image=image, image_name=image_name, metadata=metadata)
|
||||
image_path = storage.get_path(image_name)
|
||||
storage.evict_cache_paths([image_path])
|
||||
|
||||
with Image.open(image_path) as loaded:
|
||||
loaded.load()
|
||||
assert loaded.format == "PNG"
|
||||
assert loaded.mode == mode
|
||||
assert loaded.tobytes() == expected_bytes
|
||||
assert loaded.info["invokeai_metadata"] == metadata
|
||||
if mode in {"LA", "RGBA"}:
|
||||
assert loaded.getchannel("A").tobytes() == image.getchannel("A").tobytes()
|
||||
if mode == "P":
|
||||
assert loaded.info["transparency"] == bytes(range(256))
|
||||
assert loaded.convert("RGBA").tobytes() == expected_rgba
|
||||
|
||||
image.close()
|
||||
|
||||
|
||||
# ── Subfolder validation tests (Point 1) ──
|
||||
|
||||
|
||||
class TestValidateSubfolder:
|
||||
"""Tests for _validate_subfolder() and get_path() with image_subfolder."""
|
||||
|
||||
def test_valid_single_segment(self, tmp_path: Path):
|
||||
storage = DiskImageFileStorage(tmp_path)
|
||||
path = storage.get_path("img.png", image_subfolder="general")
|
||||
assert path.is_relative_to(tmp_path)
|
||||
assert "general" in path.parts
|
||||
|
||||
def test_valid_nested_subfolder(self, tmp_path: Path):
|
||||
storage = DiskImageFileStorage(tmp_path)
|
||||
path = storage.get_path("img.png", image_subfolder="2026/03/17")
|
||||
assert path.is_relative_to(tmp_path)
|
||||
assert path.name == "img.png"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subfolder,error_match",
|
||||
[
|
||||
("../x", "Parent directory references not allowed"),
|
||||
("x/../y", "Parent directory references not allowed"),
|
||||
("/abs", "Absolute paths not allowed"),
|
||||
("a//b", "Empty path segments not allowed"),
|
||||
("a\\b", "Backslashes not allowed"),
|
||||
],
|
||||
ids=["parent_traversal", "mid_traversal", "absolute", "double_slash", "backslash"],
|
||||
)
|
||||
def test_invalid_subfolders(self, tmp_path: Path, subfolder: str, error_match: str):
|
||||
storage = DiskImageFileStorage(tmp_path)
|
||||
with pytest.raises(ValueError, match=error_match):
|
||||
storage.get_path("img.png", image_subfolder=subfolder)
|
||||
|
||||
def test_empty_subfolder_gives_root(self, tmp_path: Path):
|
||||
storage = DiskImageFileStorage(tmp_path)
|
||||
path = storage.get_path("img.png", image_subfolder="")
|
||||
assert path == (tmp_path / "img.png").resolve()
|
||||
|
||||
def test_thumbnail_mirrors_subfolder(self, tmp_path: Path):
|
||||
storage = DiskImageFileStorage(tmp_path)
|
||||
subfolder = "2026/03/17"
|
||||
img_path = storage.get_path("img.png", thumbnail=False, image_subfolder=subfolder)
|
||||
thumb_path = storage.get_path("img.png", thumbnail=True, image_subfolder=subfolder)
|
||||
|
||||
# Both should contain the subfolder segments
|
||||
assert subfolder.replace("/", "\\") in str(img_path) or subfolder in str(img_path)
|
||||
assert subfolder.replace("/", "\\") in str(thumb_path) or subfolder in str(thumb_path)
|
||||
|
||||
# Thumbnail should be under thumbnails folder
|
||||
thumbnails_folder = (tmp_path / "thumbnails").resolve()
|
||||
assert thumb_path.is_relative_to(thumbnails_folder)
|
||||
|
||||
|
||||
class TestSaveDeleteRoundTrip:
|
||||
"""Save/delete round-trip with subfolders, including thumbnail mirroring."""
|
||||
|
||||
def test_save_and_delete_with_subfolder(self, disk_storage: DiskImageFileStorage, tmp_path: Path):
|
||||
subfolder = "2026/04/05"
|
||||
image_name = "test_image.png"
|
||||
image = Image.new("RGB", (64, 64), color="red")
|
||||
|
||||
disk_storage.save(image=image, image_name=image_name, image_subfolder=subfolder)
|
||||
|
||||
# Image file exists
|
||||
image_path = disk_storage.get_path(image_name, image_subfolder=subfolder)
|
||||
assert image_path.exists()
|
||||
|
||||
# Thumbnail file exists in mirrored subfolder
|
||||
thumbnail_name = get_thumbnail_name(image_name)
|
||||
thumb_path = disk_storage.get_path(image_name, thumbnail=True, image_subfolder=subfolder)
|
||||
assert thumb_path.name == thumbnail_name
|
||||
assert not thumb_path.name.startswith("thumbnail_thumbnail_")
|
||||
assert thumb_path.exists()
|
||||
|
||||
# Round-trip read
|
||||
loaded = disk_storage.get(image_name, image_subfolder=subfolder)
|
||||
assert loaded.size == (64, 64)
|
||||
|
||||
# Delete removes both
|
||||
disk_storage.delete(image_name, image_subfolder=subfolder)
|
||||
assert not image_path.exists()
|
||||
assert not thumb_path.exists()
|
||||
|
||||
def test_save_flat_and_subfolder_coexist(self, disk_storage: DiskImageFileStorage, tmp_path: Path):
|
||||
image = Image.new("RGB", (32, 32), color="blue")
|
||||
|
||||
disk_storage.save(image=image, image_name="flat.png", image_subfolder="")
|
||||
disk_storage.save(image=image, image_name="nested.png", image_subfolder="general")
|
||||
|
||||
flat_path = disk_storage.get_path("flat.png", image_subfolder="")
|
||||
nested_path = disk_storage.get_path("nested.png", image_subfolder="general")
|
||||
|
||||
assert flat_path.exists()
|
||||
assert nested_path.exists()
|
||||
assert flat_path.parent != nested_path.parent
|
||||
@@ -0,0 +1,727 @@
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from shutil import copy2
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage
|
||||
from invokeai.app.services.image_moves.image_moves_default import (
|
||||
ImageMoveQueueActive,
|
||||
ImageMoveService,
|
||||
)
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
|
||||
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
|
||||
from invokeai.app.services.session_queue.session_queue_common import DEFAULT_QUEUE_ID, SessionQueueStatus
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
from invokeai.app.services.shared.sqlite.sqlite_util import init_db
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
|
||||
|
||||
def _build_db(tmp_path: Path) -> SqliteDatabase:
|
||||
logger = InvokeAILogger.get_logger()
|
||||
config = InvokeAIAppConfig(use_memory_db=False)
|
||||
config._root = tmp_path
|
||||
image_files = DiskImageFileStorage(tmp_path / "images")
|
||||
return init_db(config=config, logger=logger, image_files=image_files)
|
||||
|
||||
|
||||
def _save_record(
|
||||
records: SqliteImageRecordStorage,
|
||||
image_name: str,
|
||||
subfolder: str,
|
||||
created_at: str,
|
||||
is_intermediate: bool = False,
|
||||
) -> None:
|
||||
records.save(
|
||||
image_name=image_name,
|
||||
image_origin=ResourceOrigin.INTERNAL,
|
||||
image_category=ImageCategory.GENERAL,
|
||||
width=16,
|
||||
height=16,
|
||||
has_workflow=False,
|
||||
is_intermediate=is_intermediate,
|
||||
image_subfolder=subfolder,
|
||||
)
|
||||
with records._db.transaction() as cursor:
|
||||
cursor.execute("UPDATE images SET created_at = ? WHERE image_name = ?;", (created_at, image_name))
|
||||
|
||||
|
||||
def _save_image(
|
||||
service: ImageMoveService,
|
||||
records: SqliteImageRecordStorage,
|
||||
image_name: str,
|
||||
subfolder: str,
|
||||
created_at: str,
|
||||
color: str,
|
||||
is_intermediate: bool = False,
|
||||
) -> None:
|
||||
_save_record(
|
||||
records,
|
||||
image_name=image_name,
|
||||
subfolder=subfolder,
|
||||
created_at=created_at,
|
||||
is_intermediate=is_intermediate,
|
||||
)
|
||||
service.image_files.save(Image.new("RGB", (16, 16), color), image_name=image_name, image_subfolder=subfolder)
|
||||
|
||||
|
||||
def _service(tmp_path: Path, strategy: str = "date") -> tuple[ImageMoveService, SqliteImageRecordStorage]:
|
||||
db = _build_db(tmp_path)
|
||||
records = SqliteImageRecordStorage(db=db)
|
||||
storage = DiskImageFileStorage(tmp_path / "images")
|
||||
invoker = MagicMock()
|
||||
invoker.services.configuration.pil_compress_level = 6
|
||||
storage.start(invoker)
|
||||
config = InvokeAIAppConfig(use_memory_db=True, image_subfolder_strategy=strategy)
|
||||
config._root = tmp_path
|
||||
service = ImageMoveService(db=db, image_files=storage, config=config, logger=InvokeAILogger.get_logger())
|
||||
return service, records
|
||||
|
||||
|
||||
def _job_item_states(service: ImageMoveService, job_id: int) -> dict[str, str]:
|
||||
with service._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT image_name, state FROM image_subfolder_move_items WHERE job_id = ? ORDER BY image_name;",
|
||||
(job_id,),
|
||||
)
|
||||
return {row["image_name"]: row["state"] for row in cursor.fetchall()}
|
||||
|
||||
|
||||
def _job_states(service: ImageMoveService) -> dict[int, str]:
|
||||
with service._db.transaction() as cursor:
|
||||
cursor.execute("SELECT id, state FROM image_subfolder_move_jobs ORDER BY id;")
|
||||
return {row["id"]: row["state"] for row in cursor.fetchall()}
|
||||
|
||||
|
||||
def test_move_all_images_uses_created_at_for_date_strategy(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-a.png"
|
||||
_save_record(records, image_name=image_name, subfolder="", created_at="2024-02-03 04:05:06.000")
|
||||
service.image_files.save(Image.new("RGB", (16, 16), "red"), image_name=image_name)
|
||||
|
||||
result = service.move_all_images()
|
||||
|
||||
assert result.planned == 1
|
||||
assert result.committed == 1
|
||||
record = records.get(image_name)
|
||||
assert record.image_subfolder == "2024/02/03"
|
||||
assert service.image_files.get_path(image_name, image_subfolder="2024/02/03").exists()
|
||||
assert not service.image_files.get_path(image_name, image_subfolder="").exists()
|
||||
|
||||
|
||||
def test_missing_intermediate_source_file_is_treated_as_success(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "missing-intermediate.png"
|
||||
_save_record(
|
||||
records,
|
||||
image_name=image_name,
|
||||
subfolder="",
|
||||
created_at="2024-02-04 04:05:06.000",
|
||||
is_intermediate=True,
|
||||
)
|
||||
|
||||
result = service.move_all_images()
|
||||
|
||||
assert result.planned == 1
|
||||
assert result.committed == 1
|
||||
assert result.errors == 0
|
||||
record = records.get(image_name)
|
||||
assert record.image_subfolder == "2024/02/04"
|
||||
assert service.get_latest_job().state == "committed"
|
||||
|
||||
|
||||
def test_missing_intermediate_source_file_removes_orphaned_thumbnail(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "missing-intermediate-with-thumbnail.png"
|
||||
old_subfolder = "old/intermediate"
|
||||
_save_image(
|
||||
service,
|
||||
records,
|
||||
image_name=image_name,
|
||||
subfolder=old_subfolder,
|
||||
created_at="2024-02-04 04:05:06.000",
|
||||
color="red",
|
||||
is_intermediate=True,
|
||||
)
|
||||
old_path = service.image_files.get_path(image_name, image_subfolder=old_subfolder)
|
||||
old_thumbnail_path = service.image_files.get_path(image_name, thumbnail=True, image_subfolder=old_subfolder)
|
||||
assert old_thumbnail_path.exists()
|
||||
old_path.unlink()
|
||||
|
||||
result = service.move_all_images()
|
||||
|
||||
assert result.committed == 1
|
||||
assert not old_thumbnail_path.exists()
|
||||
assert records.get(image_name).image_subfolder == "2024/02/04"
|
||||
|
||||
|
||||
def test_missing_non_intermediate_source_file_still_fails(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "missing-general.png"
|
||||
_save_record(
|
||||
records,
|
||||
image_name=image_name,
|
||||
subfolder="",
|
||||
created_at="2024-02-04 04:05:06.000",
|
||||
is_intermediate=False,
|
||||
)
|
||||
|
||||
with pytest.raises(FileNotFoundError, match="Source image does not exist"):
|
||||
service.plan_batch(last_image_name="", limit=100)
|
||||
|
||||
|
||||
def test_move_all_images_continues_after_missing_non_intermediate_source_file(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
missing_image_name = "missing-general.png"
|
||||
valid_image_name = "valid-general.png"
|
||||
_save_record(
|
||||
records,
|
||||
image_name=missing_image_name,
|
||||
subfolder="",
|
||||
created_at="2024-02-04 04:05:06.000",
|
||||
is_intermediate=False,
|
||||
)
|
||||
_save_image(service, records, valid_image_name, "", "2024-02-05 04:05:06.000", "blue")
|
||||
|
||||
result = service.move_all_images()
|
||||
|
||||
assert result.errors == 1
|
||||
assert result.committed == 1
|
||||
assert records.get(missing_image_name).image_subfolder == ""
|
||||
assert records.get(valid_image_name).image_subfolder == "2024/02/05"
|
||||
assert "error" in _job_states(service).values()
|
||||
assert "committed" in _job_states(service).values()
|
||||
|
||||
|
||||
def test_recovery_treats_missing_intermediate_source_file_as_success(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "missing-intermediate-recovery.png"
|
||||
_save_record(
|
||||
records,
|
||||
image_name=image_name,
|
||||
subfolder="",
|
||||
created_at="2024-02-05 04:05:06.000",
|
||||
is_intermediate=True,
|
||||
)
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
job_id = service.create_move_job(moves)
|
||||
|
||||
recovered = service.startup_recovery()
|
||||
|
||||
assert recovered.committed == 1
|
||||
assert recovered.errors == 0
|
||||
assert records.get(image_name).image_subfolder == "2024/02/05"
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
assert _job_item_states(service, job_id) == {image_name: "committed"}
|
||||
|
||||
|
||||
def test_startup_recovery_commits_after_files_moved_but_db_not_updated(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-b.png"
|
||||
_save_record(records, image_name=image_name, subfolder="", created_at="2025-06-07 08:09:10.000")
|
||||
service.image_files.save(Image.new("RGB", (16, 16), "blue"), image_name=image_name)
|
||||
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
job_id = service.create_move_job(moves)
|
||||
service.perform_filesystem_moves(job_id)
|
||||
|
||||
assert records.get(image_name).image_subfolder == ""
|
||||
|
||||
recovered = service.startup_recovery()
|
||||
|
||||
assert recovered.committed == 1
|
||||
assert records.get(image_name).image_subfolder == "2025/06/07"
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
|
||||
|
||||
def test_status_reports_unplanned_images_after_recovery(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
_save_image(service, records, "image-recovered.png", "", "2024-02-03 04:05:06.000", "red")
|
||||
_save_image(service, records, "image-unplanned.png", "", "2024-02-04 04:05:06.000", "blue")
|
||||
moves = service.plan_batch(last_image_name="", limit=1)
|
||||
job_id = service.create_move_job(moves)
|
||||
service.perform_filesystem_moves(job_id)
|
||||
|
||||
recovered = service.startup_recovery()
|
||||
status = service.get_background_status()
|
||||
|
||||
assert recovered.committed == 1
|
||||
assert records.get("image-recovered.png").image_subfolder == "2024/02/03"
|
||||
assert records.get("image-unplanned.png").image_subfolder == ""
|
||||
assert status.active_job_id is None
|
||||
assert status.latest_job is not None
|
||||
assert status.latest_job.state == "committed"
|
||||
assert status.needs_move_count == 1
|
||||
|
||||
|
||||
def test_cleanup_empty_source_directories_after_move(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-c.png"
|
||||
old_subfolder = "old/nested"
|
||||
_save_record(records, image_name=image_name, subfolder=old_subfolder, created_at="2024-11-12 01:02:03.000")
|
||||
service.image_files.save(Image.new("RGB", (16, 16), "green"), image_name=image_name, image_subfolder=old_subfolder)
|
||||
old_parent = service.image_files.get_path(image_name, image_subfolder=old_subfolder).parent
|
||||
old_thumb_parent = service.image_files.get_path(image_name, thumbnail=True, image_subfolder=old_subfolder).parent
|
||||
|
||||
service.move_all_images()
|
||||
|
||||
assert not old_parent.exists()
|
||||
assert not old_thumb_parent.exists()
|
||||
assert service.image_files.image_root.exists()
|
||||
assert service.image_files.thumbnail_root.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are not supported on this platform")
|
||||
def test_cleanup_empty_source_directories_stays_within_symlinked_root(tmp_path: Path) -> None:
|
||||
service, _records = _service(tmp_path, strategy="date")
|
||||
real_root = tmp_path / "real-root"
|
||||
linked_root = tmp_path / "linked-root"
|
||||
sibling = tmp_path / "sibling"
|
||||
real_root.mkdir()
|
||||
sibling.mkdir()
|
||||
try:
|
||||
linked_root.symlink_to(real_root, target_is_directory=True)
|
||||
except OSError as e:
|
||||
pytest.skip(f"symlink creation is not available: {e}")
|
||||
nested = linked_root / "old" / "nested"
|
||||
nested.mkdir(parents=True)
|
||||
|
||||
service._remove_empty_parents(nested, linked_root)
|
||||
|
||||
assert real_root.exists()
|
||||
assert linked_root.exists()
|
||||
assert sibling.exists()
|
||||
assert not (real_root / "old").exists()
|
||||
|
||||
|
||||
def test_startup_recovery_cleans_empty_source_directories(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-recovery-cleanup.png"
|
||||
old_subfolder = "old/recovery"
|
||||
_save_image(service, records, image_name, old_subfolder, "2024-11-13 01:02:03.000", "green")
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
job_id = service.create_move_job(moves)
|
||||
move = moves[0]
|
||||
old_parent = service.image_files.get_path(image_name, image_subfolder=old_subfolder).parent
|
||||
old_thumb_parent = service.image_files.get_path(image_name, thumbnail=True, image_subfolder=old_subfolder).parent
|
||||
move.new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
move.new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
move.old_path.replace(move.new_path)
|
||||
move.old_thumbnail_path.replace(move.new_thumbnail_path)
|
||||
|
||||
recovered = service.startup_recovery()
|
||||
|
||||
assert recovered.committed == 1
|
||||
assert recovered.errors == 0
|
||||
assert not old_parent.exists()
|
||||
assert not old_thumb_parent.exists()
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
|
||||
|
||||
def test_preflight_rejects_active_uncommitted_job_for_same_image(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-d.png"
|
||||
_save_record(records, image_name=image_name, subfolder="", created_at="2024-01-02 03:04:05.000")
|
||||
service.image_files.save(Image.new("RGB", (16, 16), "yellow"), image_name=image_name)
|
||||
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
service.create_move_job(moves)
|
||||
|
||||
with pytest.raises(ValueError, match="active image move job"):
|
||||
service.plan_batch(last_image_name="", limit=100)
|
||||
|
||||
|
||||
def test_create_move_job_rejects_second_active_job_from_stale_plan(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-active-race.png"
|
||||
_save_image(service, records, image_name, "", "2024-01-03 03:04:05.000", "yellow")
|
||||
|
||||
stale_plan_a = service.plan_batch(last_image_name="", limit=100)
|
||||
stale_plan_b = service.plan_batch(last_image_name="", limit=100)
|
||||
service.create_move_job(stale_plan_a)
|
||||
|
||||
with pytest.raises(ValueError, match="active image move job"):
|
||||
service.create_move_job(stale_plan_b)
|
||||
|
||||
|
||||
def test_startup_recovery_completes_planned_job_before_any_file_move(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-e.png"
|
||||
_save_image(service, records, image_name, "", "2024-03-04 05:06:07.000", "purple")
|
||||
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
job_id = service.create_move_job(moves)
|
||||
|
||||
recovered_once = service.startup_recovery()
|
||||
recovered_twice = service.startup_recovery()
|
||||
|
||||
assert recovered_once.committed == 1
|
||||
assert recovered_once.errors == 0
|
||||
assert recovered_twice.committed == 0
|
||||
assert recovered_twice.errors == 0
|
||||
assert records.get(image_name).image_subfolder == "2024/03/04"
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
assert _job_item_states(service, job_id) == {image_name: "committed"}
|
||||
|
||||
|
||||
def test_background_recovery_can_start_when_journal_job_is_active(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-background-recovery.png"
|
||||
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
|
||||
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
|
||||
|
||||
status = service.start_background_recovery()
|
||||
assert status.is_running is True
|
||||
assert status.operation == "recovery"
|
||||
|
||||
assert service._future is not None
|
||||
service._future.result(timeout=5)
|
||||
|
||||
assert records.get(image_name).image_subfolder == "2024/03/05"
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
|
||||
|
||||
def test_start_runs_recovery_before_normal_operation(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-startup-recovery.png"
|
||||
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
|
||||
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
|
||||
service.perform_filesystem_moves(job_id)
|
||||
|
||||
service.start(MagicMock())
|
||||
|
||||
assert records.get(image_name).image_subfolder == "2024/03/05"
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
assert service.is_maintenance_active() is False
|
||||
|
||||
|
||||
def test_start_leaves_maintenance_active_when_recovery_remains_incomplete(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-startup-recovery-retry.png"
|
||||
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
|
||||
service.create_move_job(service.plan_batch(last_image_name="", limit=100))
|
||||
|
||||
with patch.object(service, "complete_partial_filesystem_moves", side_effect=OSError("temporary failure")):
|
||||
service.start(MagicMock())
|
||||
|
||||
assert records.get(image_name).image_subfolder == ""
|
||||
assert service.is_maintenance_active() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("pending", "in_progress"), [(1, 0), (0, 1)])
|
||||
def test_background_move_rejects_active_queue_work(tmp_path: Path, pending: int, in_progress: int) -> None:
|
||||
service, _records = _service(tmp_path, strategy="date")
|
||||
invoker = MagicMock()
|
||||
invoker.services.session_queue.get_queue_status.return_value = SessionQueueStatus(
|
||||
queue_id=DEFAULT_QUEUE_ID,
|
||||
item_id=None,
|
||||
batch_id=None,
|
||||
session_id=None,
|
||||
pending=pending,
|
||||
in_progress=in_progress,
|
||||
waiting=0,
|
||||
completed=0,
|
||||
failed=0,
|
||||
canceled=0,
|
||||
total=1,
|
||||
)
|
||||
service.start(invoker)
|
||||
|
||||
with pytest.raises(ImageMoveQueueActive, match="queue work is active"):
|
||||
service.start_background_move_all()
|
||||
|
||||
|
||||
def test_background_move_is_reserved_before_queue_check(tmp_path: Path) -> None:
|
||||
service, _records = _service(tmp_path, strategy="date")
|
||||
invoker = MagicMock()
|
||||
|
||||
def get_queue_status(queue_id: str) -> SessionQueueStatus:
|
||||
assert queue_id == DEFAULT_QUEUE_ID
|
||||
assert service.is_maintenance_active() is True
|
||||
return SessionQueueStatus(
|
||||
queue_id=DEFAULT_QUEUE_ID,
|
||||
item_id=None,
|
||||
batch_id=None,
|
||||
session_id=None,
|
||||
pending=1,
|
||||
in_progress=0,
|
||||
waiting=0,
|
||||
completed=0,
|
||||
failed=0,
|
||||
canceled=0,
|
||||
total=1,
|
||||
)
|
||||
|
||||
invoker.services.session_queue.get_queue_status.side_effect = get_queue_status
|
||||
service.start(invoker)
|
||||
|
||||
with pytest.raises(ImageMoveQueueActive, match="queue work is active"):
|
||||
service.start_background_move_all()
|
||||
|
||||
assert service.is_maintenance_active() is False
|
||||
|
||||
|
||||
def test_maintenance_is_active_while_background_job_or_uncommitted_journal_exists(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-maintenance-active.png"
|
||||
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
|
||||
service.create_move_job(service.plan_batch(last_image_name="", limit=100))
|
||||
|
||||
assert service.is_maintenance_active() is True
|
||||
|
||||
release_worker = threading.Event()
|
||||
|
||||
def wait_for_release() -> None:
|
||||
release_worker.wait(timeout=5)
|
||||
|
||||
service._start_background_operation("recovery", wait_for_release)
|
||||
try:
|
||||
assert service.is_maintenance_active() is True
|
||||
finally:
|
||||
release_worker.set()
|
||||
assert service._future is not None
|
||||
service._future.result(timeout=5)
|
||||
|
||||
|
||||
def test_background_worker_error_is_exposed_in_status(tmp_path: Path) -> None:
|
||||
service, _records = _service(tmp_path, strategy="date")
|
||||
started_worker = threading.Event()
|
||||
release_worker = threading.Event()
|
||||
|
||||
def raise_error() -> None:
|
||||
started_worker.set()
|
||||
release_worker.wait(timeout=5)
|
||||
raise RuntimeError("background failed")
|
||||
|
||||
status = service._start_background_operation("move_all", raise_error)
|
||||
assert started_worker.wait(timeout=5) is True
|
||||
assert status.is_running is True
|
||||
|
||||
assert service._future is not None
|
||||
release_worker.set()
|
||||
service._future.result(timeout=5)
|
||||
|
||||
status = service.get_background_status()
|
||||
assert status.is_running is False
|
||||
assert status.operation is None
|
||||
assert status.last_error == "background failed"
|
||||
|
||||
|
||||
def test_stop_waits_for_active_background_job_without_recording_error(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-background-stop.png"
|
||||
_save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple")
|
||||
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
|
||||
release_worker = threading.Event()
|
||||
|
||||
def wait_for_shutdown() -> None:
|
||||
release_worker.wait(timeout=5)
|
||||
|
||||
service._start_background_operation("recovery", wait_for_shutdown)
|
||||
|
||||
stop_thread = threading.Thread(target=service.stop)
|
||||
stop_thread.start()
|
||||
assert stop_thread.is_alive()
|
||||
|
||||
release_worker.set()
|
||||
stop_thread.join(timeout=5)
|
||||
|
||||
assert not stop_thread.is_alive()
|
||||
assert service.get_job(job_id).error_message is None
|
||||
assert service.get_background_status().last_error is None
|
||||
|
||||
|
||||
def test_startup_recovery_completes_partial_multi_image_move(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
_save_image(service, records, "image-f.png", "", "2024-04-05 06:07:08.000", "orange")
|
||||
_save_image(service, records, "image-g.png", "", "2024-04-06 06:07:08.000", "cyan")
|
||||
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
job_id = service.create_move_job(moves)
|
||||
first_move = moves[0]
|
||||
first_move.new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
first_move.new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
first_move.old_path.replace(first_move.new_path)
|
||||
first_move.old_thumbnail_path.replace(first_move.new_thumbnail_path)
|
||||
|
||||
recovered_once = service.startup_recovery()
|
||||
recovered_twice = service.startup_recovery()
|
||||
|
||||
assert recovered_once.committed == 2
|
||||
assert recovered_once.errors == 0
|
||||
assert recovered_twice.committed == 0
|
||||
assert recovered_twice.errors == 0
|
||||
assert records.get("image-f.png").image_subfolder == "2024/04/05"
|
||||
assert records.get("image-g.png").image_subfolder == "2024/04/06"
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
assert _job_item_states(service, job_id) == {"image-f.png": "committed", "image-g.png": "committed"}
|
||||
|
||||
|
||||
def test_startup_recovery_marks_committed_after_db_update_but_before_journal_commit(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-h.png"
|
||||
_save_image(service, records, image_name, "", "2024-05-06 07:08:09.000", "pink")
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
job_id = service.create_move_job(moves)
|
||||
service.perform_filesystem_moves(job_id)
|
||||
|
||||
with service._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"UPDATE images SET image_subfolder = ? WHERE image_name = ?;",
|
||||
("2024/05/06", image_name),
|
||||
)
|
||||
|
||||
recovered_once = service.startup_recovery()
|
||||
recovered_twice = service.startup_recovery()
|
||||
|
||||
assert recovered_once.committed == 1
|
||||
assert recovered_once.errors == 0
|
||||
assert recovered_twice.committed == 0
|
||||
assert recovered_twice.errors == 0
|
||||
assert records.get(image_name).image_subfolder == "2024/05/06"
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
assert _job_item_states(service, job_id) == {image_name: "committed"}
|
||||
|
||||
|
||||
def test_startup_recovery_marks_error_when_both_old_and_new_full_size_files_exist(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-i.png"
|
||||
_save_image(service, records, image_name, "", "2024-07-08 09:10:11.000", "red")
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
job_id = service.create_move_job(moves)
|
||||
move = moves[0]
|
||||
move.new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
copy2(move.old_path, move.new_path)
|
||||
|
||||
recovered = service.startup_recovery()
|
||||
|
||||
assert recovered.committed == 0
|
||||
assert recovered.errors == 1
|
||||
assert records.get(image_name).image_subfolder == ""
|
||||
assert service.get_job(job_id).state == "error"
|
||||
assert _job_item_states(service, job_id) == {image_name: "error"}
|
||||
|
||||
|
||||
def test_startup_recovery_marks_error_when_neither_old_nor_new_full_size_file_exists(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-j.png"
|
||||
_save_image(service, records, image_name, "", "2024-08-09 10:11:12.000", "blue")
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
job_id = service.create_move_job(moves)
|
||||
moves[0].old_path.unlink()
|
||||
|
||||
recovered = service.startup_recovery()
|
||||
|
||||
assert recovered.committed == 0
|
||||
assert recovered.errors == 1
|
||||
assert records.get(image_name).image_subfolder == ""
|
||||
assert service.get_job(job_id).state == "error"
|
||||
assert _job_item_states(service, job_id) == {image_name: "error"}
|
||||
|
||||
|
||||
def test_startup_recovery_keeps_job_recoverable_after_ordinary_exception(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-k.png"
|
||||
_save_image(service, records, image_name, "", "2024-09-10 11:12:13.000", "white")
|
||||
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
|
||||
|
||||
with patch.object(service, "complete_partial_filesystem_moves", side_effect=OSError("temporary failure")):
|
||||
recovered = service.startup_recovery()
|
||||
|
||||
assert recovered.committed == 0
|
||||
assert recovered.errors == 1
|
||||
job = service.get_job(job_id)
|
||||
assert job.state == "planned"
|
||||
assert job.error_message == "temporary failure"
|
||||
|
||||
recovered_retry = service.startup_recovery()
|
||||
|
||||
assert recovered_retry.committed == 1
|
||||
assert recovered_retry.errors == 0
|
||||
assert records.get(image_name).image_subfolder == "2024/09/10"
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
|
||||
|
||||
def test_startup_recovery_regenerates_thumbnail_when_old_and_new_thumbnails_exist(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-l.png"
|
||||
_save_image(service, records, image_name, "", "2024-10-11 12:13:14.000", "black")
|
||||
moves = service.plan_batch(last_image_name="", limit=100)
|
||||
job_id = service.create_move_job(moves)
|
||||
move = moves[0]
|
||||
move.new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
move.new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
move.old_path.replace(move.new_path)
|
||||
copy2(move.old_thumbnail_path, move.new_thumbnail_path)
|
||||
|
||||
recovered = service.startup_recovery()
|
||||
|
||||
assert recovered.committed == 1
|
||||
assert recovered.errors == 0
|
||||
assert records.get(image_name).image_subfolder == "2024/10/11"
|
||||
assert move.new_thumbnail_path.exists()
|
||||
assert not move.old_thumbnail_path.exists()
|
||||
assert service.get_job(job_id).state == "committed"
|
||||
|
||||
|
||||
def test_preflight_rejects_duplicate_thumbnail_destination_paths(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
_save_image(service, records, "same-name.jpg", "", "2024-12-13 14:15:16.000", "red")
|
||||
_save_image(service, records, "same-name.png", "", "2024-12-13 14:15:16.000", "green")
|
||||
|
||||
with pytest.raises(ValueError, match="Duplicate destination thumbnail path"):
|
||||
service.plan_batch(last_image_name="", limit=100)
|
||||
|
||||
|
||||
def test_successful_filesystem_move_fsyncs_files_and_directories(tmp_path: Path) -> None:
|
||||
service, records = _service(tmp_path, strategy="date")
|
||||
image_name = "image-m.png"
|
||||
_save_image(service, records, image_name, "", "2025-01-02 03:04:05.000", "blue")
|
||||
job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100))
|
||||
|
||||
with (
|
||||
patch.object(service, "_fsync_file") as fsync_file,
|
||||
patch.object(service, "_fsync_dir") as fsync_dir,
|
||||
):
|
||||
service.perform_filesystem_moves(job_id)
|
||||
|
||||
moved = service._get_items(job_id)[0]
|
||||
fsync_file.assert_any_call(moved.new_path)
|
||||
fsync_file.assert_any_call(moved.new_thumbnail_path)
|
||||
fsync_dir.assert_any_call(moved.new_path.parent)
|
||||
fsync_dir.assert_any_call(moved.old_path.parent)
|
||||
fsync_dir.assert_any_call(moved.new_thumbnail_path.parent)
|
||||
fsync_dir.assert_any_call(moved.old_thumbnail_path.parent)
|
||||
|
||||
|
||||
def test_fsync_dir_ignores_platform_close_failures(tmp_path: Path) -> None:
|
||||
service, _records = _service(tmp_path, strategy="date")
|
||||
|
||||
with (
|
||||
patch("invokeai.app.services.image_moves.image_moves_default.os.open", return_value=123),
|
||||
patch(
|
||||
"invokeai.app.services.image_moves.image_moves_default.os.fsync",
|
||||
side_effect=OSError(9, "Bad file descriptor"),
|
||||
),
|
||||
patch(
|
||||
"invokeai.app.services.image_moves.image_moves_default.os.close",
|
||||
side_effect=OSError(9, "Bad file descriptor"),
|
||||
),
|
||||
):
|
||||
service._fsync_dir(tmp_path)
|
||||
|
||||
|
||||
def test_fsync_file_ignores_platform_fsync_failures(tmp_path: Path) -> None:
|
||||
service, _records = _service(tmp_path, strategy="date")
|
||||
path = tmp_path / "image.png"
|
||||
path.write_bytes(b"test")
|
||||
|
||||
with patch(
|
||||
"invokeai.app.services.image_moves.image_moves_default.os.fsync",
|
||||
side_effect=OSError(9, "Bad file descriptor"),
|
||||
):
|
||||
service._fsync_file(path)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""DB-backed tests for SqliteImageRecordStorage.
|
||||
|
||||
Verifies that image_subfolder round-trips correctly through save(), get(),
|
||||
get_many(), and delete_intermediates() against a real (in-memory) SQLite database.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin
|
||||
from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
from tests.fixtures.sqlite_database import create_mock_sqlite_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> SqliteImageRecordStorage:
|
||||
config = InvokeAIAppConfig(use_memory_db=True)
|
||||
logger = InvokeAILogger.get_logger(config=config)
|
||||
db = create_mock_sqlite_database(config, logger)
|
||||
return SqliteImageRecordStorage(db=db)
|
||||
|
||||
|
||||
def _save(store: SqliteImageRecordStorage, name: str, subfolder: str = "", is_intermediate: bool = False) -> None:
|
||||
store.save(
|
||||
image_name=name,
|
||||
image_origin=ResourceOrigin.INTERNAL,
|
||||
image_category=ImageCategory.GENERAL,
|
||||
width=64,
|
||||
height=64,
|
||||
has_workflow=False,
|
||||
is_intermediate=is_intermediate,
|
||||
image_subfolder=subfolder,
|
||||
)
|
||||
|
||||
|
||||
class TestImageSubfolderRoundTrip:
|
||||
"""save() -> get() preserves image_subfolder."""
|
||||
|
||||
def test_default_empty_subfolder(self, store: SqliteImageRecordStorage) -> None:
|
||||
_save(store, "img_default.png")
|
||||
record = store.get("img_default.png")
|
||||
assert record.image_subfolder == ""
|
||||
|
||||
def test_custom_subfolder(self, store: SqliteImageRecordStorage) -> None:
|
||||
_save(store, "img_sub.png", subfolder="2026/04/11")
|
||||
record = store.get("img_sub.png")
|
||||
assert record.image_subfolder == "2026/04/11"
|
||||
|
||||
def test_nested_subfolder(self, store: SqliteImageRecordStorage) -> None:
|
||||
_save(store, "img_nested.png", subfolder="a/b/c/d")
|
||||
record = store.get("img_nested.png")
|
||||
assert record.image_subfolder == "a/b/c/d"
|
||||
|
||||
|
||||
class TestGetManySubfolder:
|
||||
"""get_many() deserializes image_subfolder for every row."""
|
||||
|
||||
def test_get_many_returns_subfolders(self, store: SqliteImageRecordStorage) -> None:
|
||||
_save(store, "flat.png", subfolder="")
|
||||
_save(store, "dated.png", subfolder="2026/01")
|
||||
_save(store, "hashed.png", subfolder="ab")
|
||||
|
||||
result = store.get_many(limit=10, order_dir=SQLiteDirection.Ascending)
|
||||
by_name = {r.image_name: r.image_subfolder for r in result.items}
|
||||
|
||||
assert by_name["flat.png"] == ""
|
||||
assert by_name["dated.png"] == "2026/01"
|
||||
assert by_name["hashed.png"] == "ab"
|
||||
|
||||
|
||||
class TestDeleteIntermediatesSubfolder:
|
||||
"""delete_intermediates() returns (name, subfolder) pairs and removes rows."""
|
||||
|
||||
def test_returns_subfolder_pairs(self, store: SqliteImageRecordStorage) -> None:
|
||||
_save(store, "keep.png", subfolder="general", is_intermediate=False)
|
||||
_save(store, "tmp1.png", subfolder="intermediate", is_intermediate=True)
|
||||
_save(store, "tmp2.png", subfolder="intermediate", is_intermediate=True)
|
||||
|
||||
pairs = store.delete_intermediates()
|
||||
|
||||
# Should return only intermediate images with their subfolders
|
||||
assert len(pairs) == 2
|
||||
names_and_subs = set(pairs)
|
||||
assert ("tmp1.png", "intermediate") in names_and_subs
|
||||
assert ("tmp2.png", "intermediate") in names_and_subs
|
||||
|
||||
# Non-intermediate image should still exist
|
||||
record = store.get("keep.png")
|
||||
assert record.image_subfolder == "general"
|
||||
|
||||
def test_intermediates_are_deleted(self, store: SqliteImageRecordStorage) -> None:
|
||||
_save(store, "tmp.png", subfolder="x", is_intermediate=True)
|
||||
store.delete_intermediates()
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException
|
||||
|
||||
with pytest.raises(ImageRecordNotFoundException):
|
||||
store.get("tmp.png")
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Tests for ImageService (images_default.py).
|
||||
|
||||
Covers subfolder forwarding for all strategies and the delete_images_on_board
|
||||
silent-failure contract (Points 2 & 3 from PR review).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.services.image_records.image_records_common import (
|
||||
ImageCategory,
|
||||
ImageRecord,
|
||||
ResourceOrigin,
|
||||
)
|
||||
from invokeai.app.services.images.images_default import ImageService
|
||||
from invokeai.app.util.misc import get_iso_timestamp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_service() -> ImageService:
|
||||
svc = ImageService()
|
||||
invoker = MagicMock()
|
||||
|
||||
# Wire up service references
|
||||
invoker.services.names.create_image_name.return_value = "abc12345-test.png"
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder="some/sub")
|
||||
invoker.services.board_image_records.get_board_for_image.return_value = None
|
||||
invoker.services.urls.get_image_url.return_value = "http://localhost/img.png"
|
||||
invoker.services.configuration.image_subfolder_strategy = "flat"
|
||||
|
||||
svc.start(invoker)
|
||||
return svc
|
||||
|
||||
|
||||
def _make_record(
|
||||
image_name: str = "abc12345-test.png",
|
||||
image_subfolder: str = "",
|
||||
is_intermediate: bool = False,
|
||||
) -> ImageRecord:
|
||||
now = get_iso_timestamp()
|
||||
return ImageRecord(
|
||||
image_name=image_name,
|
||||
image_origin=ResourceOrigin.INTERNAL,
|
||||
image_category=ImageCategory.GENERAL,
|
||||
width=64,
|
||||
height=64,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
is_intermediate=is_intermediate,
|
||||
starred=False,
|
||||
has_workflow=False,
|
||||
image_subfolder=image_subfolder,
|
||||
)
|
||||
|
||||
|
||||
# ── Point 2: subfolder forwarding tests ──
|
||||
|
||||
|
||||
class TestCreateSubfolderForwarding:
|
||||
"""Verify that create() computes and forwards the correct subfolder for each strategy."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"strategy_name,expected_subfolder",
|
||||
[
|
||||
("flat", ""),
|
||||
("type", "general"),
|
||||
("hash", "ab"), # first 2 chars of "abc12345-test.png"
|
||||
],
|
||||
ids=["flat", "type", "hash"],
|
||||
)
|
||||
def test_create_forwards_subfolder(self, image_service: ImageService, strategy_name: str, expected_subfolder: str):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.configuration.image_subfolder_strategy = strategy_name
|
||||
|
||||
# Make get_dto work by returning a record with the expected subfolder
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder=expected_subfolder)
|
||||
|
||||
image = Image.new("RGB", (64, 64))
|
||||
image_service.create(
|
||||
image=image,
|
||||
image_origin=ResourceOrigin.INTERNAL,
|
||||
image_category=ImageCategory.GENERAL,
|
||||
)
|
||||
|
||||
# Assert image_records.save was called with the right subfolder
|
||||
save_call = invoker.services.image_records.save
|
||||
save_call.assert_called_once()
|
||||
assert save_call.call_args.kwargs["image_subfolder"] == expected_subfolder
|
||||
|
||||
# Assert image_files.save was called with the same subfolder
|
||||
file_save = invoker.services.image_files.save
|
||||
file_save.assert_called_once()
|
||||
assert file_save.call_args.kwargs["image_subfolder"] == expected_subfolder
|
||||
|
||||
def test_create_date_strategy_produces_date_subfolder(self, image_service: ImageService):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.configuration.image_subfolder_strategy = "date"
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder="2026/04/05")
|
||||
|
||||
image = Image.new("RGB", (64, 64))
|
||||
image_service.create(
|
||||
image=image,
|
||||
image_origin=ResourceOrigin.INTERNAL,
|
||||
image_category=ImageCategory.GENERAL,
|
||||
)
|
||||
|
||||
subfolder = invoker.services.image_records.save.call_args.kwargs["image_subfolder"]
|
||||
# Date strategy should produce YYYY/MM/DD format
|
||||
parts = subfolder.split("/")
|
||||
assert len(parts) == 3
|
||||
assert all(p.isdigit() for p in parts)
|
||||
|
||||
def test_create_type_strategy_intermediate(self, image_service: ImageService):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.configuration.image_subfolder_strategy = "type"
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder="intermediate")
|
||||
|
||||
image = Image.new("RGB", (64, 64))
|
||||
image_service.create(
|
||||
image=image,
|
||||
image_origin=ResourceOrigin.INTERNAL,
|
||||
image_category=ImageCategory.GENERAL,
|
||||
is_intermediate=True,
|
||||
)
|
||||
|
||||
subfolder = invoker.services.image_records.save.call_args.kwargs["image_subfolder"]
|
||||
assert subfolder == "intermediate"
|
||||
|
||||
|
||||
class TestReadOperationsForwardSubfolder:
|
||||
"""Verify that read operations look up the record and forward image_subfolder."""
|
||||
|
||||
def test_get_pil_image(self, image_service: ImageService):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder="2026/01/01")
|
||||
|
||||
image_service.get_pil_image("test.png")
|
||||
|
||||
invoker.services.image_files.get.assert_called_once_with("test.png", image_subfolder="2026/01/01")
|
||||
|
||||
def test_get_workflow(self, image_service: ImageService):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder="general")
|
||||
|
||||
image_service.get_workflow("test.png")
|
||||
|
||||
invoker.services.image_files.get_workflow.assert_called_once_with("test.png", image_subfolder="general")
|
||||
|
||||
def test_get_graph(self, image_service: ImageService):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder="general")
|
||||
|
||||
image_service.get_graph("test.png")
|
||||
|
||||
invoker.services.image_files.get_graph.assert_called_once_with("test.png", image_subfolder="general")
|
||||
|
||||
def test_get_path(self, image_service: ImageService):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder="ab")
|
||||
|
||||
image_service.get_path("test.png")
|
||||
|
||||
invoker.services.image_files.get_path.assert_called_once_with("test.png", False, image_subfolder="ab")
|
||||
|
||||
def test_get_path_thumbnail(self, image_service: ImageService):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder="ab")
|
||||
|
||||
image_service.get_path("test.png", thumbnail=True)
|
||||
|
||||
invoker.services.image_files.get_path.assert_called_once_with("test.png", True, image_subfolder="ab")
|
||||
|
||||
|
||||
class TestDeleteForwardsSubfolder:
|
||||
"""Verify that delete operations forward image_subfolder."""
|
||||
|
||||
def test_delete_forwards_subfolder(self, image_service: ImageService):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.image_records.get.return_value = _make_record(image_subfolder="2026/04/05")
|
||||
|
||||
image_service.delete("test.png")
|
||||
|
||||
invoker.services.image_files.delete.assert_called_once_with("test.png", image_subfolder="2026/04/05")
|
||||
invoker.services.image_records.delete.assert_called_once_with("test.png")
|
||||
|
||||
def test_delete_intermediates_forwards_subfolder(self, image_service: ImageService):
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.image_records.delete_intermediates.return_value = [
|
||||
("img1.png", "intermediate"),
|
||||
("img2.png", "intermediate"),
|
||||
]
|
||||
|
||||
count = image_service.delete_intermediates()
|
||||
|
||||
assert count == 2
|
||||
calls = invoker.services.image_files.delete.call_args_list
|
||||
assert calls[0].args == ("img1.png",)
|
||||
assert calls[0].kwargs == {"image_subfolder": "intermediate"}
|
||||
assert calls[1].args == ("img2.png",)
|
||||
assert calls[1].kwargs == {"image_subfolder": "intermediate"}
|
||||
|
||||
|
||||
# ── Point 3: delete_images_on_board silent-failure contract ──
|
||||
|
||||
|
||||
class TestDeleteImagesOnBoardContract:
|
||||
"""Tests for the silent-failure behavior of delete_images_on_board."""
|
||||
|
||||
def test_db_rows_deleted_even_when_file_delete_fails(self, image_service: ImageService):
|
||||
"""Current behavior: DB rows are deleted even if file cleanup fails for some images.
|
||||
This test documents the contract so any change is intentional."""
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.board_image_records.get_all_board_image_names_for_board.return_value = [
|
||||
"good.png",
|
||||
"bad.png",
|
||||
]
|
||||
|
||||
# First image record lookup succeeds, second fails
|
||||
good_record = _make_record(image_name="good.png", image_subfolder="general")
|
||||
bad_record = _make_record(image_name="bad.png", image_subfolder="bad/path")
|
||||
|
||||
invoker.services.image_records.get.side_effect = [good_record, bad_record]
|
||||
# File delete succeeds for first, fails for second
|
||||
invoker.services.image_files.delete.side_effect = [None, Exception("disk error")]
|
||||
|
||||
image_service.delete_images_on_board("board-1")
|
||||
|
||||
# DB rows are still deleted for all images
|
||||
invoker.services.image_records.delete_many.assert_called_once_with(["good.png", "bad.png"])
|
||||
|
||||
def test_file_cleanup_failure_does_not_raise(self, image_service: ImageService):
|
||||
"""File cleanup errors are swallowed, not propagated."""
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.board_image_records.get_all_board_image_names_for_board.return_value = ["img.png"]
|
||||
|
||||
record = _make_record(image_name="img.png", image_subfolder="sub")
|
||||
invoker.services.image_records.get.return_value = record
|
||||
invoker.services.image_files.delete.side_effect = Exception("permission denied")
|
||||
|
||||
# Should not raise
|
||||
image_service.delete_images_on_board("board-1")
|
||||
|
||||
# DB delete still happens
|
||||
invoker.services.image_records.delete_many.assert_called_once()
|
||||
|
||||
def test_record_lookup_failure_does_not_block_others(self, image_service: ImageService):
|
||||
"""If getting the record for one image fails, other images are still processed."""
|
||||
invoker = image_service._ImageService__invoker # type: ignore
|
||||
invoker.services.board_image_records.get_all_board_image_names_for_board.return_value = [
|
||||
"missing.png",
|
||||
"ok.png",
|
||||
]
|
||||
|
||||
ok_record = _make_record(image_name="ok.png", image_subfolder="")
|
||||
invoker.services.image_records.get.side_effect = [Exception("not found"), ok_record]
|
||||
|
||||
image_service.delete_images_on_board("board-1")
|
||||
|
||||
# File delete was attempted for the second image only
|
||||
invoker.services.image_files.delete.assert_called_once_with("ok.png", image_subfolder="")
|
||||
# DB rows are still deleted for all
|
||||
invoker.services.image_records.delete_many.assert_called_once_with(["missing.png", "ok.png"])
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Tests for missing model detection (_scan_for_missing_models) and bulk deletion.
|
||||
"""
|
||||
|
||||
import gc
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.config import InvokeAIAppConfig
|
||||
from invokeai.app.services.model_install import ModelInstallServiceBase
|
||||
from invokeai.app.services.model_records import UnknownModelException
|
||||
from invokeai.backend.model_manager.configs.textual_inversion import TI_File_SD1_Config
|
||||
from invokeai.backend.model_manager.taxonomy import (
|
||||
BaseModelType,
|
||||
ModelFormat,
|
||||
ModelSourceType,
|
||||
ModelType,
|
||||
)
|
||||
from tests.backend.model_manager.model_manager_fixtures import * # noqa F403
|
||||
|
||||
|
||||
class TestScanForMissingModels:
|
||||
"""Tests for ModelInstallService._scan_for_missing_models()."""
|
||||
|
||||
def test_no_missing_models(
|
||||
self, mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
"""When all registered models exist on disk, _scan_for_missing_models returns an empty list."""
|
||||
mm2_installer.register_path(embedding_file)
|
||||
missing = mm2_installer._scan_for_missing_models()
|
||||
assert len(missing) == 0
|
||||
|
||||
def test_detects_missing_model(
|
||||
self, mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
"""A model whose path does not exist on disk is reported as missing."""
|
||||
# Register a real model first, then add a fake one with a non-existent path
|
||||
mm2_installer.register_path(embedding_file)
|
||||
|
||||
fake_config = TI_File_SD1_Config(
|
||||
key="missing-model-key-1",
|
||||
path="/nonexistent/path/missing_model.safetensors",
|
||||
name="MissingModel",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.TextualInversion,
|
||||
format=ModelFormat.EmbeddingFile,
|
||||
hash="FAKEHASH1",
|
||||
file_size=1024,
|
||||
source="test/source",
|
||||
source_type=ModelSourceType.Path,
|
||||
)
|
||||
mm2_installer.record_store.add_model(fake_config)
|
||||
|
||||
missing = mm2_installer._scan_for_missing_models()
|
||||
assert len(missing) == 1
|
||||
assert missing[0].key == "missing-model-key-1"
|
||||
|
||||
def test_mix_of_existing_and_missing(
|
||||
self,
|
||||
mm2_installer: ModelInstallServiceBase,
|
||||
embedding_file: Path,
|
||||
diffusers_dir: Path,
|
||||
mm2_app_config: InvokeAIAppConfig,
|
||||
) -> None:
|
||||
"""With multiple models, only the ones with missing files are returned."""
|
||||
key_existing = mm2_installer.register_path(embedding_file)
|
||||
mm2_installer.register_path(diffusers_dir)
|
||||
|
||||
# Add two models with non-existent paths
|
||||
fake1 = TI_File_SD1_Config(
|
||||
key="missing-key-1",
|
||||
path="/nonexistent/missing1.safetensors",
|
||||
name="Missing1",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.TextualInversion,
|
||||
format=ModelFormat.EmbeddingFile,
|
||||
hash="FAKEHASH_A",
|
||||
file_size=1024,
|
||||
source="test/source1",
|
||||
source_type=ModelSourceType.Path,
|
||||
)
|
||||
fake2 = TI_File_SD1_Config(
|
||||
key="missing-key-2",
|
||||
path="/nonexistent/missing2.safetensors",
|
||||
name="Missing2",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.TextualInversion,
|
||||
format=ModelFormat.EmbeddingFile,
|
||||
hash="FAKEHASH_B",
|
||||
file_size=2048,
|
||||
source="test/source2",
|
||||
source_type=ModelSourceType.Path,
|
||||
)
|
||||
mm2_installer.record_store.add_model(fake1)
|
||||
mm2_installer.record_store.add_model(fake2)
|
||||
|
||||
missing = mm2_installer._scan_for_missing_models()
|
||||
missing_keys = {m.key for m in missing}
|
||||
assert len(missing) == 2
|
||||
assert "missing-key-1" in missing_keys
|
||||
assert "missing-key-2" in missing_keys
|
||||
assert key_existing not in missing_keys
|
||||
|
||||
def test_empty_store_returns_empty(self, mm2_installer: ModelInstallServiceBase) -> None:
|
||||
"""With no models registered, _scan_for_missing_models returns an empty list."""
|
||||
missing = mm2_installer._scan_for_missing_models()
|
||||
assert len(missing) == 0
|
||||
|
||||
|
||||
class TestBulkDelete:
|
||||
"""Tests for bulk model deletion."""
|
||||
|
||||
def test_delete_installed_model(
|
||||
self, mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
"""Deleting an installed model removes it from the store and disk."""
|
||||
key = mm2_installer.install_path(embedding_file)
|
||||
record = mm2_installer.record_store.get_model(key)
|
||||
model_path = mm2_app_config.models_path / record.path
|
||||
assert model_path.exists()
|
||||
assert mm2_installer.record_store.exists(key)
|
||||
|
||||
gc.collect()
|
||||
mm2_installer.delete(key)
|
||||
|
||||
with pytest.raises(UnknownModelException):
|
||||
mm2_installer.record_store.get_model(key)
|
||||
|
||||
def test_unregister_missing_model(
|
||||
self, mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
"""Unregistering a model whose file is missing removes it from the DB."""
|
||||
fake_config = TI_File_SD1_Config(
|
||||
key="missing-to-delete",
|
||||
path="/nonexistent/path/gone.safetensors",
|
||||
name="GoneModel",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.TextualInversion,
|
||||
format=ModelFormat.EmbeddingFile,
|
||||
hash="FAKEHASH_GONE",
|
||||
file_size=1024,
|
||||
source="test/source",
|
||||
source_type=ModelSourceType.Path,
|
||||
)
|
||||
mm2_installer.record_store.add_model(fake_config)
|
||||
assert mm2_installer.record_store.exists("missing-to-delete")
|
||||
|
||||
# Unregister removes it from DB without touching disk
|
||||
mm2_installer.unregister("missing-to-delete")
|
||||
|
||||
with pytest.raises(UnknownModelException):
|
||||
mm2_installer.record_store.get_model("missing-to-delete")
|
||||
|
||||
def test_delete_unknown_key_raises(self, mm2_installer: ModelInstallServiceBase) -> None:
|
||||
"""Deleting a model with an unknown key raises UnknownModelException."""
|
||||
with pytest.raises(UnknownModelException):
|
||||
mm2_installer.delete("nonexistent-key-12345")
|
||||
|
||||
def test_scan_then_unregister_clears_missing(
|
||||
self, mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
"""After unregistering all missing models, _scan_for_missing_models returns empty."""
|
||||
# Add two models with non-existent paths
|
||||
for i in range(2):
|
||||
config = TI_File_SD1_Config(
|
||||
key=f"missing-bulk-{i}",
|
||||
path=f"/nonexistent/bulk_{i}.safetensors",
|
||||
name=f"BulkMissing{i}",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.TextualInversion,
|
||||
format=ModelFormat.EmbeddingFile,
|
||||
hash=f"BULKHASH{i}",
|
||||
file_size=1024,
|
||||
source=f"test/bulk{i}",
|
||||
source_type=ModelSourceType.Path,
|
||||
)
|
||||
mm2_installer.record_store.add_model(config)
|
||||
|
||||
missing = mm2_installer._scan_for_missing_models()
|
||||
assert len(missing) == 2
|
||||
|
||||
# Unregister all missing (simulates bulk delete for missing models)
|
||||
for model in missing:
|
||||
mm2_installer.unregister(model.key)
|
||||
|
||||
assert len(mm2_installer._scan_for_missing_models()) == 0
|
||||
|
||||
def test_bulk_unregister_does_not_affect_existing_models(
|
||||
self,
|
||||
mm2_installer: ModelInstallServiceBase,
|
||||
embedding_file: Path,
|
||||
mm2_app_config: InvokeAIAppConfig,
|
||||
) -> None:
|
||||
"""Unregistering missing models does not affect models that exist on disk."""
|
||||
existing_key = mm2_installer.register_path(embedding_file)
|
||||
|
||||
fake_config = TI_File_SD1_Config(
|
||||
key="missing-selective",
|
||||
path="/nonexistent/selective.safetensors",
|
||||
name="SelectiveMissing",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.TextualInversion,
|
||||
format=ModelFormat.EmbeddingFile,
|
||||
hash="SELECTIVEHASH",
|
||||
file_size=1024,
|
||||
source="test/selective",
|
||||
source_type=ModelSourceType.Path,
|
||||
)
|
||||
mm2_installer.record_store.add_model(fake_config)
|
||||
|
||||
# Only unregister the missing one
|
||||
missing = mm2_installer._scan_for_missing_models()
|
||||
assert len(missing) == 1
|
||||
for model in missing:
|
||||
mm2_installer.unregister(model.key)
|
||||
|
||||
# Existing model should still be there
|
||||
assert mm2_installer.record_store.exists(existing_key)
|
||||
assert len(mm2_installer._scan_for_missing_models()) == 0
|
||||
@@ -0,0 +1,600 @@
|
||||
"""
|
||||
Test the model installer
|
||||
"""
|
||||
|
||||
import gc
|
||||
import platform
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
from pydantic_core import Url
|
||||
|
||||
from invokeai.app.services.config import InvokeAIAppConfig
|
||||
from invokeai.app.services.events.events_base import EventServiceBase
|
||||
from invokeai.app.services.events.events_common import (
|
||||
ModelInstallCompleteEvent,
|
||||
ModelInstallDownloadProgressEvent,
|
||||
ModelInstallDownloadsCompleteEvent,
|
||||
ModelInstallDownloadStartedEvent,
|
||||
ModelInstallErrorEvent,
|
||||
ModelInstallStartedEvent,
|
||||
)
|
||||
from invokeai.app.services.model_install import (
|
||||
HFModelSource,
|
||||
ModelInstallService,
|
||||
ModelInstallServiceBase,
|
||||
)
|
||||
from invokeai.app.services.model_install.model_install_common import (
|
||||
InstallStatus,
|
||||
InvalidModelConfigException,
|
||||
LocalModelSource,
|
||||
ModelInstallJob,
|
||||
URLModelSource,
|
||||
)
|
||||
from invokeai.app.services.model_records import ModelRecordChanges, UnknownModelException
|
||||
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig
|
||||
from invokeai.backend.model_manager.taxonomy import (
|
||||
BaseModelType,
|
||||
ModelFormat,
|
||||
ModelRepoVariant,
|
||||
ModelSourceType,
|
||||
ModelType,
|
||||
)
|
||||
from tests.backend.model_manager.model_manager_fixtures import * # noqa F403
|
||||
from tests.test_nodes import TestEventService
|
||||
|
||||
OS = platform.uname().system
|
||||
|
||||
|
||||
def test_registration(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None:
|
||||
store = mm2_installer.record_store
|
||||
matches = store.search_by_attr(model_name="test_embedding")
|
||||
assert len(matches) == 0
|
||||
key = mm2_installer.register_path(embedding_file)
|
||||
# Not raising here is sufficient - key should be UUIDv4
|
||||
uuid.UUID(key, version=4)
|
||||
|
||||
|
||||
def test_registration_meta(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None:
|
||||
store = mm2_installer.record_store
|
||||
key = mm2_installer.register_path(embedding_file)
|
||||
model_record = store.get_model(key)
|
||||
assert model_record is not None
|
||||
assert model_record.name == "test_embedding"
|
||||
assert model_record.type == ModelType.TextualInversion
|
||||
assert Path(model_record.path) == embedding_file
|
||||
assert Path(model_record.path).exists()
|
||||
assert model_record.base == BaseModelType("sd-1")
|
||||
assert model_record.description is None
|
||||
assert model_record.source is not None
|
||||
assert Path(model_record.source) == embedding_file
|
||||
|
||||
|
||||
def test_registration_meta_override_fail(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None:
|
||||
with pytest.raises(InvalidModelConfigException):
|
||||
mm2_installer.register_path(embedding_file, ModelRecordChanges(name="banana_sushi", type=ModelType("lora")))
|
||||
|
||||
|
||||
def test_registration_meta_override_succeed(mm2_installer: ModelInstallServiceBase, embedding_file: Path) -> None:
|
||||
store = mm2_installer.record_store
|
||||
key = mm2_installer.register_path(
|
||||
embedding_file, ModelRecordChanges(name="banana_sushi", source="fake/repo_id", key="xyzzy")
|
||||
)
|
||||
model_record = store.get_model(key)
|
||||
assert model_record.name == "banana_sushi"
|
||||
assert model_record.source == "fake/repo_id"
|
||||
assert model_record.key == "xyzzy"
|
||||
|
||||
|
||||
def test_install(
|
||||
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
store = mm2_installer.record_store
|
||||
key = mm2_installer.install_path(embedding_file)
|
||||
model_record = store.get_model(key)
|
||||
assert model_record.path.endswith(f"{key}/test_embedding.safetensors")
|
||||
assert (mm2_app_config.models_path / model_record.path).exists()
|
||||
assert model_record.source == embedding_file.as_posix()
|
||||
|
||||
|
||||
def test_rename(
|
||||
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
store = mm2_installer.record_store
|
||||
key = mm2_installer.install_path(embedding_file)
|
||||
model_record = store.get_model(key)
|
||||
assert model_record.path.endswith(f"{key}/test_embedding.safetensors")
|
||||
new_model_record = store.update_model(
|
||||
key,
|
||||
ModelRecordChanges(name="new model name", base=BaseModelType.StableDiffusion2),
|
||||
allow_class_change=True,
|
||||
)
|
||||
# Renaming the model record shouldn't rename the file
|
||||
assert new_model_record.name == "new model name"
|
||||
assert model_record.path.endswith(f"{key}/test_embedding.safetensors")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fixture_name,size,key,destination",
|
||||
[
|
||||
("embedding_file", 15440, "foo", "foo/test_embedding.safetensors"),
|
||||
("diffusers_dir", 8241 if OS == "Windows" else 7907, "bar", "bar"), # EOL chars
|
||||
],
|
||||
)
|
||||
def test_background_install(
|
||||
mm2_installer: ModelInstallServiceBase,
|
||||
fixture_name: str,
|
||||
key: str,
|
||||
size: int,
|
||||
destination: str,
|
||||
mm2_app_config: InvokeAIAppConfig,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
"""Note: may want to break this down into several smaller unit tests."""
|
||||
path: Path = request.getfixturevalue(fixture_name)
|
||||
description = "Test of metadata assignment"
|
||||
source = LocalModelSource(path=path, inplace=False)
|
||||
job = mm2_installer.import_model(source, config=ModelRecordChanges(key=key, description=description))
|
||||
assert job is not None
|
||||
assert isinstance(job, ModelInstallJob)
|
||||
|
||||
# See if job is registered properly
|
||||
assert job in mm2_installer.get_job_by_source(source)
|
||||
|
||||
# test that the job object tracked installation correctly
|
||||
jobs = mm2_installer.wait_for_installs()
|
||||
assert len(jobs) > 0
|
||||
my_job = [x for x in jobs if x.source == source]
|
||||
assert len(my_job) == 1
|
||||
assert job == my_job[0]
|
||||
assert job.status == InstallStatus.COMPLETED
|
||||
assert job.total_bytes == size
|
||||
|
||||
# test that the expected events were issued
|
||||
bus: TestEventService = mm2_installer.event_bus
|
||||
assert bus
|
||||
assert hasattr(bus, "events")
|
||||
|
||||
assert len(bus.events) == 2
|
||||
assert isinstance(bus.events[0], ModelInstallStartedEvent)
|
||||
assert isinstance(bus.events[1], ModelInstallCompleteEvent)
|
||||
assert Path(bus.events[0].source.path) == source
|
||||
assert Path(bus.events[1].source.path) == source
|
||||
key = bus.events[1].key
|
||||
assert key is not None
|
||||
|
||||
# see if the thing actually got installed at the expected location
|
||||
model_record = mm2_installer.record_store.get_model(key)
|
||||
assert model_record is not None
|
||||
assert model_record.path.endswith(destination)
|
||||
assert (mm2_app_config.models_path / model_record.path).exists()
|
||||
|
||||
# see if metadata was properly passed through
|
||||
assert model_record.description == description
|
||||
|
||||
# see if job filtering works
|
||||
assert mm2_installer.get_job_by_source(source)[0] == job
|
||||
|
||||
# see if prune works properly
|
||||
mm2_installer.prune_jobs()
|
||||
assert not mm2_installer.get_job_by_source(source)
|
||||
|
||||
|
||||
def test_not_inplace_install(
|
||||
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
# An non in-place install will/should call `register_path()` internally
|
||||
source = LocalModelSource(path=embedding_file, inplace=False)
|
||||
job = mm2_installer.import_model(source)
|
||||
mm2_installer.wait_for_installs()
|
||||
assert job is not None
|
||||
assert job.config_out is not None
|
||||
# Non in-place install should _move_ the model from the original location to the models path
|
||||
# The model config's path should be different from the original file
|
||||
assert Path(job.config_out.path) != embedding_file
|
||||
# Original file should _not_ exist after install
|
||||
assert not embedding_file.exists()
|
||||
assert (mm2_app_config.models_path / job.config_out.path).exists()
|
||||
|
||||
|
||||
def test_inplace_install(
|
||||
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
# An in-place install will/should call `install_path()` internally
|
||||
source = LocalModelSource(path=embedding_file, inplace=True)
|
||||
job = mm2_installer.import_model(source)
|
||||
mm2_installer.wait_for_installs()
|
||||
assert job is not None
|
||||
assert job.config_out is not None
|
||||
# In-place install should not touch the model file, just register it
|
||||
# The model config's path should be the same as the original file
|
||||
assert Path(job.config_out.path) == embedding_file
|
||||
# Model file should still exist after install
|
||||
assert embedding_file.exists()
|
||||
assert Path(job.config_out.path).exists()
|
||||
|
||||
|
||||
def test_external_install(mm2_installer: ModelInstallServiceBase) -> None:
|
||||
config = ModelRecordChanges(name="ChatGPT Image", description="External model", key="chatgpt_image")
|
||||
job = mm2_installer.heuristic_import("external://openai/gpt-image-1", config=config)
|
||||
|
||||
mm2_installer.wait_for_installs()
|
||||
|
||||
assert job.status == InstallStatus.COMPLETED
|
||||
assert job.config_out is not None
|
||||
assert isinstance(job.config_out, ExternalApiModelConfig)
|
||||
assert job.config_out.provider_id == "openai"
|
||||
assert job.config_out.provider_model_id == "gpt-image-1"
|
||||
assert job.config_out.base == BaseModelType.External
|
||||
assert job.config_out.type == ModelType.ExternalImageGenerator
|
||||
assert job.config_out.source_type == ModelSourceType.External
|
||||
|
||||
|
||||
def test_external_install_is_idempotent(mm2_installer: ModelInstallServiceBase) -> None:
|
||||
first_job = mm2_installer.heuristic_import(
|
||||
"external://openai/gpt-image-1",
|
||||
config=ModelRecordChanges(name="Initial name"),
|
||||
)
|
||||
mm2_installer.wait_for_installs()
|
||||
|
||||
second_job = mm2_installer.heuristic_import(
|
||||
"external://openai/gpt-image-1",
|
||||
config=ModelRecordChanges(name="Updated name"),
|
||||
)
|
||||
mm2_installer.wait_for_installs()
|
||||
|
||||
assert first_job.status == InstallStatus.COMPLETED
|
||||
assert second_job.status == InstallStatus.COMPLETED
|
||||
assert first_job.config_out is not None
|
||||
assert second_job.config_out is not None
|
||||
assert first_job.config_out.key == second_job.config_out.key
|
||||
|
||||
external_models = mm2_installer.record_store.search_by_attr(
|
||||
base_model=BaseModelType.External,
|
||||
model_type=ModelType.ExternalImageGenerator,
|
||||
)
|
||||
assert len(external_models) == 1
|
||||
assert isinstance(external_models[0], ExternalApiModelConfig)
|
||||
assert external_models[0].name == "Updated name"
|
||||
|
||||
|
||||
def test_delete_install(
|
||||
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
store = mm2_installer.record_store
|
||||
key = mm2_installer.install_path(embedding_file) # non in-place install
|
||||
model_record = store.get_model(key)
|
||||
assert (mm2_app_config.models_path / model_record.path).exists()
|
||||
assert not embedding_file.exists()
|
||||
# ensure file handles are released on Windows
|
||||
gc.collect()
|
||||
mm2_installer.delete(key)
|
||||
# after deletion, installed copy should not exist
|
||||
assert not (mm2_app_config.models_path / model_record.path).exists()
|
||||
with pytest.raises(UnknownModelException):
|
||||
store.get_model(key)
|
||||
|
||||
|
||||
def test_delete_register(
|
||||
mm2_installer: ModelInstallServiceBase, embedding_file: Path, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
store = mm2_installer.record_store
|
||||
key = mm2_installer.register_path(embedding_file) # in-place install
|
||||
model_record = store.get_model(key)
|
||||
assert Path(model_record.path).exists()
|
||||
assert embedding_file.exists()
|
||||
mm2_installer.delete(key)
|
||||
assert Path(model_record.path).exists()
|
||||
with pytest.raises(UnknownModelException):
|
||||
store.get_model(key)
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_simple_download(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None:
|
||||
source = URLModelSource(url=Url("https://www.test.foo/download/test_embedding.safetensors"))
|
||||
|
||||
bus: TestEventService = mm2_installer.event_bus
|
||||
store = mm2_installer.record_store
|
||||
assert store is not None
|
||||
assert bus is not None
|
||||
assert hasattr(bus, "events") # the dummy event service has this
|
||||
|
||||
job = mm2_installer.import_model(source)
|
||||
assert job.source == source
|
||||
job_list = mm2_installer.wait_for_installs(timeout=10)
|
||||
assert len(job_list) == 1
|
||||
assert job.complete
|
||||
assert job.config_out
|
||||
|
||||
key = job.config_out.key
|
||||
model_record = store.get_model(key)
|
||||
assert (mm2_app_config.models_path / model_record.path).exists()
|
||||
|
||||
assert len(bus.events) == 5
|
||||
assert isinstance(bus.events[0], ModelInstallDownloadStartedEvent) # download starts
|
||||
assert isinstance(bus.events[1], ModelInstallDownloadProgressEvent) # download progresses
|
||||
assert isinstance(bus.events[2], ModelInstallDownloadsCompleteEvent) # download completed
|
||||
assert isinstance(bus.events[3], ModelInstallStartedEvent) # install started
|
||||
assert isinstance(bus.events[4], ModelInstallCompleteEvent) # install completed
|
||||
|
||||
|
||||
def test_import_waits_for_startup_restore(
|
||||
mm2_app_config: InvokeAIAppConfig,
|
||||
mm2_record_store,
|
||||
mm2_download_queue,
|
||||
mm2_session,
|
||||
embedding_file: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
installer = ModelInstallService(
|
||||
app_config=mm2_app_config,
|
||||
record_store=mm2_record_store,
|
||||
download_queue=mm2_download_queue,
|
||||
event_bus=TestEventService(),
|
||||
session=mm2_session,
|
||||
)
|
||||
restore_started = threading.Event()
|
||||
release_restore = threading.Event()
|
||||
imported = threading.Event()
|
||||
|
||||
def _blocked_restore() -> None:
|
||||
restore_started.set()
|
||||
assert release_restore.wait(timeout=5)
|
||||
|
||||
monkeypatch.setattr(installer, "_restore_incomplete_installs", _blocked_restore)
|
||||
|
||||
try:
|
||||
installer.start()
|
||||
assert restore_started.wait(timeout=5)
|
||||
|
||||
import_thread = threading.Thread(
|
||||
target=lambda: (
|
||||
installer.import_model(LocalModelSource(path=embedding_file)),
|
||||
imported.set(),
|
||||
)
|
||||
)
|
||||
import_thread.start()
|
||||
|
||||
time.sleep(0.1)
|
||||
assert not imported.is_set()
|
||||
|
||||
release_restore.set()
|
||||
import_thread.join(timeout=5)
|
||||
assert imported.is_set()
|
||||
installer.wait_for_installs(timeout=5)
|
||||
finally:
|
||||
release_restore.set()
|
||||
installer.stop()
|
||||
|
||||
|
||||
def test_huggingface_blob_url_uses_resolve_download_url(mm2_installer: ModelInstallServiceBase) -> None:
|
||||
source = URLModelSource(
|
||||
url=Url("https://huggingface.co/h94/IP-Adapter/blob/main/sdxl_models/ip-adapter.safetensors")
|
||||
)
|
||||
|
||||
assert isinstance(mm2_installer, ModelInstallService)
|
||||
files, metadata = mm2_installer._remote_files_from_source(source)
|
||||
|
||||
assert metadata is None
|
||||
assert len(files) == 1
|
||||
assert str(files[0].url) == "https://huggingface.co/h94/IP-Adapter/resolve/main/sdxl_models/ip-adapter.safetensors"
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_huggingface_install(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None:
|
||||
source = URLModelSource(url=Url("https://huggingface.co/stabilityai/sdxl-turbo"))
|
||||
|
||||
bus: TestEventService = mm2_installer.event_bus
|
||||
store = mm2_installer.record_store
|
||||
assert isinstance(bus, EventServiceBase)
|
||||
assert store is not None
|
||||
|
||||
job = mm2_installer.import_model(source)
|
||||
job_list = mm2_installer.wait_for_installs(timeout=10)
|
||||
assert len(job_list) == 1
|
||||
assert job.complete
|
||||
assert job.config_out
|
||||
|
||||
key = job.config_out.key
|
||||
model_record = store.get_model(key)
|
||||
assert (mm2_app_config.models_path / model_record.path).exists()
|
||||
assert model_record.type == ModelType.Main
|
||||
assert model_record.format == ModelFormat.Diffusers
|
||||
|
||||
assert any(isinstance(x, ModelInstallStartedEvent) for x in bus.events)
|
||||
assert any(isinstance(x, ModelInstallDownloadProgressEvent) for x in bus.events)
|
||||
assert any(isinstance(x, ModelInstallCompleteEvent) for x in bus.events)
|
||||
assert len(bus.events) >= 3
|
||||
|
||||
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_huggingface_repo_id(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None:
|
||||
source = HFModelSource(repo_id="stabilityai/sdxl-turbo", variant=ModelRepoVariant.Default)
|
||||
|
||||
bus = mm2_installer.event_bus
|
||||
store = mm2_installer.record_store
|
||||
assert isinstance(bus, EventServiceBase)
|
||||
assert store is not None
|
||||
|
||||
job = mm2_installer.import_model(source)
|
||||
job_list = mm2_installer.wait_for_installs(timeout=10)
|
||||
assert len(job_list) == 1
|
||||
assert job.complete
|
||||
assert job.config_out
|
||||
|
||||
key = job.config_out.key
|
||||
model_record = store.get_model(key)
|
||||
assert (mm2_app_config.models_path / model_record.path).exists()
|
||||
assert model_record.type == ModelType.Main
|
||||
assert model_record.format == ModelFormat.Diffusers
|
||||
|
||||
assert hasattr(bus, "events") # the dummyeventservice has this
|
||||
assert len(bus.events) >= 3
|
||||
event_types = [type(x) for x in bus.events]
|
||||
assert all(
|
||||
x in event_types
|
||||
for x in [
|
||||
ModelInstallDownloadProgressEvent,
|
||||
ModelInstallDownloadsCompleteEvent,
|
||||
ModelInstallStartedEvent,
|
||||
ModelInstallCompleteEvent,
|
||||
]
|
||||
)
|
||||
|
||||
completed_events = [x for x in bus.events if isinstance(x, ModelInstallCompleteEvent)]
|
||||
downloading_events = [x for x in bus.events if isinstance(x, ModelInstallDownloadProgressEvent)]
|
||||
assert completed_events[0].total_bytes == downloading_events[-1].bytes
|
||||
assert job.total_bytes == completed_events[0].total_bytes
|
||||
print(downloading_events[-1])
|
||||
print(job.download_parts)
|
||||
assert job.total_bytes == sum(x["total_bytes"] for x in downloading_events[-1].parts)
|
||||
|
||||
|
||||
def test_restore_paused_hf_install_preserves_access_token(
|
||||
mm2_installer: ModelInstallServiceBase,
|
||||
mm2_app_config: InvokeAIAppConfig,
|
||||
mm2_download_queue,
|
||||
mm2_session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
assert isinstance(mm2_installer, ModelInstallService)
|
||||
|
||||
access_token = "hf_test_access_token"
|
||||
tmpdir = mm2_app_config.models_path / f"tmpinstall_resume_token_{uuid.uuid4().hex}"
|
||||
tmpdir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
paused_job = ModelInstallJob(
|
||||
id=99999,
|
||||
source=HFModelSource(
|
||||
repo_id="stabilityai/sdxl-turbo",
|
||||
variant=ModelRepoVariant.Default,
|
||||
access_token=access_token,
|
||||
),
|
||||
config_in=ModelRecordChanges(),
|
||||
local_path=tmpdir,
|
||||
)
|
||||
paused_job._install_tmpdir = tmpdir
|
||||
paused_job.status = InstallStatus.PAUSED
|
||||
|
||||
mm2_installer._write_install_marker(paused_job, status=InstallStatus.PAUSED)
|
||||
|
||||
marker = mm2_installer._read_install_marker(tmpdir)
|
||||
assert marker is not None
|
||||
assert marker["access_token"] == access_token
|
||||
|
||||
restored_installer = ModelInstallService(
|
||||
app_config=mm2_app_config,
|
||||
record_store=mm2_installer.record_store,
|
||||
download_queue=mm2_download_queue,
|
||||
session=mm2_session,
|
||||
)
|
||||
restored_installer._restore_incomplete_installs()
|
||||
restored_jobs = restored_installer.list_jobs()
|
||||
assert len(restored_jobs) == 1
|
||||
|
||||
restored_job = restored_jobs[0]
|
||||
assert restored_job.paused
|
||||
assert isinstance(restored_job.source, HFModelSource)
|
||||
assert restored_job.source.access_token == access_token
|
||||
|
||||
captured: dict[str, str | None] = {}
|
||||
|
||||
def _capture_resume(job: ModelInstallJob) -> None:
|
||||
assert isinstance(job.source, HFModelSource)
|
||||
captured["access_token"] = job.source.access_token
|
||||
|
||||
monkeypatch.setattr(restored_installer, "_resume_remote_download", _capture_resume)
|
||||
restored_installer.resume_job(restored_job)
|
||||
assert captured["access_token"] == access_token
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_404_download(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None:
|
||||
source = URLModelSource(url=Url("https://test.com/missing_model.safetensors"))
|
||||
job = mm2_installer.import_model(source)
|
||||
mm2_installer.wait_for_installs(timeout=10)
|
||||
assert job.status == InstallStatus.ERROR
|
||||
assert job.errored
|
||||
assert job.error_type == "HTTPError"
|
||||
assert job.error
|
||||
assert "NOT FOUND" in job.error
|
||||
assert job.error_traceback is not None
|
||||
assert job.error_traceback.startswith("Traceback")
|
||||
bus = mm2_installer.event_bus
|
||||
assert bus is not None
|
||||
assert hasattr(bus, "events") # the dummyeventservice has this
|
||||
event_types = [type(x) for x in bus.events]
|
||||
assert ModelInstallErrorEvent in event_types
|
||||
|
||||
|
||||
def test_other_error_during_install(
|
||||
monkeypatch: pytest.MonkeyPatch, mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig
|
||||
) -> None:
|
||||
def raise_runtime_error(*args, **kwargs):
|
||||
raise RuntimeError("Test error")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"invokeai.app.services.model_install.model_install_default.ModelInstallService._register_or_install",
|
||||
raise_runtime_error,
|
||||
)
|
||||
source = LocalModelSource(path=Path("tests/data/embedding/test_embedding.safetensors"))
|
||||
job = mm2_installer.import_model(source)
|
||||
mm2_installer.wait_for_installs(timeout=10)
|
||||
assert job.status == InstallStatus.ERROR
|
||||
assert job.errored
|
||||
assert job.error_type == "RuntimeError"
|
||||
assert job.error == "Test error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model_params",
|
||||
[
|
||||
# SDXL, Lora
|
||||
{
|
||||
"repo_id": "InvokeAI-test/textual_inversion_tests::learned_embeds-steps-1000.safetensors",
|
||||
"name": "test_lora",
|
||||
"type": "embedding",
|
||||
},
|
||||
# SDXL, Lora - incorrect type
|
||||
{
|
||||
"repo_id": "InvokeAI-test/textual_inversion_tests::learned_embeds-steps-1000.safetensors",
|
||||
"name": "test_lora",
|
||||
"type": "lora",
|
||||
},
|
||||
],
|
||||
)
|
||||
@pytest.mark.timeout(timeout=10, method="thread")
|
||||
def test_heuristic_import_with_type(mm2_installer: ModelInstallServiceBase, model_params: Dict[str, str]):
|
||||
"""Test whether or not type is respected on configs when passed to heuristic import."""
|
||||
assert "name" in model_params and "type" in model_params
|
||||
config1: Dict[str, Any] = {
|
||||
"name": f"{model_params['name']}_1",
|
||||
"type": model_params["type"],
|
||||
"hash": "placeholder1",
|
||||
}
|
||||
config2: Dict[str, Any] = {
|
||||
"name": f"{model_params['name']}_2",
|
||||
"type": ModelType(model_params["type"]),
|
||||
"hash": "placeholder2",
|
||||
}
|
||||
assert "repo_id" in model_params
|
||||
install_job1 = mm2_installer.heuristic_import(source=model_params["repo_id"], config=config1)
|
||||
mm2_installer.wait_for_job(install_job1, timeout=10)
|
||||
if model_params["type"] != "embedding":
|
||||
assert install_job1.errored
|
||||
assert install_job1.error_type == "InvalidModelConfigException"
|
||||
return
|
||||
assert install_job1.complete
|
||||
assert install_job1.config_out if model_params["type"] == "embedding" else not install_job1.config_out
|
||||
|
||||
install_job2 = mm2_installer.heuristic_import(source=model_params["repo_id"], config=config2)
|
||||
mm2_installer.wait_for_job(install_job2, timeout=10)
|
||||
assert install_job2.complete
|
||||
assert install_job2.config_out if model_params["type"] == "embedding" else not install_job2.config_out
|
||||
@@ -0,0 +1,115 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers import AutoencoderTiny
|
||||
|
||||
from invokeai.app.invocations.model import ModelIdentifierField
|
||||
from invokeai.app.services.invocation_services import InvocationServices
|
||||
from invokeai.app.services.model_manager import ModelManagerServiceBase
|
||||
from invokeai.app.services.shared.invocation_context import (
|
||||
InvocationContext,
|
||||
InvocationContextData,
|
||||
build_invocation_context,
|
||||
)
|
||||
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalModelCapabilities
|
||||
from invokeai.backend.model_manager.load.load_base import LoadedModelWithoutConfig
|
||||
from tests.backend.model_manager.model_manager_fixtures import * # noqa F403
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_context(
|
||||
mock_services: InvocationServices,
|
||||
mm2_model_manager: ModelManagerServiceBase,
|
||||
) -> InvocationContext:
|
||||
mock_services.model_manager = mm2_model_manager
|
||||
return build_invocation_context(
|
||||
services=mock_services,
|
||||
data=InvocationContextData(queue_item=None, invocation=None, source_invocation_id=None), # type: ignore
|
||||
is_canceled=None, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def test_download_and_cache(mock_context: InvocationContext, mm2_root_dir: Path) -> None:
|
||||
downloaded_path = mock_context.models.download_and_cache_model(
|
||||
"https://www.test.foo/download/test_embedding.safetensors"
|
||||
)
|
||||
assert downloaded_path.is_file()
|
||||
assert downloaded_path.exists()
|
||||
assert downloaded_path.name == "test_embedding.safetensors"
|
||||
assert downloaded_path.parent.parent == mm2_root_dir / "models/.download_cache"
|
||||
|
||||
downloaded_path_2 = mock_context.models.download_and_cache_model(
|
||||
"https://www.test.foo/download/test_embedding.safetensors"
|
||||
)
|
||||
assert downloaded_path == downloaded_path_2
|
||||
|
||||
|
||||
def test_load_from_path(mock_context: InvocationContext, embedding_file: Path) -> None:
|
||||
downloaded_path = mock_context.models.download_and_cache_model(
|
||||
"https://www.test.foo/download/test_embedding.safetensors"
|
||||
)
|
||||
loaded_model_1 = mock_context.models.load_local_model(downloaded_path)
|
||||
assert isinstance(loaded_model_1, LoadedModelWithoutConfig)
|
||||
|
||||
loaded_model_2 = mock_context.models.load_local_model(downloaded_path)
|
||||
assert isinstance(loaded_model_2, LoadedModelWithoutConfig)
|
||||
assert loaded_model_1.model is loaded_model_2.model
|
||||
|
||||
loaded_model_3 = mock_context.models.load_local_model(embedding_file)
|
||||
assert isinstance(loaded_model_3, LoadedModelWithoutConfig)
|
||||
assert loaded_model_1.model is not loaded_model_3.model
|
||||
assert isinstance(loaded_model_1.model, dict)
|
||||
assert isinstance(loaded_model_3.model, dict)
|
||||
assert torch.equal(loaded_model_1.model["emb_params"], loaded_model_3.model["emb_params"])
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="This requires a test model to load")
|
||||
def test_load_from_dir(mock_context: InvocationContext, vae_directory: Path) -> None:
|
||||
loaded_model = mock_context.models.load_local_model(vae_directory)
|
||||
assert isinstance(loaded_model, LoadedModelWithoutConfig)
|
||||
assert isinstance(loaded_model.model, AutoencoderTiny)
|
||||
|
||||
|
||||
def test_download_and_load(mock_context: InvocationContext) -> None:
|
||||
loaded_model_1 = mock_context.models.load_remote_model("https://www.test.foo/download/test_embedding.safetensors")
|
||||
assert isinstance(loaded_model_1, LoadedModelWithoutConfig)
|
||||
|
||||
loaded_model_2 = mock_context.models.load_remote_model("https://www.test.foo/download/test_embedding.safetensors")
|
||||
assert isinstance(loaded_model_2, LoadedModelWithoutConfig)
|
||||
assert loaded_model_1.model is loaded_model_2.model # should be cached copy
|
||||
|
||||
|
||||
def test_external_model_load_raises(
|
||||
mock_context: InvocationContext, mm2_model_manager: ModelManagerServiceBase
|
||||
) -> None:
|
||||
config = ExternalApiModelConfig(
|
||||
key="external_test",
|
||||
name="External Test",
|
||||
provider_id="openai",
|
||||
provider_model_id="gpt-image-1",
|
||||
capabilities=ExternalModelCapabilities(modes=["txt2img"]),
|
||||
)
|
||||
mm2_model_manager.store.add_model(config)
|
||||
|
||||
model_field = ModelIdentifierField.from_config(config)
|
||||
|
||||
with pytest.raises(ValueError, match="External API models"):
|
||||
mock_context.models.load(model_field)
|
||||
|
||||
with pytest.raises(ValueError, match="External API models"):
|
||||
mock_context.models.load_by_attrs(name=config.name, base=config.base, type=config.type)
|
||||
|
||||
|
||||
def test_download_diffusers(mock_context: InvocationContext) -> None:
|
||||
model_path = mock_context.models.download_and_cache_model("stabilityai/sdxl-turbo")
|
||||
assert (model_path / "model_index.json").exists()
|
||||
assert (model_path / "vae").is_dir()
|
||||
|
||||
|
||||
def test_download_diffusers_subfolder(mock_context: InvocationContext) -> None:
|
||||
model_path = mock_context.models.download_and_cache_model("stabilityai/sdxl-turbo::vae")
|
||||
assert model_path.is_dir()
|
||||
assert (model_path / "diffusion_pytorch_model.fp16.safetensors").exists() or (
|
||||
model_path / "diffusion_pytorch_model.safetensors"
|
||||
).exists()
|
||||
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
Test the refactored model config classes.
|
||||
"""
|
||||
|
||||
from hashlib import sha256
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from invokeai.app.services.config import InvokeAIAppConfig
|
||||
from invokeai.app.services.model_records import (
|
||||
DuplicateModelException,
|
||||
ModelRecordOrderBy,
|
||||
ModelRecordServiceBase,
|
||||
ModelRecordServiceSQL,
|
||||
UnknownModelException,
|
||||
)
|
||||
from invokeai.app.services.model_records.model_records_base import ModelRecordChanges
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.backend.model_manager.configs.controlnet import ControlAdapterDefaultSettings
|
||||
from invokeai.backend.model_manager.configs.lora import LoRA_LyCORIS_SDXL_Config
|
||||
from invokeai.backend.model_manager.configs.main import (
|
||||
Main_Diffusers_SD1_Config,
|
||||
Main_Diffusers_SD2_Config,
|
||||
Main_Diffusers_SDXL_Config,
|
||||
MainModelDefaultSettings,
|
||||
)
|
||||
from invokeai.backend.model_manager.configs.qwen3_encoder import Qwen3Encoder_Qwen3Encoder_Config
|
||||
from invokeai.backend.model_manager.configs.text_llm import TextLLM_Diffusers_Config
|
||||
from invokeai.backend.model_manager.configs.textual_inversion import TI_File_SD1_Config
|
||||
from invokeai.backend.model_manager.configs.vae import VAE_Diffusers_SD1_Config
|
||||
from invokeai.backend.model_manager.taxonomy import (
|
||||
BaseModelType,
|
||||
ModelFormat,
|
||||
ModelRepoVariant,
|
||||
ModelSourceType,
|
||||
ModelType,
|
||||
ModelVariantType,
|
||||
Qwen3VariantType,
|
||||
SchedulerPredictionType,
|
||||
)
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
from tests.fixtures.sqlite_database import create_mock_sqlite_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(
|
||||
datadir: Any,
|
||||
) -> ModelRecordServiceSQL:
|
||||
config = InvokeAIAppConfig()
|
||||
config._root = datadir
|
||||
logger = InvokeAILogger.get_logger(config=config)
|
||||
db = create_mock_sqlite_database(config, logger)
|
||||
return ModelRecordServiceSQL(db, logger)
|
||||
|
||||
|
||||
def example_ti_config(key: Optional[str] = None) -> TI_File_SD1_Config:
|
||||
config = TI_File_SD1_Config(
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
path="/tmp/pokemon.bin",
|
||||
file_size=1024,
|
||||
name="old name",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.TextualInversion,
|
||||
format=ModelFormat.EmbeddingFile,
|
||||
hash="ABC123",
|
||||
)
|
||||
if key is not None:
|
||||
config.key = key
|
||||
return config
|
||||
|
||||
|
||||
def test_type(store: ModelRecordServiceBase):
|
||||
config = example_ti_config("key1")
|
||||
store.add_model(config)
|
||||
config1 = store.get_model("key1")
|
||||
assert isinstance(config1, TI_File_SD1_Config)
|
||||
|
||||
|
||||
def test_raises_on_violating_uniqueness(store: ModelRecordServiceBase):
|
||||
# Models have a uniqueness constraint by their name, base and type
|
||||
config1 = example_ti_config("key1")
|
||||
config2 = config1.model_copy(deep=True)
|
||||
config2.key = "key2"
|
||||
store.add_model(config1)
|
||||
with pytest.raises(DuplicateModelException):
|
||||
store.add_model(config1)
|
||||
with pytest.raises(DuplicateModelException):
|
||||
store.add_model(config2)
|
||||
|
||||
|
||||
def test_model_records_updates_model(store: ModelRecordServiceBase):
|
||||
config = example_ti_config("key1")
|
||||
store.add_model(config)
|
||||
config = store.get_model("key1")
|
||||
assert config.name == "old name"
|
||||
new_name = "new name"
|
||||
changes = ModelRecordChanges(name=new_name)
|
||||
store.update_model(config.key, changes)
|
||||
new_config = store.get_model("key1")
|
||||
assert new_config.name == new_name
|
||||
|
||||
|
||||
def test_model_records_updates_model_class(store: ModelRecordServiceBase):
|
||||
config = example_ti_config("key1")
|
||||
store.add_model(config)
|
||||
changes = ModelRecordChanges(
|
||||
type=ModelType.LoRA,
|
||||
format=ModelFormat.LyCORIS,
|
||||
base=BaseModelType.StableDiffusionXL,
|
||||
)
|
||||
new_config = store.update_model(config.key, changes, allow_class_change=True)
|
||||
assert isinstance(new_config, LoRA_LyCORIS_SDXL_Config)
|
||||
|
||||
|
||||
def test_update_changing_type_drops_stale_format_and_variant(store: ModelRecordServiceBase):
|
||||
"""When the type changes, format/variant from the old class must not block validation of the new class.
|
||||
|
||||
Regression test for https://github.com/invoke-ai/InvokeAI/issues/9090: switching a misidentified
|
||||
Qwen3 encoder to TextLLM previously failed because the old `format=qwen3_encoder` and `variant`
|
||||
fields were carried over and no discriminator under `type=text_llm` matched.
|
||||
"""
|
||||
config = Qwen3Encoder_Qwen3Encoder_Config(
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
path="/tmp/Qwen2.5-1.5B-Instruct",
|
||||
file_size=1024,
|
||||
name="Qwen2.5-1.5B-Instruct",
|
||||
hash="ABC123",
|
||||
variant=Qwen3VariantType.Qwen3_4B,
|
||||
)
|
||||
config.key = "key1"
|
||||
store.add_model(config)
|
||||
|
||||
changes = ModelRecordChanges(type=ModelType.TextLLM)
|
||||
new_config = store.update_model(config.key, changes, allow_class_change=True)
|
||||
assert isinstance(new_config, TextLLM_Diffusers_Config)
|
||||
|
||||
|
||||
def test_model_records_rejects_invalid_attr_changes(store: ModelRecordServiceBase):
|
||||
config = example_ti_config("key1")
|
||||
store.add_model(config)
|
||||
config = store.get_model("key1")
|
||||
# upcast_attention is an invalid field for TIs
|
||||
changes = ModelRecordChanges(upcast_attention=True)
|
||||
with pytest.raises(ValidationError):
|
||||
store.update_model(config.key, changes)
|
||||
|
||||
|
||||
def test_model_records_rejects_invalid_attr_changes_that_change_class(store: ModelRecordServiceBase):
|
||||
config = example_ti_config("key1")
|
||||
store.add_model(config)
|
||||
config = store.get_model("key1")
|
||||
# upcast_attention is an invalid field for TIs
|
||||
changes = ModelRecordChanges(upcast_attention=True)
|
||||
with pytest.raises(ValidationError):
|
||||
store.update_model(config.key, changes)
|
||||
|
||||
|
||||
def test_unknown_key(store: ModelRecordServiceBase):
|
||||
config = example_ti_config("key1")
|
||||
store.add_model(config)
|
||||
with pytest.raises(UnknownModelException):
|
||||
store.update_model("unknown_key", ModelRecordChanges())
|
||||
|
||||
|
||||
def test_delete(store: ModelRecordServiceBase):
|
||||
config = example_ti_config("key1")
|
||||
store.add_model(config)
|
||||
config = store.get_model("key1")
|
||||
store.del_model("key1")
|
||||
with pytest.raises(UnknownModelException):
|
||||
config = store.get_model("key1")
|
||||
|
||||
|
||||
def test_exists(store: ModelRecordServiceBase):
|
||||
config = example_ti_config("key1")
|
||||
store.add_model(config)
|
||||
assert store.exists("key1")
|
||||
assert not store.exists("key2")
|
||||
|
||||
|
||||
def test_filter(store: ModelRecordServiceBase):
|
||||
config1 = Main_Diffusers_SD1_Config(
|
||||
key="config1",
|
||||
path="/tmp/config1",
|
||||
name="config1",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.Main,
|
||||
hash="CONFIG1HASH",
|
||||
file_size=1001,
|
||||
source="test/source",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config2 = Main_Diffusers_SD1_Config(
|
||||
key="config2",
|
||||
path="/tmp/config2",
|
||||
name="config2",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.Main,
|
||||
hash="CONFIG2HASH",
|
||||
file_size=1002,
|
||||
source="test/source",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config3 = VAE_Diffusers_SD1_Config(
|
||||
key="config3",
|
||||
path="/tmp/config3",
|
||||
name="config3",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.VAE,
|
||||
hash="CONFIG3HASH",
|
||||
file_size=1003,
|
||||
source="test/source",
|
||||
source_type=ModelSourceType.Path,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
for c in config1, config2, config3:
|
||||
store.add_model(c)
|
||||
matches = store.search_by_attr(model_type=ModelType.Main)
|
||||
assert len(matches) == 2
|
||||
assert matches[0].name in {"config1", "config2"}
|
||||
|
||||
matches = store.search_by_attr(model_type=ModelType.VAE)
|
||||
assert len(matches) == 1
|
||||
assert matches[0].name == "config3"
|
||||
assert matches[0].key == "config3"
|
||||
assert isinstance(matches[0].type, ModelType) # This tests that we get proper enums back
|
||||
|
||||
matches = store.search_by_hash("CONFIG1HASH")
|
||||
assert len(matches) == 1
|
||||
assert matches[0].hash == "CONFIG1HASH"
|
||||
|
||||
matches = store.all_models()
|
||||
assert len(matches) == 3
|
||||
|
||||
|
||||
def test_unique_by_path(store: ModelRecordServiceBase):
|
||||
config1 = Main_Diffusers_SD1_Config(
|
||||
path="/tmp/config1",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.Main,
|
||||
name="nonuniquename",
|
||||
hash="CONFIG1HASH",
|
||||
file_size=1004,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config2 = Main_Diffusers_SD2_Config(
|
||||
path="/tmp/config2",
|
||||
base=BaseModelType.StableDiffusion2,
|
||||
type=ModelType.Main,
|
||||
name="nonuniquename",
|
||||
hash="CONFIG1HASH",
|
||||
file_size=1005,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config3 = VAE_Diffusers_SD1_Config(
|
||||
path="/tmp/config3",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.VAE,
|
||||
name="nonuniquename",
|
||||
hash="CONFIG1HASH",
|
||||
file_size=1006,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config4 = Main_Diffusers_SD1_Config(
|
||||
path="/tmp/config1",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.Main,
|
||||
name="nonuniquename",
|
||||
hash="CONFIG1HASH",
|
||||
file_size=1007,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
# config1, config2 and config3 are compatible because they have unique paths
|
||||
# of name, type and base
|
||||
for c in config1, config2, config3:
|
||||
c.key = sha256(c.path.encode("utf-8")).hexdigest()
|
||||
store.add_model(c)
|
||||
|
||||
# config4 clashes with config1 (same path) and should raise an integrity error
|
||||
with pytest.raises(DuplicateModelException):
|
||||
config4.key = sha256(config4.path.encode("utf-8")).hexdigest()
|
||||
store.add_model(config4)
|
||||
|
||||
|
||||
def test_filter_2(store: ModelRecordServiceBase):
|
||||
config1 = Main_Diffusers_SD1_Config(
|
||||
path="/tmp/config1",
|
||||
name="config1",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.Main,
|
||||
hash="CONFIG1HASH",
|
||||
file_size=1008,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config2 = Main_Diffusers_SD1_Config(
|
||||
path="/tmp/config2",
|
||||
name="config2",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.Main,
|
||||
hash="CONFIG2HASH",
|
||||
file_size=1009,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config3 = Main_Diffusers_SD2_Config(
|
||||
path="/tmp/config3",
|
||||
name="dup_name1",
|
||||
base=BaseModelType.StableDiffusion2,
|
||||
type=ModelType.Main,
|
||||
hash="CONFIG3HASH",
|
||||
file_size=1010,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config4 = Main_Diffusers_SDXL_Config(
|
||||
path="/tmp/config4",
|
||||
name="dup_name1",
|
||||
base=BaseModelType.StableDiffusionXL,
|
||||
type=ModelType.Main,
|
||||
hash="CONFIG3HASH",
|
||||
file_size=1011,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config5 = VAE_Diffusers_SD1_Config(
|
||||
path="/tmp/config5",
|
||||
name="dup_name1",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.VAE,
|
||||
hash="CONFIG3HASH",
|
||||
file_size=1012,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
for c in config1, config2, config3, config4, config5:
|
||||
store.add_model(c)
|
||||
|
||||
matches = store.search_by_attr(
|
||||
model_type=ModelType.Main,
|
||||
model_name="dup_name1",
|
||||
)
|
||||
assert len(matches) == 2
|
||||
|
||||
matches = store.search_by_attr(
|
||||
base_model=BaseModelType.StableDiffusion1,
|
||||
model_type=ModelType.Main,
|
||||
)
|
||||
assert len(matches) == 2
|
||||
|
||||
matches = store.search_by_attr(
|
||||
base_model=BaseModelType.StableDiffusion1,
|
||||
model_type=ModelType.VAE,
|
||||
model_name="dup_name1",
|
||||
)
|
||||
assert len(matches) == 1
|
||||
|
||||
|
||||
def test_search_by_attr_sorting(store: ModelRecordServiceSQL):
|
||||
config1 = Main_Diffusers_SD1_Config(
|
||||
path="/tmp/config1",
|
||||
name="alpha",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.Main,
|
||||
hash="CONFIG1HASH",
|
||||
file_size=1000,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config2 = Main_Diffusers_SD2_Config(
|
||||
path="/tmp/config2",
|
||||
name="beta",
|
||||
base=BaseModelType.StableDiffusion2,
|
||||
type=ModelType.Main,
|
||||
hash="CONFIG2HASH",
|
||||
file_size=2000,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
variant=ModelVariantType.Normal,
|
||||
prediction_type=SchedulerPredictionType.Epsilon,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
config3 = VAE_Diffusers_SD1_Config(
|
||||
path="/tmp/config3",
|
||||
name="gamma",
|
||||
base=BaseModelType.StableDiffusion1,
|
||||
type=ModelType.VAE,
|
||||
hash="CONFIG3HASH",
|
||||
file_size=500,
|
||||
source="test/source/",
|
||||
source_type=ModelSourceType.Path,
|
||||
repo_variant=ModelRepoVariant.Default,
|
||||
)
|
||||
for c in config1, config2, config3:
|
||||
store.add_model(c)
|
||||
|
||||
# Test sorting by Name Ascending
|
||||
matches = store.search_by_attr(order_by=ModelRecordOrderBy.Name, direction=SQLiteDirection.Ascending)
|
||||
assert len(matches) == 3
|
||||
assert matches[0].name == "alpha"
|
||||
assert matches[1].name == "beta"
|
||||
assert matches[2].name == "gamma"
|
||||
|
||||
# Test sorting by Name Descending
|
||||
matches = store.search_by_attr(order_by=ModelRecordOrderBy.Name, direction=SQLiteDirection.Descending)
|
||||
assert matches[0].name == "gamma"
|
||||
assert matches[1].name == "beta"
|
||||
assert matches[2].name == "alpha"
|
||||
|
||||
# Test sorting by Size Ascending
|
||||
matches = store.search_by_attr(order_by=ModelRecordOrderBy.Size, direction=SQLiteDirection.Ascending)
|
||||
assert matches[0].name == "gamma" # 500
|
||||
assert matches[1].name == "alpha" # 1000
|
||||
assert matches[2].name == "beta" # 2000
|
||||
|
||||
# Test sorting by Size Descending
|
||||
matches = store.search_by_attr(order_by=ModelRecordOrderBy.Size, direction=SQLiteDirection.Descending)
|
||||
assert matches[0].name == "beta" # 2000
|
||||
assert matches[1].name == "alpha" # 1000
|
||||
assert matches[2].name == "gamma" # 500
|
||||
|
||||
|
||||
def test_model_record_changes():
|
||||
# This test guards against some unexpected behaviours from pydantic's union evaluation. See #6035
|
||||
changes = ModelRecordChanges.model_validate({"default_settings": {"preprocessor": "value"}})
|
||||
assert isinstance(changes.default_settings, ControlAdapterDefaultSettings)
|
||||
|
||||
changes = ModelRecordChanges.model_validate({"default_settings": {"vae": "value"}})
|
||||
assert isinstance(changes.default_settings, MainModelDefaultSettings)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for session queue clear() user_id scoping."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
"""Create a SqliteSessionQueue backed by the mock invoker's in-memory database."""
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(session_queue: SqliteSessionQueue, queue_id: str, user_id: str) -> None:
|
||||
"""Directly insert a minimal queue item for the given user."""
|
||||
session_id = str(uuid.uuid4())
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(queue_id, "{}", session_id, batch_id, None, 0, None, None, None, None, user_id),
|
||||
)
|
||||
|
||||
|
||||
def _count_items(session_queue: SqliteSessionQueue, queue_id: str, user_id: str | None = None) -> int:
|
||||
"""Count items in the queue, optionally filtered by user_id."""
|
||||
with session_queue._db.transaction() as cursor:
|
||||
if user_id is not None:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM session_queue WHERE queue_id = ? AND user_id = ?",
|
||||
(queue_id, user_id),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM session_queue WHERE queue_id = ?",
|
||||
(queue_id,),
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
|
||||
def test_clear_with_user_id_only_deletes_own_items(session_queue: SqliteSessionQueue) -> None:
|
||||
"""Non-admin clear (user_id provided) should only remove that user's items."""
|
||||
queue_id = "default"
|
||||
user_a = "user_a"
|
||||
user_b = "user_b"
|
||||
|
||||
_insert_queue_item(session_queue, queue_id, user_a)
|
||||
_insert_queue_item(session_queue, queue_id, user_a)
|
||||
_insert_queue_item(session_queue, queue_id, user_b)
|
||||
|
||||
result = session_queue.clear(queue_id, user_id=user_a)
|
||||
|
||||
assert result.deleted == 2
|
||||
assert _count_items(session_queue, queue_id, user_a) == 0
|
||||
assert _count_items(session_queue, queue_id, user_b) == 1
|
||||
|
||||
|
||||
def test_clear_without_user_id_deletes_all_items(session_queue: SqliteSessionQueue) -> None:
|
||||
"""Admin clear (no user_id) should remove all items in the queue."""
|
||||
queue_id = "default"
|
||||
|
||||
_insert_queue_item(session_queue, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue, queue_id, "user_b")
|
||||
_insert_queue_item(session_queue, queue_id, "user_c")
|
||||
|
||||
result = session_queue.clear(queue_id)
|
||||
|
||||
assert result.deleted == 3
|
||||
assert _count_items(session_queue, queue_id) == 0
|
||||
|
||||
|
||||
def test_clear_with_user_id_does_not_affect_other_queues(session_queue: SqliteSessionQueue) -> None:
|
||||
"""Clearing one queue should not affect items in another queue."""
|
||||
queue_a = "queue_a"
|
||||
queue_b = "queue_b"
|
||||
user_id = "user_x"
|
||||
|
||||
_insert_queue_item(session_queue, queue_a, user_id)
|
||||
_insert_queue_item(session_queue, queue_b, user_id)
|
||||
|
||||
result = session_queue.clear(queue_a, user_id=user_id)
|
||||
|
||||
assert result.deleted == 1
|
||||
assert _count_items(session_queue, queue_a) == 0
|
||||
assert _count_items(session_queue, queue_b) == 1
|
||||
|
||||
|
||||
def test_clear_returns_zero_when_no_matching_items(session_queue: SqliteSessionQueue) -> None:
|
||||
"""Clear should return 0 deleted when there are no items for the given user."""
|
||||
queue_id = "default"
|
||||
|
||||
_insert_queue_item(session_queue, queue_id, "user_b")
|
||||
|
||||
result = session_queue.clear(queue_id, user_id="user_a")
|
||||
|
||||
assert result.deleted == 0
|
||||
assert _count_items(session_queue, queue_id) == 1
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Tests for session queue dequeue() ordering: FIFO and round-robin modes."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from pydantic_core import to_jsonable_python
|
||||
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import (
|
||||
ROUND_ROBIN_DEQUEUE_QUERY,
|
||||
SqliteSessionQueue,
|
||||
)
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
|
||||
_EMPTY_SESSION_JSON = json.dumps(to_jsonable_python(GraphExecutionState(graph=Graph()).model_dump()))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue_fifo(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
"""Queue backed by a single-user (FIFO) invoker."""
|
||||
# Default config has multiuser=False, so FIFO is always used.
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue_round_robin(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
"""Queue backed by a multiuser invoker with round_robin mode."""
|
||||
mock_invoker.services.configuration = InvokeAIAppConfig(
|
||||
use_memory_db=True,
|
||||
node_cache_size=0,
|
||||
multiuser=True,
|
||||
session_queue_mode="round_robin",
|
||||
)
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(
|
||||
session_queue: SqliteSessionQueue,
|
||||
queue_id: str,
|
||||
user_id: str,
|
||||
priority: int = 0,
|
||||
) -> int:
|
||||
"""Directly insert a minimal queue item and return its item_id."""
|
||||
session_id = str(uuid.uuid4())
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(queue_id, _EMPTY_SESSION_JSON, session_id, batch_id, None, priority, None, None, None, None, user_id),
|
||||
)
|
||||
return cursor.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def _dequeue_user_ids(session_queue: SqliteSessionQueue, count: int) -> list[Optional[str]]:
|
||||
"""Dequeue `count` items and return the list of user_ids in dequeue order."""
|
||||
result = []
|
||||
for _ in range(count):
|
||||
item = session_queue.dequeue()
|
||||
result.append(item.user_id if item is not None else None)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FIFO tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fifo_single_user_order(session_queue_fifo: SqliteSessionQueue) -> None:
|
||||
"""FIFO: items from a single user are dequeued in insertion order."""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_fifo, 3)
|
||||
assert user_ids == ["user_a", "user_a", "user_a"]
|
||||
|
||||
|
||||
def test_fifo_multi_user_preserves_insertion_order(session_queue_fifo: SqliteSessionQueue) -> None:
|
||||
"""FIFO: jobs from multiple users are dequeued in strict insertion order, not interleaved."""
|
||||
queue_id = "default"
|
||||
# Insert A1, A2, B1, C1, C2, A3 – FIFO should preserve this exact order.
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_b")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_c")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_c")
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_fifo, 6)
|
||||
assert user_ids == ["user_a", "user_a", "user_b", "user_c", "user_c", "user_a"]
|
||||
|
||||
|
||||
def test_fifo_priority_respected(session_queue_fifo: SqliteSessionQueue) -> None:
|
||||
"""FIFO: higher-priority items are dequeued before lower-priority ones."""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a", priority=0)
|
||||
_insert_queue_item(session_queue_fifo, queue_id, "user_a", priority=10)
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_fifo, 2)
|
||||
# Both are user_a; second inserted item has higher priority and should come first.
|
||||
assert user_ids == ["user_a", "user_a"]
|
||||
|
||||
|
||||
def test_fifo_returns_none_when_empty(session_queue_fifo: SqliteSessionQueue) -> None:
|
||||
"""FIFO: dequeue returns None when the queue is empty."""
|
||||
assert session_queue_fifo.dequeue() is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round-robin tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_round_robin_interleaves_users(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin: jobs from multiple users are interleaved one per user per round.
|
||||
|
||||
Queue insertion order (matching the issue example):
|
||||
A job 1, A job 2, B job 1, C job 1, C job 2, A job 3
|
||||
|
||||
Expected dequeue order:
|
||||
A job 1, B job 1, C job 1, A job 2, C job 2, A job 3
|
||||
"""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_b")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_c")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_c")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_round_robin, 6)
|
||||
assert user_ids == ["user_a", "user_b", "user_c", "user_a", "user_c", "user_a"]
|
||||
|
||||
|
||||
def test_round_robin_single_user_behaves_like_fifo(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin with only one user produces the same order as FIFO."""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_round_robin, 3)
|
||||
assert user_ids == ["user_a", "user_a", "user_a"]
|
||||
|
||||
|
||||
def test_round_robin_handles_user_joining_mid_queue(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin: a user who joins later is correctly interleaved."""
|
||||
queue_id = "default"
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a")
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_b")
|
||||
|
||||
user_ids = _dequeue_user_ids(session_queue_round_robin, 3)
|
||||
# Round 1: A (oldest rank-1 item), B (rank-1 item)
|
||||
# Round 2: A (rank-2 item)
|
||||
assert user_ids == ["user_a", "user_b", "user_a"]
|
||||
|
||||
|
||||
def test_round_robin_returns_none_when_empty(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin: dequeue returns None when the queue is empty."""
|
||||
assert session_queue_round_robin.dequeue() is None
|
||||
|
||||
|
||||
def test_round_robin_priority_within_user_respected(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin: within a single user's items, higher priority is dequeued first."""
|
||||
queue_id = "default"
|
||||
# Insert low-priority item first, then high-priority for same user.
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a", priority=0)
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_a", priority=10)
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, "user_b", priority=0)
|
||||
|
||||
# Round 1: user_a's best item (priority 10), user_b's only item.
|
||||
# Round 2: user_a's remaining item (priority 0).
|
||||
items = []
|
||||
for _ in range(3):
|
||||
item = session_queue_round_robin.dequeue()
|
||||
assert item is not None
|
||||
items.append((item.user_id, item.priority))
|
||||
|
||||
assert items[0] == ("user_a", 10)
|
||||
assert items[1] == ("user_b", 0)
|
||||
assert items[2] == ("user_a", 0)
|
||||
|
||||
|
||||
def _seed_completed_history(
|
||||
session_queue: SqliteSessionQueue,
|
||||
queue_id: str,
|
||||
user_id: str,
|
||||
count: int,
|
||||
) -> None:
|
||||
"""Insert `count` completed items (with started_at set) for a user, simulating retained history."""
|
||||
with session_queue._db.transaction() as cursor:
|
||||
for i in range(count):
|
||||
session_id = str(uuid.uuid4())
|
||||
batch_id = str(uuid.uuid4())
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue
|
||||
(queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id, status, started_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'completed', ?, ?)
|
||||
""",
|
||||
(
|
||||
queue_id,
|
||||
_EMPTY_SESSION_JSON,
|
||||
session_id,
|
||||
batch_id,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
user_id,
|
||||
# Monotonically increasing timestamps so MAX(started_at) is well-defined per user.
|
||||
f"2026-01-01 {i // 3600 % 24:02d}:{i // 60 % 60:02d}:{i % 60:02d}",
|
||||
f"2026-01-01 {i // 3600 % 24:02d}:{i // 60 % 60:02d}:{i % 60:02d}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_round_robin_dequeue_does_not_scan_full_history(session_queue_round_robin: SqliteSessionQueue) -> None:
|
||||
"""Round-robin dequeue cost must scale with active users, not retained queue history.
|
||||
|
||||
Regression guard for the scaling concern: the per-user "last served" lookup must be an
|
||||
indexed seek (MAX(started_at) WHERE user_id = ?) rather than a GROUP BY / scan over every
|
||||
historical started row. `max_queue_history` is unbounded by default, so a plan that scans
|
||||
the full history makes each dequeue O(total history) instead of O(active users).
|
||||
|
||||
We seed a large completed history across several users plus a few pending items, then assert
|
||||
the dequeue query plan never scans the `session_queue` base table and resolves the
|
||||
last-served lookup via a seek on `idx_session_queue_user_started_at`.
|
||||
"""
|
||||
queue_id = "default"
|
||||
for u in ("user_a", "user_b", "user_c"):
|
||||
_seed_completed_history(session_queue_round_robin, queue_id, u, count=500)
|
||||
_insert_queue_item(session_queue_round_robin, queue_id, u)
|
||||
|
||||
with session_queue_round_robin._db.transaction() as cursor:
|
||||
plan_rows = cursor.execute("EXPLAIN QUERY PLAN " + ROUND_ROBIN_DEQUEUE_QUERY).fetchall()
|
||||
details = [row["detail"] for row in plan_rows]
|
||||
|
||||
# No step may scan the session_queue base table — that is the full-history scan we are
|
||||
# eliminating. (CTE result scans like "SCAN uni" / "SCAN (subquery-N)" are fine; those are
|
||||
# one row per pending user.)
|
||||
offending = [d for d in details if d.startswith("SCAN session_queue")]
|
||||
assert not offending, f"dequeue plan scans full queue history: {offending}\nfull plan: {details}"
|
||||
|
||||
# The last-served lookup must use the started_at index as a per-user seek.
|
||||
assert any("idx_session_queue_user_started_at" in d and "user_id=?" in d for d in details), (
|
||||
f"last-served lookup is not an indexed seek; plan: {details}"
|
||||
)
|
||||
|
||||
# And the dequeue must still return the least-recently-served user (correctness under history).
|
||||
# user_a's history ends earliest only if seeded first; all three were seeded equal counts with
|
||||
# identical timestamps, so item_id ASC tie-breaks to the first-inserted pending item (user_a).
|
||||
item = session_queue_round_robin.dequeue()
|
||||
assert item is not None
|
||||
assert item.user_id == "user_a"
|
||||
|
||||
|
||||
def test_round_robin_ignored_in_single_user_mode(mock_invoker: Invoker) -> None:
|
||||
"""When multiuser=False, round_robin config is ignored and FIFO is used."""
|
||||
mock_invoker.services.configuration = InvokeAIAppConfig(
|
||||
use_memory_db=True,
|
||||
node_cache_size=0,
|
||||
multiuser=False,
|
||||
session_queue_mode="round_robin",
|
||||
)
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
|
||||
queue_id = "default"
|
||||
_insert_queue_item(queue, queue_id, "user_a")
|
||||
_insert_queue_item(queue, queue_id, "user_a")
|
||||
_insert_queue_item(queue, queue_id, "user_b")
|
||||
|
||||
# FIFO order: user_a, user_a, user_b
|
||||
user_ids = _dequeue_user_ids(queue, 3)
|
||||
assert user_ids == ["user_a", "user_a", "user_b"]
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Regression tests for the cross-user identifier leak in QueueItemStatusChangedEvent.
|
||||
|
||||
When user A's queue item changes status while user B's item is currently in_progress,
|
||||
the embedded SessionQueueStatus inside the event must NOT expose B's item_id,
|
||||
session_id, or batch_id. The full event ships to user:{A.user_id} and admin rooms,
|
||||
so unredacted fields would let owner A learn user B's identifiers.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.invocations.call_saved_workflow import CallSavedWorkflowInvocation
|
||||
from invokeai.app.services.events.events_common import QueueItemStatusChangedEvent
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from tests.test_nodes import PromptTestInvocation, TestEventService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(session_queue: SqliteSessionQueue, user_id: str) -> int:
|
||||
graph = Graph()
|
||||
graph.add_node(PromptTestInvocation(id="prompt", prompt="test"))
|
||||
session = GraphExecutionState(graph=graph)
|
||||
session_json = session.model_dump_json(warnings=False, exclude_none=True)
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (
|
||||
queue_id, session, session_id, batch_id, field_values,
|
||||
priority, workflow, origin, destination, retried_from_item_id, user_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("default", session_json, session.id, batch_id, None, 0, None, None, None, None, user_id),
|
||||
)
|
||||
return cursor.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def _insert_waiting_workflow_call_parent(
|
||||
session_queue: SqliteSessionQueue, user_id: str
|
||||
) -> tuple[int, GraphExecutionState]:
|
||||
parent_graph = Graph()
|
||||
parent_graph.add_node(CallSavedWorkflowInvocation(id="call-node", workflow_id="workflow-a"))
|
||||
parent_session = GraphExecutionState(graph=parent_graph)
|
||||
invocation = parent_session.next()
|
||||
assert isinstance(invocation, CallSavedWorkflowInvocation)
|
||||
|
||||
frame = parent_session.build_workflow_call_frame(invocation.id, invocation.workflow_id)
|
||||
child_session = parent_session.create_child_workflow_execution_state(Graph(), frame)
|
||||
parent_session.begin_waiting_on_workflow_call(frame)
|
||||
parent_session.attach_waiting_workflow_call_child_session(child_session)
|
||||
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (
|
||||
queue_id, session, session_id, batch_id, field_values,
|
||||
priority, workflow, origin, destination, retried_from_item_id, user_id, status
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"default",
|
||||
parent_session.model_dump_json(warnings=False, exclude_none=True),
|
||||
parent_session.id,
|
||||
batch_id,
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
user_id,
|
||||
"waiting",
|
||||
),
|
||||
)
|
||||
return cursor.lastrowid, child_session # type: ignore[return-value]
|
||||
|
||||
|
||||
def _last_status_event_for_item(event_bus: TestEventService, item_id: int) -> QueueItemStatusChangedEvent:
|
||||
matches = [e for e in event_bus.events if isinstance(e, QueueItemStatusChangedEvent) and e.item_id == item_id]
|
||||
assert matches, f"No QueueItemStatusChangedEvent found for item {item_id}"
|
||||
return matches[-1]
|
||||
|
||||
|
||||
def test_event_redacts_other_users_current_item_identifiers(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
"""When user A's pending item is canceled while user B's item is in_progress, the
|
||||
embedded queue_status in A's status-changed event must not expose B's identifiers."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
|
||||
# Make user B's item the in-progress one. We must dequeue B first; FIFO would dequeue A
|
||||
# because it was inserted first, so reverse the insertion: cancel A's, re-insert as new.
|
||||
# Simpler: dequeue twice. First dequeue picks A (older); promote B by inserting in
|
||||
# right order means we need B to be the in_progress item when A's event fires.
|
||||
# Cancel A first to make it ineligible, then dequeue B.
|
||||
# Actually we need A to be pending when its status changes — so we must dequeue B first.
|
||||
# Re-do: insert B BEFORE A by temporarily inserting A second. Recreate cleanly:
|
||||
session_queue.delete_queue_item(a_item_id)
|
||||
session_queue.delete_queue_item(b_item_id)
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == b_item_id
|
||||
assert in_progress.user_id == user_b
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
# Now cancel user A's pending item. The emitted event for A must not leak B's
|
||||
# current-item identifiers via the embedded queue_status.
|
||||
canceled = session_queue.cancel_queue_item(a_item_id)
|
||||
assert canceled.user_id == user_a
|
||||
|
||||
a_event = _last_status_event_for_item(event_bus, a_item_id)
|
||||
assert a_event.user_id == user_a
|
||||
assert a_event.queue_status.item_id is None, "must not leak other user's current item_id"
|
||||
assert a_event.queue_status.session_id is None, "must not leak other user's current session_id"
|
||||
assert a_event.queue_status.batch_id is None, "must not leak other user's current batch_id"
|
||||
# Aggregate counts in the embedded status are global and OK to share.
|
||||
assert a_event.queue_status.in_progress == 1
|
||||
assert a_event.queue_status.canceled == 1
|
||||
|
||||
|
||||
def test_event_preserves_owner_current_item_identifiers(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
"""When the current in-progress item belongs to the same user as the changed item, the
|
||||
embedded queue_status must continue to expose the identifiers (no over-redaction)."""
|
||||
user_a = "user-a"
|
||||
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == a_item_id
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
completed = session_queue.complete_queue_item(a_item_id)
|
||||
assert completed.user_id == user_a
|
||||
|
||||
# The event for A's transition fires AFTER the row is marked completed, so by the time
|
||||
# _set_queue_item_status reads get_current it returns None — there is no in-progress
|
||||
# item to leak. queue_status fields should therefore be None.
|
||||
a_event = _last_status_event_for_item(event_bus, a_item_id)
|
||||
assert a_event.user_id == user_a
|
||||
assert a_event.queue_status.item_id is None # no in-progress item at all
|
||||
assert a_event.queue_status.completed == 1
|
||||
|
||||
|
||||
def test_event_redacts_when_current_item_disappears_between_reads(
|
||||
session_queue: SqliteSessionQueue,
|
||||
mock_invoker: Invoker,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Regression: _set_queue_item_status reads get_current twice — once via
|
||||
get_queue_status (which embeds the in-progress item's identifiers into the
|
||||
SessionQueueStatus) and once again to decide whether to redact those
|
||||
identifiers. The two reads are not atomic. If user B is the in-progress
|
||||
item when the first read happens, and B then completes/cancels before the
|
||||
second read, the redaction guard sees current_item is None and skips
|
||||
scrubbing — leaving B's item_id, session_id, and batch_id in the event
|
||||
sent to user A. The fix must make the redaction decision derive from the
|
||||
same snapshot that supplied the embedded identifiers."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == b_item_id
|
||||
assert in_progress.user_id == user_b
|
||||
|
||||
real_get_current = session_queue.get_current
|
||||
b_snapshot = real_get_current(queue_id="default")
|
||||
assert b_snapshot is not None and b_snapshot.user_id == user_b
|
||||
|
||||
# Simulate the race: the read inside get_queue_status sees B's in-progress
|
||||
# item; the redaction read returns None as if B finished in between.
|
||||
call_count = {"n": 0}
|
||||
|
||||
def racey_get_current(queue_id: str) -> Optional[SessionQueueItem]:
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return b_snapshot
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(session_queue, "get_current", racey_get_current)
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
canceled = session_queue.cancel_queue_item(a_item_id)
|
||||
assert canceled.user_id == user_a
|
||||
# The patched read must have been consulted at least once. The pre-fix
|
||||
# implementation made two reads (embedding + redaction) and leaked when
|
||||
# the second returned None; the fixed implementation makes a single read
|
||||
# whose snapshot drives both embedding and redaction. Either way, the
|
||||
# invariant below is the one that matters.
|
||||
assert call_count["n"] >= 1
|
||||
|
||||
a_event = _last_status_event_for_item(event_bus, a_item_id)
|
||||
assert a_event.user_id == user_a
|
||||
assert a_event.queue_status.item_id is None, (
|
||||
"race-window leak: other user's item_id survived because the second "
|
||||
"get_current() returned None and the redaction guard was skipped"
|
||||
)
|
||||
assert a_event.queue_status.session_id is None, "race-window leak of session_id"
|
||||
assert a_event.queue_status.batch_id is None, "race-window leak of batch_id"
|
||||
|
||||
|
||||
def test_event_preserves_identifiers_when_current_item_is_the_changed_item(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
"""The dequeue() transition makes the changed item itself the in-progress current item.
|
||||
queue_status must expose its identifiers since they belong to the event's owner."""
|
||||
user_a = "user-a"
|
||||
a_item_id = _insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == a_item_id
|
||||
|
||||
a_event = _last_status_event_for_item(event_bus, a_item_id)
|
||||
assert a_event.status == "in_progress"
|
||||
assert a_event.user_id == user_a
|
||||
# Current item == changed item == owned by user_a → no redaction
|
||||
assert a_event.queue_status.item_id == a_item_id
|
||||
assert a_event.queue_status.session_id == in_progress.session_id
|
||||
assert a_event.queue_status.batch_id == in_progress.batch_id
|
||||
|
||||
|
||||
def test_workflow_call_child_enqueue_event_redacts_other_users_current_item_identifiers(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
"""The child enqueue path emits QueueItemStatusChangedEvent without going through
|
||||
_set_queue_item_status, so it must apply the same per-owner current-item redaction."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
parent_item_id, child_session = _insert_waiting_workflow_call_parent(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == b_item_id
|
||||
assert in_progress.user_id == user_b
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
event_bus.events.clear()
|
||||
|
||||
parent_queue_item = session_queue.get_queue_item(parent_item_id)
|
||||
child_queue_item = session_queue.enqueue_workflow_call_child(parent_queue_item, child_session)
|
||||
|
||||
child_event = _last_status_event_for_item(event_bus, child_queue_item.item_id)
|
||||
assert child_event.user_id == user_a
|
||||
assert child_event.queue_status.item_id is None, "must not leak other user's current item_id"
|
||||
assert child_event.queue_status.session_id is None, "must not leak other user's current session_id"
|
||||
assert child_event.queue_status.batch_id is None, "must not leak other user's current batch_id"
|
||||
assert child_event.queue_status.in_progress == 1
|
||||
assert child_event.queue_status.waiting == 1
|
||||
@@ -0,0 +1,103 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.events.events_common import QueueItemStatusChangedEvent
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from tests.test_nodes import PromptTestInvocation, TestEventService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(
|
||||
session_queue: SqliteSessionQueue,
|
||||
queue_id: str = "default",
|
||||
destination: str | None = None,
|
||||
) -> int:
|
||||
graph = Graph()
|
||||
graph.add_node(PromptTestInvocation(id="prompt", prompt="test"))
|
||||
session = GraphExecutionState(graph=graph)
|
||||
session_json = session.model_dump_json(warnings=False, exclude_none=True)
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (
|
||||
queue_id,
|
||||
session,
|
||||
session_id,
|
||||
batch_id,
|
||||
field_values,
|
||||
priority,
|
||||
workflow,
|
||||
origin,
|
||||
destination,
|
||||
retried_from_item_id,
|
||||
user_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(queue_id, session_json, session.id, batch_id, None, 0, None, None, destination, None, "system"),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def test_status_sequence_increments_for_queue_item_lifecycle(
|
||||
session_queue: SqliteSessionQueue, mock_invoker: Invoker
|
||||
) -> None:
|
||||
item_id = _insert_queue_item(session_queue)
|
||||
|
||||
pending_item = session_queue.get_queue_item(item_id)
|
||||
assert pending_item.status == "pending"
|
||||
assert pending_item.status_sequence == 0
|
||||
|
||||
in_progress_item = session_queue.dequeue()
|
||||
assert in_progress_item is not None
|
||||
assert in_progress_item.item_id == item_id
|
||||
assert in_progress_item.status == "in_progress"
|
||||
assert in_progress_item.status_sequence == 1
|
||||
|
||||
completed_item = session_queue.complete_queue_item(item_id)
|
||||
assert completed_item.status == "completed"
|
||||
assert completed_item.status_sequence == 2
|
||||
|
||||
event_bus: TestEventService = mock_invoker.services.events
|
||||
status_events = [event for event in event_bus.events if isinstance(event, QueueItemStatusChangedEvent)]
|
||||
|
||||
assert len(status_events) == 2
|
||||
assert [event.status for event in status_events] == ["in_progress", "completed"]
|
||||
assert [event.status_sequence for event in status_events] == [1, 2]
|
||||
|
||||
|
||||
def test_status_sequence_increments_for_bulk_cancel_paths(session_queue: SqliteSessionQueue) -> None:
|
||||
first_item_id = _insert_queue_item(session_queue)
|
||||
second_item_id = _insert_queue_item(session_queue)
|
||||
|
||||
result = session_queue.cancel_all_except_current("default")
|
||||
|
||||
assert result.canceled == 2
|
||||
assert session_queue.get_queue_item(first_item_id).status == "canceled"
|
||||
assert session_queue.get_queue_item(first_item_id).status_sequence == 1
|
||||
assert session_queue.get_queue_item(second_item_id).status == "canceled"
|
||||
assert session_queue.get_queue_item(second_item_id).status_sequence == 1
|
||||
|
||||
|
||||
def test_status_sequence_continues_after_dequeue_then_cancel(session_queue: SqliteSessionQueue) -> None:
|
||||
item_id = _insert_queue_item(session_queue)
|
||||
|
||||
in_progress_item = session_queue.dequeue()
|
||||
assert in_progress_item is not None
|
||||
assert in_progress_item.item_id == item_id
|
||||
assert in_progress_item.status_sequence == 1
|
||||
|
||||
canceled_item = session_queue.cancel_queue_item(item_id)
|
||||
assert canceled_item.status == "canceled"
|
||||
assert canceled_item.status_sequence == 2
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Regression tests for multiuser queue status / list scoping.
|
||||
|
||||
The queue badge in multiuser mode shows "X/Y" where X is the requesting user's own
|
||||
pending+in_progress jobs and Y is the global total across all users. For this to work,
|
||||
get_queue_status must report GLOBAL aggregate counts and ADDITIONALLY return the requesting
|
||||
user's own counts in user_pending/user_in_progress.
|
||||
|
||||
Separately, the virtualized queue list fetches ids via get_queue_item_ids and hydrates them
|
||||
via get_queue_items_by_item_ids (which redacts other users' items). So get_queue_item_ids must
|
||||
return every user's ids — otherwise a non-admin never sees the (redacted) entries belonging to
|
||||
other users.
|
||||
|
||||
A regression (#9018) scoped both of these to the calling user, which (a) collapsed the badge to
|
||||
a single own-count and (b) hid other users' redacted entries entirely. These tests guard the
|
||||
restored behavior.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from tests.test_nodes import PromptTestInvocation
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
def _insert_queue_item(session_queue: SqliteSessionQueue, user_id: str) -> int:
|
||||
graph = Graph()
|
||||
graph.add_node(PromptTestInvocation(id="prompt", prompt="test"))
|
||||
session = GraphExecutionState(graph=graph)
|
||||
session_json = session.model_dump_json(warnings=False, exclude_none=True)
|
||||
batch_id = str(uuid.uuid4())
|
||||
with session_queue._db.transaction() as cursor:
|
||||
cursor.execute(
|
||||
"""--sql
|
||||
INSERT INTO session_queue (
|
||||
queue_id, session, session_id, batch_id, field_values,
|
||||
priority, workflow, origin, destination, retried_from_item_id, user_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("default", session_json, session.id, batch_id, None, 0, None, None, None, None, user_id),
|
||||
)
|
||||
return cursor.lastrowid # type: ignore[return-value]
|
||||
|
||||
|
||||
def test_status_aggregate_counts_are_global_with_user_subcounts(session_queue: SqliteSessionQueue) -> None:
|
||||
"""A non-admin caller (user_id set) sees global aggregate counts plus their own subcounts."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
_insert_queue_item(session_queue, user_id=user_a)
|
||||
_insert_queue_item(session_queue, user_id=user_a)
|
||||
_insert_queue_item(session_queue, user_id=user_b)
|
||||
|
||||
status = session_queue.get_queue_status(queue_id="default", user_id=user_a)
|
||||
|
||||
# Global counts span every user's pending items.
|
||||
assert status.pending == 3
|
||||
assert status.total == 3
|
||||
# Per-user subcounts reflect only user A's items → badge renders "2/3".
|
||||
assert status.user_pending == 2
|
||||
assert status.user_in_progress == 0
|
||||
|
||||
|
||||
def test_status_admin_global_call_omits_user_subcounts(session_queue: SqliteSessionQueue) -> None:
|
||||
"""An admin/global caller (user_id=None) gets global counts and no per-user subcounts."""
|
||||
_insert_queue_item(session_queue, user_id="user-a")
|
||||
_insert_queue_item(session_queue, user_id="user-b")
|
||||
|
||||
status = session_queue.get_queue_status(queue_id="default")
|
||||
|
||||
assert status.pending == 2
|
||||
assert status.total == 2
|
||||
assert status.user_pending is None
|
||||
assert status.user_in_progress is None
|
||||
|
||||
|
||||
def test_status_current_item_redacted_for_non_owner_but_counts_global(session_queue: SqliteSessionQueue) -> None:
|
||||
"""When the in-progress item belongs to another user, its identifiers are hidden from a
|
||||
non-owner, but the aggregate counts (and the user's own subcounts) remain populated."""
|
||||
user_a = "user-a"
|
||||
user_b = "user-b"
|
||||
b_item_id = _insert_queue_item(session_queue, user_id=user_b)
|
||||
_insert_queue_item(session_queue, user_id=user_a)
|
||||
|
||||
in_progress = session_queue.dequeue()
|
||||
assert in_progress is not None and in_progress.item_id == b_item_id
|
||||
|
||||
status = session_queue.get_queue_status(queue_id="default", user_id=user_a)
|
||||
|
||||
# B's in-progress item identifiers are hidden from A.
|
||||
assert status.item_id is None
|
||||
assert status.session_id is None
|
||||
assert status.batch_id is None
|
||||
# But counts are still global, and A's own subcounts are present.
|
||||
assert status.in_progress == 1 # B's item, counted globally
|
||||
assert status.user_in_progress == 0 # A owns none in progress
|
||||
assert status.user_pending == 1 # A's single pending item
|
||||
|
||||
|
||||
def test_get_queue_item_ids_returns_all_users_ids(session_queue: SqliteSessionQueue) -> None:
|
||||
"""get_queue_item_ids returns ids for every user so the virtualized list can show the
|
||||
(redacted) entries belonging to other users. Redaction happens at hydration time."""
|
||||
a_item_id = _insert_queue_item(session_queue, user_id="user-a")
|
||||
b_item_id = _insert_queue_item(session_queue, user_id="user-b")
|
||||
|
||||
result = session_queue.get_queue_item_ids(queue_id="default")
|
||||
|
||||
assert set(result.item_ids) == {a_item_id, b_item_id}
|
||||
assert result.total_count == 2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
"""Tests for workflow-call retry semantics in the session queue."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.events.events_common import QueueItemsRetriedEvent
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem
|
||||
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState
|
||||
from tests.test_nodes import TestEventService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue:
|
||||
db = mock_invoker.services.board_records._db
|
||||
queue = SqliteSessionQueue(db=db)
|
||||
queue.start(mock_invoker)
|
||||
return queue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_bus(mock_invoker: Invoker) -> TestEventService:
|
||||
assert isinstance(mock_invoker.services.events, TestEventService)
|
||||
return mock_invoker.services.events
|
||||
|
||||
|
||||
def _build_queue_item(
|
||||
*,
|
||||
item_id: int,
|
||||
session: GraphExecutionState,
|
||||
user_id: str,
|
||||
status: str,
|
||||
root_item_id: int | None = None,
|
||||
retried_from_item_id: int | None = None,
|
||||
) -> SessionQueueItem:
|
||||
now = datetime.now()
|
||||
return SessionQueueItem(
|
||||
item_id=item_id,
|
||||
status=status,
|
||||
priority=0,
|
||||
batch_id=f"batch-{item_id}",
|
||||
origin=None,
|
||||
destination=None,
|
||||
session_id=session.id,
|
||||
error_type=None,
|
||||
error_message=None,
|
||||
error_traceback=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
queue_id="default",
|
||||
user_id=user_id,
|
||||
user_display_name=None,
|
||||
user_email=None,
|
||||
field_values=None,
|
||||
retried_from_item_id=retried_from_item_id,
|
||||
workflow_call_id=None,
|
||||
parent_item_id=None,
|
||||
parent_session_id=None,
|
||||
root_item_id=root_item_id,
|
||||
workflow_call_depth=None,
|
||||
session=session,
|
||||
workflow=None,
|
||||
)
|
||||
|
||||
|
||||
def test_retry_items_by_id_retries_root_once_for_child_chain_item(
|
||||
session_queue: SqliteSessionQueue, event_bus: TestEventService, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
root_session = GraphExecutionState(graph=Graph())
|
||||
child_session = GraphExecutionState(graph=Graph())
|
||||
|
||||
root_item = _build_queue_item(item_id=10, session=root_session, user_id="user-1", status="failed")
|
||||
child_item = _build_queue_item(
|
||||
item_id=11,
|
||||
session=child_session,
|
||||
user_id="user-1",
|
||||
status="failed",
|
||||
root_item_id=root_item.item_id,
|
||||
)
|
||||
|
||||
items = {root_item.item_id: root_item, child_item.item_id: child_item}
|
||||
monkeypatch.setattr(session_queue, "get_queue_item", lambda item_id: items[item_id])
|
||||
|
||||
retry_result = session_queue.retry_items_by_id("default", [child_item.item_id, root_item.item_id])
|
||||
|
||||
assert retry_result.retried_item_ids == [root_item.item_id]
|
||||
|
||||
all_items = session_queue.list_all_queue_items("default")
|
||||
retried_items = [item for item in all_items if item.retried_from_item_id == root_item.item_id]
|
||||
assert len(retried_items) == 1
|
||||
assert retried_items[0].status == "pending"
|
||||
assert retried_items[0].workflow_call_id is None
|
||||
assert retried_items[0].parent_item_id is None
|
||||
assert retried_items[0].root_item_id is None
|
||||
|
||||
retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)]
|
||||
assert len(retry_events) == 1
|
||||
assert retry_events[0].retried_item_ids == [root_item.item_id]
|
||||
assert retry_events[0].user_ids == ["user-1"]
|
||||
assert retry_events[0].retried_item_ids_by_user == {"user-1": [root_item.item_id]}
|
||||
|
||||
|
||||
def test_retry_items_by_id_emits_unique_owner_ids_for_multiple_roots(
|
||||
session_queue: SqliteSessionQueue, event_bus: TestEventService, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
first_root_item = _build_queue_item(
|
||||
item_id=20, session=GraphExecutionState(graph=Graph()), user_id="user-1", status="failed"
|
||||
)
|
||||
second_root_item = _build_queue_item(
|
||||
item_id=21, session=GraphExecutionState(graph=Graph()), user_id="user-2", status="canceled"
|
||||
)
|
||||
|
||||
items = {
|
||||
first_root_item.item_id: first_root_item,
|
||||
second_root_item.item_id: second_root_item,
|
||||
}
|
||||
monkeypatch.setattr(session_queue, "get_queue_item", lambda item_id: items[item_id])
|
||||
|
||||
retry_result = session_queue.retry_items_by_id("default", [first_root_item.item_id, second_root_item.item_id])
|
||||
|
||||
assert retry_result.retried_item_ids == [first_root_item.item_id, second_root_item.item_id]
|
||||
|
||||
retry_events = [event for event in event_bus.events if isinstance(event, QueueItemsRetriedEvent)]
|
||||
assert len(retry_events) == 1
|
||||
assert retry_events[0].user_ids == ["user-1", "user-2"]
|
||||
assert retry_events[0].retried_item_ids_by_user == {
|
||||
"user-1": [first_root_item.item_id],
|
||||
"user-2": [second_root_item.item_id],
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import sqlite3
|
||||
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_2026_07_01_add_workflow_call_queue_metadata import (
|
||||
AddWorkflowCallQueueMetadataCallback,
|
||||
build_migration,
|
||||
)
|
||||
|
||||
|
||||
def _get_columns(cursor: sqlite3.Cursor, table_name: str) -> set[str]:
|
||||
cursor.execute(f"PRAGMA table_info({table_name});")
|
||||
return {row[1] for row in cursor.fetchall()}
|
||||
|
||||
|
||||
def _get_indexes(cursor: sqlite3.Cursor) -> set[str]:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type = 'index';")
|
||||
return {row[0] for row in cursor.fetchall()}
|
||||
|
||||
|
||||
def test_adds_workflow_call_columns_and_indexes_to_session_queue() -> None:
|
||||
db = sqlite3.connect(":memory:")
|
||||
cursor = db.cursor()
|
||||
cursor.execute("CREATE TABLE session_queue (item_id INTEGER PRIMARY KEY);")
|
||||
|
||||
AddWorkflowCallQueueMetadataCallback()(cursor)
|
||||
|
||||
assert _get_columns(cursor, "session_queue") >= {
|
||||
"workflow_call_id",
|
||||
"parent_item_id",
|
||||
"parent_session_id",
|
||||
"root_item_id",
|
||||
"workflow_call_depth",
|
||||
}
|
||||
assert _get_indexes(cursor) >= {
|
||||
"idx_session_queue_workflow_call_id",
|
||||
"idx_session_queue_parent_item_id",
|
||||
"idx_session_queue_parent_session_id",
|
||||
"idx_session_queue_root_item_id",
|
||||
"idx_session_queue_workflow_call_depth",
|
||||
}
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
def test_migration_is_idempotent_and_tolerates_missing_session_queue() -> None:
|
||||
db = sqlite3.connect(":memory:")
|
||||
cursor = db.cursor()
|
||||
|
||||
AddWorkflowCallQueueMetadataCallback()(cursor)
|
||||
cursor.execute("CREATE TABLE session_queue (item_id INTEGER PRIMARY KEY, workflow_call_id TEXT);")
|
||||
AddWorkflowCallQueueMetadataCallback()(cursor)
|
||||
AddWorkflowCallQueueMetadataCallback()(cursor)
|
||||
|
||||
assert _get_columns(cursor, "session_queue") >= {
|
||||
"workflow_call_id",
|
||||
"parent_item_id",
|
||||
"parent_session_id",
|
||||
"root_item_id",
|
||||
"workflow_call_depth",
|
||||
}
|
||||
|
||||
db.close()
|
||||
|
||||
|
||||
def test_build_migration_declares_stable_id_and_dependency() -> None:
|
||||
migration = build_migration()
|
||||
|
||||
assert migration.id == "2026_07_01_add_workflow_call_queue_metadata"
|
||||
assert migration.depends_on == "migration_33"
|
||||
assert migration.from_version is None
|
||||
assert migration.to_version is None
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Tests for migration 31: Add image_subfolder column to images table."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_31 import (
|
||||
Migration31Callback,
|
||||
build_migration_31,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db() -> sqlite3.Connection:
|
||||
"""In-memory SQLite database with a minimal images table mimicking pre-migration schema."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE images (
|
||||
image_name TEXT NOT NULL PRIMARY KEY,
|
||||
image_origin TEXT NOT NULL,
|
||||
image_category TEXT NOT NULL,
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
session_id TEXT,
|
||||
node_id TEXT,
|
||||
metadata TEXT,
|
||||
is_intermediate BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%f', 'NOW')),
|
||||
updated_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%f', 'NOW')),
|
||||
deleted_at DATETIME,
|
||||
starred BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
has_workflow BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
"""
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
class TestMigration31:
|
||||
def test_adds_image_subfolder_column(self, db: sqlite3.Connection):
|
||||
"""Migration adds image_subfolder column to existing images table."""
|
||||
callback = Migration31Callback()
|
||||
cursor = db.cursor()
|
||||
callback(cursor)
|
||||
|
||||
cursor.execute("PRAGMA table_info(images);")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
assert "image_subfolder" in columns
|
||||
|
||||
def test_existing_rows_get_empty_string_default(self, db: sqlite3.Connection):
|
||||
"""Pre-existing image rows should get image_subfolder = '' after migration."""
|
||||
db.execute(
|
||||
"INSERT INTO images (image_name, image_origin, image_category, width, height, has_workflow) "
|
||||
"VALUES ('old_image.png', 'internal', 'general', 512, 512, 0)"
|
||||
)
|
||||
db.commit()
|
||||
|
||||
callback = Migration31Callback()
|
||||
callback(db.cursor())
|
||||
db.commit()
|
||||
|
||||
row = db.execute("SELECT image_subfolder FROM images WHERE image_name = 'old_image.png'").fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == ""
|
||||
|
||||
def test_idempotent_migration(self, db: sqlite3.Connection):
|
||||
"""Running migration twice should not fail (column already exists)."""
|
||||
callback = Migration31Callback()
|
||||
cursor = db.cursor()
|
||||
callback(cursor)
|
||||
# Running again should be safe
|
||||
callback(cursor)
|
||||
|
||||
cursor.execute("PRAGMA table_info(images);")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
assert columns.count("image_subfolder") == 1
|
||||
|
||||
def test_no_images_table_is_noop(self):
|
||||
"""If images table doesn't exist, migration is a no-op."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
callback = Migration31Callback()
|
||||
cursor = conn.cursor()
|
||||
# Should not raise
|
||||
callback(cursor)
|
||||
|
||||
def test_build_migration_31_version_numbers(self):
|
||||
"""build_migration_31 returns correct version range."""
|
||||
migration = build_migration_31()
|
||||
assert migration.from_version == 30
|
||||
assert migration.to_version == 31
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for migration 32: Repair model_relationships foreign keys."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_32 import (
|
||||
Migration32Callback,
|
||||
build_migration_32,
|
||||
)
|
||||
|
||||
|
||||
def _create_models_table(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE models (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
config TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _create_broken_relationships_table(conn: sqlite3.Connection) -> None:
|
||||
"""Recreates the broken state left by migration 22: FKs reference the dropped models_old table."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE model_relationships (
|
||||
model_key_1 TEXT NOT NULL,
|
||||
model_key_2 TEXT NOT NULL,
|
||||
created_at TEXT DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
|
||||
PRIMARY KEY (model_key_1, model_key_2),
|
||||
FOREIGN KEY (model_key_1) REFERENCES "models_old"(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (model_key_2) REFERENCES "models_old"(id) ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX keyx_model_relationships_model_key_2 ON model_relationships(model_key_2);")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
_create_models_table(conn)
|
||||
_create_broken_relationships_table(conn)
|
||||
conn.execute("INSERT INTO models (id, config) VALUES ('a', '{}'), ('b', '{}'), ('c', '{}')")
|
||||
return conn
|
||||
|
||||
|
||||
class TestMigration32:
|
||||
def test_repoints_foreign_keys_to_models(self, db: sqlite3.Connection):
|
||||
"""After migration, the foreign keys reference models, not models_old."""
|
||||
Migration32Callback()(db.cursor())
|
||||
db.commit()
|
||||
|
||||
sql = db.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='model_relationships'").fetchone()[
|
||||
0
|
||||
]
|
||||
assert "models_old" not in sql
|
||||
assert "REFERENCES models(id)" in sql
|
||||
|
||||
def test_preserves_valid_links(self, db: sqlite3.Connection):
|
||||
"""Links between existing models are preserved."""
|
||||
db.execute("INSERT INTO model_relationships (model_key_1, model_key_2) VALUES ('a', 'b')")
|
||||
db.commit()
|
||||
|
||||
Migration32Callback()(db.cursor())
|
||||
db.commit()
|
||||
|
||||
rows = db.execute("SELECT model_key_1, model_key_2 FROM model_relationships ORDER BY model_key_1").fetchall()
|
||||
assert rows == [("a", "b")]
|
||||
|
||||
def test_drops_orphaned_links(self, db: sqlite3.Connection):
|
||||
"""Links referencing missing models are dropped so the restored FKs are satisfiable."""
|
||||
db.execute("INSERT INTO model_relationships (model_key_1, model_key_2) VALUES ('a', 'b')")
|
||||
db.execute("INSERT INTO model_relationships (model_key_1, model_key_2) VALUES ('a', 'gone')")
|
||||
db.commit()
|
||||
|
||||
Migration32Callback()(db.cursor())
|
||||
db.commit()
|
||||
|
||||
rows = db.execute("SELECT model_key_1, model_key_2 FROM model_relationships").fetchall()
|
||||
assert rows == [("a", "b")]
|
||||
|
||||
def test_cascade_works_after_repair(self, db: sqlite3.Connection):
|
||||
"""ON DELETE CASCADE against models works once the FKs are repaired."""
|
||||
db.execute("INSERT INTO model_relationships (model_key_1, model_key_2) VALUES ('a', 'b')")
|
||||
db.commit()
|
||||
|
||||
Migration32Callback()(db.cursor())
|
||||
db.commit()
|
||||
|
||||
db.execute("PRAGMA foreign_keys = ON;")
|
||||
db.execute("DELETE FROM models WHERE id = 'a'")
|
||||
db.commit()
|
||||
|
||||
rows = db.execute("SELECT * FROM model_relationships").fetchall()
|
||||
assert rows == []
|
||||
|
||||
def test_index_recreated(self, db: sqlite3.Connection):
|
||||
"""The lookup index on model_key_2 is recreated on the rebuilt table."""
|
||||
Migration32Callback()(db.cursor())
|
||||
db.commit()
|
||||
|
||||
idx = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='keyx_model_relationships_model_key_2'"
|
||||
).fetchone()
|
||||
assert idx is not None
|
||||
|
||||
def test_idempotent_when_already_correct(self, db: sqlite3.Connection):
|
||||
"""Running on an already-correct table is a no-op (no rebuild)."""
|
||||
Migration32Callback()(db.cursor())
|
||||
db.commit()
|
||||
# Second run should detect the correct FKs and do nothing.
|
||||
Migration32Callback()(db.cursor())
|
||||
db.commit()
|
||||
|
||||
sql = db.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='model_relationships'").fetchone()[
|
||||
0
|
||||
]
|
||||
assert "REFERENCES models(id)" in sql
|
||||
|
||||
def test_no_relationships_table_is_noop(self):
|
||||
"""If the table doesn't exist, migration is a no-op."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
Migration32Callback()(conn.cursor()) # should not raise
|
||||
|
||||
def test_build_migration_32_version_numbers(self):
|
||||
migration = build_migration_32()
|
||||
assert migration.from_version == 31
|
||||
assert migration.to_version == 32
|
||||
@@ -0,0 +1,252 @@
|
||||
import importlib
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.shared.sqlite_migrator.migration_loader import (
|
||||
MigrationBuildContext,
|
||||
MigrationLoaderError,
|
||||
build_migrations,
|
||||
)
|
||||
|
||||
|
||||
def _write_package(tmp_path: Path, package_name: str, modules: dict[str, str]) -> str:
|
||||
package_path = tmp_path / package_name
|
||||
package_path.mkdir()
|
||||
(package_path / "__init__.py").write_text("", encoding="utf-8")
|
||||
for module_name, module_source in modules.items():
|
||||
(package_path / f"{module_name}.py").write_text(module_source, encoding="utf-8")
|
||||
importlib.invalidate_caches()
|
||||
return package_name
|
||||
|
||||
|
||||
def test_build_migrations_discovers_modules_in_numeric_order(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
package_name = _write_package(
|
||||
tmp_path,
|
||||
"test_migrations_order",
|
||||
{
|
||||
"migration_2": """
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
def build_migration_2():
|
||||
return Migration(from_version=1, to_version=2, callback=lambda cursor: None)
|
||||
""",
|
||||
"migration_1": """
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
def build_migration_1():
|
||||
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
|
||||
""",
|
||||
"not_a_migration": "VALUE = 1",
|
||||
},
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
migrations = build_migrations(
|
||||
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
|
||||
package_name=package_name,
|
||||
)
|
||||
|
||||
assert [migration.id for migration in migrations] == ["migration_1", "migration_2"]
|
||||
|
||||
|
||||
def test_build_migrations_injects_requested_dependencies(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
package_name = _write_package(
|
||||
tmp_path,
|
||||
"test_migrations_injection",
|
||||
{
|
||||
"migration_1": """
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
def build_migration_1(app_config, logger, image_files):
|
||||
assert app_config == "config"
|
||||
assert logger.name == "test"
|
||||
assert image_files == "images"
|
||||
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
|
||||
""",
|
||||
},
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
migrations = build_migrations(
|
||||
MigrationBuildContext(app_config="config", logger=Logger("test"), image_files="images"),
|
||||
package_name=package_name,
|
||||
)
|
||||
|
||||
assert [migration.id for migration in migrations] == ["migration_1"]
|
||||
|
||||
|
||||
def test_build_migrations_supports_dated_descriptive_modules(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
package_name = _write_package(
|
||||
tmp_path,
|
||||
"test_migrations_dated",
|
||||
{
|
||||
"migration_2026_06_30_add_example_table": """
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
def build_migration():
|
||||
return Migration(
|
||||
id="2026_06_30_add_example_table",
|
||||
depends_on="migration_1",
|
||||
callback=lambda cursor: None,
|
||||
)
|
||||
""",
|
||||
"migration_1": """
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
def build_migration_1():
|
||||
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
|
||||
""",
|
||||
},
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
migrations = build_migrations(
|
||||
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
|
||||
package_name=package_name,
|
||||
)
|
||||
|
||||
assert [migration.id for migration in migrations] == ["migration_1", "2026_06_30_add_example_table"]
|
||||
|
||||
|
||||
def test_build_migrations_rejects_dated_module_id_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
package_name = _write_package(
|
||||
tmp_path,
|
||||
"test_migrations_dated_id_mismatch",
|
||||
{
|
||||
"migration_2026_06_30_add_example_table": """
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
def build_migration():
|
||||
return Migration(
|
||||
id="2026_06_30_typo",
|
||||
depends_on="migration_1",
|
||||
callback=lambda cursor: None,
|
||||
)
|
||||
""",
|
||||
},
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
with pytest.raises(MigrationLoaderError, match="must return migration id"):
|
||||
build_migrations(
|
||||
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
|
||||
package_name=package_name,
|
||||
)
|
||||
|
||||
|
||||
def test_build_migrations_rejects_numeric_module_id_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
package_name = _write_package(
|
||||
tmp_path,
|
||||
"test_migrations_numeric_id_mismatch",
|
||||
{
|
||||
"migration_1": """
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
def build_migration_1():
|
||||
return Migration(id="wrong", from_version=0, to_version=1, callback=lambda cursor: None)
|
||||
""",
|
||||
},
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
with pytest.raises(MigrationLoaderError, match="must return migration id"):
|
||||
build_migrations(
|
||||
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
|
||||
package_name=package_name,
|
||||
)
|
||||
|
||||
|
||||
def test_build_migrations_rejects_unknown_builder_dependency(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
package_name = _write_package(
|
||||
tmp_path,
|
||||
"test_migrations_unknown_dependency",
|
||||
{
|
||||
"migration_1": """
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
def build_migration_1(unknown_service):
|
||||
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
|
||||
""",
|
||||
},
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
with pytest.raises(MigrationLoaderError, match="unknown dependency"):
|
||||
build_migrations(
|
||||
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
|
||||
package_name=package_name,
|
||||
)
|
||||
|
||||
|
||||
def test_build_migrations_rejects_missing_expected_builder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
package_name = _write_package(
|
||||
tmp_path,
|
||||
"test_migrations_missing_builder",
|
||||
{
|
||||
"migration_1": """
|
||||
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
|
||||
def build_something_else():
|
||||
return Migration(from_version=0, to_version=1, callback=lambda cursor: None)
|
||||
""",
|
||||
},
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
with pytest.raises(MigrationLoaderError, match="build_migration_1"):
|
||||
build_migrations(
|
||||
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
|
||||
package_name=package_name,
|
||||
)
|
||||
|
||||
|
||||
def test_build_migrations_rejects_non_migration_return(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
package_name = _write_package(
|
||||
tmp_path,
|
||||
"test_migrations_bad_return",
|
||||
{
|
||||
"migration_1": """
|
||||
def build_migration_1():
|
||||
return object()
|
||||
""",
|
||||
},
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
with pytest.raises(MigrationLoaderError, match="must return Migration"):
|
||||
build_migrations(
|
||||
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
|
||||
package_name=package_name,
|
||||
)
|
||||
|
||||
|
||||
def test_build_migrations_rejects_malformed_migration_module(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
package_name = _write_package(
|
||||
tmp_path,
|
||||
"test_migrations_malformed",
|
||||
{
|
||||
"migration_latest": "VALUE = 1",
|
||||
},
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
|
||||
with pytest.raises(MigrationLoaderError, match="Malformed migration module"):
|
||||
build_migrations(
|
||||
MigrationBuildContext(app_config=object(), logger=Logger("test"), image_files=object()),
|
||||
package_name=package_name,
|
||||
)
|
||||
|
||||
|
||||
def test_build_migrations_discovers_production_migrations(tmp_path: Path) -> None:
|
||||
class FakeConfig:
|
||||
root_path = tmp_path
|
||||
models_path = tmp_path / "models"
|
||||
convert_cache_path = tmp_path / "models" / ".cache"
|
||||
legacy_conf_path = tmp_path / "models.yaml"
|
||||
legacy_conf_dir = tmp_path
|
||||
|
||||
migrations = build_migrations(
|
||||
MigrationBuildContext(app_config=FakeConfig(), logger=Logger("test"), image_files=object())
|
||||
)
|
||||
legacy_migrations = [migration for migration in migrations if migration.to_version is not None]
|
||||
latest_legacy_version = max(migration.to_version or 0 for migration in legacy_migrations)
|
||||
|
||||
assert [migration.id for migration in legacy_migrations] == [
|
||||
f"migration_{i}" for i in range(1, latest_legacy_version + 1)
|
||||
]
|
||||
assert [migration.depends_on for migration in legacy_migrations] == [None] + [
|
||||
f"migration_{i}" for i in range(1, latest_legacy_version)
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from invokeai.app.services.invocation_services import InvocationServices
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.app.services.session_processor.session_processor_default import DefaultSessionProcessor
|
||||
|
||||
|
||||
def _services(**overrides):
|
||||
services = {
|
||||
"board_image_records": object(),
|
||||
"board_images": object(),
|
||||
"board_records": object(),
|
||||
"boards": object(),
|
||||
"bulk_download": object(),
|
||||
"configuration": object(),
|
||||
"events": object(),
|
||||
"images": object(),
|
||||
"image_files": object(),
|
||||
"image_records": object(),
|
||||
"logger": object(),
|
||||
"model_images": object(),
|
||||
"model_manager": object(),
|
||||
"model_relationships": object(),
|
||||
"model_relationship_records": object(),
|
||||
"download_queue": object(),
|
||||
"external_generation": object(),
|
||||
"performance_statistics": object(),
|
||||
"session_queue": object(),
|
||||
"session_processor": object(),
|
||||
"invocation_cache": object(),
|
||||
"names": object(),
|
||||
"urls": object(),
|
||||
"workflow_records": object(),
|
||||
"tensors": object(),
|
||||
"conditioning": object(),
|
||||
"style_preset_records": object(),
|
||||
"style_preset_image_files": object(),
|
||||
"workflow_thumbnails": object(),
|
||||
"client_state_persistence": object(),
|
||||
"users": object(),
|
||||
"image_moves": None,
|
||||
}
|
||||
services.update(overrides)
|
||||
return InvocationServices(**services)
|
||||
|
||||
|
||||
def test_image_moves_start_before_session_processor() -> None:
|
||||
started: list[str] = []
|
||||
image_moves = MagicMock()
|
||||
image_moves.start.side_effect = lambda _invoker: started.append("image_moves")
|
||||
session_processor = MagicMock()
|
||||
session_processor.start.side_effect = lambda _invoker: started.append("session_processor")
|
||||
|
||||
Invoker(_services(image_moves=image_moves, session_processor=session_processor))
|
||||
|
||||
assert started == ["image_moves", "session_processor"]
|
||||
|
||||
|
||||
def test_session_processor_detects_active_image_move_maintenance() -> None:
|
||||
image_moves = MagicMock()
|
||||
image_moves.is_maintenance_active.return_value = True
|
||||
processor = DefaultSessionProcessor()
|
||||
processor._invoker = MagicMock()
|
||||
processor._invoker.services.image_moves = image_moves
|
||||
|
||||
assert processor._is_image_move_maintenance_active() is True
|
||||
|
||||
|
||||
def test_session_processor_allows_processing_without_image_move_maintenance() -> None:
|
||||
image_moves = MagicMock()
|
||||
image_moves.is_maintenance_active.return_value = False
|
||||
processor = DefaultSessionProcessor()
|
||||
processor._invoker = MagicMock()
|
||||
processor._invoker.services.image_moves = image_moves
|
||||
|
||||
assert processor._is_image_move_maintenance_active() is False
|
||||
@@ -0,0 +1,13 @@
|
||||
from tests.app.services import workflow_call_test_utils as workflow_call_tests
|
||||
|
||||
|
||||
def test_run_node_propagates_keyboard_interrupt(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_node_propagates_keyboard_interrupt(monkeypatch)
|
||||
|
||||
|
||||
def test_run_node_does_not_swallow_sigint_in_subprocess() -> None:
|
||||
workflow_call_tests.test_run_node_does_not_swallow_sigint_in_subprocess()
|
||||
|
||||
|
||||
def test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch) -> None:
|
||||
workflow_call_tests.test_on_after_run_session_does_not_complete_incomplete_session(monkeypatch)
|
||||
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.board_records.board_records_common import (
|
||||
BoardRecordNotFoundException,
|
||||
BoardRecordOrderBy,
|
||||
)
|
||||
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
||||
from invokeai.app.services.config.config_default import InvokeAIAppConfig
|
||||
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
|
||||
from invokeai.backend.util.logging import InvokeAILogger
|
||||
from tests.fixtures.sqlite_database import create_mock_sqlite_database
|
||||
|
||||
|
||||
def _create_board_storage() -> SqliteBoardRecordStorage:
|
||||
config = InvokeAIAppConfig(use_memory_db=True)
|
||||
db = create_mock_sqlite_database(config=config, logger=InvokeAILogger.get_logger())
|
||||
return SqliteBoardRecordStorage(db=db)
|
||||
|
||||
|
||||
def test_sql_injection_payload_in_board_name_is_stored_as_plain_text() -> None:
|
||||
storage = _create_board_storage()
|
||||
|
||||
payload = "name'); DROP TABLE boards; --"
|
||||
injected_board = storage.save(payload, "0")
|
||||
|
||||
fetched = storage.get(injected_board.board_id)
|
||||
assert fetched.board_name == payload
|
||||
|
||||
another_board = storage.save("safe board", "0")
|
||||
assert storage.get(another_board.board_id).board_name == "safe board"
|
||||
|
||||
|
||||
def test_sql_injection_payload_in_board_id_does_not_bypass_where_clause() -> None:
|
||||
storage = _create_board_storage()
|
||||
|
||||
storage.save("first board", "0")
|
||||
storage.save("second board", "0")
|
||||
|
||||
payload = "does-not-exist' OR '1'='1"
|
||||
|
||||
with pytest.raises(BoardRecordNotFoundException):
|
||||
storage.get(payload)
|
||||
|
||||
|
||||
def test_sql_injection_payload_in_delete_does_not_delete_other_rows() -> None:
|
||||
storage = _create_board_storage()
|
||||
|
||||
first = storage.save("first board", "0")
|
||||
second = storage.save("second board", "0")
|
||||
|
||||
payload = f"{first.board_id}' OR '1'='1"
|
||||
storage.delete(payload)
|
||||
|
||||
remaining = storage.get_many(
|
||||
order_by=BoardRecordOrderBy.CreatedAt,
|
||||
direction=SQLiteDirection.Ascending,
|
||||
limit=10,
|
||||
offset=0,
|
||||
include_archived=True,
|
||||
user_id="0",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
assert {board.board_id for board in remaining.items} == {first.board_id, second.board_id}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
from tests.app.services import workflow_call_test_utils as workflow_call_tests
|
||||
|
||||
|
||||
def test_run_node_fails_cleanly_for_invalid_batch_child_workflow(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_node_fails_cleanly_for_invalid_batch_child_workflow(monkeypatch)
|
||||
|
||||
|
||||
def test_run_completes_call_saved_workflow_with_batched_child_returns(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_completes_call_saved_workflow_with_batched_child_returns(monkeypatch)
|
||||
|
||||
|
||||
def test_run_zips_grouped_batch_children(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_zips_grouped_batch_children(monkeypatch)
|
||||
|
||||
|
||||
def test_run_expands_ungrouped_batch_children_as_cartesian_product(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_expands_ungrouped_batch_children_as_cartesian_product(monkeypatch)
|
||||
|
||||
|
||||
def test_run_fails_batched_child_workflow_and_cancels_remaining_siblings(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_fails_batched_child_workflow_and_cancels_remaining_siblings(monkeypatch)
|
||||
|
||||
|
||||
def test_run_supports_generator_backed_integer_batched_child_workflow(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_supports_generator_backed_integer_batched_child_workflow(monkeypatch)
|
||||
|
||||
|
||||
def test_run_supports_generator_backed_image_batched_child_workflow(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_supports_generator_backed_image_batched_child_workflow(monkeypatch)
|
||||
|
||||
|
||||
def test_run_rejects_non_generator_connected_batched_child_workflow(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_rejects_non_generator_connected_batched_child_workflow(monkeypatch)
|
||||
@@ -0,0 +1,613 @@
|
||||
from typing import Any
|
||||
|
||||
from invokeai.app.services.shared.workflow_call_compatibility import (
|
||||
WorkflowCallCompatibilityReason,
|
||||
get_workflow_call_compatibility,
|
||||
)
|
||||
|
||||
|
||||
def _invocation_node(node_id: str, invocation_type: str, inputs: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": node_id,
|
||||
"type": "invocation",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"id": node_id,
|
||||
"type": invocation_type,
|
||||
"version": "1.0.0",
|
||||
"nodePack": "invokeai",
|
||||
"label": "",
|
||||
"notes": "",
|
||||
"isOpen": True,
|
||||
"isIntermediate": False,
|
||||
"useCache": True,
|
||||
"dynamicInputTemplates": {},
|
||||
"inputs": inputs,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _workflow_dump(*, nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": "Child Workflow",
|
||||
"author": "Tester",
|
||||
"description": "",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "",
|
||||
"notes": "",
|
||||
"exposedFields": [],
|
||||
"meta": {"category": "user", "version": "1.0.0"},
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"form": None,
|
||||
}
|
||||
|
||||
|
||||
def _return_nodes() -> list[dict[str, Any]]:
|
||||
return [
|
||||
_invocation_node(
|
||||
"return-value", "workflow_return_value", {"key": {"value": "result"}, "value": {"value": None}}
|
||||
),
|
||||
_invocation_node("return-collect", "collect", {"collection": {"value": []}}),
|
||||
_invocation_node("return", "workflow_return", {"values": {"value": []}}),
|
||||
]
|
||||
|
||||
|
||||
def _return_edges(source: str, source_handle: str) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"id": "edge-return-value",
|
||||
"type": "default",
|
||||
"source": source,
|
||||
"sourceHandle": source_handle,
|
||||
"target": "return-value",
|
||||
"targetHandle": "value",
|
||||
},
|
||||
{
|
||||
"id": "edge-return-collect",
|
||||
"type": "default",
|
||||
"source": "return-value",
|
||||
"sourceHandle": "value",
|
||||
"target": "return-collect",
|
||||
"targetHandle": "item",
|
||||
},
|
||||
{
|
||||
"id": "edge-return-values",
|
||||
"type": "default",
|
||||
"source": "return-collect",
|
||||
"sourceHandle": "collection",
|
||||
"target": "return",
|
||||
"targetHandle": "values",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _services():
|
||||
return type(
|
||||
"Services",
|
||||
(),
|
||||
{
|
||||
"board_images": type(
|
||||
"BoardImages",
|
||||
(),
|
||||
{
|
||||
"get_all_board_image_names_for_board": staticmethod(
|
||||
lambda board_id, categories, is_intermediate: ["img-a", "img-b"]
|
||||
)
|
||||
},
|
||||
)(),
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
def _services_that_fail_on_image_enumeration():
|
||||
def fail(*args: Any, **kwargs: Any) -> list[str]:
|
||||
raise AssertionError("image names should not be enumerated for structural compatibility")
|
||||
|
||||
return type(
|
||||
"Services",
|
||||
(),
|
||||
{
|
||||
"board_images": type(
|
||||
"BoardImages",
|
||||
(),
|
||||
{"get_all_board_image_names_for_board": staticmethod(fail)},
|
||||
)(),
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_returns_ok_for_simple_callable_workflow() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node("collection", "integer_collection", {"collection": {"value": [1]}}),
|
||||
*_return_nodes(),
|
||||
],
|
||||
edges=_return_edges("collection", "collection"),
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is True
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
|
||||
assert compatibility.message is None
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_allows_legacy_none_board_values() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node(
|
||||
"image",
|
||||
"blank_image",
|
||||
{"board": {"value": "none"}, "width": {"value": 64}, "height": {"value": 64}},
|
||||
),
|
||||
*_return_nodes(),
|
||||
],
|
||||
edges=_return_edges("image", "image"),
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is True
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
|
||||
assert compatibility.message is None
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_allows_single_return_value_connected_directly() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node("sum", "add", {"a": {"value": 1}, "b": {"value": 2}}),
|
||||
_invocation_node(
|
||||
"return-value", "workflow_return_value", {"key": {"value": "sum"}, "value": {"value": None}}
|
||||
),
|
||||
_invocation_node("return", "workflow_return", {"values": {"value": []}}),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-sum-return-value",
|
||||
"type": "default",
|
||||
"source": "sum",
|
||||
"sourceHandle": "value",
|
||||
"target": "return-value",
|
||||
"targetHandle": "value",
|
||||
},
|
||||
{
|
||||
"id": "edge-return-value-return",
|
||||
"type": "default",
|
||||
"source": "return-value",
|
||||
"sourceHandle": "value",
|
||||
"target": "return",
|
||||
"targetHandle": "values",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is True
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
|
||||
assert compatibility.message is None
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_reports_missing_workflow_return() -> None:
|
||||
workflow = _workflow_dump(nodes=[_invocation_node("add", "add", {"a": {"value": 1}, "b": {"value": 2}})], edges=[])
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is False
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.MissingWorkflowReturn
|
||||
assert compatibility.message == "The workflow must contain exactly one workflow_return node."
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_reports_multiple_workflow_return_nodes() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node("return-a", "workflow_return", {"values": {"value": []}}),
|
||||
_invocation_node("return-b", "workflow_return", {"values": {"value": []}}),
|
||||
],
|
||||
edges=[],
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is False
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.MultipleWorkflowReturn
|
||||
assert compatibility.message == "The workflow must not contain more than one workflow_return node."
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_reports_generator_capacity_limit() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node(
|
||||
"generator",
|
||||
"integer_generator",
|
||||
{
|
||||
"generator": {
|
||||
"value": {
|
||||
"type": "integer_generator_arithmetic_sequence",
|
||||
"start": 0,
|
||||
"step": 1,
|
||||
"count": 1_000_000,
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
_invocation_node(
|
||||
"batch",
|
||||
"integer_batch",
|
||||
{"integers": {"value": []}, "batch_group_id": {"value": "None"}},
|
||||
),
|
||||
_invocation_node("target", "integer", {"value": {"value": 0}}),
|
||||
*_return_nodes(),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-generator-batch",
|
||||
"type": "default",
|
||||
"source": "generator",
|
||||
"sourceHandle": "integers",
|
||||
"target": "batch",
|
||||
"targetHandle": "integers",
|
||||
},
|
||||
{
|
||||
"id": "edge-batch-target",
|
||||
"type": "default",
|
||||
"source": "batch",
|
||||
"sourceHandle": "integers",
|
||||
"target": "target",
|
||||
"targetHandle": "value",
|
||||
},
|
||||
*_return_edges("target", "value"),
|
||||
],
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=10,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is False
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.ExceedsCapacity
|
||||
assert compatibility.message == "call_saved_workflow exceeds remaining queue capacity for child workflow executions"
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_does_not_report_present_malformed_workflow_return_as_missing() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
{
|
||||
"id": "return",
|
||||
"type": "invocation",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"type": "workflow_return",
|
||||
"version": "1.0.0",
|
||||
"nodePack": "invokeai",
|
||||
"label": "",
|
||||
"notes": "",
|
||||
"isOpen": True,
|
||||
"isIntermediate": False,
|
||||
"useCache": True,
|
||||
"dynamicInputTemplates": {},
|
||||
"inputs": {"values": {"value": []}},
|
||||
},
|
||||
}
|
||||
],
|
||||
edges=[],
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is False
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.InvalidGraph
|
||||
assert compatibility.message != "The workflow must contain exactly one workflow_return node."
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_reports_unsupported_connected_batch_input() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node("source", "integer", {"value": {"value": 7}}),
|
||||
_invocation_node(
|
||||
"batch", "integer_batch", {"integers": {"value": []}, "batch_group_id": {"value": "None"}}
|
||||
),
|
||||
_invocation_node("target", "integer", {"value": {"value": 0}}),
|
||||
_invocation_node("collect", "collect", {"collection": {"value": []}}),
|
||||
*_return_nodes(),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-source-batch",
|
||||
"type": "default",
|
||||
"source": "source",
|
||||
"sourceHandle": "value",
|
||||
"target": "batch",
|
||||
"targetHandle": "integers",
|
||||
},
|
||||
{
|
||||
"id": "edge-batch-target",
|
||||
"type": "default",
|
||||
"source": "batch",
|
||||
"sourceHandle": "integers",
|
||||
"target": "target",
|
||||
"targetHandle": "value",
|
||||
},
|
||||
{
|
||||
"id": "edge-target-collect",
|
||||
"type": "default",
|
||||
"source": "target",
|
||||
"sourceHandle": "value",
|
||||
"target": "collect",
|
||||
"targetHandle": "item",
|
||||
},
|
||||
*_return_edges("collect", "collection"),
|
||||
],
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is False
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.UnsupportedBatchInput
|
||||
assert "connected batch child workflow inputs" in (compatibility.message or "")
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_allows_batch_returned_by_name() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node(
|
||||
"batch", "integer_batch", {"integers": {"value": [2, 4]}, "batch_group_id": {"value": "None"}}
|
||||
),
|
||||
*_return_nodes(),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-batch-return",
|
||||
"type": "default",
|
||||
"source": "batch",
|
||||
"sourceHandle": "integers",
|
||||
"target": "return-value",
|
||||
"targetHandle": "value",
|
||||
},
|
||||
*_return_edges("return-value", "value")[1:],
|
||||
],
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is True
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
|
||||
assert compatibility.message is None
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_can_skip_generator_expansion_for_list_views() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node(
|
||||
"generator",
|
||||
"image_generator",
|
||||
{
|
||||
"generator": {
|
||||
"value": {
|
||||
"type": "image_generator_images_from_board",
|
||||
"board_id": "board-a",
|
||||
"category": "images",
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
_invocation_node("batch", "image_batch", {"images": {"value": []}, "batch_group_id": {"value": "None"}}),
|
||||
*_return_nodes(),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-generator-batch",
|
||||
"type": "default",
|
||||
"source": "generator",
|
||||
"sourceHandle": "collection",
|
||||
"target": "batch",
|
||||
"targetHandle": "images",
|
||||
},
|
||||
{
|
||||
"id": "edge-batch-return",
|
||||
"type": "default",
|
||||
"source": "batch",
|
||||
"sourceHandle": "images",
|
||||
"target": "return-value",
|
||||
"targetHandle": "value",
|
||||
},
|
||||
*_return_edges("return-value", "value")[1:],
|
||||
],
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services_that_fail_on_image_enumeration(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
resolve_generator_items=False,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is True
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
|
||||
assert compatibility.message is None
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_reports_multiple_batch_inputs_as_unsupported_batch_input() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node("source-a", "integer", {"value": {"value": 7}}),
|
||||
_invocation_node("source-b", "integer", {"value": {"value": 8}}),
|
||||
_invocation_node(
|
||||
"batch", "integer_batch", {"integers": {"value": []}, "batch_group_id": {"value": "None"}}
|
||||
),
|
||||
_invocation_node("target", "integer", {"value": {"value": 0}}),
|
||||
_invocation_node("collect", "collect", {"collection": {"value": []}}),
|
||||
*_return_nodes(),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-source-a-batch",
|
||||
"type": "default",
|
||||
"source": "source-a",
|
||||
"sourceHandle": "value",
|
||||
"target": "batch",
|
||||
"targetHandle": "integers",
|
||||
},
|
||||
{
|
||||
"id": "edge-source-b-batch",
|
||||
"type": "default",
|
||||
"source": "source-b",
|
||||
"sourceHandle": "value",
|
||||
"target": "batch",
|
||||
"targetHandle": "integers",
|
||||
},
|
||||
{
|
||||
"id": "edge-batch-target",
|
||||
"type": "default",
|
||||
"source": "batch",
|
||||
"sourceHandle": "integers",
|
||||
"target": "target",
|
||||
"targetHandle": "value",
|
||||
},
|
||||
{
|
||||
"id": "edge-target-collect",
|
||||
"type": "default",
|
||||
"source": "target",
|
||||
"sourceHandle": "value",
|
||||
"target": "collect",
|
||||
"targetHandle": "item",
|
||||
},
|
||||
*_return_edges("collect", "collection"),
|
||||
],
|
||||
)
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is False
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.UnsupportedBatchInput
|
||||
assert "multiple connected batch inputs" in (compatibility.message or "")
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_allows_workflow_with_required_exposed_input() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node("target", "integer", {"value": {}}),
|
||||
_invocation_node("collect", "collect", {"collection": {"value": []}}),
|
||||
*_return_nodes(),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-target-collect",
|
||||
"type": "default",
|
||||
"source": "target",
|
||||
"sourceHandle": "value",
|
||||
"target": "collect",
|
||||
"targetHandle": "item",
|
||||
},
|
||||
*_return_edges("collect", "collection"),
|
||||
],
|
||||
)
|
||||
workflow["exposedFields"] = [{"nodeId": "target", "fieldName": "value"}]
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is True
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
|
||||
assert compatibility.message is None
|
||||
|
||||
|
||||
def test_get_workflow_call_compatibility_allows_workflow_with_required_structured_exposed_input() -> None:
|
||||
workflow = _workflow_dump(
|
||||
nodes=[
|
||||
_invocation_node("template", "prompt_template", {"style_preset": {}}),
|
||||
_invocation_node("collect", "collect", {"collection": {"value": []}}),
|
||||
*_return_nodes(),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-template-collect",
|
||||
"type": "default",
|
||||
"source": "template",
|
||||
"sourceHandle": "positive_prompt",
|
||||
"target": "collect",
|
||||
"targetHandle": "item",
|
||||
},
|
||||
*_return_edges("collect", "collection"),
|
||||
],
|
||||
)
|
||||
workflow["exposedFields"] = [{"nodeId": "template", "fieldName": "style_preset"}]
|
||||
|
||||
compatibility = get_workflow_call_compatibility(
|
||||
workflow=workflow,
|
||||
workflow_id="workflow-a",
|
||||
services=_services(),
|
||||
user_id="user-1",
|
||||
maximum_children=1000,
|
||||
)
|
||||
|
||||
assert compatibility.is_callable is True
|
||||
assert compatibility.reason is WorkflowCallCompatibilityReason.Ok
|
||||
assert compatibility.message is None
|
||||
@@ -0,0 +1,153 @@
|
||||
from tests.app.services import workflow_call_test_utils as workflow_call_tests
|
||||
|
||||
|
||||
def test_run_node_enters_waiting_state_without_executing_child_inline(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_node_enters_waiting_state_without_executing_child_inline(monkeypatch)
|
||||
|
||||
|
||||
def test_run_persists_waiting_session_without_completing_queue_item(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_persists_waiting_session_without_completing_queue_item(monkeypatch)
|
||||
|
||||
|
||||
def test_workflow_call_coordinator_suspends_parent_and_enqueues_child_queue_item(monkeypatch) -> None:
|
||||
workflow_call_tests.test_workflow_call_coordinator_suspends_parent_and_enqueues_child_queue_item(monkeypatch)
|
||||
|
||||
|
||||
def test_workflow_call_queue_lifecycle_leaves_non_call_workflows_on_normal_execution_path(monkeypatch) -> None:
|
||||
workflow_call_tests.test_workflow_call_queue_lifecycle_leaves_non_call_workflows_on_normal_execution_path(
|
||||
monkeypatch
|
||||
)
|
||||
|
||||
|
||||
def test_default_session_processor_uses_runner_workflow_call_lifecycle(monkeypatch) -> None:
|
||||
workflow_call_tests.test_default_session_processor_uses_runner_workflow_call_lifecycle(monkeypatch)
|
||||
|
||||
|
||||
def test_workflow_call_queue_lifecycle_resumes_parent_from_completed_child(monkeypatch) -> None:
|
||||
workflow_call_tests.test_workflow_call_queue_lifecycle_resumes_parent_from_completed_child(monkeypatch)
|
||||
|
||||
|
||||
def test_run_preserves_canceled_child_workflow_chain_without_failing_parent(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_preserves_canceled_child_workflow_chain_without_failing_parent(monkeypatch)
|
||||
|
||||
|
||||
def test_run_does_not_resume_canceled_parent_after_completed_child(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_does_not_resume_canceled_parent_after_completed_child(monkeypatch)
|
||||
|
||||
|
||||
def test_run_does_not_fail_canceled_parent_after_child_return_error(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_does_not_fail_canceled_parent_after_child_return_error(monkeypatch)
|
||||
|
||||
|
||||
def test_run_queue_item_tolerates_queue_item_deleted_mid_run(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_queue_item_tolerates_queue_item_deleted_mid_run(monkeypatch)
|
||||
|
||||
|
||||
def test_run_queue_item_tolerates_parent_deleted_while_child_runs(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_queue_item_tolerates_parent_deleted_while_child_runs(monkeypatch)
|
||||
|
||||
|
||||
def test_run_queue_item_tolerates_parent_deleted_before_completed_parent_mutation(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_queue_item_tolerates_parent_deleted_before_completed_parent_mutation(monkeypatch)
|
||||
|
||||
|
||||
def test_run_queue_item_tolerates_parent_deleted_before_failed_parent_mutation(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_queue_item_tolerates_parent_deleted_before_failed_parent_mutation(monkeypatch)
|
||||
|
||||
|
||||
def test_run_queue_item_tolerates_parent_deleted_before_canceled_parent_mutation(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_queue_item_tolerates_parent_deleted_before_canceled_parent_mutation(monkeypatch)
|
||||
|
||||
|
||||
def test_workflow_call_coordinator_builds_child_queue_item_with_relationship_metadata(monkeypatch) -> None:
|
||||
workflow_call_tests.test_workflow_call_coordinator_builds_child_queue_item_with_relationship_metadata(monkeypatch)
|
||||
|
||||
|
||||
def test_workflow_call_coordinator_cleans_up_enqueued_children_when_boundary_setup_fails(monkeypatch) -> None:
|
||||
workflow_call_tests.test_workflow_call_coordinator_cleans_up_enqueued_children_when_boundary_setup_fails(
|
||||
monkeypatch
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_call_coordinator_rejects_child_expansion_that_exceeds_remaining_queue_capacity(monkeypatch) -> None:
|
||||
workflow_call_tests.test_workflow_call_coordinator_rejects_child_expansion_that_exceeds_remaining_queue_capacity(
|
||||
monkeypatch
|
||||
)
|
||||
|
||||
|
||||
def test_run_completes_call_saved_workflow_and_runs_downstream_nodes(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_completes_call_saved_workflow_and_runs_downstream_nodes(monkeypatch)
|
||||
|
||||
|
||||
def test_run_completes_parent_queue_item_when_return_get_is_terminal(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_completes_parent_queue_item_when_return_get_is_terminal(monkeypatch)
|
||||
|
||||
|
||||
def test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_node_records_child_execution_state_for_call_saved_workflow(monkeypatch)
|
||||
|
||||
|
||||
def test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_executes_child_workflow_and_completes_parent_queue_item(monkeypatch)
|
||||
|
||||
|
||||
def test_run_completes_call_saved_workflow_with_child_return_collection(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_completes_call_saved_workflow_with_child_return_collection(monkeypatch)
|
||||
|
||||
|
||||
def test_run_extracts_named_call_saved_workflow_return(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_extracts_named_call_saved_workflow_return(monkeypatch)
|
||||
|
||||
|
||||
def test_workflow_call_batch_aggregation_rejects_inconsistent_return_keys() -> None:
|
||||
workflow_call_tests.test_workflow_call_batch_aggregation_rejects_inconsistent_return_keys()
|
||||
|
||||
|
||||
def test_workflow_call_return_aggregation_failure_cancels_remaining_siblings(monkeypatch) -> None:
|
||||
workflow_call_tests.test_workflow_call_return_aggregation_failure_cancels_remaining_siblings(monkeypatch)
|
||||
|
||||
|
||||
def test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_fails_call_saved_workflow_when_child_has_no_workflow_return(monkeypatch)
|
||||
|
||||
|
||||
def test_run_respects_child_dependency_readiness(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_respects_child_dependency_readiness(monkeypatch)
|
||||
|
||||
|
||||
def test_run_respects_child_if_branching(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_respects_child_if_branching(monkeypatch)
|
||||
|
||||
|
||||
def test_run_supports_nested_call_saved_workflow_execution(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_supports_nested_call_saved_workflow_execution(monkeypatch)
|
||||
|
||||
|
||||
def test_run_cascades_nested_child_workflow_failures_to_all_parents(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_cascades_nested_child_workflow_failures_to_all_parents(monkeypatch)
|
||||
|
||||
|
||||
def test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_forwards_literal_dynamic_workflow_inputs_to_child_workflow(monkeypatch)
|
||||
|
||||
|
||||
def test_run_forwards_connected_dynamic_workflow_inputs_to_child_workflow(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_forwards_connected_dynamic_workflow_inputs_to_child_workflow(monkeypatch)
|
||||
|
||||
|
||||
def test_run_rejects_non_exposed_dynamic_workflow_inputs(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_rejects_non_exposed_dynamic_workflow_inputs(monkeypatch)
|
||||
|
||||
|
||||
def test_run_fails_call_saved_workflow_when_child_workflow_graph_cannot_be_built(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_fails_call_saved_workflow_when_child_workflow_graph_cannot_be_built(monkeypatch)
|
||||
|
||||
|
||||
def test_run_fails_call_saved_workflow_with_invalid_selection_without_entering_waiting_state(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_fails_call_saved_workflow_with_invalid_selection_without_entering_waiting_state(
|
||||
monkeypatch
|
||||
)
|
||||
|
||||
|
||||
def test_run_fails_call_saved_workflow_when_depth_limit_is_exceeded(monkeypatch) -> None:
|
||||
workflow_call_tests.test_run_fails_call_saved_workflow_when_depth_limit_is_exceeded(monkeypatch)
|
||||
@@ -0,0 +1,270 @@
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.shared.graph import Graph
|
||||
from invokeai.app.services.shared.workflow_graph_builder import (
|
||||
UnsupportedWorkflowNodeError,
|
||||
build_graph_from_workflow,
|
||||
)
|
||||
|
||||
|
||||
def _build_workflow_node(
|
||||
node_id: str,
|
||||
invocation_type: str,
|
||||
inputs: dict[str, object],
|
||||
*,
|
||||
is_intermediate: bool = False,
|
||||
use_cache: bool = True,
|
||||
):
|
||||
return {
|
||||
"id": node_id,
|
||||
"type": "invocation",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"id": node_id,
|
||||
"type": invocation_type,
|
||||
"version": "1.0.0",
|
||||
"nodePack": "invokeai",
|
||||
"label": "",
|
||||
"notes": "",
|
||||
"isOpen": True,
|
||||
"isIntermediate": is_intermediate,
|
||||
"useCache": use_cache,
|
||||
"dynamicInputTemplates": {},
|
||||
"inputs": {name: {"value": value} for name, value in inputs.items()},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_connector_node(node_id: str):
|
||||
return {
|
||||
"id": node_id,
|
||||
"type": "connector",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"id": node_id,
|
||||
"type": "connector",
|
||||
"label": "Connector",
|
||||
"isOpen": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_workflow(edges: list[dict], nodes: list[dict]):
|
||||
return {
|
||||
"name": "Child Workflow",
|
||||
"author": "Tester",
|
||||
"description": "",
|
||||
"version": "1.0.0",
|
||||
"contact": "",
|
||||
"tags": "",
|
||||
"notes": "",
|
||||
"exposedFields": [],
|
||||
"meta": {"version": "1.0.0", "category": "user"},
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"form": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_named_return_nodes():
|
||||
return [
|
||||
_build_workflow_node("return-value-1", "workflow_return_value", {"key": "result", "value": None}),
|
||||
_build_workflow_node("return-collect-1", "collect", {"collection": []}),
|
||||
_build_workflow_node("return-1", "workflow_return", {"values": []}),
|
||||
]
|
||||
|
||||
|
||||
def _build_named_return_edges(source: str, source_handle: str):
|
||||
return [
|
||||
{
|
||||
"id": "edge-return-value",
|
||||
"type": "default",
|
||||
"source": source,
|
||||
"sourceHandle": source_handle,
|
||||
"target": "return-value-1",
|
||||
"targetHandle": "value",
|
||||
},
|
||||
{
|
||||
"id": "edge-return-collect",
|
||||
"type": "default",
|
||||
"source": "return-value-1",
|
||||
"sourceHandle": "value",
|
||||
"target": "return-collect-1",
|
||||
"targetHandle": "item",
|
||||
},
|
||||
{
|
||||
"id": "edge-return-values",
|
||||
"type": "default",
|
||||
"source": "return-collect-1",
|
||||
"sourceHandle": "collection",
|
||||
"target": "return-1",
|
||||
"targetHandle": "values",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_build_graph_from_workflow_converts_invocation_nodes():
|
||||
workflow = _build_workflow(
|
||||
nodes=[
|
||||
_build_workflow_node("add-1", "add", {"a": 1, "b": 2}),
|
||||
_build_workflow_node("return-1", "workflow_return", {"values": []}),
|
||||
],
|
||||
edges=[],
|
||||
)
|
||||
|
||||
graph = build_graph_from_workflow(workflow)
|
||||
|
||||
assert isinstance(graph, Graph)
|
||||
assert set(graph.nodes.keys()) == {"add-1", "return-1"}
|
||||
assert graph.nodes["add-1"].get_type() == "add"
|
||||
assert graph.nodes["add-1"].a == 1
|
||||
assert graph.nodes["add-1"].b == 2
|
||||
assert graph.nodes["return-1"].get_type() == "workflow_return"
|
||||
|
||||
|
||||
def test_build_graph_from_workflow_flattens_connector_edges():
|
||||
workflow = _build_workflow(
|
||||
nodes=[
|
||||
_build_workflow_node("add-1", "add", {"a": 1, "b": 2}),
|
||||
_build_connector_node("connector-1"),
|
||||
_build_workflow_node("add-2", "add", {"a": 999, "b": 3}),
|
||||
*_build_named_return_nodes(),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-1",
|
||||
"type": "default",
|
||||
"source": "add-1",
|
||||
"sourceHandle": "value",
|
||||
"target": "connector-1",
|
||||
"targetHandle": "in",
|
||||
},
|
||||
{
|
||||
"id": "edge-2",
|
||||
"type": "default",
|
||||
"source": "connector-1",
|
||||
"sourceHandle": "out",
|
||||
"target": "add-2",
|
||||
"targetHandle": "a",
|
||||
},
|
||||
*_build_named_return_edges("add-2", "value"),
|
||||
],
|
||||
)
|
||||
|
||||
graph = build_graph_from_workflow(workflow)
|
||||
|
||||
assert len(graph.edges) == 4
|
||||
first_edge, second_edge, third_edge, fourth_edge = graph.edges
|
||||
assert first_edge.source.node_id == "add-1"
|
||||
assert first_edge.source.field == "value"
|
||||
assert first_edge.destination.node_id == "add-2"
|
||||
assert first_edge.destination.field == "a"
|
||||
assert second_edge.source.node_id == "add-2"
|
||||
assert second_edge.source.field == "value"
|
||||
assert second_edge.destination.node_id == "return-value-1"
|
||||
assert second_edge.destination.field == "value"
|
||||
assert third_edge.destination.node_id == "return-collect-1"
|
||||
assert third_edge.destination.field == "item"
|
||||
assert fourth_edge.destination.node_id == "return-1"
|
||||
assert fourth_edge.destination.field == "values"
|
||||
assert graph.nodes["add-2"].a == 0
|
||||
assert graph.nodes["add-2"].b == 3
|
||||
assert graph.nodes["return-1"].values == []
|
||||
|
||||
|
||||
def test_build_graph_from_workflow_uses_defaults_for_inputs_without_saved_values():
|
||||
collect_node = _build_workflow_node("return-collect-1", "collect", {})
|
||||
collect_node["data"]["inputs"] = {
|
||||
"item": {"name": "item", "label": "", "description": ""},
|
||||
"collection": {"name": "collection", "label": "", "description": ""},
|
||||
}
|
||||
workflow = _build_workflow(
|
||||
nodes=[
|
||||
_build_workflow_node("return-value-1", "workflow_return_value", {"key": "result", "value": None}),
|
||||
collect_node,
|
||||
_build_workflow_node("return-1", "workflow_return", {"values": []}),
|
||||
],
|
||||
edges=[
|
||||
{
|
||||
"id": "edge-return-collect",
|
||||
"type": "default",
|
||||
"source": "return-value-1",
|
||||
"sourceHandle": "value",
|
||||
"target": "return-collect-1",
|
||||
"targetHandle": "item",
|
||||
},
|
||||
{
|
||||
"id": "edge-return-values",
|
||||
"type": "default",
|
||||
"source": "return-collect-1",
|
||||
"sourceHandle": "collection",
|
||||
"target": "return-1",
|
||||
"targetHandle": "values",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
graph = build_graph_from_workflow(workflow)
|
||||
|
||||
assert graph.nodes["return-collect-1"].collection == []
|
||||
|
||||
|
||||
def test_build_graph_from_workflow_uses_default_for_legacy_auto_board_values():
|
||||
workflow = _build_workflow(
|
||||
nodes=[
|
||||
_build_workflow_node("image-1", "blank_image", {"board": "auto", "width": 64, "height": 64}),
|
||||
*_build_named_return_nodes(),
|
||||
],
|
||||
edges=_build_named_return_edges("image-1", "image"),
|
||||
)
|
||||
|
||||
graph = build_graph_from_workflow(workflow)
|
||||
|
||||
assert graph.nodes["image-1"].board is None
|
||||
|
||||
|
||||
def test_build_graph_from_workflow_uses_default_for_legacy_none_board_values():
|
||||
workflow = _build_workflow(
|
||||
nodes=[
|
||||
_build_workflow_node("image-1", "blank_image", {"board": "none", "width": 64, "height": 64}),
|
||||
*_build_named_return_nodes(),
|
||||
],
|
||||
edges=_build_named_return_edges("image-1", "image"),
|
||||
)
|
||||
|
||||
graph = build_graph_from_workflow(workflow)
|
||||
|
||||
assert graph.nodes["image-1"].board is None
|
||||
|
||||
|
||||
def test_build_graph_from_workflow_rejects_batch_special_nodes_with_clear_error():
|
||||
workflow = _build_workflow(
|
||||
nodes=[_build_workflow_node("image-batch-1", "image_batch", {"images": []})],
|
||||
edges=[],
|
||||
)
|
||||
|
||||
with pytest.raises(UnsupportedWorkflowNodeError, match="call_saved_workflow does not yet support batch-special"):
|
||||
build_graph_from_workflow(workflow)
|
||||
|
||||
|
||||
def test_build_graph_from_workflow_rejects_workflows_without_workflow_return():
|
||||
workflow = _build_workflow(
|
||||
nodes=[_build_workflow_node("add-1", "add", {"a": 1, "b": 2})],
|
||||
edges=[],
|
||||
)
|
||||
|
||||
with pytest.raises(UnsupportedWorkflowNodeError, match="exactly one workflow_return"):
|
||||
build_graph_from_workflow(workflow)
|
||||
|
||||
|
||||
def test_build_graph_from_workflow_rejects_workflows_with_multiple_workflow_return_nodes():
|
||||
workflow = _build_workflow(
|
||||
nodes=[
|
||||
_build_workflow_node("return-1", "workflow_return", {"values": []}),
|
||||
_build_workflow_node("return-2", "workflow_return", {"values": []}),
|
||||
],
|
||||
edges=[],
|
||||
)
|
||||
|
||||
with pytest.raises(UnsupportedWorkflowNodeError, match="exactly one workflow_return"):
|
||||
build_graph_from_workflow(workflow)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for password utilities."""
|
||||
|
||||
from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password
|
||||
|
||||
|
||||
def test_hash_password():
|
||||
"""Test password hashing."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert hashed != password
|
||||
assert len(hashed) > 0
|
||||
|
||||
|
||||
def test_verify_password():
|
||||
"""Test password verification."""
|
||||
password = "TestPassword123"
|
||||
hashed = hash_password(password)
|
||||
|
||||
assert verify_password(password, hashed)
|
||||
assert not verify_password("WrongPassword", hashed)
|
||||
|
||||
|
||||
def test_validate_password_strength_valid():
|
||||
"""Test password strength validation with valid passwords."""
|
||||
valid, msg = validate_password_strength("ValidPass123")
|
||||
assert valid
|
||||
assert msg == ""
|
||||
|
||||
|
||||
def test_validate_password_strength_too_short():
|
||||
"""Test password strength validation with short password."""
|
||||
valid, msg = validate_password_strength("Pass1")
|
||||
assert not valid
|
||||
assert "at least 8 characters" in msg
|
||||
|
||||
|
||||
def test_validate_password_strength_no_uppercase():
|
||||
"""Test password strength validation without uppercase."""
|
||||
valid, msg = validate_password_strength("password123")
|
||||
assert not valid
|
||||
assert "uppercase" in msg.lower()
|
||||
|
||||
|
||||
def test_validate_password_strength_no_lowercase():
|
||||
"""Test password strength validation without lowercase."""
|
||||
valid, msg = validate_password_strength("PASSWORD123")
|
||||
assert not valid
|
||||
assert "lowercase" in msg.lower()
|
||||
|
||||
|
||||
def test_validate_password_strength_no_digit():
|
||||
"""Test password strength validation without digit."""
|
||||
valid, msg = validate_password_strength("PasswordTest")
|
||||
assert not valid
|
||||
assert "number" in msg.lower()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for token service."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from invokeai.app.services.auth.token_service import TokenData, create_access_token, verify_token
|
||||
|
||||
|
||||
def test_create_access_token():
|
||||
"""Test creating an access token."""
|
||||
data = TokenData(user_id="test-user", email="test@example.com", is_admin=False)
|
||||
token = create_access_token(data)
|
||||
|
||||
assert token is not None
|
||||
assert len(token) > 0
|
||||
|
||||
|
||||
def test_verify_valid_token():
|
||||
"""Test verifying a valid token."""
|
||||
data = TokenData(user_id="test-user", email="test@example.com", is_admin=True)
|
||||
token = create_access_token(data)
|
||||
|
||||
verified_data = verify_token(token)
|
||||
|
||||
assert verified_data is not None
|
||||
assert verified_data.user_id == data.user_id
|
||||
assert verified_data.email == data.email
|
||||
assert verified_data.is_admin == data.is_admin
|
||||
|
||||
|
||||
def test_verify_invalid_token():
|
||||
"""Test verifying an invalid token."""
|
||||
verified_data = verify_token("invalid-token")
|
||||
assert verified_data is None
|
||||
|
||||
|
||||
def test_token_with_custom_expiration():
|
||||
"""Test creating token with custom expiration."""
|
||||
data = TokenData(user_id="test-user", email="test@example.com", is_admin=False)
|
||||
token = create_access_token(data, expires_delta=timedelta(hours=1))
|
||||
|
||||
verified_data = verify_token(token)
|
||||
assert verified_data is not None
|
||||
assert verified_data.user_id == data.user_id
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Tests for user service."""
|
||||
|
||||
from logging import Logger
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
|
||||
from invokeai.app.services.users.users_common import UserCreateRequest, UserUpdateRequest
|
||||
from invokeai.app.services.users.users_default import UserService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logger() -> Logger:
|
||||
"""Create a logger for testing."""
|
||||
return Logger("test_user_service")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(logger: Logger) -> SqliteDatabase:
|
||||
"""Create an in-memory database for testing."""
|
||||
db = SqliteDatabase(db_path=None, logger=logger, verbose=False)
|
||||
# Create users table manually for testing
|
||||
db._conn.execute("""
|
||||
CREATE TABLE users (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
|
||||
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
|
||||
last_login_at DATETIME
|
||||
);
|
||||
""")
|
||||
db._conn.commit()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_service(db: SqliteDatabase) -> UserService:
|
||||
"""Create a user service for testing."""
|
||||
return UserService(db)
|
||||
|
||||
|
||||
def test_create_user(user_service: UserService):
|
||||
"""Test creating a user."""
|
||||
user_data = UserCreateRequest(
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
password="TestPassword123",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
user = user_service.create(user_data)
|
||||
|
||||
assert user.email == "test@example.com"
|
||||
assert user.display_name == "Test User"
|
||||
assert user.is_admin is False
|
||||
assert user.is_active is True
|
||||
assert user.user_id is not None
|
||||
|
||||
|
||||
def test_create_user_weak_password(user_service: UserService):
|
||||
"""Test creating a user with weak password fails when strict checking is enabled."""
|
||||
user_data = UserCreateRequest(
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
password="weak",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="at least 8 characters"):
|
||||
user_service.create(user_data, strict_password_checking=True)
|
||||
|
||||
|
||||
def test_create_user_weak_password_non_strict(user_service: UserService):
|
||||
"""Test creating a user with weak password succeeds when strict checking is disabled."""
|
||||
user_data = UserCreateRequest(
|
||||
email="weakpass@example.com",
|
||||
display_name="Test User",
|
||||
password="weak",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
user = user_service.create(user_data, strict_password_checking=False)
|
||||
assert user.email == "weakpass@example.com"
|
||||
|
||||
|
||||
def test_create_duplicate_user(user_service: UserService):
|
||||
"""Test creating a duplicate user."""
|
||||
user_data = UserCreateRequest(
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
password="TestPassword123",
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
user_service.create(user_data)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
user_service.create(user_data)
|
||||
|
||||
|
||||
def test_get_user(user_service: UserService):
|
||||
"""Test getting a user by ID."""
|
||||
user_data = UserCreateRequest(
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
password="TestPassword123",
|
||||
)
|
||||
|
||||
created_user = user_service.create(user_data)
|
||||
retrieved_user = user_service.get(created_user.user_id)
|
||||
|
||||
assert retrieved_user is not None
|
||||
assert retrieved_user.user_id == created_user.user_id
|
||||
assert retrieved_user.email == created_user.email
|
||||
|
||||
|
||||
def test_get_nonexistent_user(user_service: UserService):
|
||||
"""Test getting a nonexistent user."""
|
||||
user = user_service.get("nonexistent-id")
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_get_user_by_email(user_service: UserService):
|
||||
"""Test getting a user by email."""
|
||||
user_data = UserCreateRequest(
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
password="TestPassword123",
|
||||
)
|
||||
|
||||
created_user = user_service.create(user_data)
|
||||
retrieved_user = user_service.get_by_email("test@example.com")
|
||||
|
||||
assert retrieved_user is not None
|
||||
assert retrieved_user.user_id == created_user.user_id
|
||||
assert retrieved_user.email == "test@example.com"
|
||||
|
||||
|
||||
def test_update_user(user_service: UserService):
|
||||
"""Test updating a user."""
|
||||
user_data = UserCreateRequest(
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
password="TestPassword123",
|
||||
)
|
||||
|
||||
user = user_service.create(user_data)
|
||||
|
||||
updates = UserUpdateRequest(
|
||||
display_name="Updated Name",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
updated_user = user_service.update(user.user_id, updates)
|
||||
|
||||
assert updated_user.display_name == "Updated Name"
|
||||
assert updated_user.is_admin is True
|
||||
|
||||
|
||||
def test_delete_user(user_service: UserService):
|
||||
"""Test deleting a user."""
|
||||
user_data = UserCreateRequest(
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
password="TestPassword123",
|
||||
)
|
||||
|
||||
user = user_service.create(user_data)
|
||||
user_service.delete(user.user_id)
|
||||
|
||||
retrieved_user = user_service.get(user.user_id)
|
||||
assert retrieved_user is None
|
||||
|
||||
|
||||
def test_authenticate_valid_credentials(user_service: UserService):
|
||||
"""Test authenticating with valid credentials."""
|
||||
user_data = UserCreateRequest(
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
password="TestPassword123",
|
||||
)
|
||||
|
||||
user_service.create(user_data)
|
||||
authenticated_user = user_service.authenticate("test@example.com", "TestPassword123")
|
||||
|
||||
assert authenticated_user is not None
|
||||
assert authenticated_user.email == "test@example.com"
|
||||
assert authenticated_user.last_login_at is not None
|
||||
|
||||
|
||||
def test_authenticate_invalid_password(user_service: UserService):
|
||||
"""Test authenticating with invalid password."""
|
||||
user_data = UserCreateRequest(
|
||||
email="test@example.com",
|
||||
display_name="Test User",
|
||||
password="TestPassword123",
|
||||
)
|
||||
|
||||
user_service.create(user_data)
|
||||
authenticated_user = user_service.authenticate("test@example.com", "WrongPassword")
|
||||
|
||||
assert authenticated_user is None
|
||||
|
||||
|
||||
def test_authenticate_nonexistent_user(user_service: UserService):
|
||||
"""Test authenticating nonexistent user."""
|
||||
authenticated_user = user_service.authenticate("nonexistent@example.com", "TestPassword123")
|
||||
assert authenticated_user is None
|
||||
|
||||
|
||||
def test_has_admin(user_service: UserService):
|
||||
"""Test checking if admin exists."""
|
||||
assert user_service.has_admin() is False
|
||||
|
||||
user_data = UserCreateRequest(
|
||||
email="admin@example.com",
|
||||
display_name="Admin User",
|
||||
password="AdminPassword123",
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
user_service.create(user_data)
|
||||
assert user_service.has_admin() is True
|
||||
|
||||
|
||||
def test_create_admin(user_service: UserService):
|
||||
"""Test creating an admin user."""
|
||||
user_data = UserCreateRequest(
|
||||
email="admin@example.com",
|
||||
display_name="Admin User",
|
||||
password="AdminPassword123",
|
||||
)
|
||||
|
||||
admin = user_service.create_admin(user_data)
|
||||
|
||||
assert admin.is_admin is True
|
||||
assert admin.email == "admin@example.com"
|
||||
|
||||
|
||||
def test_create_admin_when_exists(user_service: UserService):
|
||||
"""Test creating admin when one already exists."""
|
||||
user_data = UserCreateRequest(
|
||||
email="admin@example.com",
|
||||
display_name="Admin User",
|
||||
password="AdminPassword123",
|
||||
)
|
||||
|
||||
user_service.create_admin(user_data)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
user_service.create_admin(user_data)
|
||||
|
||||
|
||||
def test_list_users(user_service: UserService):
|
||||
"""Test listing users."""
|
||||
for i in range(5):
|
||||
user_data = UserCreateRequest(
|
||||
email=f"test{i}@example.com",
|
||||
display_name=f"Test User {i}",
|
||||
password="TestPassword123",
|
||||
)
|
||||
user_service.create(user_data)
|
||||
|
||||
users = user_service.list_users()
|
||||
assert len(users) == 5
|
||||
|
||||
limited_users = user_service.list_users(limit=2)
|
||||
assert len(limited_users) == 2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.api.extract_metadata_from_image import ExtractedMetadata, extract_metadata_from_image
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_logger():
|
||||
return MagicMock(spec=logging.Logger)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_metadata():
|
||||
return json.dumps({"param1": "value1", "param2": 123})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_workflow():
|
||||
return json.dumps({"name": "test_workflow", "version": "1.0"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_graph():
|
||||
return json.dumps({"nodes": {}, "edges": []})
|
||||
|
||||
|
||||
def test_extract_valid_metadata_from_image(mock_logger, valid_metadata, valid_workflow, valid_graph):
|
||||
# Create a mock image with valid metadata
|
||||
mock_image = MagicMock(spec=Image.Image)
|
||||
mock_image.info = {
|
||||
"invokeai_metadata": valid_metadata,
|
||||
"invokeai_workflow": valid_workflow,
|
||||
"invokeai_graph": valid_graph,
|
||||
}
|
||||
|
||||
# Mock the validation functions
|
||||
with patch(
|
||||
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
|
||||
) as mock_workflow_validate:
|
||||
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json") as _mock_graph_validate:
|
||||
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
|
||||
|
||||
# Assert correct calls to validators
|
||||
mock_workflow_validate.assert_called_once_with(valid_workflow)
|
||||
# TODO(psyche): The extract_metadata_from_image does not validate the graph correctly. See note in `extract_metadata_from_image.py`.
|
||||
# Skipping this.
|
||||
# _mock_graph_validate.assert_called_once_with(valid_graph)
|
||||
|
||||
# Assert correct extraction
|
||||
assert result == ExtractedMetadata(
|
||||
invokeai_metadata=valid_metadata, invokeai_workflow=valid_workflow, invokeai_graph=valid_graph
|
||||
)
|
||||
|
||||
|
||||
def test_extract_invalid_metadata(mock_logger, valid_workflow, valid_graph):
|
||||
# Invalid metadata (not JSON)
|
||||
invalid_metadata = "not a valid json"
|
||||
|
||||
mock_image = MagicMock(spec=Image.Image)
|
||||
mock_image.info = {
|
||||
"invokeai_metadata": invalid_metadata,
|
||||
"invokeai_workflow": valid_workflow,
|
||||
"invokeai_graph": valid_graph,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
|
||||
):
|
||||
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
|
||||
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
|
||||
|
||||
assert mock_logger.debug.to_have_been_called_with("Failed to parse metadata for uploaded image")
|
||||
|
||||
# Invalid metadata should be None, others valid
|
||||
assert result.invokeai_metadata is None
|
||||
assert result.invokeai_workflow == valid_workflow
|
||||
assert result.invokeai_graph == valid_graph
|
||||
|
||||
|
||||
def test_metadata_wrong_type(mock_logger, valid_workflow, valid_graph):
|
||||
# Valid JSON but not a dict
|
||||
metadata_array = json.dumps(["item1", "item2"])
|
||||
|
||||
mock_image = MagicMock(spec=Image.Image)
|
||||
mock_image.info = {
|
||||
"invokeai_metadata": metadata_array,
|
||||
"invokeai_workflow": valid_workflow,
|
||||
"invokeai_graph": valid_graph,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
|
||||
):
|
||||
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
|
||||
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
|
||||
|
||||
# Metadata should be None as it's not a dict
|
||||
assert result.invokeai_metadata is None
|
||||
assert result.invokeai_workflow == valid_workflow
|
||||
assert result.invokeai_graph == valid_graph
|
||||
|
||||
|
||||
def test_with_non_string_metadata(mock_logger, valid_workflow, valid_graph):
|
||||
# Some implementations might include metadata as non-string values
|
||||
mock_image = MagicMock(spec=Image.Image)
|
||||
mock_image.info = {
|
||||
"invokeai_metadata": 12345, # Not a string
|
||||
"invokeai_workflow": valid_workflow,
|
||||
"invokeai_graph": valid_graph,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
|
||||
):
|
||||
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
|
||||
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
|
||||
|
||||
assert mock_logger.debug.to_have_been_called_with("Failed to parse metadata for uploaded image")
|
||||
|
||||
assert result.invokeai_metadata is None
|
||||
assert result.invokeai_workflow == valid_workflow
|
||||
assert result.invokeai_graph == valid_graph
|
||||
|
||||
|
||||
def test_invalid_workflow(mock_logger, valid_metadata, valid_graph):
|
||||
invalid_workflow = "not a valid workflow json"
|
||||
|
||||
mock_image = MagicMock(spec=Image.Image)
|
||||
mock_image.info = {
|
||||
"invokeai_metadata": valid_metadata,
|
||||
"invokeai_workflow": invalid_workflow,
|
||||
"invokeai_graph": valid_graph,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
|
||||
) as mock_validate:
|
||||
mock_validate.side_effect = ValueError("Invalid workflow")
|
||||
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
|
||||
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
|
||||
|
||||
assert result.invokeai_metadata == valid_metadata
|
||||
assert result.invokeai_workflow is None
|
||||
assert result.invokeai_graph == valid_graph
|
||||
|
||||
|
||||
def test_invalid_graph(mock_logger, valid_metadata, valid_workflow):
|
||||
invalid_graph = "not a valid graph json"
|
||||
|
||||
mock_image = MagicMock(spec=Image.Image)
|
||||
mock_image.info = {
|
||||
"invokeai_metadata": valid_metadata,
|
||||
"invokeai_workflow": valid_workflow,
|
||||
"invokeai_graph": invalid_graph,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
|
||||
):
|
||||
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json") as mock_validate:
|
||||
mock_validate.side_effect = ValueError("Invalid graph")
|
||||
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
|
||||
|
||||
assert result.invokeai_metadata == valid_metadata
|
||||
assert result.invokeai_workflow == valid_workflow
|
||||
assert result.invokeai_graph is None
|
||||
|
||||
|
||||
def test_with_overrides(mock_logger, valid_metadata, valid_workflow, valid_graph):
|
||||
# Different values in the image
|
||||
mock_image = MagicMock(spec=Image.Image)
|
||||
|
||||
# When overrides are provided, they should be used instead of the values in the image, we shouldn'teven try
|
||||
# to parse the values in the image
|
||||
mock_image.info = {
|
||||
"invokeai_metadata": 12345,
|
||||
"invokeai_workflow": 12345,
|
||||
"invokeai_graph": 12345,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"invokeai.app.services.workflow_records.workflow_records_common.WorkflowWithoutIDValidator.validate_json"
|
||||
):
|
||||
with patch("invokeai.app.services.shared.graph.Graph.model_validate_json"):
|
||||
result = extract_metadata_from_image(mock_image, valid_metadata, valid_workflow, valid_graph, mock_logger)
|
||||
|
||||
# Override values should be used
|
||||
assert result.invokeai_metadata == valid_metadata
|
||||
assert result.invokeai_workflow == valid_workflow
|
||||
assert result.invokeai_graph == valid_graph
|
||||
|
||||
|
||||
def test_with_no_metadata(mock_logger):
|
||||
# Image with no metadata
|
||||
mock_image = MagicMock(spec=Image.Image)
|
||||
mock_image.info = {}
|
||||
|
||||
result = extract_metadata_from_image(mock_image, None, None, None, mock_logger)
|
||||
|
||||
assert result.invokeai_metadata is None
|
||||
assert result.invokeai_workflow is None
|
||||
assert result.invokeai_graph is None
|
||||
|
||||
|
||||
def test_empty_string_overrides_do_not_fall_back_to_image_metadata(
|
||||
mock_logger, valid_metadata, valid_workflow, valid_graph
|
||||
):
|
||||
mock_image = MagicMock(spec=Image.Image)
|
||||
mock_image.info = {
|
||||
"invokeai_metadata": valid_metadata,
|
||||
"invokeai_workflow": valid_workflow,
|
||||
"invokeai_graph": valid_graph,
|
||||
}
|
||||
|
||||
result = extract_metadata_from_image(mock_image, "", "", "", mock_logger)
|
||||
|
||||
assert result.invokeai_metadata is None
|
||||
assert result.invokeai_workflow is None
|
||||
assert result.invokeai_graph is None
|
||||
@@ -0,0 +1,180 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from invokeai.app.api.sockets import SocketIO
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def _patch_multiuser_context(monkeypatch: pytest.MonkeyPatch, *, user_id: str, is_admin: bool) -> None:
|
||||
user = SimpleNamespace(user_id=user_id, is_active=True)
|
||||
invoker = SimpleNamespace(
|
||||
services=SimpleNamespace(
|
||||
configuration=SimpleNamespace(multiuser=True),
|
||||
users=SimpleNamespace(get=lambda candidate_user_id: user if candidate_user_id == user_id else None),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("invokeai.app.api.dependencies.ApiDependencies", SimpleNamespace(invoker=invoker))
|
||||
monkeypatch.setattr(
|
||||
"invokeai.app.api.sockets.verify_token",
|
||||
lambda token: SimpleNamespace(user_id=user_id, is_admin=is_admin) if token == "valid-token" else None,
|
||||
)
|
||||
|
||||
|
||||
def _patch_single_user_context(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
invoker = SimpleNamespace(services=SimpleNamespace(configuration=SimpleNamespace(multiuser=False)))
|
||||
monkeypatch.setattr("invokeai.app.api.dependencies.ApiDependencies", SimpleNamespace(invoker=invoker))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_user_joins_workflow_rooms_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
socketio = SocketIO(FastAPI())
|
||||
socketio._sio.enter_room = AsyncMock()
|
||||
_patch_multiuser_context(monkeypatch, user_id="user-1", is_admin=False)
|
||||
|
||||
accepted = await socketio._handle_connect("sid-1", {}, {"token": "valid-token"})
|
||||
|
||||
assert accepted is True
|
||||
socketio._sio.enter_room.assert_any_call("sid-1", "user:user-1")
|
||||
socketio._sio.enter_room.assert_any_call("sid-1", "workflows:shared")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_admin_joins_admin_room_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
socketio = SocketIO(FastAPI())
|
||||
socketio._sio.enter_room = AsyncMock()
|
||||
_patch_multiuser_context(monkeypatch, user_id="admin-1", is_admin=True)
|
||||
|
||||
accepted = await socketio._handle_connect("sid-1", {}, {"token": "valid-token"})
|
||||
|
||||
assert accepted is True
|
||||
socketio._sio.enter_room.assert_any_call("sid-1", "user:admin-1")
|
||||
socketio._sio.enter_room.assert_any_call("sid-1", "workflows:shared")
|
||||
socketio._sio.enter_room.assert_any_call("sid-1", "admin")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_single_user_socket_joins_workflow_rooms_on_connect(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
socketio = SocketIO(FastAPI())
|
||||
socketio._sio.enter_room = AsyncMock()
|
||||
_patch_single_user_context(monkeypatch)
|
||||
|
||||
accepted = await socketio._handle_connect("sid-1", {}, None)
|
||||
|
||||
assert accepted is True
|
||||
socketio._sio.enter_room.assert_any_call("sid-1", "user:system")
|
||||
socketio._sio.enter_room.assert_any_call("sid-1", "workflows:shared")
|
||||
socketio._sio.enter_room.assert_any_call("sid-1", "admin")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_private_workflow_event_is_emitted_only_to_owner_and_admin() -> None:
|
||||
socketio = SocketIO(FastAPI())
|
||||
socketio._sio.emit = AsyncMock()
|
||||
|
||||
event_payload = SimpleNamespace(
|
||||
__event_name__="workflow_created",
|
||||
workflow_id="wf-1",
|
||||
user_id="owner-1",
|
||||
is_public=False,
|
||||
model_dump=lambda mode="json": {"workflow_id": "wf-1", "user_id": "owner-1", "is_public": False},
|
||||
)
|
||||
|
||||
await socketio._handle_workflow_event(("workflow_created", event_payload))
|
||||
|
||||
socketio._sio.emit.assert_any_call(
|
||||
event="workflow_created",
|
||||
data={"workflow_id": "wf-1", "user_id": "owner-1", "is_public": False},
|
||||
room="user:owner-1",
|
||||
)
|
||||
socketio._sio.emit.assert_any_call(
|
||||
event="workflow_created",
|
||||
data={"workflow_id": "wf-1", "user_id": "owner-1", "is_public": False},
|
||||
room="admin",
|
||||
)
|
||||
assert socketio._sio.emit.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_single_user_workflow_event_is_emitted_once_to_admin_room(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
socketio = SocketIO(FastAPI())
|
||||
socketio._sio.emit = AsyncMock()
|
||||
_patch_single_user_context(monkeypatch)
|
||||
|
||||
event_payload = SimpleNamespace(
|
||||
__event_name__="workflow_created",
|
||||
workflow_id="wf-1",
|
||||
user_id="system",
|
||||
is_public=False,
|
||||
model_dump=lambda mode="json": {"workflow_id": "wf-1", "user_id": "system", "is_public": False},
|
||||
)
|
||||
|
||||
await socketio._handle_workflow_event(("workflow_created", event_payload))
|
||||
|
||||
socketio._sio.emit.assert_awaited_once_with(
|
||||
event="workflow_created",
|
||||
data={"workflow_id": "wf-1", "user_id": "system", "is_public": False},
|
||||
room="admin",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_workflow_event_is_emitted_to_shared_room() -> None:
|
||||
socketio = SocketIO(FastAPI())
|
||||
socketio._sio.emit = AsyncMock()
|
||||
|
||||
event_payload = SimpleNamespace(
|
||||
__event_name__="workflow_updated",
|
||||
workflow_id="wf-1",
|
||||
user_id="owner-1",
|
||||
old_is_public=False,
|
||||
new_is_public=True,
|
||||
model_dump=lambda mode="json": {
|
||||
"workflow_id": "wf-1",
|
||||
"user_id": "owner-1",
|
||||
"old_is_public": False,
|
||||
"new_is_public": True,
|
||||
},
|
||||
)
|
||||
|
||||
await socketio._handle_workflow_event(("workflow_updated", event_payload))
|
||||
|
||||
socketio._sio.emit.assert_any_call(
|
||||
event="workflow_updated",
|
||||
data={"workflow_id": "wf-1", "user_id": "owner-1", "old_is_public": False, "new_is_public": True},
|
||||
room="workflows:shared",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_to_private_transition_emits_access_revoked_to_shared_room() -> None:
|
||||
socketio = SocketIO(FastAPI())
|
||||
socketio._sio.emit = AsyncMock()
|
||||
|
||||
event_payload = SimpleNamespace(
|
||||
__event_name__="workflow_updated",
|
||||
workflow_id="wf-1",
|
||||
user_id="owner-1",
|
||||
old_is_public=True,
|
||||
new_is_public=False,
|
||||
model_dump=lambda mode="json": {
|
||||
"workflow_id": "wf-1",
|
||||
"user_id": "owner-1",
|
||||
"old_is_public": True,
|
||||
"new_is_public": False,
|
||||
},
|
||||
)
|
||||
|
||||
await socketio._handle_workflow_event(("workflow_updated", event_payload))
|
||||
|
||||
socketio._sio.emit.assert_any_call(
|
||||
event="workflow_access_revoked",
|
||||
data={"workflow_id": "wf-1", "user_id": "owner-1", "timestamp": ANY},
|
||||
room="workflows:shared",
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.util.controlnet_utils import prepare_control_image
|
||||
from invokeai.backend.image_util.util import nms
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_channels", [1, 2, 3])
|
||||
def test_prepare_control_image_num_channels(num_channels):
|
||||
"""Test that the `num_channels` parameter is applied correctly in prepare_control_image(...)."""
|
||||
np_image = np.zeros((256, 256, 3), dtype=np.uint8)
|
||||
pil_image = Image.fromarray(np_image)
|
||||
|
||||
torch_image = prepare_control_image(
|
||||
image=pil_image,
|
||||
width=256,
|
||||
height=256,
|
||||
num_channels=num_channels,
|
||||
device="cpu",
|
||||
do_classifier_free_guidance=False,
|
||||
)
|
||||
|
||||
assert torch_image.shape == (1, num_channels, 256, 256)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_channels", [0, 4])
|
||||
def test_prepare_control_image_num_channels_too_large(num_channels):
|
||||
"""Test that an exception is raised in prepare_control_image(...) if the `num_channels` parameter is out of the
|
||||
supported range.
|
||||
"""
|
||||
np_image = np.zeros((256, 256, 3), dtype=np.uint8)
|
||||
pil_image = Image.fromarray(np_image)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_ = prepare_control_image(
|
||||
image=pil_image,
|
||||
width=256,
|
||||
height=256,
|
||||
num_channels=num_channels,
|
||||
device="cpu",
|
||||
do_classifier_free_guidance=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("threshold,sigma", [(None, 1.0), (1, None)])
|
||||
def test_nms_invalid_options(threshold: None | int, sigma: None | float):
|
||||
"""Test that an exception is raised in nms(...) if only one of the `threshold` or `sigma` parameters are provided."""
|
||||
with pytest.raises(ValueError):
|
||||
nms(np.zeros((256, 256, 3), dtype=np.uint8), threshold, sigma)
|
||||
@@ -0,0 +1,61 @@
|
||||
from fastapi import FastAPI
|
||||
from pydantic import create_model
|
||||
|
||||
from invokeai.app.invocations.baseinvocation import InvocationRegistry
|
||||
from invokeai.app.util.custom_openapi import get_openapi_func
|
||||
|
||||
|
||||
class _FakeOutput:
|
||||
pass
|
||||
|
||||
|
||||
class _InvocationB:
|
||||
__name__ = "InvocationB"
|
||||
|
||||
@classmethod
|
||||
def model_json_schema(cls, mode: str, ref_template: str) -> dict:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
@classmethod
|
||||
def get_output_annotation(cls) -> type:
|
||||
return _FakeOutput
|
||||
|
||||
@classmethod
|
||||
def get_type(cls) -> str:
|
||||
return "b_type"
|
||||
|
||||
|
||||
class _InvocationA:
|
||||
__name__ = "InvocationA"
|
||||
|
||||
@classmethod
|
||||
def model_json_schema(cls, mode: str, ref_template: str) -> dict:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
@classmethod
|
||||
def get_output_annotation(cls) -> type:
|
||||
return _FakeOutput
|
||||
|
||||
@classmethod
|
||||
def get_type(cls) -> str:
|
||||
return "a_type"
|
||||
|
||||
|
||||
def test_invocation_output_map_required_is_sorted(monkeypatch: object) -> None:
|
||||
"""The 'required' list in InvocationOutputMap must be sorted so that the
|
||||
generated openapi.json is deterministic regardless of set-iteration order."""
|
||||
|
||||
# A FastAPI app needs at least one route to produce a schema with 'components'.
|
||||
DummyResponse = create_model("DummyResponse", ok=(bool, ...))
|
||||
app = FastAPI(title="test")
|
||||
app.get("/healthz", response_model=DummyResponse)(lambda: DummyResponse(ok=True))
|
||||
|
||||
monkeypatch.setattr(InvocationRegistry, "get_output_classes", classmethod(lambda cls: [])) # type: ignore[arg-type]
|
||||
monkeypatch.setattr( # type: ignore[arg-type]
|
||||
InvocationRegistry, "get_invocation_classes", classmethod(lambda cls: [_InvocationB, _InvocationA])
|
||||
)
|
||||
|
||||
schema = get_openapi_func(app)()
|
||||
required = schema["components"]["schemas"]["InvocationOutputMap"]["required"]
|
||||
|
||||
assert required == ["a_type", "b_type"], f"Expected sorted required list, got: {required}"
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from invokeai.app.util.dynamicprompts import find_missing_wildcards
|
||||
|
||||
|
||||
def test_find_missing_wildcards_detects_unknown_wildcard_in_variant() -> None:
|
||||
# Regression: `__random__` inside a variant is parsed as a wildcard reference. Left unchecked it
|
||||
# sends the combinatorial generator into an infinite loop, so it must be reported up front.
|
||||
assert find_missing_wildcards("{__random__8chan|fenster|stuff}") == ["random"]
|
||||
|
||||
|
||||
def test_find_missing_wildcards_detects_unknown_wildcard_nested_in_sequence_in_variant() -> None:
|
||||
# The wildcard hangs the generator even when wrapped in other text inside the variant value.
|
||||
assert find_missing_wildcards("{a __nope__|b}") == ["nope"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt", ["a __nope__ b", "__nope__", "a photo, __my_style__"])
|
||||
def test_find_missing_wildcards_ignores_wildcards_outside_variants(prompt: str) -> None:
|
||||
# A wildcard used as plain literal text generates fine (no hang), so it must not be reported.
|
||||
assert find_missing_wildcards(prompt) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt", ["plain text", "{a|b|c}", "a {2$$x|y|z}"])
|
||||
def test_find_missing_wildcards_ignores_prompts_without_wildcards(prompt: str) -> None:
|
||||
assert find_missing_wildcards(prompt) == []
|
||||
|
||||
|
||||
def test_find_missing_wildcards_dedupes_repeated_unknown_wildcards() -> None:
|
||||
assert find_missing_wildcards("{__nope__|a} {__nope__|b} {__other__|c}") == ["nope", "other"]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for diffusion step callback preview image generation."""
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from invokeai.app.util.step_callback import (
|
||||
QWEN_IMAGE_LATENT_RGB_BIAS,
|
||||
QWEN_IMAGE_LATENT_RGB_FACTORS,
|
||||
sample_to_lowres_estimated_image,
|
||||
)
|
||||
|
||||
|
||||
class TestSampleToLowresEstimatedImage:
|
||||
"""Test the latent-to-preview-image conversion used during denoising."""
|
||||
|
||||
def test_qwen_image_preview_produces_valid_image(self):
|
||||
"""A synthetic Qwen latent tensor produces a valid RGB preview image."""
|
||||
# Create a small 1x16x4x4 latent tensor (batch=1, channels=16, 4x4 spatial)
|
||||
torch.manual_seed(42)
|
||||
sample = torch.randn(1, 16, 4, 4)
|
||||
|
||||
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
|
||||
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
|
||||
|
||||
image = sample_to_lowres_estimated_image(
|
||||
samples=sample,
|
||||
latent_rgb_factors=factors,
|
||||
latent_rgb_bias=bias,
|
||||
)
|
||||
|
||||
assert isinstance(image, Image.Image)
|
||||
assert image.size == (4, 4)
|
||||
assert image.mode == "RGB"
|
||||
|
||||
def test_qwen_image_preview_deterministic(self):
|
||||
"""The same input tensor always produces the same preview image."""
|
||||
sample = torch.ones(1, 16, 2, 2)
|
||||
|
||||
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
|
||||
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
|
||||
|
||||
image1 = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
|
||||
image2 = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
|
||||
|
||||
assert list(image1.getdata()) == list(image2.getdata())
|
||||
|
||||
def test_qwen_image_preview_known_value(self):
|
||||
"""Verify the preview computation against a hand-calculated expected value.
|
||||
|
||||
With a 1x16x1x1 tensor of all ones:
|
||||
- latent_image = [1,1,...,1] @ factors = sum of each column of factors
|
||||
- R = sum(col 0) = 0.3677, G = sum(col 1) = 0.4577, B = sum(col 2) = 0.9101
|
||||
- After bias: R = 0.1842, G = 0.3709, B = 0.5741
|
||||
- After scale ((x+1)/2): R = 0.5921, G = 0.6855, B = 0.7871
|
||||
- After quantize (*255): R = 151, G = 175, B = 201
|
||||
"""
|
||||
sample = torch.ones(1, 16, 1, 1)
|
||||
|
||||
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
|
||||
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
|
||||
|
||||
image = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
|
||||
|
||||
assert image.size == (1, 1)
|
||||
pixel = image.getpixel((0, 0))
|
||||
|
||||
# Compute expected values
|
||||
col_sums = [sum(row[c] for row in QWEN_IMAGE_LATENT_RGB_FACTORS) for c in range(3)]
|
||||
expected = []
|
||||
for c in range(3):
|
||||
val = col_sums[c] + QWEN_IMAGE_LATENT_RGB_BIAS[c]
|
||||
val = (val + 1) / 2 # scale from [-1,1] to [0,1]
|
||||
val = max(0.0, min(1.0, val)) # clamp
|
||||
expected.append(int(val * 255))
|
||||
|
||||
assert pixel == tuple(expected), f"Expected {tuple(expected)}, got {pixel}"
|
||||
|
||||
def test_qwen_image_preview_zeros_tensor(self):
|
||||
"""A zero tensor with bias produces a valid image reflecting just the bias."""
|
||||
sample = torch.zeros(1, 16, 2, 2)
|
||||
|
||||
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
|
||||
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
|
||||
|
||||
image = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
|
||||
|
||||
assert isinstance(image, Image.Image)
|
||||
assert image.size == (2, 2)
|
||||
|
||||
# All pixels should be identical (uniform zero input)
|
||||
pixels = [image.getpixel((x, y)) for y in range(image.height) for x in range(image.width)]
|
||||
assert all(p == pixels[0] for p in pixels)
|
||||
|
||||
# With zero input, result = bias, scaled: ((bias + 1) / 2) * 255
|
||||
expected = []
|
||||
for c in range(3):
|
||||
val = (QWEN_IMAGE_LATENT_RGB_BIAS[c] + 1) / 2
|
||||
val = max(0.0, min(1.0, val))
|
||||
expected.append(int(val * 255))
|
||||
assert pixels[0] == tuple(expected)
|
||||
|
||||
def test_qwen_image_factors_have_correct_shape(self):
|
||||
"""Qwen Image uses 16 latent channels, so factors should be 16x3."""
|
||||
assert len(QWEN_IMAGE_LATENT_RGB_FACTORS) == 16
|
||||
for row in QWEN_IMAGE_LATENT_RGB_FACTORS:
|
||||
assert len(row) == 3
|
||||
assert len(QWEN_IMAGE_LATENT_RGB_BIAS) == 3
|
||||
|
||||
def test_3d_input_accepted(self):
|
||||
"""sample_to_lowres_estimated_image accepts 3D input (no batch dim)."""
|
||||
sample = torch.randn(16, 4, 4) # no batch dimension
|
||||
|
||||
factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype)
|
||||
bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype)
|
||||
|
||||
image = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias)
|
||||
|
||||
assert isinstance(image, Image.Image)
|
||||
assert image.size == (4, 4)
|
||||
@@ -0,0 +1,127 @@
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.dangerously_run_function_in_subprocess import dangerously_run_function_in_subprocess
|
||||
|
||||
# These tests are a bit fiddly, because the depend on the import behaviour of torch. They use subprocesses to isolate
|
||||
# the import behaviour of torch, and then check that the function behaves as expected. We have to hack in some logging
|
||||
# to check that the tested function is behaving as expected.
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA device.")
|
||||
def test_configure_torch_cuda_allocator_configures_backend():
|
||||
"""Test that configure_torch_cuda_allocator() raises a RuntimeError if the configured backend does not match the
|
||||
expected backend."""
|
||||
|
||||
def test_func():
|
||||
import os
|
||||
|
||||
# Unset the environment variable if it is set so that we can test setting it
|
||||
try:
|
||||
del os.environ["PYTORCH_CUDA_ALLOC_CONF"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
|
||||
|
||||
mock_logger = MagicMock()
|
||||
|
||||
# Set the PyTorch CUDA memory allocator to cudaMallocAsync
|
||||
configure_torch_cuda_allocator("backend:cudaMallocAsync", logger=mock_logger)
|
||||
|
||||
# Verify that the PyTorch CUDA memory allocator was configured correctly
|
||||
import torch
|
||||
|
||||
assert torch.cuda.get_allocator_backend() == "cudaMallocAsync"
|
||||
|
||||
# Verify that the logger was called with the correct message
|
||||
mock_logger.info.assert_called_once()
|
||||
args, _kwargs = mock_logger.info.call_args
|
||||
logged_message = args[0]
|
||||
print(logged_message)
|
||||
|
||||
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
|
||||
assert returncode == 0
|
||||
assert "PyTorch CUDA memory allocator: cudaMallocAsync" in stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA device.")
|
||||
def test_configure_torch_cuda_allocator_raises_if_torch_already_imported():
|
||||
"""Test that configure_torch_cuda_allocator() raises a RuntimeError if torch was already imported."""
|
||||
|
||||
def test_func():
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Import torch before calling configure_torch_cuda_allocator()
|
||||
import torch # noqa: F401
|
||||
|
||||
from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
|
||||
|
||||
try:
|
||||
configure_torch_cuda_allocator("backend:cudaMallocAsync", logger=MagicMock())
|
||||
except RuntimeError as e:
|
||||
print(e)
|
||||
|
||||
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
|
||||
assert returncode == 0
|
||||
assert "configure_torch_cuda_allocator() must be called before importing torch." in stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA device.")
|
||||
def test_configure_torch_cuda_allocator_warns_if_env_var_is_set_differently():
|
||||
"""Test that configure_torch_cuda_allocator() logs at WARNING level if PYTORCH_CUDA_ALLOC_CONF is set and doesn't
|
||||
match the requested configuration."""
|
||||
|
||||
def test_func():
|
||||
import os
|
||||
|
||||
# Explicitly set the environment variable
|
||||
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "backend:native"
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
|
||||
|
||||
mock_logger = MagicMock()
|
||||
|
||||
# Set the PyTorch CUDA memory allocator a different configuration
|
||||
configure_torch_cuda_allocator("backend:cudaMallocAsync", logger=mock_logger)
|
||||
|
||||
# Verify that the logger was called with the correct message
|
||||
mock_logger.warning.assert_called_once()
|
||||
args, _kwargs = mock_logger.warning.call_args
|
||||
logged_message = args[0]
|
||||
print(logged_message)
|
||||
|
||||
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
|
||||
assert returncode == 0
|
||||
assert "Attempted to configure the PyTorch CUDA memory allocator with 'backend:cudaMallocAsync'" in stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA device.")
|
||||
def test_configure_torch_cuda_allocator_logs_if_env_var_is_already_set_correctly():
|
||||
"""Test that configure_torch_cuda_allocator() logs at INFO level if PYTORCH_CUDA_ALLOC_CONF is set and matches the
|
||||
requested configuration."""
|
||||
|
||||
def test_func():
|
||||
import os
|
||||
|
||||
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "backend:native"
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from invokeai.app.util.torch_cuda_allocator import configure_torch_cuda_allocator
|
||||
|
||||
mock_logger = MagicMock()
|
||||
|
||||
configure_torch_cuda_allocator("backend:native", logger=mock_logger)
|
||||
|
||||
mock_logger.info.assert_called_once()
|
||||
args, _kwargs = mock_logger.info.call_args
|
||||
logged_message = args[0]
|
||||
print(logged_message)
|
||||
|
||||
stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func)
|
||||
assert returncode == 0
|
||||
assert "PYTORCH_CUDA_ALLOC_CONF is already set to 'backend:native'" in stdout
|
||||
Reference in New Issue
Block a user