refactor(update)!: make obtainability a property of updates, not a surface

The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the
"Price alert threshold" number meant, or what "Price Alerts" was alerting about.
That was not a copy problem: the implementation exposed our mechanism (a page
scrape) and our SQL predicates as the user's concepts. Two concrete defects came
from the same root:

- the alert population included versions the user already owns (neither the event
  generator nor the query filtered on is_in_library; in the owner's library 28 of
  52 gated versions were already downloaded, so most "alerts" were about files
  already on disk, which cannot become cheaper *for them*);
- a threshold-filtered state list lived in a notification surface, so an empty
  panel had three indistinguishable causes and read as a broken feature.

The information model is now the version plus ownership: cost is shown only where
a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and
gated -> the price when it is known, `Paid` without a number when it is not, and
early access keeps its countdown because "free on <date>" decides between waiting
and paying. The numeric threshold has no place in that model: every decision is
categorical (wait / pay / skip), so the setting, the comparison and the whole
alert-state machine are gone.

- both alert-state columns are removed from the schema rather than left dead; a
  database created by an unreleased build has them dropped on open (native
  ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else
- gate events are emitted only for versions the user does not have, and the
  price-drop event goes with the threshold it belonged to
- both alert endpoints, PriceAlertsHandler and the service-registry adapter field
  it needed are removed: events already reach the UI through the refresh response
- the bell tab, panel, CSS, both entry points, the unread watermark and their
  locales are removed; the setting keeps only the enable flag and the refresh
  interval and is framed as plumbing
- "Price unavailable" is replaced by `Paid`: the gate is certain from the public
  API, only the number is best-effort, and that is our plumbing, not the user's
  problem

Verified against a copy of the owner's real database: 52 gated versions ->
28 owned (now silent) + 24 the feature is actually about; the drop migration ran
and both removed endpoints 404.
This commit is contained in:
Will Miao
2026-10-05 08:54:08 +08:00
parent 156d2e5eb9
commit e42d649df0
35 changed files with 403 additions and 2494 deletions
@@ -284,7 +284,7 @@ describe('ModelVersionsTab download button visibility', () => {
});
});
describe('ModelVersionsTab price badges', () => {
describe('ModelVersionsTab obtainability badges', () => {
let getModelApiClient;
let fetchModelUpdateVersions;
@@ -316,63 +316,83 @@ describe('ModelVersionsTab price badges', () => {
return document.querySelector(`.model-version-row[data-version-id="${versionId}"]`);
}
it('shows the price for a gated version whose price was captured', async () => {
function renderOne(version) {
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
{
versionId: 20,
name: 'Alpha',
versionId: 1,
name: 'v1',
isInLibrary: false,
shouldIgnore: false,
isPaid: true,
paidAccess: { permanent: true, endsAt: null },
priceBuzz: 5000,
listPriceBuzz: 5000,
priceCheckedAt: 1791039694.5,
priceAttemptedAt: 1791039694.5,
...version,
},
]));
return renderVersions();
}
await renderVersions();
const PAID_GATE = { permanent: true, endsAt: null };
expect(rowFor(20).textContent).toContain('5,000 Buzz');
it('shows the download price for a paid version the user does not have', async () => {
await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: 5000 });
expect(rowFor(1).textContent).toContain('5,000 Buzz');
});
it('marks a gated version with no readable price as unavailable', async () => {
// The mature-model case: we looked, no host would serve the page.
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
{
versionId: 21,
name: 'Beta',
isInLibrary: false,
shouldIgnore: false,
isPaid: true,
paidAccess: { permanent: true, endsAt: null },
priceBuzz: null,
priceAttemptedAt: 1791039694.5,
},
]));
it('says Paid without a number when the price could not be read', async () => {
// The gate is certain (it comes from the public API); only the number is
// best-effort, and that is our plumbing rather than the user's problem.
await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: null });
await renderVersions();
expect(rowFor(21).textContent).toContain('Price unavailable');
expect(rowFor(1).textContent).toContain('Paid');
expect(rowFor(1).textContent).not.toContain('Buzz');
});
it('stays quiet when the price was never looked up', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
{
versionId: 22,
name: 'Gamma',
isInLibrary: false,
shouldIgnore: false,
isPaid: true,
paidAccess: { permanent: true, endsAt: null },
priceBuzz: null,
priceAttemptedAt: null,
},
]));
it('shows the early access countdown alongside the price', async () => {
await renderOne({
isEarlyAccess: true,
earlyAccessEndsAt: new Date(Date.now() + 3 * 86400000).toISOString(),
paidAccess: { permanent: false, endsAt: '2999-01-01T00:00:00.000Z' },
priceBuzz: 500,
});
await renderVersions();
const text = rowFor(1).textContent;
expect(text).toContain('Early Access');
expect(text).toContain('500 Buzz');
});
expect(rowFor(22).textContent).not.toContain('Price unavailable');
it('shows nothing about cost for a version already in the library', async () => {
// A version on disk cannot become cheaper *for this user*.
await renderOne({
isInLibrary: true,
isPaid: true,
paidAccess: PAID_GATE,
priceBuzz: 5000,
gateLapsedAt: '2026-09-28T00:00:00.000Z',
});
const text = rowFor(1).textContent;
expect(text).not.toContain('Buzz');
expect(text).not.toContain('Paid');
expect(text).not.toContain('Free Now');
expect(text).toContain('In Library');
});
it('marks a version that became free and is not in the library', async () => {
await renderOne({
isInLibrary: false,
isPaid: false,
priceBuzz: null,
gateLapsedAt: '2026-09-28T00:00:00.000Z',
});
expect(rowFor(1).textContent).toContain('Free Now');
});
it('shows nothing at all for a plain free version', async () => {
await renderOne({ isInLibrary: false, isPaid: false, priceBuzz: null });
const text = rowFor(1).textContent;
expect(text).not.toContain('Buzz');
expect(text).not.toContain('Paid');
expect(text).not.toContain('Free Now');
});
});
@@ -1,300 +0,0 @@
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('reports how many gated versions have no readable price', async () => {
global.fetch = vi.fn().mockResolvedValue(
createFetchResponse(
alertPayload([BELOW_THRESHOLD_ALERT], { unavailableCount: 3 })
)
);
await service.loadPriceAlerts({ force: true });
expect(service.priceAlertsUnavailableCount).toBe(3);
const note = document.getElementById('priceAlertsStale');
expect(note.classList.contains('hidden')).toBe(false);
expect(note.textContent).toContain('3 paid version(s) have no readable price');
});
it('explains a 0 Buzz threshold instead of claiming there is nothing', async () => {
// The exact confusion a real user hit: 52 priced versions, threshold 0,
// and an empty panel saying "nothing is under your threshold".
global.fetch = vi.fn().mockResolvedValue(
createFetchResponse(
alertPayload([], { thresholdBuzz: 0, pricedCount: 52 })
)
);
await service.loadPriceAlerts({ force: true });
const empty = document.getElementById('priceAlertsEmpty');
expect(empty.classList.contains('hidden')).toBe(false);
expect(empty.textContent).toContain('52 paid version(s) have a known price');
expect(empty.textContent).toContain('threshold is 0 Buzz');
});
it('keeps the plain empty message when a real threshold is set', async () => {
global.fetch = vi.fn().mockResolvedValue(
createFetchResponse(
alertPayload([], { thresholdBuzz: 500, pricedCount: 52 })
)
);
await service.loadPriceAlerts({ force: true });
const empty = document.getElementById('priceAlertsEmpty');
expect(empty.textContent).toBe('Nothing is under your price threshold right now.');
});
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();
});
});
+7 -172
View File
@@ -17,7 +17,6 @@ from py.routes.handlers.misc_handlers import (
BackupHandler,
DoctorHandler,
MiscHandlerSet,
PriceAlertsHandler,
FileSystemHandler,
HealthCheckHandler,
LoraCodeHandler,
@@ -2896,7 +2895,6 @@ def _stub_misc_handler_set(**overrides) -> MiscHandlerSet:
"doctor",
"example_workflows",
"base_model",
"price_alerts",
"model_source_handler",
"agent_handler",
"download_routing",
@@ -2920,177 +2918,14 @@ def test_every_misc_route_definition_resolves_to_a_handler():
] == []
def test_price_alerts_route_is_registered_once_app_wide():
definitions = [
def test_price_alert_endpoints_are_gone():
"""The redesign removed the standalone alerts surface: obtainability is an
attribute of the update surfaces, so no route may serve an alert list."""
leftovers = [
definition
for definition in MISC_ROUTE_DEFINITIONS
if definition.handler_name == "get_price_alerts"
if "price-alert" in definition.path or "price_alert" in definition.handler_name
]
# 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, *, unavailable_count=0, priced_count=0):
self.alerts = alerts
self.calls = []
self.unavailable_count = unavailable_count
self.priced_count = priced_count
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
def count_unavailable_prices(self, model_type=None):
return self.unavailable_count
def count_priced_versions(self, model_type=None):
return self.priced_count
@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["unavailableCount"] == 0
assert payload["pricedCount"] == 0
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]
assert leftovers == []
+8 -42
View File
@@ -326,39 +326,6 @@ async def test_refresh_model_updates_filters_records_without_updates():
assert call["target_model_ids"] is None
@pytest.mark.asyncio
async def test_get_price_alerts_returns_flagged_versions():
"""The price alert list is a GET (companion-extension friendly) and reports
the threshold it was computed with."""
class AlertsService:
def __init__(self):
self.calls = []
async def get_price_alerts(self, model_type, limit=200):
self.calls.append((model_type, limit))
return [{"modelId": 1, "versionId": 12, "priceBuzz": 250}]
update_service = AlertsService()
settings = {"price_tracking_enabled": True, "price_alert_threshold_buzz": 300}
handler = ModelUpdateHandler(
service=DummyService(SimpleNamespace(version_index={})),
update_service=update_service,
metadata_provider_selector=lambda *_: None,
settings_service=SimpleNamespace(get=lambda key, default=None: settings.get(key, default)),
logger=logging.getLogger(__name__),
)
request = SimpleNamespace(query={"limit": "50"})
response = await handler.get_price_alerts(request) # pyright: ignore[reportArgumentType]
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["enabled"] is True
assert payload["thresholdBuzz"] == 300
assert payload["alerts"] == [{"modelId": 1, "versionId": 12, "priceBuzz": 250}]
assert update_service.calls == [("lora", 50)]
@pytest.mark.asyncio
@@ -1216,13 +1183,12 @@ def test_every_common_route_definition_resolves_to_a_handler():
assert missing == []
def test_get_price_alerts_is_registered_as_a_route():
"""The alerts endpoint must be declared and reachable via GET."""
def test_price_alert_endpoints_are_not_registered():
"""The redesign dropped the standalone alerts surface: obtainability rides on
the update surfaces, so no per-type alert route may remain."""
definition = next(
d
for d in COMMON_ROUTE_DEFINITIONS
if d.handler_name == "get_price_alerts"
)
assert definition.method == "GET"
assert definition.build_path("loras") == "/api/lm/loras/updates/price-alerts"
assert [
definition.path_template
for definition in COMMON_ROUTE_DEFINITIONS
if "price-alert" in definition.path_template
] == []
+88 -238
View File
@@ -1153,9 +1153,9 @@ def test_build_record_emits_became_free_event(tmp_path):
assert version.gate_lapsed_at is not None
assert version.price_buzz is None
assert version.price_checked_at is None
assert version.price_alert_state is False
assert [event["kind"] for event in record.events] == ["became_free"]
assert record.events[0]["versionId"] == 7
assert record.events[0]["isInLibrary"] is False
def test_build_record_emits_new_gate_event(tmp_path):
@@ -1318,9 +1318,12 @@ def test_migration_adds_price_columns_to_legacy_db(tmp_path):
"accepts_blue_buzz",
"price_sale_ends_at",
"price_checked_at",
"price_alert_state",
"price_check_attempted_at",
):
assert column in columns
# The alert-state columns were removed together with the threshold they served.
assert "price_alert_state" not in columns
assert "price_alert_since" not in columns
record = service._get_record("lora", 999)
assert record is not None
@@ -1343,7 +1346,6 @@ def test_price_fields_round_trip_through_sqlite(tmp_path):
generation_price_buzz=100,
accepts_blue_buzz=True,
price_sale_ends_at="2026-10-01T00:00:00.000Z",
price_alert_state=True,
is_in_library=True,
gate_lapsed_at=None,
)
@@ -1358,7 +1360,7 @@ def test_price_fields_round_trip_through_sqlite(tmp_path):
assert loaded.accepts_blue_buzz is True
assert loaded.price_sale_ends_at == "2026-10-01T00:00:00.000Z"
assert loaded.price_checked_at == 123.5
assert loaded.price_alert_state is True
assert loaded.price_check_attempted_at is None
# --- Optional price capture --------------------------------------------------
@@ -1625,107 +1627,14 @@ def _prices(price_buzz: int) -> dict:
return {12: {"price_buzz": price_buzz, "list_price_buzz": price_buzz}}
@pytest.mark.asyncio
async def test_price_alert_state_persists_and_fires_on_crossing(tmp_path):
service = _price_service(
tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=300
)
scanner = DummyScanner(LOCAL_RAW_DATA)
# First sight: the state is recorded (so the alerts list shows it) but nothing
# is announced - otherwise enabling the feature would toast every cheap
# version in the library at once.
first_refresh = await service.refresh_for_model_type(
"lora",
scanner,
PriceProvider(_gated_response("2999-01-01T00:00:00.000Z"), prices=_prices(250)),
)
version = next(v for v in first_refresh[1].versions if v.version_id == 12)
assert version.price_alert_state is True
assert first_refresh[1].events == []
alerts = await service.get_price_alerts("lora")
assert [alert["versionId"] for alert in alerts] == [12]
assert alerts[0]["priceBuzz"] == 250
assert alerts[0]["modelId"] == 1
# Price rises above the threshold: the state resets silently.
raised = await service.refresh_for_model_type(
"lora",
scanner,
PriceProvider(_gated_response("2999-02-01T00:00:00.000Z"), prices=_prices(900)),
)
assert raised[1].versions[0].price_alert_state is False
assert raised[1].events == []
assert await service.get_price_alerts("lora") == []
# …and drops back under it: now it is news.
dropped = await service.refresh_for_model_type(
"lora",
scanner,
PriceProvider(_gated_response("2999-03-01T00:00:00.000Z"), prices=_prices(200)),
)
assert dropped[1].versions[0].price_alert_state is True
assert [event["kind"] for event in dropped[1].events] == ["price_drop"]
assert dropped[1].events[0]["priceBuzz"] == 200
# Events are derived, never persisted: a later read reports none.
stored = await service.get_record("lora", 1)
assert stored.events == []
assert stored.versions[0].price_alert_state is True
@pytest.mark.asyncio
async def test_price_alert_not_fired_above_threshold(tmp_path):
service = _price_service(
tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=100
)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
record = await service.get_record("lora", 1)
assert record.versions[0].price_alert_state is False
assert record.events == []
assert await service.get_price_alerts("lora") == []
@pytest.mark.asyncio
async def test_price_alert_requires_price_tracking(tmp_path):
"""With tracking off there are no prices, so no price alert either."""
service = _price_service(tmp_path, price_alert_threshold_buzz=100000)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
record = await service.get_record("lora", 1)
assert record.versions[0].price_alert_state is False
assert await service.get_price_alerts("lora") == []
@pytest.mark.asyncio
async def test_price_alerts_respect_ignores_and_model_type(tmp_path):
service = _price_service(
tmp_path, price_tracking_enabled=True, price_alert_threshold_buzz=1000
)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
assert len(await service.get_price_alerts("lora")) == 1
await service.set_version_should_ignore("lora", 1, 12, True)
assert await service.get_price_alerts("lora") == []
assert await service.get_price_alerts("checkpoint") == []
@pytest.mark.asyncio
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) ---------
@@ -1760,133 +1669,14 @@ def _free_response_for(version_id: int) -> dict:
}
@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
@pytest.mark.asyncio
@@ -1907,11 +1697,6 @@ async def test_failed_price_attempt_is_recorded_as_unavailable(tmp_path):
assert version.price_buzz is None
assert version.price_checked_at is None
assert version.price_check_attempted_at is not None
assert service.count_unavailable_prices("lora") == 1
assert service.count_unavailable_prices("checkpoint") == 0
# Nothing to alert on, and no price alert state.
assert await service.get_price_alerts("lora") == []
assert version.price_alert_state is False
@pytest.mark.asyncio
@@ -1928,7 +1713,6 @@ async def test_successful_price_attempt_sets_both_markers(tmp_path):
assert version.price_buzz == 250
assert version.price_checked_at is not None
assert version.price_check_attempted_at is not None
assert service.count_unavailable_prices("lora") == 0
@pytest.mark.asyncio
@@ -1940,7 +1724,6 @@ async def test_no_price_attempt_is_recorded_while_tracking_is_off(tmp_path):
record = await service.get_record("lora", 1)
assert record.versions[0].price_check_attempted_at is None
assert service.count_unavailable_prices("lora") == 0
@pytest.mark.asyncio
@@ -1951,13 +1734,11 @@ async def test_unavailable_marker_clears_when_the_version_becomes_free(tmp_path)
await service.refresh_for_model_type(
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=None)
)
assert service.count_unavailable_prices("lora") == 1
await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE))
record = await service.get_record("lora", 1)
assert record.versions[0].price_check_attempted_at is None
assert service.count_unavailable_prices("lora") == 0
def _long_ttl_service(tmp_path, **settings):
@@ -2020,21 +1801,8 @@ async def test_failed_price_attempt_is_not_retried_within_the_ttl(tmp_path):
await service.refresh_for_model_type("lora", scanner, provider)
assert provider.price_calls == 1
assert service.count_unavailable_prices("lora") == 1
@pytest.mark.asyncio
async def test_priced_count_reports_known_prices(tmp_path):
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
assert service.count_priced_versions("lora") == 0
await service.refresh_for_model_type("lora", scanner, provider)
assert service.count_priced_versions("lora") == 1
assert service.count_priced_versions("checkpoint") == 0
@pytest.mark.asyncio
@@ -2051,3 +1819,85 @@ async def test_forced_refresh_reprices_within_the_ttl(tmp_path):
)
assert provider.price_calls == 2
# --- Ownership scoping and the removed threshold -----------------------------
def test_gate_event_is_suppressed_for_a_version_the_user_owns(tmp_path):
"""A version already on disk cannot become cheaper *for this user*, so it must
not produce an event. More than half of a real library's gated versions are
owned, so this is the common case, not an edge case."""
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
existing = make_record(replace(_remote_gated(7), is_in_library=True))
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[7],
remote_versions=[_remote_free(7)],
existing=existing,
timestamp=1000.0,
)
assert record.events == []
# The lapse itself is still recorded - it is the *notification* that is
# pointless for a version the user already has.
assert record.versions[0].gate_lapsed_at is not None
def test_new_gate_event_is_suppressed_for_a_version_the_user_owns(tmp_path):
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
existing = make_record(replace(_remote_free(7), is_in_library=True))
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[7],
remote_versions=[_remote_gated(7)],
existing=existing,
timestamp=1000.0,
)
assert record.events == []
def test_threshold_setting_is_gone_from_the_defaults():
"""The redesign removed the numeric threshold: every obtainability decision is
categorical (wait / pay / skip), so no number is stored or compared."""
from py.services.settings_manager import DEFAULT_SETTINGS
assert "price_alert_threshold_buzz" not in DEFAULT_SETTINGS
assert "price_tracking_enabled" in DEFAULT_SETTINGS
assert "price_check_ttl_hours" in DEFAULT_SETTINGS
def test_obsolete_alert_columns_are_dropped_from_a_development_database(tmp_path):
"""A database created while the feature was unreleased still carries the two
alert-state columns; opening it must drop them rather than leave dead schema."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path))
with service._connect() as conn:
conn.execute(
"ALTER TABLE model_update_versions "
"ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0"
)
conn.execute(
"ALTER TABLE model_update_versions ADD COLUMN price_alert_since REAL"
)
conn.commit()
assert "price_alert_state" in service._get_table_columns(
conn, "model_update_versions"
)
# A fresh service instance runs the migration on open.
reopened = ModelUpdateService(str(db_path))
columns = reopened._get_table_columns(
reopened._connect(), "model_update_versions"
)
assert "price_alert_state" not in columns
assert "price_alert_since" not in columns