mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 08:21:27 -03:00
feat(relink): accept CivitArchive URLs when linking models
This commit is contained in:
@@ -2504,4 +2504,172 @@ describe('Interaction-level regression coverage', () => {
|
||||
|
||||
delete stateStub.currentPageType;
|
||||
});
|
||||
|
||||
it('opens the relink modal from the relink-civitai menu action', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu">
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="relink-civitai"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="relinkCivitaiModal" class="modal">
|
||||
<input type="text" id="civitaiModelUrl" />
|
||||
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||
<button class="confirm-btn" id="confirmRelinkBtn"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
const showModalSpy = vi.spyOn(contextMenu, 'showRelinkCivitaiModal').mockImplementation(() => {});
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
document.querySelector('[data-action="relink-civitai"]').dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
expect(showModalSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an unsupported relink URL with an inline error and no fetch', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu"></div>
|
||||
<div id="relinkCivitaiModal" class="modal">
|
||||
<input type="text" id="civitaiModelUrl" />
|
||||
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||
<button class="confirm-btn" id="confirmRelinkBtn"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
contextMenu.showRelinkCivitaiModal();
|
||||
|
||||
document.getElementById('civitaiModelUrl').value = 'https://example.com/models/123456';
|
||||
await contextMenu._boundRelinkHandler();
|
||||
|
||||
expect(document.getElementById('civitaiModelUrlError').textContent)
|
||||
.toBe('Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(modalManagerMock.closeModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts a valid CivitArchive URL to the relink endpoint with the civarchive source', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu"></div>
|
||||
<div id="relinkCivitaiModal" class="modal">
|
||||
<input type="text" id="civitaiModelUrl" />
|
||||
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||
<button class="confirm-btn" id="confirmRelinkBtn"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
global.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
}));
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
contextMenu.showRelinkCivitaiModal();
|
||||
|
||||
document.getElementById('civitaiModelUrl').value = 'https://civarchive.com/models/123456?modelVersionId=789012';
|
||||
await contextMenu._boundRelinkHandler();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('relinkCivitaiModal');
|
||||
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Re-linking via CivitArchive...');
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
file_path: '/models/test.safetensors',
|
||||
model_id: '123456',
|
||||
model_version_id: '789012',
|
||||
source: 'civarchive',
|
||||
}),
|
||||
});
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.linkCivArchSuccess', {}, 'success');
|
||||
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts a Civitai URL without a source key so backend defaults apply', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu"></div>
|
||||
<div id="relinkCivitaiModal" class="modal">
|
||||
<input type="text" id="civitaiModelUrl" />
|
||||
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||
<button class="confirm-btn" id="confirmRelinkBtn"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
global.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
}));
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
contextMenu.showRelinkCivitaiModal();
|
||||
|
||||
document.getElementById('civitaiModelUrl').value = 'https://civitai.com/models/65423?modelVersionId=777';
|
||||
await contextMenu._boundRelinkHandler();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
file_path: '/models/test.safetensors',
|
||||
model_id: '65423',
|
||||
model_version_id: '777',
|
||||
}),
|
||||
});
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.relinkSuccess', {}, 'success');
|
||||
});
|
||||
|
||||
it('derives relink endpoint prefixes for all model types', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu"></div>
|
||||
`;
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
|
||||
contextMenu.modelType = 'lora';
|
||||
expect(contextMenu.getModelTypePrefix()).toBe('loras');
|
||||
contextMenu.modelType = 'checkpoint';
|
||||
expect(contextMenu.getModelTypePrefix()).toBe('checkpoints');
|
||||
contextMenu.modelType = 'embedding';
|
||||
expect(contextMenu.getModelTypePrefix()).toBe('embeddings');
|
||||
contextMenu.modelType = 'unknown';
|
||||
expect(contextMenu.getModelTypePrefix()).toBe('loras');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,20 +2,15 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js';
|
||||
|
||||
describe('ModelContextMenuMixin.extractModelVersionId', () => {
|
||||
it('accepts civitai.red model URLs', () => {
|
||||
expect(
|
||||
ModelContextMenuMixin.extractModelVersionId(
|
||||
'https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777'
|
||||
)
|
||||
).toEqual({ modelId: '65423', modelVersionId: '777' });
|
||||
describe('ModelContextMenuMixin.getModelTypePrefix', () => {
|
||||
it('maps every known model type to its API route prefix', () => {
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'lora' })).toBe('loras');
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'checkpoint' })).toBe('checkpoints');
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'embedding' })).toBe('embeddings');
|
||||
});
|
||||
|
||||
it('rejects model-like URLs from unsupported hosts', () => {
|
||||
expect(
|
||||
ModelContextMenuMixin.extractModelVersionId(
|
||||
'https://example.com/models/65423?modelVersionId=777'
|
||||
)
|
||||
).toEqual({ modelId: null, modelVersionId: null });
|
||||
it('falls back to the loras prefix for unknown types', () => {
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'unknown' })).toBe('loras');
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({})).toBe('loras');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Regression guard: every `.modal` element shipped via components/modals.html
|
||||
// must be registered in ModalManager.initialize(). An unregistered modal makes
|
||||
// modalManager.showModal(id) silently no-op (see getModal returning undefined),
|
||||
// which manifests as "clicking the menu item does nothing" with no console
|
||||
// error — exactly the Link-to-CivitArchive bug this file guards against.
|
||||
describe('ModalManager registry parity', () => {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const modalsHtml = readFileSync(
|
||||
path.join(repoRoot, 'templates/components/modals.html'),
|
||||
'utf-8'
|
||||
);
|
||||
const modalManagerSrc = readFileSync(
|
||||
path.join(repoRoot, 'static/js/managers/ModalManager.js'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const collectModalIds = (target, seen = new Set()) => {
|
||||
if (statSync(target).isFile()) {
|
||||
extractIds(readFileSync(target, 'utf-8'), seen);
|
||||
return seen;
|
||||
}
|
||||
for (const entry of readdirSync(target, { withFileTypes: true })) {
|
||||
collectModalIds(path.join(target, entry.name), seen);
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
const extractIds = (content, seen) => {
|
||||
for (const match of content.matchAll(/id="([A-Za-z][\w-]*)"[^>]*class="modal"/g)) {
|
||||
seen.add(match[1]);
|
||||
}
|
||||
};
|
||||
|
||||
it('registers every modal declared in templates', () => {
|
||||
const includeFiles = [
|
||||
...modalsHtml.matchAll(/\{%\s*include\s*'([^']+\.html)'\s*%\}/g),
|
||||
].map((m) => m[1]);
|
||||
|
||||
expect(includeFiles.length).toBeGreaterThan(0);
|
||||
|
||||
const declaredIds = new Set();
|
||||
for (const relPath of includeFiles) {
|
||||
collectModalIds(path.join(repoRoot, 'templates', relPath), declaredIds);
|
||||
}
|
||||
|
||||
expect(declaredIds.size).toBeGreaterThan(0);
|
||||
|
||||
const unregistered = [...declaredIds].filter(
|
||||
(id) => !modalManagerSrc.includes(`registerModal('${id}'`)
|
||||
);
|
||||
|
||||
expect(
|
||||
unregistered,
|
||||
'Modal ids rendered on pages but never registered in ModalManager.initialize() — showModal() will silently do nothing for them'
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getThumbnailUrl,
|
||||
extractCivitaiImageId,
|
||||
extractCivitaiModelUrlParts,
|
||||
classifyModelRelinkUrl,
|
||||
isCivitaiUrl,
|
||||
isSupportedCivitaiPageHost,
|
||||
OptimizationMode
|
||||
@@ -305,4 +306,48 @@ describe('civitaiUtils', () => {
|
||||
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyModelRelinkUrl', () => {
|
||||
it('classifies civitai.com model URLs', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://civitai.com/models/649516/name?modelVersionId=726676')
|
||||
).toEqual({ source: 'civitai', modelId: '649516', modelVersionId: '726676' });
|
||||
});
|
||||
|
||||
it('classifies civitai.red model URLs without a version id', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://civitai.red/models/65423/')
|
||||
).toEqual({ source: 'civitai', modelId: '65423', modelVersionId: null });
|
||||
});
|
||||
|
||||
it('classifies civarchive and civitaiarchive model URLs', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://civarchive.com/models/1746460')
|
||||
).toEqual({ source: 'civarchive', modelId: '1746460', modelVersionId: null });
|
||||
expect(
|
||||
classifyModelRelinkUrl('http://www.civitaiarchive.com/models/42?modelVersionId=43')
|
||||
).toEqual({ source: 'civarchive', modelId: '42', modelVersionId: '43' });
|
||||
});
|
||||
|
||||
it('rejects archive hosts when the path has no numeric model id', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://civarchive.com/images/123')
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
});
|
||||
|
||||
it('rejects unsupported hosts and malformed input', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://example.com/models/65423')
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
expect(
|
||||
classifyModelRelinkUrl('not a url')
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
expect(
|
||||
classifyModelRelinkUrl('')
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
expect(
|
||||
classifyModelRelinkUrl(null)
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,16 @@ import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.config import config
|
||||
from py.routes.handlers.model_handlers import ModelCivitaiHandler, ModelUpdateHandler
|
||||
from py.routes.handlers.model_handlers import (
|
||||
ModelCivitaiHandler,
|
||||
ModelManagementHandler,
|
||||
ModelUpdateHandler,
|
||||
)
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
|
||||
@@ -965,3 +970,110 @@ def test_serialize_version_file_count_defaults_to_none():
|
||||
)
|
||||
serialized = ModelUpdateHandler._serialize_version(version, None)
|
||||
assert serialized["fileCount"] is None
|
||||
|
||||
|
||||
def _build_relink_handler(metadata_sync):
|
||||
service = SimpleNamespace(
|
||||
scanner=SimpleNamespace(update_single_model_cache=AsyncMock())
|
||||
)
|
||||
return ModelManagementHandler(
|
||||
service=service,
|
||||
logger=logging.getLogger(__name__),
|
||||
metadata_sync=metadata_sync,
|
||||
preview_service=SimpleNamespace(),
|
||||
tag_update_service=SimpleNamespace(),
|
||||
lifecycle_service=SimpleNamespace(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_civitai_rejects_unsupported_source():
|
||||
metadata_sync = SimpleNamespace(
|
||||
load_local_metadata=AsyncMock(return_value={}),
|
||||
relink_metadata=AsyncMock(),
|
||||
)
|
||||
handler = _build_relink_handler(metadata_sync)
|
||||
|
||||
request = SimpleNamespace(
|
||||
json=AsyncMock(
|
||||
return_value={
|
||||
"file_path": "/tmp/model.safetensors",
|
||||
"model_id": "123",
|
||||
"model_version_id": "456",
|
||||
"source": "huggingface",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
response = await handler.relink_civitai(request)
|
||||
assert response.status == 400
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
assert "Unsupported relink source" in payload["error"]
|
||||
metadata_sync.relink_metadata.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_civitai_passes_provider_name_for_civarchive_source():
|
||||
metadata_sync = SimpleNamespace(
|
||||
load_local_metadata=AsyncMock(return_value={"model_name": "Local"}),
|
||||
relink_metadata=AsyncMock(
|
||||
return_value={"model_name": "Archived", "sha256": "abc"}
|
||||
),
|
||||
)
|
||||
handler = _build_relink_handler(metadata_sync)
|
||||
|
||||
request = SimpleNamespace(
|
||||
json=AsyncMock(
|
||||
return_value={
|
||||
"file_path": "/tmp/model.safetensors",
|
||||
"model_id": "123",
|
||||
"model_version_id": "456",
|
||||
"source": "civarchive",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
response = await handler.relink_civitai(request)
|
||||
assert response.status == 200
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is True
|
||||
assert "CivArchive" in payload["message"]
|
||||
metadata_sync.relink_metadata.assert_awaited_once_with(
|
||||
file_path="/tmp/model.safetensors",
|
||||
metadata={"model_name": "Local"},
|
||||
model_id=123,
|
||||
model_version_id=456,
|
||||
provider_name="civarchive_api",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_civitai_surfaces_provider_unavailable_without_500():
|
||||
metadata_sync = SimpleNamespace(
|
||||
load_local_metadata=AsyncMock(return_value={}),
|
||||
relink_metadata=AsyncMock(
|
||||
side_effect=ValueError(
|
||||
"CivitArchive is not available or not enabled. "
|
||||
"Enable the CivitArchive API in settings to relink via CivArchive."
|
||||
)
|
||||
),
|
||||
)
|
||||
handler = _build_relink_handler(metadata_sync)
|
||||
|
||||
request = SimpleNamespace(
|
||||
json=AsyncMock(
|
||||
return_value={
|
||||
"file_path": "/tmp/model.safetensors",
|
||||
"model_id": "123",
|
||||
"model_version_id": None,
|
||||
"source": "civarchive",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
response = await handler.relink_civitai(request)
|
||||
assert response.status == 400
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
assert "CivitArchive" in payload["error"]
|
||||
|
||||
@@ -560,6 +560,131 @@ async def test_relink_metadata_raises_when_version_missing():
|
||||
model_version_id=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_metadata_uses_named_civarchive_provider(tmp_path):
|
||||
default_provider = SimpleNamespace(
|
||||
get_model_by_hash=AsyncMock(),
|
||||
get_model_version=AsyncMock(),
|
||||
)
|
||||
civarchive_provider = SimpleNamespace(
|
||||
get_model_by_hash=AsyncMock(),
|
||||
get_model_version=AsyncMock(
|
||||
return_value={
|
||||
"files": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "Model",
|
||||
"hashes": {"SHA256": "ABCDEF"},
|
||||
}
|
||||
],
|
||||
"model": {"name": "Archived"},
|
||||
"images": [],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def select_provider(name: str):
|
||||
return civarchive_provider if name == "civarchive_api" else default_provider
|
||||
|
||||
provider_selector = AsyncMock(side_effect=select_provider)
|
||||
helpers = build_service(
|
||||
default_provider=default_provider,
|
||||
provider_selector=provider_selector,
|
||||
)
|
||||
|
||||
metadata = {"model_name": "Local", "sha256": "original"}
|
||||
result = await helpers.service.relink_metadata(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
metadata=metadata,
|
||||
model_id=1,
|
||||
model_version_id=2,
|
||||
provider_name="civarchive_api",
|
||||
)
|
||||
|
||||
assert result["model_name"] == "Archived"
|
||||
assert result["sha256"] == "original"
|
||||
provider_selector.assert_awaited_with("civarchive_api")
|
||||
civarchive_provider.get_model_version.assert_awaited_once_with(1, 2)
|
||||
helpers.default_provider_factory.assert_not_awaited()
|
||||
helpers.metadata_manager.save_metadata.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_metadata_raises_when_version_missing_with_civarchive():
|
||||
default_provider = SimpleNamespace(
|
||||
get_model_by_hash=AsyncMock(),
|
||||
get_model_version=AsyncMock(),
|
||||
)
|
||||
civarchive_provider = SimpleNamespace(
|
||||
get_model_by_hash=AsyncMock(),
|
||||
get_model_version=AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
async def select_provider(name: str):
|
||||
return civarchive_provider if name == "civarchive_api" else default_provider
|
||||
|
||||
provider_selector = AsyncMock(side_effect=select_provider)
|
||||
helpers = build_service(
|
||||
default_provider=default_provider,
|
||||
provider_selector=provider_selector,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="CivitArchive"):
|
||||
await helpers.service.relink_metadata(
|
||||
file_path="/tmp/model.safetensors",
|
||||
metadata={},
|
||||
model_id=9,
|
||||
model_version_id=None,
|
||||
provider_name="civarchive_api",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_metadata_raises_friendly_error_when_provider_unavailable():
|
||||
provider_selector = AsyncMock(
|
||||
side_effect=ValueError("Provider 'civarchive_api' is not registered")
|
||||
)
|
||||
helpers = build_service(provider_selector=provider_selector)
|
||||
|
||||
with pytest.raises(ValueError, match="CivitArchive is not available or not enabled"):
|
||||
await helpers.service.relink_metadata(
|
||||
file_path="/tmp/model.safetensors",
|
||||
metadata={},
|
||||
model_id=9,
|
||||
model_version_id=None,
|
||||
provider_name="civarchive_api",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_metadata_default_call_uses_default_provider_factory(tmp_path):
|
||||
helpers = build_service()
|
||||
helpers.default_provider.get_model_version.return_value = {
|
||||
"files": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "Model",
|
||||
"hashes": {"SHA256": "ABCDEF"},
|
||||
}
|
||||
],
|
||||
"model": {"name": "Remote"},
|
||||
"images": [],
|
||||
}
|
||||
|
||||
result = await helpers.service.relink_metadata(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
metadata={"model_name": "Local", "sha256": "original"},
|
||||
model_id=1,
|
||||
model_version_id=None,
|
||||
)
|
||||
|
||||
assert result["model_name"] == "Remote"
|
||||
assert result["sha256"] == "original"
|
||||
helpers.default_provider_factory.assert_awaited_once()
|
||||
helpers.provider_selector.assert_not_awaited()
|
||||
helpers.metadata_manager.save_metadata.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_and_update_model_persists_db_checked_when_sqlite_fails(tmp_path):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user