"""Tests for the measuringtools plugin. The plugin ships default-disabled, so the shared session app does not register its blueprint. This module builds its own app, registers the blueprint, runs the plugin's on_install seeding, and restores the process-wide plugin_manager singleton afterwards (create_app repoints it) so other test modules are unaffected - the same snapshot/restore dance used by test_plugin_migrations. Coverage: derived calibration status (all four buckets + the 30-day boundary), type CRUD with the in-use delete guard, tool create/update in one merged payload, and the calibration report shape. """ from datetime import date, timedelta import pytest from werkzeug.security import generate_password_hash from shopdb import create_app from shopdb.extensions import db as _db from shopdb.plugins import plugin_manager from plugins.measuringtools.models import ( derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS) # ============================================================================= # Pure unit tests: derived status (no app needed) # ============================================================================= def test_derive_status_unknown_when_no_date(): assert derive_status(None) == 'unknown' def test_derive_status_overdue_in_past(): assert derive_status(date.today() - timedelta(days=1)) == 'overdue' def test_derive_status_current_far_future(): assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS + 1)) == 'current' def test_derive_status_duesoon_within_window(): assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS - 1)) == 'duesoon' def test_derive_status_duesoon_on_boundary(): """Exactly DUESOON_WINDOW_DAYS out is still 'due soon' (inclusive).""" assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon' def test_derive_status_duesoon_today(): """Due today is not yet overdue - it falls in the due-soon window.""" assert derive_status(date.today()) == 'duesoon' # ============================================================================= # API tests (self-contained app with the blueprint registered) # ============================================================================= @pytest.fixture(scope='module') def mt_app(): """A testing app with the measuringtools blueprint registered + seeded.""" saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry, plugin_manager.loader, plugin_manager.migration_manager, plugin_manager._registered_prefixes) application = create_app('testing') from plugins.measuringtools.plugin import MeasuringToolsPlugin plugin = MeasuringToolsPlugin() pm = application.extensions['plugin_manager'] if plugin.meta.api_prefix not in pm._registered_prefixes: pm._register_plugin_components(plugin) with application.app_context(): _db.create_all() _seed(application, plugin) yield application _db.session.remove() _db.drop_all() (plugin_manager._app, plugin_manager._db, plugin_manager.registry, plugin_manager.loader, plugin_manager.migration_manager, plugin_manager._registered_prefixes) = saved def _seed(application, plugin): """Seed permissions, an admin, a status, and the plugin's own reference data.""" from shopdb.core.models import Permission, User, Role, AssetStatus Permission.seed() role = Role(rolename='admin', description='Administrator') _db.session.add(role) _db.session.add(AssetStatus(status='In Use', description='In use')) _db.session.flush() user = User(username='testadmin', email='admin@test.local', passwordhash=generate_password_hash('testpass')) user.roles.append(role) _db.session.add(user) _db.session.commit() # Seeds AssetType 'measuring_tool' + the starter tool types. plugin.on_install(application) @pytest.fixture def client(mt_app): return mt_app.test_client() @pytest.fixture def auth_headers(client): response = client.post('/api/auth/login', json={'username': 'testadmin', 'password': 'testpass'}) assert response.status_code == 200, response.get_json() token = response.get_json()['data']['access_token'] return {'Authorization': f'Bearer {token}'} def _status_id(client): return 1 # only status seeded # -- Type CRUD + in-use guard ------------------------------------------------- def test_starter_types_seeded(client): response = client.get('/api/measuringtools/types') assert response.status_code == 200 names = {t['name'] for t in response.get_json()['data']} assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names def test_type_create_requires_auth(client): response = client.post('/api/measuringtools/types', json={'name': 'Nope'}) assert response.status_code == 401 def test_type_crud_lifecycle(client, auth_headers): created = client.post('/api/measuringtools/types', headers=auth_headers, json={'name': 'Pin Gage', 'description': 'Pin gage set', 'color': '#123456'}) assert created.status_code == 201, created.get_json() type_id = created.get_json()['data']['measuringtooltypeid'] updated = client.put(f'/api/measuringtools/types/{type_id}', headers=auth_headers, json={'description': 'Updated'}) assert updated.status_code == 200 assert updated.get_json()['data']['description'] == 'Updated' dup = client.post('/api/measuringtools/types', headers=auth_headers, json={'name': 'Pin Gage'}) assert dup.status_code == 409 deleted = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers) assert deleted.status_code == 200 def test_type_delete_blocked_when_in_use(client, auth_headers): type_created = client.post('/api/measuringtools/types', headers=auth_headers, json={'name': 'Depth Gage'}) type_id = type_created.get_json()['data']['measuringtooltypeid'] tool_created = client.post('/api/measuringtools', headers=auth_headers, json={'assetnumber': 'MT-INUSE-1', 'measuringtooltypeid': type_id, 'statusid': _status_id(client)}) assert tool_created.status_code == 201, tool_created.get_json() blocked = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers) assert blocked.status_code == 409 assert 'still use this type' in blocked.get_json()['data']['error']['message'] # -- Tool create/update merged payload ---------------------------------------- def test_tool_create_and_get_merged(client, auth_headers): caliper = client.get('/api/measuringtools/types').get_json()['data'] caliper_id = next(t['measuringtooltypeid'] for t in caliper if t['name'] == 'Caliper') created = client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-001', 'name': 'Bench caliper', 'serialnumber': 'SN-CAL-9', 'gaugelabreference': 'GL-42', 'measuringtooltypeid': caliper_id, 'statusid': _status_id(client), 'calibrationintervaldays': 365, 'lastcalibrationdate': '2026-01-01', 'nextcalibrationdate': '2027-01-01', 'calibrationprovider': 'Metro Cal Lab', }) assert created.status_code == 201, created.get_json() payload = created.get_json()['data'] # Asset core fields at the top level, extension nested. assert payload['assetnumber'] == 'MT-001' assert payload['serialnumber'] == 'SN-CAL-9' assert payload['gaugelabreference'] == 'GL-42' ext = payload['measuringtool'] assert ext['measuringtooltypename'] == 'Caliper' assert ext['calibrationprovider'] == 'Metro Cal Lab' assert ext['calibrationstatus'] == 'current' tool_id = ext['measuringtoolid'] fetched = client.get(f'/api/measuringtools/{tool_id}') assert fetched.status_code == 200 assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id def test_tool_payload_carries_location_code_and_name(mt_app, client, auth_headers): """A tool at an operation location exposes locationcode (leading token) and locationname so a printed label can encode the inspection operation.""" with mt_app.app_context(): from shopdb.core.models import Location location = Location(locationname='0615 Blisk Inspection') _db.session.add(location) _db.session.commit() location_id = location.locationid created = client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-LOC-1', 'statusid': _status_id(client), 'locationid': location_id, }) assert created.status_code == 201, created.get_json() payload = created.get_json()['data'] assert payload['locationname'] == '0615 Blisk Inspection' assert payload['locationcode'] == '0615' def test_tool_payload_location_code_none_when_unplaced(client, auth_headers): """A tool with no location degrades gracefully: locationcode is None.""" created = client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-NOLOC-1', 'statusid': _status_id(client), }) assert created.status_code == 201, created.get_json() assert created.get_json()['data']['locationcode'] is None def test_tool_update_merged_payload(client, auth_headers): created = client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-UPD-1', 'statusid': _status_id(client), 'nextcalibrationdate': '2030-01-01', }) tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, json={ 'name': 'Renamed tool', # asset core field 'calibrationprovider': 'In-house', # extension field 'nextcalibrationdate': str(date.today() - timedelta(days=5)), # -> overdue }) assert updated.status_code == 200, updated.get_json() data = updated.get_json()['data'] assert data['name'] == 'Renamed tool' assert data['measuringtool']['calibrationprovider'] == 'In-house' assert data['measuringtool']['calibrationstatus'] == 'overdue' def test_tool_create_duplicate_assetnumber_conflicts(client, auth_headers): body = {'assetnumber': 'MT-DUP', 'statusid': _status_id(client)} first = client.post('/api/measuringtools', headers=auth_headers, json=body) assert first.status_code == 201 second = client.post('/api/measuringtools', headers=auth_headers, json=body) assert second.status_code == 409 def test_list_filter_by_calibrationstatus(client, auth_headers): client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client), 'nextcalibrationdate': str(date.today() - timedelta(days=10)), }) response = client.get('/api/measuringtools?calibrationstatus=overdue') assert response.status_code == 200 rows = response.get_json()['data'] assert rows, 'expected at least one overdue tool' assert all(r['measuringtool']['calibrationstatus'] == 'overdue' for r in rows) # -- Maintenance reference identifier ----------------------------------------- def test_maintenancereference_roundtrip(client, auth_headers): """Maintenance reference persists on create and update.""" created = client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-MNT-1', 'statusid': _status_id(client), 'maintenancereference': 'MNT-9', }) assert created.status_code == 201, created.get_json() assert created.get_json()['data']['maintenancereference'] == 'MNT-9' tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, json={'maintenancereference': 'MNT-10'}) assert updated.status_code == 200 assert updated.get_json()['data']['maintenancereference'] == 'MNT-10' # -- Global search ------------------------------------------------------------ def _mt_search_hits(client, auth_headers, term): resp = client.get(f'/api/search?q={term}', headers=auth_headers) assert resp.status_code == 200, resp.get_json() return [r for r in resp.get_json()['data']['results'] if r.get('type') == 'measuring_tool'] def test_search_finds_tool_by_assetnumber(mt_app, client, auth_headers, monkeypatch): """A measuring tool appears in global search by asset number, routed right.""" client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-SEARCH-AN', 'statusid': _status_id(client)}) pm = mt_app.extensions['plugin_manager'] monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) hits = _mt_search_hits(client, auth_headers, 'MT-SEARCH-AN') assert hits assert hits[0]['url'].startswith('/measuringtools/') def test_search_finds_tool_by_gaugelabreference(mt_app, client, auth_headers, monkeypatch): """A gage-tag lookup by gaugelabreference finds the tool.""" client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-SEARCH-GL', 'statusid': _status_id(client), 'gaugelabreference': 'GLREF-778'}) pm = mt_app.extensions['plugin_manager'] monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) hits = _mt_search_hits(client, auth_headers, 'GLREF-778') assert hits assert hits[0]['url'].startswith('/measuringtools/') def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers, monkeypatch): """search_measuring_tool_enabled=false hides measuring tools from search.""" from shopdb.core.models import Setting client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-SEARCH-OFF', 'statusid': _status_id(client), 'gaugelabreference': 'GLREF-OFF-1'}) pm = mt_app.extensions['plugin_manager'] monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) Setting.set('search_measuring_tool_enabled', False, valuetype='boolean', category='search') try: assert _mt_search_hits(client, auth_headers, 'MT-SEARCH-OFF') == [] assert _mt_search_hits(client, auth_headers, 'GLREF-OFF-1') == [] finally: Setting.set('search_measuring_tool_enabled', True, valuetype='boolean', category='search') # -- Report shape ------------------------------------------------------------- def test_calibration_report_shape(client, auth_headers): response = client.get('/api/measuringtools/report/calibration') assert response.status_code == 200 data = response.get_json()['data'] assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'} assert set(data['buckets']) == {'overdue', 'duesoon', 'current', 'unknown'} # Buckets and counts agree. for key, rows in data['buckets'].items(): assert data['counts'][key] == len(rows) assert 'statuscolors' in data # -- Core integration: asset serialization ------------------------------------ def _caliper_id(client): types = client.get('/api/measuringtools/types').get_json()['data'] return next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper') def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client, auth_headers): """Asset.to_dict resolves the measuringtool extension (typedata + pluginid).""" from shopdb.core.models import Asset created = client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-TD-1', 'statusid': _status_id(client), 'measuringtooltypeid': _caliper_id(client), }) assert created.status_code == 201, created.get_json() tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] assetid = created.get_json()['data']['assetid'] with mt_app.app_context(): asset = _db.session.get(Asset, assetid) result = asset.to_dict(include_type_data=True) assert result['pluginid'] == tool_id assert result['typedata']['measuringtooltypename'] == 'Caliper' assert result['typedata']['measuringtoolid'] == tool_id # -- Core integration: shop-floor map ----------------------------------------- def test_map_lists_measuringtool_subtypes(client, auth_headers): """The map filter dropdown carries a Measuring Tool subtype list with color.""" response = client.get('/api/assets/map') assert response.status_code == 200, response.get_json() subtypes = response.get_json()['data']['filters']['subtypes'] assert 'Measuring Tool' in subtypes names = {s['name'] for s in subtypes['Measuring Tool']} assert 'Caliper' in names assert all('color' in s for s in subtypes['Measuring Tool']) def test_map_honors_measuringtool_subtype_filter(client, auth_headers): """?assettype=measuring_tool&subtype= returns only that subtype's tools.""" types = client.get('/api/measuringtools/types').get_json()['data'] caliper_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Caliper') micrometer_id = next(t['measuringtooltypeid'] for t in types if t['name'] == 'Micrometer') client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-MAP-CAL', 'statusid': _status_id(client), 'measuringtooltypeid': caliper_id, 'mapx': 10, 'mapy': 20}) client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-MAP-MIC', 'statusid': _status_id(client), 'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40}) response = client.get( f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}') assert response.status_code == 200, response.get_json() numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']} assert 'MT-MAP-CAL' in numbers assert 'MT-MAP-MIC' not in numbers def test_map_item_carries_measuringtool_typedata(client, auth_headers): """A mapped tool's item carries the extension typedata for marker coloring.""" client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client), 'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66}) response = client.get('/api/assets/map?assettype=measuring_tool') item = next(a for a in response.get_json()['data']['assets'] if a['assetnumber'] == 'MT-MAP-TD') assert item['typedata']['measuringtooltypename'] == 'Caliper' # -- Core integration: dashboard ---------------------------------------------- def test_dashboard_counts_include_measuringtools(client, auth_headers): """Dashboard total and counts include active measuring tools.""" before = client.get('/api/dashboard').get_json()['data'] client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-DASH-1', 'statusid': _status_id(client)}) after = client.get('/api/dashboard').get_json()['data'] assert after['counts']['measuringtools'] == before['counts']['measuringtools'] + 1 assert after['totalmeasuringtool'] == before['totalmeasuringtool'] + 1 assert after['counts']['total'] == before['counts']['total'] + 1 # -- Map overlay endpoint (ADR-010) ------------------------------------------- def test_map_overlay_shape_and_derivation(client, auth_headers): """map-overlay returns per-asset derived calibration status + color.""" created = client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-OVL-1', 'statusid': _status_id(client), 'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue assetid = created.get_json()['data']['assetid'] response = client.get('/api/measuringtools/map-overlay') assert response.status_code == 200, response.get_json() rows = response.get_json()['data'] # ADR-010 overlay contract: [{assetid, color, label}], only due/overdue tools. row = next(r for r in rows if r['assetid'] == assetid) assert set(row) == {'assetid', 'color', 'label'} assert row['label'] == 'Overdue' assert row['color'] == STATUS_COLORS['overdue'] def test_map_overlay_excludes_inactive(client, auth_headers): """A soft-deleted tool drops out of the overlay.""" created = client.post('/api/measuringtools', headers=auth_headers, json={ 'assetnumber': 'MT-OVL-DEL', 'statusid': _status_id(client)}) tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] assetid = created.get_json()['data']['assetid'] client.delete(f'/api/measuringtools/{tool_id}', headers=auth_headers) rows = client.get('/api/measuringtools/map-overlay').get_json()['data'] assert all(r['assetid'] != assetid for r in rows) # -- Presentation route token (ADR-010) --------------------------------------- def test_asset_presentation_route_token(): """Presentation route links through the by-asset resolver (only {assetid}).""" from plugins.measuringtools.plugin import MeasuringToolsPlugin entries = MeasuringToolsPlugin().get_asset_presentation() entry = next(e for e in entries if e['assettype'] == 'measuring_tool') assert entry['route'] == '/measuringtools/by-asset/{assetid}'