mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 09:35:31 -03:00
feat(update): add a Buzz price alerts panel to the notification bell
P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what got cheaper / became free", without a permanent button (the grid filter was dropped by owner decision, so the panel carries the actions itself). - price_alert_since records when an alert started, so the panel can say "dropped 3 d ago" and count what is new since the user last looked; it is set on the first sight of an already-cheap version, preserved while the alert stands, and cleared when the price rises back above the threshold - get_price_alerts() compares the threshold at read time (editing it takes effect immediately, no refresh needed) and returns both kinds in one list; model_type=None covers every type, which the shared update DB makes a single query. "became free" needs no price data, so it is reported even while price tracking is off - GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model type, decorating rows best-effort with the local model name and file path from the scanner indexes (a cold cache just omits them) - a third tab in the notification bell: segments for under-threshold and became-free, the three states (tracking off / nothing matching / stale), and per-row actions (CivitAI always, Open when the model is local) - two non-permanent entry points share one helper: the controls-bar updates dropdown and the global context menu, whose label carries the unread count - unread state stays client-side (localStorage watermark); the count is fetched once on init and only when price tracking is enabled - the per-type frontend client method is removed as dead code; the per-type backend route stays for the companion extension
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { describe, beforeEach, afterEach, expect, it, vi } from 'vitest';
|
||||
import { UpdateService } from '../../../static/js/managers/UpdateService.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
import { modalManager } from '../../../static/js/managers/ModalManager.js';
|
||||
|
||||
/**
|
||||
* The price alerts panel is rendered into the notification bell modal, so the
|
||||
* tests build the same element ids the template provides and drive the service
|
||||
* directly.
|
||||
*/
|
||||
function buildPanelDom() {
|
||||
document.body.innerHTML = `
|
||||
<div id="updateModal">
|
||||
<button data-notification-tab="updates"></button>
|
||||
<button data-notification-tab="priceAlerts"></button>
|
||||
<div data-notification-panel="updates"></div>
|
||||
<div data-notification-panel="priceAlerts">
|
||||
<div id="priceAlertsDisabled" class="hidden"></div>
|
||||
<button id="priceAlertsSettingsBtn"></button>
|
||||
<button data-price-alert-segment="below_threshold"></button>
|
||||
<button data-price-alert-segment="became_free"></button>
|
||||
<span id="priceAlertsThreshold"></span>
|
||||
<p id="priceAlertsStale" class="hidden"></p>
|
||||
<p id="priceAlertsEmpty"></p>
|
||||
<ul id="priceAlertsList"></ul>
|
||||
</div>
|
||||
<span id="updatesTabBadge"></span>
|
||||
<span id="bannerTabBadge"></span>
|
||||
<span id="priceAlertsTabBadge"></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function alertPayload(alerts, overrides = {}) {
|
||||
return {
|
||||
success: true,
|
||||
enabled: true,
|
||||
thresholdBuzz: 500,
|
||||
newestCheckedAt: 1791039694.5,
|
||||
alerts,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const BELOW_THRESHOLD_ALERT = {
|
||||
modelId: 2981320,
|
||||
modelType: 'checkpoint',
|
||||
modelName: 'Glorious Art',
|
||||
versionId: 3379626,
|
||||
versionName: 'Alpha',
|
||||
kind: 'below_threshold',
|
||||
priceBuzz: 250,
|
||||
listPriceBuzz: 500,
|
||||
acceptsBlueBuzz: true,
|
||||
priceAlertSince: 1791000000,
|
||||
isInLibrary: false,
|
||||
filePath: null,
|
||||
civitaiUrl: 'https://civitai.com/models/2981320?modelVersionId=3379626',
|
||||
};
|
||||
|
||||
const BECAME_FREE_ALERT = {
|
||||
modelId: 1802980,
|
||||
modelType: 'lora',
|
||||
modelName: 'Eira Kishida',
|
||||
versionId: 3262917,
|
||||
versionName: 'v3',
|
||||
kind: 'became_free',
|
||||
priceBuzz: null,
|
||||
priceAlertSince: null,
|
||||
gateLapsedAt: '2026-09-28T00:00:00.000Z',
|
||||
isInLibrary: true,
|
||||
filePath: '/models/loras/eira.safetensors',
|
||||
civitaiUrl: 'https://civitai.com/models/1802980?modelVersionId=3262917',
|
||||
};
|
||||
|
||||
function createFetchResponse(payload, ok = true) {
|
||||
return { json: vi.fn().mockResolvedValue(payload), ok, statusText: '' };
|
||||
}
|
||||
|
||||
describe('UpdateService price alerts panel', () => {
|
||||
let service;
|
||||
|
||||
beforeEach(() => {
|
||||
buildPanelDom();
|
||||
localStorage.clear();
|
||||
state.global = state.global || {};
|
||||
state.global.settings = state.global.settings || {};
|
||||
service = new UpdateService();
|
||||
service.setupNotificationCenter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
document.body.innerHTML = '';
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('loads the global endpoint and renders a below-threshold row', async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])));
|
||||
|
||||
await service.loadPriceAlerts({ force: true });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(String(global.fetch.mock.calls[0][0])).toContain('/api/lm/price-alerts');
|
||||
|
||||
const items = document.querySelectorAll('#priceAlertsList .price-alert-item');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].textContent).toContain('Glorious Art');
|
||||
expect(items[0].textContent).toContain('250 Buzz');
|
||||
// The stored list price is shown struck through.
|
||||
expect(items[0].querySelector('.price-alert-was').textContent).toBe('500 Buzz');
|
||||
expect(items[0].textContent).toContain('Blue Buzz OK');
|
||||
expect(items[0].textContent).toContain('Not in library');
|
||||
// No local file -> no "Open" action, only CivitAI.
|
||||
expect(items[0].querySelector('[data-price-alert-action="open"]')).toBeNull();
|
||||
expect(
|
||||
items[0].querySelector('[data-price-alert-action="civitai"]')
|
||||
).not.toBeNull();
|
||||
expect(document.getElementById('priceAlertsDisabled').classList.contains('hidden')).toBe(true);
|
||||
expect(document.getElementById('priceAlertsEmpty').classList.contains('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('separates became-free alerts into their own segment', async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT]))
|
||||
);
|
||||
|
||||
await service.loadPriceAlerts({ force: true });
|
||||
|
||||
// Below-threshold is the default segment.
|
||||
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1);
|
||||
|
||||
service.priceAlertSegment = 'became_free';
|
||||
service.renderPriceAlerts();
|
||||
|
||||
const items = document.querySelectorAll('#priceAlertsList .price-alert-item');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].textContent).toContain('Free now');
|
||||
expect(items[0].textContent).toContain('In library');
|
||||
// In-library row offers both actions.
|
||||
expect(items[0].querySelector('[data-price-alert-action="open"]')).not.toBeNull();
|
||||
expect(items[0].querySelector('[data-price-alert-action="civitai"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('counts unread alerts and clears the count once viewed', async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT, BECAME_FREE_ALERT]))
|
||||
);
|
||||
|
||||
await service.loadPriceAlerts({ force: true });
|
||||
|
||||
expect(service.getUnreadPriceAlertCount()).toBe(2);
|
||||
const badge = document.getElementById('priceAlertsTabBadge');
|
||||
expect(badge.classList.contains('visible')).toBe(true);
|
||||
expect(badge.textContent).toBe('2');
|
||||
|
||||
service.markPriceAlertsViewed();
|
||||
|
||||
expect(service.getUnreadPriceAlertCount()).toBe(0);
|
||||
expect(badge.classList.contains('visible')).toBe(false);
|
||||
|
||||
// A later alert is unread again.
|
||||
service.priceAlerts = [
|
||||
{ ...BELOW_THRESHOLD_ALERT, priceAlertSince: 1791000000 + 1000 },
|
||||
];
|
||||
expect(service.refreshUnreadPriceAlertCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('explains itself while price tracking is off', async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
createFetchResponse(
|
||||
alertPayload([BECAME_FREE_ALERT], { enabled: false, thresholdBuzz: 0 })
|
||||
)
|
||||
);
|
||||
|
||||
await service.loadPriceAlerts({ force: true });
|
||||
|
||||
expect(
|
||||
document.getElementById('priceAlertsDisabled').classList.contains('hidden')
|
||||
).toBe(false);
|
||||
expect(document.getElementById('priceAlertsThreshold').textContent).toBe('');
|
||||
});
|
||||
|
||||
it('shows the empty state when the segment has no matches', async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])));
|
||||
|
||||
await service.loadPriceAlerts({ force: true });
|
||||
service.priceAlertSegment = 'became_free';
|
||||
service.renderPriceAlerts();
|
||||
|
||||
const empty = document.getElementById('priceAlertsEmpty');
|
||||
expect(empty.classList.contains('hidden')).toBe(false);
|
||||
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the last known list when the request fails', async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createFetchResponse(alertPayload([BELOW_THRESHOLD_ALERT])))
|
||||
.mockResolvedValueOnce(createFetchResponse({ success: false, error: 'boom' }, false));
|
||||
|
||||
await service.loadPriceAlerts({ force: true });
|
||||
await service.loadPriceAlerts({ force: true });
|
||||
|
||||
expect(document.querySelectorAll('#priceAlertsList .price-alert-item')).toHaveLength(1);
|
||||
const stale = document.getElementById('priceAlertsStale');
|
||||
expect(stale.classList.contains('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('switches to the tab without closing an already-open bell', () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([])));
|
||||
const toggleSpy = vi.spyOn(service, 'toggleUpdateModal');
|
||||
const getModalSpy = vi
|
||||
.spyOn(modalManager, 'getModal')
|
||||
.mockReturnValue({ isOpen: true });
|
||||
|
||||
service.openPriceAlertsTab();
|
||||
|
||||
expect(getModalSpy).toHaveBeenCalledWith('updateModal');
|
||||
// toggleUpdateModal() would have *closed* the open bell.
|
||||
expect(toggleSpy).not.toHaveBeenCalled();
|
||||
expect(service.activeNotificationTab).toBe('priceAlerts');
|
||||
|
||||
getModalSpy.mockRestore();
|
||||
toggleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('opens the bell when it is closed', () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(createFetchResponse(alertPayload([])));
|
||||
const toggleSpy = vi
|
||||
.spyOn(service, 'toggleUpdateModal')
|
||||
.mockImplementation(() => {});
|
||||
const getModalSpy = vi
|
||||
.spyOn(modalManager, 'getModal')
|
||||
.mockReturnValue({ isOpen: false });
|
||||
|
||||
service.openPriceAlertsTab();
|
||||
|
||||
expect(toggleSpy).toHaveBeenCalled();
|
||||
expect(service.activeNotificationTab).toBe('priceAlerts');
|
||||
|
||||
getModalSpy.mockRestore();
|
||||
toggleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import subprocess
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
@@ -16,6 +16,8 @@ from py.routes.handlers import misc_handlers
|
||||
from py.routes.handlers.misc_handlers import (
|
||||
BackupHandler,
|
||||
DoctorHandler,
|
||||
MiscHandlerSet,
|
||||
PriceAlertsHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
@@ -2865,3 +2867,220 @@ async def test_sidecar_migration_handler_relocate_root_requires_old_root():
|
||||
assert response.status == 400
|
||||
assert "old_root" in payload["error"]
|
||||
assert use_case.calls == []
|
||||
|
||||
|
||||
# --- Global price alerts panel endpoint -------------------------------------
|
||||
|
||||
|
||||
class _AnyHandler:
|
||||
def __getattr__(self, _name):
|
||||
return lambda request: None
|
||||
|
||||
|
||||
def _stub_misc_handler_set(**overrides) -> MiscHandlerSet:
|
||||
names = (
|
||||
"health",
|
||||
"settings",
|
||||
"usage_stats",
|
||||
"lora_code",
|
||||
"trained_words",
|
||||
"model_examples",
|
||||
"node_registry",
|
||||
"model_library",
|
||||
"metadata_archive",
|
||||
"backup",
|
||||
"filesystem",
|
||||
"custom_words",
|
||||
"wildcards",
|
||||
"supporters",
|
||||
"doctor",
|
||||
"example_workflows",
|
||||
"base_model",
|
||||
"price_alerts",
|
||||
"model_source_handler",
|
||||
"agent_handler",
|
||||
"download_routing",
|
||||
"sidecar_migration",
|
||||
)
|
||||
handlers = {name: _AnyHandler() for name in names}
|
||||
handlers.update(overrides)
|
||||
return MiscHandlerSet(**handlers)
|
||||
|
||||
|
||||
def test_every_misc_route_definition_resolves_to_a_handler():
|
||||
"""A route added to the table without a mapping entry 500s only on a live
|
||||
server, so assert the whole table resolves here."""
|
||||
|
||||
mapping = _stub_misc_handler_set().to_route_mapping()
|
||||
|
||||
assert [
|
||||
definition.handler_name
|
||||
for definition in MISC_ROUTE_DEFINITIONS
|
||||
if definition.handler_name not in mapping
|
||||
] == []
|
||||
|
||||
|
||||
def test_price_alerts_route_is_registered_once_app_wide():
|
||||
definitions = [
|
||||
definition
|
||||
for definition in MISC_ROUTE_DEFINITIONS
|
||||
if definition.handler_name == "get_price_alerts"
|
||||
]
|
||||
|
||||
# Exactly one entry, and no {prefix}: the panel spans every model type.
|
||||
assert len(definitions) == 1
|
||||
assert definitions[0].method == "GET"
|
||||
assert definitions[0].path == "/api/lm/price-alerts"
|
||||
|
||||
|
||||
def _price_alerts_adapter(update_service, scanners=None):
|
||||
async def _unused_scanner():
|
||||
raise AssertionError("scanner should not be requested in this test")
|
||||
|
||||
async def _get_scanner():
|
||||
return scanners
|
||||
|
||||
return ServiceRegistryAdapter(
|
||||
get_lora_scanner=_get_scanner,
|
||||
get_checkpoint_scanner=_unused_scanner,
|
||||
get_embedding_scanner=_unused_scanner,
|
||||
get_downloaded_version_history_service=_unused_scanner,
|
||||
get_model_update_service=AsyncMock(return_value=update_service),
|
||||
)
|
||||
|
||||
|
||||
class _FakeUpdateService:
|
||||
def __init__(self, alerts):
|
||||
self.alerts = alerts
|
||||
self.calls = []
|
||||
|
||||
async def get_price_alerts(self, model_type=None, *, threshold_buzz=None, limit=200):
|
||||
self.calls.append((model_type, threshold_buzz, limit))
|
||||
return [dict(alert) for alert in self.alerts]
|
||||
|
||||
def newest_price_checked_at(self):
|
||||
return 1791039694.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alerts_handler_returns_the_global_list():
|
||||
update_service = _FakeUpdateService(
|
||||
[
|
||||
{
|
||||
"modelId": 2981320,
|
||||
"modelType": "checkpoint",
|
||||
"versionId": 3379626,
|
||||
"kind": "below_threshold",
|
||||
"priceBuzz": 250,
|
||||
}
|
||||
]
|
||||
)
|
||||
settings = DummySettings(
|
||||
{
|
||||
"price_tracking_enabled": True,
|
||||
"price_alert_threshold_buzz": 300,
|
||||
"civitai_host": "civitai.com",
|
||||
}
|
||||
)
|
||||
handler = PriceAlertsHandler(
|
||||
settings_service=settings,
|
||||
service_registry=_price_alerts_adapter(update_service),
|
||||
)
|
||||
|
||||
response = await handler.get_price_alerts(
|
||||
FakeRequest(method="GET", query={"limit": "50"}) # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["enabled"] is True
|
||||
assert payload["thresholdBuzz"] == 300
|
||||
assert payload["newestCheckedAt"] == 1791039694.5
|
||||
assert payload["alerts"][0]["civitaiUrl"] == (
|
||||
"https://civitai.com/models/2981320?modelVersionId=3379626"
|
||||
)
|
||||
# All model types in one call, with the threshold resolved from settings.
|
||||
assert update_service.calls == [(None, 300, 50)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alerts_handler_decorates_local_context():
|
||||
update_service = _FakeUpdateService(
|
||||
[
|
||||
{
|
||||
"modelId": 1,
|
||||
"modelType": "lora",
|
||||
"versionId": 12,
|
||||
"kind": "below_threshold",
|
||||
"priceBuzz": 250,
|
||||
}
|
||||
]
|
||||
)
|
||||
cache = SimpleNamespace(
|
||||
model_id_index={1: [{"model_name": "Glorious Art", "file_name": "glorious.safetensors"}]},
|
||||
version_index={12: {"file_path": "/models/loras/glorious.safetensors", "file_name": "glorious.safetensors"}},
|
||||
)
|
||||
|
||||
class _Scanner:
|
||||
async def get_cached_data(self):
|
||||
return cache
|
||||
|
||||
async def _get_scanner():
|
||||
return _Scanner()
|
||||
|
||||
adapter = ServiceRegistryAdapter(
|
||||
get_lora_scanner=_get_scanner,
|
||||
get_checkpoint_scanner=_get_scanner,
|
||||
get_embedding_scanner=_get_scanner,
|
||||
get_downloaded_version_history_service=_get_scanner,
|
||||
get_model_update_service=AsyncMock(return_value=update_service),
|
||||
)
|
||||
handler = PriceAlertsHandler(
|
||||
settings_service=DummySettings({"civitai_host": "civitai.red"}),
|
||||
service_registry=adapter,
|
||||
)
|
||||
|
||||
response = await handler.get_price_alerts(
|
||||
FakeRequest(method="GET") # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
alert = _json_payload(response)["alerts"][0]
|
||||
|
||||
assert alert["modelName"] == "Glorious Art"
|
||||
assert alert["filePath"] == "/models/loras/glorious.safetensors"
|
||||
assert alert["civitaiUrl"].startswith("https://civitai.red/models/1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alerts_handler_survives_a_cold_scanner_cache():
|
||||
"""Local context is best-effort: the panel must still render."""
|
||||
|
||||
update_service = _FakeUpdateService(
|
||||
[{"modelId": 1, "modelType": "lora", "versionId": 12, "kind": "became_free"}]
|
||||
)
|
||||
|
||||
async def _broken_scanner():
|
||||
raise RuntimeError("scanner not ready")
|
||||
|
||||
adapter = ServiceRegistryAdapter(
|
||||
get_lora_scanner=_broken_scanner,
|
||||
get_checkpoint_scanner=_broken_scanner,
|
||||
get_embedding_scanner=_broken_scanner,
|
||||
get_downloaded_version_history_service=_broken_scanner,
|
||||
get_model_update_service=AsyncMock(return_value=update_service),
|
||||
)
|
||||
handler = PriceAlertsHandler(
|
||||
settings_service=DummySettings(), service_registry=adapter
|
||||
)
|
||||
|
||||
response = await handler.get_price_alerts(
|
||||
FakeRequest(method="GET") # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
payload = _json_payload(response)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
# Tracking is off in this fixture, so the panel explains itself.
|
||||
assert payload["enabled"] is False
|
||||
assert "modelName" not in payload["alerts"][0]
|
||||
assert "filePath" not in payload["alerts"][0]
|
||||
|
||||
@@ -1723,3 +1723,164 @@ async def test_get_price_alerts_tolerates_bad_limit(tmp_path):
|
||||
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
|
||||
|
||||
assert await service.get_price_alerts("lora", limit="not-a-number") == []
|
||||
|
||||
|
||||
# --- Price alerts panel semantics (read-time threshold, across types) ---------
|
||||
|
||||
|
||||
def _gated_response_for(version_id: int, ends_at: str) -> dict:
|
||||
return {
|
||||
"modelVersions": [
|
||||
{
|
||||
"id": version_id,
|
||||
"baseModel": "Pony",
|
||||
"availability": "Public",
|
||||
"paidAccess": {"permanent": False, "endsAt": ends_at},
|
||||
"files": [],
|
||||
"images": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _free_response_for(version_id: int) -> dict:
|
||||
return {
|
||||
"modelVersions": [
|
||||
{
|
||||
"id": version_id,
|
||||
"baseModel": "Pony",
|
||||
"availability": "Public",
|
||||
"files": [],
|
||||
"images": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alerts_threshold_is_compared_at_read_time(tmp_path):
|
||||
"""Changing the threshold must change panel membership without a refresh."""
|
||||
|
||||
service = _price_service(
|
||||
tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300
|
||||
)
|
||||
scanner = DummyScanner(LOCAL_RAW_DATA)
|
||||
await service.refresh_for_model_type(
|
||||
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=_prices(250))
|
||||
)
|
||||
|
||||
assert len(await service.get_price_alerts("lora")) == 1
|
||||
# The stored alert state still says "hit"...
|
||||
stored = await service.get_record("lora", 1)
|
||||
assert stored.versions[0].price_alert_state is True
|
||||
# ...but a tighter threshold excludes it immediately.
|
||||
assert await service.get_price_alerts("lora", threshold_buzz=100) == []
|
||||
assert len(await service.get_price_alerts("lora", threshold_buzz=300)) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alerts_span_all_model_types(tmp_path):
|
||||
"""model_type=None is what the global panel uses: one list, all types."""
|
||||
|
||||
service = _price_service(
|
||||
tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=1000
|
||||
)
|
||||
lora_scanner = DummyScanner([{"civitai": {"modelId": 1, "id": 11}}])
|
||||
checkpoint_scanner = DummyScanner([{"civitai": {"modelId": 2, "id": 21}}])
|
||||
|
||||
await service.refresh_for_model_type(
|
||||
"lora",
|
||||
lora_scanner,
|
||||
PriceProvider(_gated_response_for(12, "2999-01-01T00:00:00.000Z"), prices=_prices(250)),
|
||||
)
|
||||
await service.refresh_for_model_type(
|
||||
"checkpoint",
|
||||
checkpoint_scanner,
|
||||
PriceProvider(
|
||||
_gated_response_for(22, "2999-01-01T00:00:00.000Z"),
|
||||
prices={22: {"price_buzz": 100}},
|
||||
),
|
||||
)
|
||||
|
||||
everything = await service.get_price_alerts()
|
||||
assert {(a["modelType"], a["modelId"], a["versionId"]) for a in everything} == {
|
||||
("lora", 1, 12),
|
||||
("checkpoint", 2, 22),
|
||||
}
|
||||
# Cheapest first.
|
||||
assert [a["priceBuzz"] for a in everything] == [100, 250]
|
||||
|
||||
assert [a["modelType"] for a in await service.get_price_alerts("checkpoint")] == [
|
||||
"checkpoint"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alert_since_tracks_the_crossing_and_clears(tmp_path):
|
||||
service = _price_service(
|
||||
tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300
|
||||
)
|
||||
scanner = DummyScanner(LOCAL_RAW_DATA)
|
||||
|
||||
first = await service.refresh_for_model_type(
|
||||
"lora",
|
||||
scanner,
|
||||
PriceProvider(_gated_response("2999-01-01T00:00:00.000Z"), prices=_prices(250)),
|
||||
)
|
||||
since = first[1].versions[0].price_alert_since
|
||||
assert since is not None
|
||||
|
||||
# Still under the threshold: the original crossing time is preserved.
|
||||
second = await service.refresh_for_model_type(
|
||||
"lora",
|
||||
scanner,
|
||||
PriceProvider(_gated_response("2999-02-01T00:00:00.000Z"), prices=_prices(200)),
|
||||
)
|
||||
assert second[1].versions[0].price_alert_since == since
|
||||
stored = await service.get_record("lora", 1)
|
||||
assert stored.versions[0].price_alert_since == since
|
||||
|
||||
# Back above it: cleared, and the row leaves the panel.
|
||||
third = await service.refresh_for_model_type(
|
||||
"lora",
|
||||
scanner,
|
||||
PriceProvider(_gated_response("2999-03-01T00:00:00.000Z"), prices=_prices(900)),
|
||||
)
|
||||
assert third[1].versions[0].price_alert_since is None
|
||||
assert await service.get_price_alerts("lora") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_alerts_report_became_free_without_price_tracking(tmp_path):
|
||||
"""Gate transitions need no price data, so the "became free" half of the panel
|
||||
works even while price tracking is switched off."""
|
||||
|
||||
service = _price_service(tmp_path) # tracking off
|
||||
scanner = DummyScanner(LOCAL_RAW_DATA)
|
||||
|
||||
await service.refresh_for_model_type("lora", scanner, DummyProvider(GATED_RESPONSE))
|
||||
assert await service.get_price_alerts("lora") == []
|
||||
|
||||
await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE))
|
||||
alerts = await service.get_price_alerts("lora")
|
||||
|
||||
assert [alert["kind"] for alert in alerts] == ["became_free"]
|
||||
assert alerts[0]["priceBuzz"] is None
|
||||
assert alerts[0]["gateLapsedAt"] is not None
|
||||
assert alerts[0]["versionId"] == 12
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_newest_price_checked_at_reports_the_latest_fetch(tmp_path):
|
||||
service = _price_service(tmp_path, price_tracking_enabled=True)
|
||||
scanner = DummyScanner(LOCAL_RAW_DATA)
|
||||
|
||||
assert service.newest_price_checked_at() is None
|
||||
|
||||
await service.refresh_for_model_type(
|
||||
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
|
||||
)
|
||||
newest = service.newest_price_checked_at()
|
||||
|
||||
assert newest is not None
|
||||
assert newest > 0
|
||||
|
||||
Reference in New Issue
Block a user