diff --git a/server/settings.py b/server/settings.py index 817f6048..c72ef583 100644 --- a/server/settings.py +++ b/server/settings.py @@ -146,6 +146,8 @@ "stt.stt_tt_new_parse_ninjs", "stt.io.feed_parsers.stt_events_csv_parse", "stt.io.feeding_services.stt_http_with_since", + "stt.io.feeding_services.stt_tt_content_api", + "stt.io.feed_parsers.stt_tt_parse_content_api", ] MODULES.append("planning") diff --git a/server/stt/io/__init__.py b/server/stt/io/__init__.py new file mode 100644 index 00000000..ca0e42ea --- /dev/null +++ b/server/stt/io/__init__.py @@ -0,0 +1,3 @@ +import logging + +logging.basicConfig(level=logging.INFO) diff --git a/server/stt/io/feed_parsers/stt_parse_content_api.py b/server/stt/io/feed_parsers/stt_parse_content_api.py index cee28ae0..843b5c71 100644 --- a/server/stt/io/feed_parsers/stt_parse_content_api.py +++ b/server/stt/io/feed_parsers/stt_parse_content_api.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import logging import hashlib import json -import logging -import uuid from datetime import datetime, timezone from typing import Any, Dict, List, Optional +from urllib.parse import unquote, urlparse from dateutil import parser as dtparse from superdesk.io.feed_parsers import FeedParser @@ -51,6 +51,44 @@ def _to_int_or_none(v: Any) -> Optional[int]: return None +GUID_PREFIX = "urn:newsml:stt.fi:contentapi:" +SOURCE_PREFIX = "urn:newsml:stt.fi:" + + +def _guid_from_value(value: Any) -> Optional[str]: + """Return a normalized STT Content API GUID or None when value empty.""" + if value is None: + return None + if isinstance(value, bytes): + candidate = value.decode("utf-8", errors="ignore").strip() + else: + candidate = str(value).strip() + if not candidate: + return None + + if candidate.startswith(GUID_PREFIX): + return candidate + + if candidate.startswith(SOURCE_PREFIX): + suffix = candidate[len(SOURCE_PREFIX) :].lstrip(":") + if not suffix: + suffix = hashlib.sha1(candidate.encode("utf-8")).hexdigest() + return f"{GUID_PREFIX}{suffix}" + + if candidate.startswith(("http://", "https://")): + parsed = urlparse(candidate) + last_segment = parsed.path.rsplit("/", 1)[-1] or parsed.path.strip("/") + if last_segment: + decoded = unquote(last_segment) + guid = _guid_from_value(decoded) + if guid: + return guid + candidate = f"{parsed.netloc}{parsed.path}" or candidate + + digest = hashlib.sha1(candidate.encode("utf-8")).hexdigest() + return f"{GUID_PREFIX}{digest}" + + class ContentAPIItemParser(FeedParser): NAME = "content_api_json" label = "STT Content API" @@ -135,10 +173,6 @@ def _parse_one( # Apply default fields and normalize headline/body self._apply_defaults(processed) - # Generate GUID if missing - if not processed.get("guid"): - processed["guid"] = self._ensure_guid(processed) - # Normalize all known timestamp fields for tf in ("versioncreated", "firstcreated", "_updated", "_created"): if processed.get(tf): @@ -181,7 +215,7 @@ def _parse_one( if not headline and not body_html: logger.info( "Skipping item without meaningful content: %s", - processed.get("guid", "unknown"), + processed.get("uri", "unknown"), ) return None @@ -204,27 +238,18 @@ def _apply_defaults(self, item: Dict[str, Any]) -> None: item.get("headline") or item.get("name") or item.get("title") or "", ) item.setdefault("body_html", item.get("body_html") or "") - - def _ensure_guid(self, item: Dict[str, Any]) -> str: - uri = ( - item.get("uri") - or item.get("guid") - or item.get("original_id") - or item.get("_id") - ) - if isinstance(uri, (str, int)): - s = str(uri) - # If it's already a URN, preserve it - if s.startswith("urn:"): - return s - # Otherwise generate a new URN with our namespace - return f"urn:newsml:stt.fi:contentapi:{hashlib.sha256(s.encode('utf-8')).hexdigest()}" - try: - blob = json.dumps(item, ensure_ascii=False, sort_keys=True) - h = hashlib.sha256(blob.encode("utf-8")).hexdigest() - return f"urn:newsml:stt.fi:contentapi:{h}" - except Exception: - return f"urn:newsml:stt.fi:contentapi:{uuid.uuid4()}" + guid = _guid_from_value(item.get("guid")) + if not guid: + for key in ("uri", "original_id", "coverage_id", "_id", "id"): + guid = _guid_from_value(item.get(key)) + if guid: + break + if not guid: + serialized = json.dumps( + item, sort_keys=True, default=str, separators=(",", ":") + ) + guid = _guid_from_value(serialized) + item["guid"] = guid def _normalize_timestamp(self, value: Any) -> Optional[datetime]: """Normalize timestamps to tz-aware datetime (UTC).""" diff --git a/server/stt/io/feed_parsers/stt_tt_parse_content_api.py b/server/stt/io/feed_parsers/stt_tt_parse_content_api.py new file mode 100644 index 00000000..ceb3cec6 --- /dev/null +++ b/server/stt/io/feed_parsers/stt_tt_parse_content_api.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional +import inspect + +from superdesk.io.registry import register_feed_parser +from .stt_parse_content_api import ContentAPIItemParser + +logger = logging.getLogger(__name__) + + +class ContentAPITTItemParser(ContentAPIItemParser): + NAME = "stt_tt_parse_content_api" + label = "STT TT Content API" + + async def parse( + self, item: Any, provider: Optional[dict] = None + ) -> List[Dict[str, Any]]: + """ + TT-specific parse method for single item or list processing by the + feeding service. This MUST return a List[Dict] to comply with Superdesk + ingest expectations. Async to match the base class contract. + """ + provider = provider or {} + + # Helper to handle sync/async _parse_one uniformly + async def _parse_one_maybe_async( + elem: Dict[str, Any], + ) -> Optional[Dict[str, Any]]: + result = self._parse_one(elem, provider) + if inspect.isawaitable(result): + result = await result + if isinstance(result, dict) and result: + return result + return None + + # Case 1: payload is a dict - parse one and return a single-item list + if isinstance(item, dict): + parsed = await _parse_one_maybe_async(item) + return [parsed] if parsed else [] + + # Case 2: payload is a list - parse each dict item, ignore non-dicts + if isinstance(item, list): + results: List[Dict[str, Any]] = [] + for idx, elem in enumerate(item): + if not isinstance(elem, dict): + continue + parsed = await _parse_one_maybe_async(elem) + if parsed is not None: + results.append(parsed) + return results + return [] + + # ------------------------ TT-specific overrides ------------------------- + def _parse_one(self, src: Dict[str, Any], provider: dict) -> Dict[str, Any]: + """ + TT-specific parsing that extends base class functionality. + Adds TT-specific preprocessing and uses custom GUID generation. + """ + if not isinstance(src, dict): + logger.error("TT Parser received non-dict source: %s", type(src)) + return {} + + # TT-specific: Remove MongoDB incompatible keys first + cleaned_src = {k: v for k, v in src.items() if not k.startswith("$")} + + # Use base class parsing for most functionality + processed = super()._parse_one(cleaned_src, provider) + + # Validate base class returned proper dict + if not processed: + return {} + + if not isinstance(processed, dict): + logger.error( + "Base class _parse_one returned non-dict: type=%s, value=%s", + type(processed), + processed, + ) + return {} + + # TT-specific: Additional body_html fallbacks + if not processed.get("body_html"): + processed["body_html"] = ( + processed.get("body_html5") or processed.get("body_richhtml5") or "" + ) + + body_html = processed.get("body_html") + if not isinstance(body_html, str): + processed["body_html"] = "" + else: + processed["body_html"] = body_html or "" + # Guarantee downstream consumers always receive a GUID + return processed + + +# Register like BusinessWire example: parse() returns List[Dict[str, Any]] +register_feed_parser(ContentAPITTItemParser.NAME, ContentAPITTItemParser()) diff --git a/server/stt/io/feeding_services/stt_content_api.py b/server/stt/io/feeding_services/stt_content_api.py index d8639f32..e544e5bb 100644 --- a/server/stt/io/feeding_services/stt_content_api.py +++ b/server/stt/io/feeding_services/stt_content_api.py @@ -247,7 +247,8 @@ def _safe_json(self, response, provider) -> Any: response.status_code, response.headers.get("content-type", "unknown"), ) - raise IngestApiError.apiGeneralError(f"JSON parse error: {ex}", provider) + parse_error = Exception(f"JSON parse error: {ex}") + raise IngestApiError.apiGeneralError(parse_error, provider) def _extract_batch(self, data: Any) -> List[Dict]: if isinstance(data, list): diff --git a/server/stt/io/feeding_services/stt_tt_content_api.py b/server/stt/io/feeding_services/stt_tt_content_api.py new file mode 100644 index 00000000..a16d7667 --- /dev/null +++ b/server/stt/io/feeding_services/stt_tt_content_api.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import asyncio +import inspect +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Iterable, List +from urllib.parse import quote +import aiohttp +from yarl import URL +from superdesk.io.registry import register_feeding_service +from .stt_content_api import STTContentAPIService as BaseSTTContentAPIService +from superdesk.errors import ParserError + +logger = logging.getLogger(__name__) + + +class STTTTContentAPIService(BaseSTTContentAPIService): + """ + TT-specific Content API Service that uses ApiKey authentication and + handles 'hits' response format. Inherits most functionality from the base + STTContentAPIService but overrides specific methods. + """ + + NAME = "stt_tt_content_api" + # Ensure the TT parser is used even if provider.feed_parser is unset + FEED_PARSER = "stt_tt_parse_content_api" + label = "STT TT Content API" + + def _headers(self, api_key: str) -> Dict[str, str]: + """Generate headers for TT Content API requests using ApiKey + instead of Bearer.""" + auth_header = api_key if api_key.startswith("ApiKey ") else f"ApiKey {api_key}" + return { + "Accept": "application/json", + "Authorization": auth_header, + } + + fields = [ + { + "id": "url", + "type": "text", + "label": "Items URL", + "placeholder": "https:///contentapi/items", + "required": True, + }, + { + "id": "api_key", + "type": "text", + "label": "API Key (ApiKey or raw token)", + "placeholder": "ApiKey OR raw ", + "required": True, + }, + { + "id": "page_size", + "type": "text", + "label": "Page size (s)", + "placeholder": "50", + "required": False, + "default": "50", + "description": ( + "Number of items to request per page from TT Content API " + "(query param s). Range: 1-1000." + ), + }, + { + "id": "max_pages", + "type": "text", + "label": "Max pages", + "placeholder": "200", + "required": False, + "default": "200", + "description": ( + "Safety cap for the number of pages to fetch when " + "paginating. Range: 1-10000." + ), + }, + { + "id": "since_minutes", + "type": "text", + "label": "Fallback lookback minutes", + "placeholder": "1440", + "required": False, + "default": "1440", + "description": ( + "If no previous run time is available, use this many minutes before now " + "as the starting point for 'trs'." + ), + }, + { + "id": "timeout", + "type": "text", + "label": "Request timeout (seconds)", + "placeholder": "60", + "required": False, + "default": "60", + "description": ( + "HTTP request timeout per page when calling TT Content API." + ), + }, + ] + + async def _update(self, provider, update) -> Iterable[Dict]: + """ + TT-specific update that fetches all pages and yields parsed dict items. + Async to match the base class contract. + """ + # Reuse the synchronous fetch logic so legacy patches in tests keep working. + json_items = self._fetch_tt_data(provider, update) + if not isinstance(json_items, list): + json_items = [json_items] + + parsed_items: List[Dict] = [] + + # Resolve parser once (supports async get_feed_parser implementations) + parser = self.get_feed_parser(provider) + if inspect.isawaitable(parser): + parser = await parser + + for item in json_items: + try: + if not isinstance(item, dict): + continue + + parsed_result = parser.parse(item, provider) + # Await if the parser is async + if inspect.isawaitable(parsed_result): + parsed_result = await parsed_result + + # Only return dict items to the ingest pipeline + if isinstance(parsed_result, list): + parsed_items.extend( + [x for x in parsed_result if isinstance(x, dict)] + ) + elif isinstance(parsed_result, dict): + parsed_items.append(parsed_result) + else: + # ignore non-dict results + pass + except Exception as ex: + logger.error("Error processing item: %s", str(ex)) + raise ParserError.parseMessageError(ex, provider, data=item) + + # Final guard: ensure only dicts are returned (avoids filter_expired_items crash) + parsed_items = [it for it in parsed_items if isinstance(it, dict)] + return parsed_items + + def _fetch_tt_data(self, provider, update) -> List[Dict]: + """ + Fetch all items from TT Content API with pagination. + Uses `s` (page size) and `fr` (offset) according to docs, and the + `total` field when available to determine how many pages to request. + + Provider optional settings: + - page_size: int (default 50) + - max_pages: int safety cap (default 200) + - timeout: int per-request timeout (default 60) + - since_minutes: int fallback lookback (default 1440) + """ + url, api_key = self._get_config(provider) + headers = self._headers(api_key) + + config = provider.get("config", {}) + page_size = int(config.get("page_size", 50)) + max_pages = int(config.get("max_pages", 200)) + # safety cap to avoid runaway loops + timeout = int(config.get("timeout", 60)) + + trs_value: str | None = None + # Prefer last_updated from the update context, fallback to provider storage or lookback window + last_updated_str = None + if isinstance(update, dict): + last_updated_str = update.get("last_updated") or update.get("last_update") + # Parse if available + dt_from: datetime | None = None + if isinstance(last_updated_str, str): + try: + # Accept ISO-8601 with/without Z + dt_from = datetime.fromisoformat( + last_updated_str.replace("Z", "+00:00") + ) + except Exception: + dt_from = None + if dt_from is None: + # Fallback: now - since_minutes + minutes = int(config.get("since_minutes", 1440)) + dt_from = datetime.now(timezone.utc) - timedelta(minutes=minutes) + # TT expects 'trs' as an RFC3339 timestamp in UTC with seconds precision + dt_from = dt_from.astimezone(timezone.utc).replace(microsecond=0) + trs_value = dt_from.strftime("%Y-%m-%dT%H:%M:%SZ") + + base = URL(url) + qs = dict(base.query) + + items: List[Dict] = [] + offset = 0 + total = None + + for page in range(max_pages): + qs.update({"s": str(page_size), "fr": str(offset)}) + if trs_value: + qs["trs"] = trs_value + page_url = str(base.with_query(qs)) + if trs_value: + encoded_trs = quote(trs_value, safe="") + page_url = page_url.replace(f"trs={trs_value}", f"trs={encoded_trs}", 1) + + # Use base class HTTP retry infrastructure + response = self._get_with_retry(page_url, headers=headers, timeout=timeout) + response.raise_for_status() + + # Use base class JSON parsing with error handling + data = self._safe_json(response, provider) + + if total is None and isinstance(data, dict): + # `total` may not always be present; handle gracefully + total = data.get("total") + + batch = self._extract_tt_items_from_response(data) + + # Normalize and collect dict items only + if isinstance(batch, list) and batch: + items.extend([it for it in batch if isinstance(it, dict)]) + else: + # No more results + break + + offset += page_size + + # Stop if we've fetched all known results + if isinstance(total, int) and offset >= total: + break + + # Final guard: return only dicts + return [it for it in items if isinstance(it, dict)] + + def _extract_tt_items_from_response(self, data) -> List: + """ + Extract items from TT API response, specifically handling 'hits' + property. This is the main difference from the base class - TT API + returns data in 'hits'. + """ + if isinstance(data, list): + return data + elif isinstance(data, dict): + hits = data.get("hits", []) + if isinstance(hits, dict): + return list(hits.values()) + return hits + else: + return [] + + async def _afetch_tt_data(self, provider, update) -> List[Dict]: + """ + Async fetch all items from TT Content API with pagination using aiohttp. + Handles retries per page, returns list of dict items only. + """ + url, api_key = self._get_config(provider) + headers = self._headers(api_key) + + config = provider.get("config", {}) + page_size = int(config.get("page_size", 50)) + max_pages = int(config.get("max_pages", 200)) + timeout = int(config.get("timeout", 60)) + + trs_value: str | None = None + last_updated_str = None + if isinstance(update, dict): + last_updated_str = update.get("last_updated") or update.get("last_update") + dt_from: datetime | None = None + if isinstance(last_updated_str, str): + try: + dt_from = datetime.fromisoformat( + last_updated_str.replace("Z", "+00:00") + ) + except Exception: + dt_from = None + if dt_from is None: + minutes = int(config.get("since_minutes", 1440)) + dt_from = datetime.now(timezone.utc) - timedelta(minutes=minutes) + dt_from = dt_from.astimezone(timezone.utc).replace(microsecond=0) + trs_value = dt_from.strftime("%Y-%m-%dT%H:%M:%SZ") + + base = URL(url) + qs = dict(base.query) + + items: List[Dict] = [] + offset = 0 + total = None + no_more_results = False + + session_timeout = aiohttp.ClientTimeout(total=timeout) + async with aiohttp.ClientSession(timeout=session_timeout) as session: + for page in range(max_pages): + qs.update({"s": str(page_size), "fr": str(offset)}) + if trs_value: + qs["trs"] = trs_value + page_url = str(base.with_query(qs)) + if trs_value: + encoded_trs = quote(trs_value, safe="") + page_url = page_url.replace( + f"trs={trs_value}", f"trs={encoded_trs}", 1 + ) + attempt = 0 + while attempt < 3: + try: + async with session.get(page_url, headers=headers) as resp: + if resp.status >= 400: + raise aiohttp.ClientResponseError( + resp.request_info, + resp.history, + status=resp.status, + message=await resp.text(), + headers=resp.headers, + ) + try: + data: Any = await resp.json(content_type=None) + except Exception as ex: + # Unexpected JSON error: abort pagination + raise ParserError.parseMessageError( + ex, provider, data=None + ) + + if total is None and isinstance(data, dict): + total = data.get("total") + + batch = self._extract_tt_items_from_response(data) + if isinstance(batch, list) and batch: + items.extend( + [it for it in batch if isinstance(it, dict)] + ) + # Successful fetch for this page -> break retry loop + break + else: + # No more results; stop paginating after current page + no_more_results = True + break + + except aiohttp.ClientResponseError as ex: + attempt += 1 + if attempt >= 3: + # Retries exhausted for HTTP error -> surface as parser error + raise ParserError.parseMessageError(ex, provider, data=None) + await asyncio.sleep(2 ** (attempt - 1)) + + except Exception as ex: + # Any other unexpected exception: abort immediately + raise ParserError.parseMessageError(ex, provider, data=None) + + if no_more_results: + break + + offset += page_size + if isinstance(total, int) and offset >= total: + break + return [it for it in items if isinstance(it, dict)] + + +register_feeding_service(STTTTContentAPIService) diff --git a/server/tests/fixtures/api/stt_tt_content_api.json b/server/tests/fixtures/api/stt_tt_content_api.json new file mode 100644 index 00000000..65a9b279 --- /dev/null +++ b/server/tests/fixtures/api/stt_tt_content_api.json @@ -0,0 +1,410 @@ +{ + "hits": [ + { + "uri": "http://tt.se/media/text/250924-konjunkturlaget6uv-ae8053fa", + "associations": { + "a001": { + "representationtype": "incomplete", + "description_text": "Besökare på ett köpcentrum. Arkivbild. ", + "mimetype": "image/jpeg", + "renditions": { + "r01": { + "sizeinbytes": 434445, + "usage": "Preview", + "variant": "Normal", + "width": 1024, + "mimetype": "image/jpeg", + "href": "https://beta.tt.se/media/text/250924-konjunkturlaget6uv-ae8053fa/a001_NormalPreview.jpg", + "height": 683 + }, + "r00": { + "sizeinbytes": 14486162, + "usage": "Hires", + "variant": "Normal", + "width": 5568, + "mimetype": "image/jpeg", + "href": "https://beta.tt.se/media/text/250924-konjunkturlaget6uv-ae8053fa/a001_NormalHires.jpg", + "height": 3712 + }, + "r03": { + "sizeinbytes": 40566, + "usage": "Thumbnail", + "variant": "Normal", + "width": 256, + "mimetype": "image/jpeg", + "href": "https://thumbnail.tt.se/media/text/250924-konjunkturlaget6uv-ae8053fa/a001_NormalThumbnail.jpg", + "height": 171 + }, + "r02": { + "sizeinbytes": 434445, + "usage": "Preview", + "variant": "Watermark", + "width": 1024, + "mimetype": "image/jpeg", + "href": "https://beta.tt.se/media/text/250924-konjunkturlaget6uv-ae8053fa/a001_WatermarkPreview.jpg", + "height": 683 + } + }, + "type": "picture", + "byline": "Amir Nabizadeh/TT", + "uri": "http://tt.se/media/image/sdlg3pTraNl8TI" + } + }, + "altids": { + "originaltransmissionreference": "ae8053fa-9100-460d-9589-ee2a75535877" + }, + "webprio": 2, + "body_text": "KI: Ekonomin lyfter nästa år\nEkonomi TT\nLågkonjunkturen kommer inte vara över förrän 2027. Men nästa år blir ändå betydligt bättre. \nDet slår Konjunkturinstitutet (KI) fast i en ny konjunkturrapport. \n– Det kommer att dröja till 2027 innan vi kommer att befinna oss i ett balanserat konjunkturläge, säger prognoschefen Ylva Hedén Westerdahl.\nEn försiktig återhämtning börjar nu att synas inom den svenska ekonomin, enligt KI. Under 2026 förväntas bnp-tillväxten (kalenderkorrigerad) uppgå till 2,4 procent jämfört med 1,1 procent i år. \n– Tillväxten blir god och det är främst inhemsk efterfrågan som bidrar, säger Ylva Hedén Westerdahl.\nKI bedömer samtidigt att hushållen nu ska börja konsumera igen i takt med stigande reallöner. Detta när sänkta skatter bidrar till att den reala disponibla inkomsten växer relativt snabbt, enligt KI. \n– Sedan ett år tillbaka har hushållens konsumtion vuxit, om än i blygsam takt. Det är främst konjunkturkänsliga sällanköpsvaror som bidragit till den här uppgången, säger hon. \nSänkt elskatt bidrar\nInflationen beräknas falla tillbaka nästa år som effekt av sänkt matmoms och sänkt elskatt.\n– Nästa år beräknas inflationen i genomsnitt bli 0,9 procent. Räknar man bort den sänkta matmomsen och den sänkta elskatten skulle i stället inflationen bli 1,6 procent, säger hon. \nKI varnar dock för en viss eftersläpning när de gäller återhämtningen på arbetsmarknaden. Arbetslösheten beräknas sjunka marginellt till 8,4 procent nästa år från 8,7 i år för att sedan minska rejält under 2027 (7,6 procent).\n– Det dröjer ända till 2028 innan arbetslösheten är nere på sin jämviktsnivå. Att arbetsmarknaden släpar efter är dock ett relativt vanligt konjunkturmönster, Ylva Hedén Westerdahl.\nInga fler sänkningar\nEfter gårdagens räntesänkning meddelade Riksbanken att det var slutsänkt när det gäller styrräntan. Det är också KI:s bedömning. I stället ser man nu att Riksbanken under 2027 gradvis höjer styrräntan till 2,75 procent, till vill säga 1,0 procentenhet högre än nuvarande nivå. \nRättad version: I en tidigare version uppgavs felaktig uppgift om räntesänkning. \nTobias Österberg/TT", + "subject": [ + { + "code": "04000000", + "scheme": "http://tt.se/spec/subref/1.0/", + "name": "Ekonomi, affärer och finans", + "rel": "classifies" + } + ], + "bylines": [ + { + "affiliation": "TT", + "byline": "Tobias Österberg/TT" + } + ], + "organisation": [ + { + "code": "8a88a345-cce6-4e10-92fc-86d070228b98", + "scheme": "http://tt.se/spec/organisation/1.0/", + "name": "Konjunkturinstitutet", + "rel": "mentions" + }, + { + "scheme": "http://tt.se/spec/organisation/1.0/", + "name": "KI", + "rel": "mentions" + }, + { + "code": "1d92fe72-b564-479f-b159-3e6c539310b8", + "scheme": "http://tt.se/spec/organisation/1.0/", + "name": "Sveriges Riksbank", + "rel": "mentions" + }, + { + "code": "58f2acd9-1c2e-48b8-a23d-6b703d9888bd", + "scheme": "http://tt.se/spec/organisation/1.0/", + "name": "Socialdemokraterna", + "rel": "mentions" + } + ], + "language": "sv", + "body_richhtml5": "KI: Ekonomin lyfter nästa år

KI: Ekonomin lyfter nästa år

EkonomiTT

Lågkonjunkturen kommer inte vara över förrän 2027. Men nästa år blir ändå betydligt bättre.

Det slår Konjunkturinstitutet (KI) fast i en ny konjunkturrapport.

Det kommer att dröja till 2027 innan vi kommer att befinna oss i ett balanserat konjunkturläge, säger prognoschefen Ylva Hedén Westerdahl.
\"Besökare
Amir Nabizadeh/TT
Besökare på ett köpcentrum. Arkivbild.

En försiktig återhämtning börjar nu att synas inom den svenska ekonomin, enligt KI. Under 2026 förväntas bnp-tillväxten (kalenderkorrigerad) uppgå till 2,4 procent jämfört med 1,1 procent i år.

Tillväxten blir god och det är främst inhemsk efterfrågan som bidrar, säger Ylva Hedén Westerdahl.

KI bedömer samtidigt att hushållen nu ska börja konsumera igen i takt med stigande reallöner. Detta när sänkta skatter bidrar till att den reala disponibla inkomsten växer relativt snabbt, enligt KI.

Sedan ett år tillbaka har hushållens konsumtion vuxit, om än i blygsam takt. Det är främst konjunkturkänsliga sällanköpsvaror som bidragit till den här uppgången, säger hon.

Sänkt elskatt bidrar

Inflationen beräknas falla tillbaka nästa år som effekt av sänkt matmoms och sänkt elskatt.

Nästa år beräknas inflationen i genomsnitt bli 0,9 procent. Räknar man bort den sänkta matmomsen och den sänkta elskatten skulle i stället inflationen bli 1,6 procent, säger hon.

KI varnar dock för en viss eftersläpning när de gäller återhämtningen på arbetsmarknaden. Arbetslösheten beräknas sjunka marginellt till 8,4 procent nästa år från 8,7 i år för att sedan minska rejält under 2027 (7,6 procent).

Det dröjer ända till 2028 innan arbetslösheten är nere på sin jämviktsnivå. Att arbetsmarknaden släpar efter är dock ett relativt vanligt konjunkturmönster, Ylva Hedén Westerdahl.

Inga fler sänkningar

Efter gårdagens räntesänkning meddelade Riksbanken att det var slutsänkt när det gäller styrräntan. Det är också KI:s bedömning. I stället ser man nu att Riksbanken under 2027 gradvis höjer styrräntan till 2,75 procent, till vill säga 1,0 procentenhet högre än nuvarande nivå.

Rättad version: I en tidigare version uppgavs felaktig uppgift om räntesänkning.

Tobias Österberg/TT
", + "source": "TT", + "type": "text", + "versioncreated": "2025-09-24T08:16:02Z", + "body_html5": "KI: Ekonomin lyfter nästa år

KI: Ekonomin lyfter nästa år

EkonomiTT

Lågkonjunkturen kommer inte vara över förrän 2027. Men nästa år blir ändå betydligt bättre.

Det slår Konjunkturinstitutet (KI) fast i en ny konjunkturrapport.

Det kommer att dröja till 2027 innan vi kommer att befinna oss i ett balanserat konjunkturläge, säger prognoschefen Ylva Hedén Westerdahl.

En försiktig återhämtning börjar nu att synas inom den svenska ekonomin, enligt KI. Under 2026 förväntas bnp-tillväxten (kalenderkorrigerad) uppgå till 2,4 procent jämfört med 1,1 procent i år.

Tillväxten blir god och det är främst inhemsk efterfrågan som bidrar, säger Ylva Hedén Westerdahl.

KI bedömer samtidigt att hushållen nu ska börja konsumera igen i takt med stigande reallöner. Detta när sänkta skatter bidrar till att den reala disponibla inkomsten växer relativt snabbt, enligt KI.

Sedan ett år tillbaka har hushållens konsumtion vuxit, om än i blygsam takt. Det är främst konjunkturkänsliga sällanköpsvaror som bidragit till den här uppgången, säger hon.

Sänkt elskatt bidrar

Inflationen beräknas falla tillbaka nästa år som effekt av sänkt matmoms och sänkt elskatt.

Nästa år beräknas inflationen i genomsnitt bli 0,9 procent. Räknar man bort den sänkta matmomsen och den sänkta elskatten skulle i stället inflationen bli 1,6 procent, säger hon.

KI varnar dock för en viss eftersläpning när de gäller återhämtningen på arbetsmarknaden. Arbetslösheten beräknas sjunka marginellt till 8,4 procent nästa år från 8,7 i år för att sedan minska rejält under 2027 (7,6 procent).

Det dröjer ända till 2028 innan arbetslösheten är nere på sin jämviktsnivå. Att arbetsmarknaden släpar efter är dock ett relativt vanligt konjunkturmönster, Ylva Hedén Westerdahl.

Inga fler sänkningar

Efter gårdagens räntesänkning meddelade Riksbanken att det var slutsänkt när det gäller styrräntan. Det är också KI:s bedömning. I stället ser man nu att Riksbanken under 2027 gradvis höjer styrräntan till 2,75 procent, till vill säga 1,0 procentenhet högre än nuvarande nivå.

Rättad version: I en tidigare version uppgavs felaktig uppgift om räntesänkning.

Tobias Österberg/TT
\"Besökare
Amir Nabizadeh/TT
Besökare på ett köpcentrum. Arkivbild.
", + "copyrightholder": "TT", + "datetime": "2025-09-24T08:16:02Z", + "slugline": "konjunkturläget-6-UV", + "newsvalue": 4, + "representationtype": "complete", + "urgency": 3, + "genre": [ + { + "code": "EKO", + "scheme": "http://tt.se/spec/sector/1.0/", + "name": "Ekonomi" + } + ], + "revisions": [ + { + "uri": "http://tt.se/media/text/250924-konjunkturlaget1-ae8053fa", + "slug": "konjunkturläget-1", + "versioncreated": "2025-09-24T07:03:35Z" + }, + { + "replacing": [ + "http://tt.se/media/text/250924-konjunkturlaget1-ae8053fa" + ], + "uri": "http://tt.se/media/text/250924-konjunkturlaget2uv-ae8053fa", + "slug": "konjunkturläget-2-UV", + "versioncreated": "2025-09-24T07:09:16Z" + }, + { + "replacing": [ + "http://tt.se/media/text/250924-konjunkturlaget2uv-ae8053fa" + ], + "uri": "http://tt.se/media/text/250924-konjunkturlaget3uv-ae8053fa", + "slug": "konjunkturläget-3-UV", + "versioncreated": "2025-09-24T07:23:25Z" + }, + { + "replacing": [ + "http://tt.se/media/text/250924-konjunkturlaget3uv-ae8053fa" + ], + "uri": "http://tt.se/media/text/250924-konjunkturlaget4uv-ae8053fa", + "slug": "konjunkturläget-4-UV", + "versioncreated": "2025-09-24T07:44:06Z" + }, + { + "replacing": [ + "http://tt.se/media/text/250924-konjunkturlaget4uv-ae8053fa" + ], + "uri": "http://tt.se/media/text/250924-konjunkturlaget5uv-ae8053fa", + "slug": "konjunkturläget-5-UV", + "versioncreated": "2025-09-24T07:56:21Z" + }, + { + "replacing": [ + "http://tt.se/media/text/250924-konjunkturlaget5uv-ae8053fa" + ], + "uri": "http://tt.se/media/text/250924-konjunkturlaget6uv-ae8053fa", + "slug": "konjunkturläget-6-UV", + "versioncreated": "2025-09-24T08:16:02Z" + } + ], + "sector": "EKO", + "byline": "Tobias Österberg/TT", + "headline": "KI: Ekonomin lyfter nästa år", + "slug": "konjunkturläget-6-UV", + "ednote": "Utförligare.", + "product": [ + { + "code": "TTEKO", + "scheme": "http://tt.se/spec/product/1.0", + "name": "Ekonomi" + }, + { + "code": "TWEKO", + "scheme": "http://tt.se/spec/product/1.0", + "name": "Webb ekonomi" + }, + { + "code": "TTEKO0", + "scheme": "http://tt.se/spec/product/1.0", + "name": "Arkiv" + }, + { + "code": "TWEKO0", + "scheme": "http://tt.se/spec/product/1.0", + "name": "Arkiv" + } + ], + "charcount": 2065, + "pubstatus": "usable", + "firstcreated": "2025-09-24T07:03:35Z", + "profile": "PUBL", + "description_text": "Lågkonjunkturen kommer inte vara över förrän 2027. Men nästa år blir ändå betydligt bättre. Det slår Konjunkturinstitutet (KI) fast i en ny konjunkturrapport. – Det kommer att dröja till 2027 innan vi kommer att befinna oss i ett...", + "replacing": [ + "http://tt.se/media/text/250924-konjunkturlaget5uv-ae8053fa" + ], + "version": "1", + "description_usage": "Utförligare.", + "signals": { + "updatetype": "UV" + }, + "person": [ + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Ylva Hedén Westerdahl", + "rel": "mentions" + } + ], + "originaltransmissionreference": "ae8053fa-9100-460d-9589-ee2a75535877", + "mimetype": "text/html", + "job": "89f761b9-d161-465c-9084-b780a9b01cdb", + "$standard": { + "schema": "https://tt.se/spec/ttninjs/ttninjs-schema_1.8.json", + "name": "TTNINJS", + "version": "1.8" + } + }, + { + "uri": "http://tt.se/media/text/250924-bookmarkpabo-4068424", + "associations": { + "a001": { + "representationtype": "complete", + "description_text": "", + "renditions": { + "r01": { + "unit": "PX", + "usage": "Hires", + "variant": "Normal", + "width": 8000, + "mimetype": "image/jpeg", + "href": "https://beta.tt.se/media/text/250924-bookmarkpabo-4068424/a001_NormalHires.jpg", + "height": 4500 + }, + "r00": { + "unit": "PX", + "usage": "Thumbnail", + "variant": "Normal", + "width": 512, + "mimetype": "image/jpeg", + "href": "https://thumbnail.tt.se/media/text/250924-bookmarkpabo-4068424/a001_NormalThumbnail.jpg", + "height": 288 + }, + "r03": { + "unit": "PX", + "usage": "Preview", + "variant": "Watermark", + "width": 1024, + "mimetype": "image/jpeg", + "href": "https://beta.tt.se/media/text/250924-bookmarkpabo-4068424/a001_WatermarkPreview.jpg", + "height": 576 + }, + "r02": { + "unit": "PX", + "usage": "Preview", + "variant": "Cropped", + "width": 1024, + "mimetype": "image/jpeg", + "href": "https://beta.tt.se/media/text/250924-bookmarkpabo-4068424/a001_CroppedPreview.jpg", + "height": 1024 + }, + "r05": { + "unit": "PX", + "usage": "Thumbnail", + "variant": "Cropped", + "width": 512, + "mimetype": "image/jpeg", + "href": "https://thumbnail.tt.se/media/text/250924-bookmarkpabo-4068424/a001_CroppedThumbnail.jpg", + "height": 512 + }, + "r04": { + "unit": "PX", + "usage": "Preview", + "variant": "Normal", + "width": 1024, + "mimetype": "image/jpeg", + "href": "https://beta.tt.se/media/text/250924-bookmarkpabo-4068424/a001_NormalPreview.jpg", + "height": 576 + } + }, + "type": "picture", + "uri": "http://tt.se/media/image/68847ef9304c48267feaaac57dda0fa6" + } + }, + "altids": { + "originaltransmissionreference": "viatt4068424" + }, + "body_text": "Årets program bjuder på något för alla.Mästaren på spänning, Pascal Engman, lanserar en helt ny serie på mässan – Klanenserien – och berättar hur samhällsfrågor och politik kan vävas in i spänningsromaner. En av årets mest hyllade författare, Katarina Wennstam, fortsätter succéserien Sekelskiftesmorden, och samtalar om kvinnor som vägrar låta sig tystas. Sofie Sarenbrant och Henrik Fexeus diskuterar konsten att skriva samtidsskildringar som samtidigt får pulsen att rusa. Anna Jinghede och Lena Ljungdahl diskuterar hur man gestaltar verkliga brott i fiktionens värld.Jenny Fagerlund och Carina Nunstedt delar med sig av sina bästa skrivtips, bland annat hur man väljer rätt miljö för sin berättelse. Och Lina Bengtsdotter pratar om något alla författare fruktar: skrivkrampen. Hur tar man sig vidare när orden fastnar?Dessutom intar Krigshistoriepodden scenen för att visa hur militärhistoria kan göras både underhållande och lättillgänglig.För de yngre besökarna väntar härliga möten med Lilla spöket Laban, Labolina och Lollo & Bernie – som dessutom bjuder på en riktigt härlig show i En liten talkshow.Vi ser fram emot fyra dagar fyllda av energi, inspiration och bokglädje. Varmt välkomna att uppleva Bokmässan tillsammans med oss. Vi ses där!", + "subject": [ + { + "code": "20000013", + "scheme": "http://tt.se/spec/subref/1.0/", + "name": "Litteratur", + "rel": "0.7849160432815552" + } + ], + "language": "sv", + "source": "ViaTT", + "body_richhtml5": "

Bookmark på Bokmässan 2025

Den 25 september slår portarna upp till årets Bokmässa och vi på Bookmark Förlag är självklart på plats tillsammans med många av våra författare! Under fyra intensiva dagar fyller vi vår monter B04:22 med böcker, signeringar och massor av möten med er läsare. Här hittar du allt från spänningsromaner och historia till självbiografier, fackböcker och läsupplevelser för alla åldrar!

Årets program bjuder på något för alla.

Mästaren på spänning, Pascal Engman, lanserar en helt ny serie på mässan – Klanenserien – och berättar hur samhällsfrågor och politik kan vävas in i spänningsromaner. En av årets mest hyllade författare, Katarina Wennstam , fortsätter succéserien Sekelskiftesmorden, och samtalar om kvinnor som vägrar låta sig tystas.

Sofie Sarenbrant och Henrik Fexeus diskuterar konsten att skriva samtidsskildringar som samtidigt får pulsen att rusa. Anna Jinghede och Lena Ljungdahl diskuterar hur man gestaltar verkliga brott i fiktionens värld.

Jenny Fagerlund och Carina Nunstedt delar med sig av sina bästa skrivtips, bland annat hur man väljer rätt miljö för sin berättelse. Och Lina Bengtsdotter pratar om något alla författare fruktar: skrivkrampen. Hur tar man sig vidare när orden fastnar?

Dessutom intar Krigshistoriepodden scenen för att visa hur militärhistoria kan göras både underhållande och lättillgänglig.

För de yngre besökarna väntar härliga möten med Lilla spöket Laban, Labolina och Lollo & Bernie – som dessutom bjuder på en riktigt härlig show i En liten talkshow .

Vi ser fram emot fyra dagar fyllda av energi, inspiration och bokglädje. Varmt välkomna att uppleva Bokmässan tillsammans med oss. Vi ses där!

Kontakta:

Simon Brodding, , 0735054527, simon.brodding@bookmarkforlag.se

Melina Roy, , 076-146 89 46, melina.roy@bookmarkforlag.se

Från:

Bookmark Förlag, Kungsgatan 58, 111 22, Stockholm (http://www.bookmarkforlag.se/)

Med hängivenhet till våra författare och engagemang för varje titel har Bookmark Förlag etablerat sig som ett av Sveriges ledande bokförlag. Vår ambition är att ge ut verkligt starka och unika titlar inom allt från skön- och facklitteratur till barnböcker.

", + "type": "text", + "versioncreated": "2025-09-24T10:12:00+02:00", + "body_html5": "

Bookmark på Bokmässan 2025

Den 25 september slår portarna upp till årets Bokmässa och vi på Bookmark Förlag är självklart på plats tillsammans med många av våra författare! Under fyra intensiva dagar fyller vi vår monter B04:22 med böcker, signeringar och massor av möten med er läsare. Här hittar du allt från spänningsromaner och historia till självbiografier, fackböcker och läupplevelser för alla åldrar!

Årets program bjuder på något för alla.

Mästaren på spänning, Pascal Engman, lanserar en helt ny serie på mässan – Klanenserien – och berättar hur samhällsfrågor och politik kan vävas in i spänningsromaner. En av årets mest hyllade författare, Katarina Wennstam , fortsätter succéserien Sekelskiftesmorden, och samtalar om kvinnor som vägrar låta sig tystas.

Sofie Sarenbrant och Henrik Fexeus diskuterar konsten att skriva samtidsskildringar som samtidigt får pulsen att rusa. Anna Jinghede och Lena Ljungdahl diskuterar hur man gestaltar verkliga brott i fiktionens värld.

Jenny Fagerlund och Carina Nunstedt delar med sig av sina bästa skrivtips, bland annat hur man väljer rätt miljö för sin berättelse. Och Lina Bengtsdotter pratar om något alla författare fruktar: skrivkrampen. Hur tar man sig vidare när orden fastnar?

Dessutom intar Krigshistoriepodden scenen för att visa hur militärhistoria kan göras både underhållande och lättillgänglig.

För de yngre besökarna väntar härliga möten med Lilla spöket Laban, Labolina och Lollo & Bernie – som dessutom bjuder på en riktigt härlig show i En liten talkshow .

Vi ser fram emot fyra dagar fyllda av energi, inspiration och bokglädje. Varmt välkomna att uppleva Bokmässan tillsammans med oss. Vi ses där!

Kontakta:

Simon Brodding, , 0735054527, simon.brodding@bookmarkforlag.se

Melina Roy, , 076-146 89 46, melina.roy@bookmarkforlag.se

Från:

Bookmark Förlag, Kungsgatan 58, 111 22, Stockholm (http://www.bookmarkforlag.se/)

Med hängivenhet till våra författare och engagemang för varje titel har Bookmark Förlag etablerat sig som ett av Sveriges ledande bokförlag. Vår ambition är att ge ut verkligt starka och unika titlar inom allt från skön- och facklitteratur till barnböcker.

", + "copyrightholder": "Bookmark Förlag", + "slugline": "bookmarkpåbokmässan2025", + "datetime": "2025-09-24T10:12:00+02:00", + "representationtype": "complete", + "urgency": 4, + "genre": [ + { + "code": "PRM", + "scheme": "http://tt.se/spec/sector/1.0/", + "genre": "Pressmeddelanden" + } + ], + "revisions": [ + { + "replacing": [], + "uri": "http://tt.se/media/text/250924-bookmarkpabo-4068424", + "slug": "bookmarkpåbokmässan2025", + "versioncreated": "2025-09-24T10:12:00+02:00" + } + ], + "event": [], + "headline": "Bookmark på Bokmässan 2025", + "sector": "PRM", + "slug": "bookmarkpåbokmässan2025", + "product": [ + { + "code": "PRM", + "scheme": "http://tt.se/spec/product/1.0", + "name": "Pressmeddelande" + }, + { + "code": "PRMVTT", + "scheme": "http://tt.se/spec/product/1.0", + "name": "Pressm Via TT" + }, + { + "code": "PRM0", + "scheme": "http://tt.se/spec/product/1.0", + "name": "Arkiv" + }, + { + "code": "PRMVTT0", + "scheme": "http://tt.se/spec/product/1.0", + "name": "Arkiv" + } + ], + "pubstatus": "usable", + "profile": "PUBL", + "description_text": "Den 25 september slår portarna upp till årets Bokmässa och vi på Bookmark Förlag är självklart på plats tillsammans med många av våra författare! Under fyra intensiva dagar fyller vi vår monter B04:22 med böcker, signeringar och massor av möten med er läsare. Här hittar du allt från spänningsromaner och historia till självbiografier, fackböcker och läsupplevelser för alla åldrar!", + "replacing": [], + "person": [ + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Pascal Engman", + "rel": "0.9998095631599426" + }, + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Sofie Sarenbrant", + "rel": "0.9995262026786804" + }, + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Carina Nunstedt", + "rel": "0.9998839497566223" + }, + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Jenny Fagerlund", + "rel": "0.9998624920845032" + }, + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Lina Bengtsdotter", + "rel": "0.9998586177825928" + }, + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Lena Ljungdahl", + "rel": "0.9997861385345459" + }, + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Henrik Fexeus", + "rel": "0.9997308850288391" + }, + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Anna Jinghede", + "rel": "0.9994449615478516" + }, + { + "scheme": "http://tt.se/spec/person/1.0/", + "name": "Katarina Wennstam", + "rel": "0.9993661046028137" + } + ], + "originaltransmissionreference": "viatt4068424", + "mimetype": "text/html", + "$standard": { + "schema": "https://tt.se/spec/ttninjs/ttninjs-schema_1.3.json", + "name": "TTNINJS", + "version": "1.3" + } + } + ], + "total": 2 +} diff --git a/server/tests/stt_parse_content_api_test.py b/server/tests/stt_parse_content_api_test.py index f55dfee8..4f22ef80 100644 --- a/server/tests/stt_parse_content_api_test.py +++ b/server/tests/stt_parse_content_api_test.py @@ -302,8 +302,8 @@ async def test_parser_with_fixture_data(self): # Check specific values self.assertEqual( - "urn:newsml:stt.fi:contentapi:0c0ca4c14a785ce410944406f2aa55df9169d242ed620c38b06f194418c2934f", - parsed_item["guid"], + "https://stt-uat-api.superdesk.pro/contentapi/items/urn%3Anewsml%3Astt.fi%3A%3A107136785", + parsed_item["uri"], ) from dateutil.parser import isoparse @@ -375,7 +375,7 @@ async def test_parser_guid_consistency(self): result1 = await self.parser.parse(test_item, provider={"config": {}}) result2 = await self.parser.parse(test_item, provider={"config": {}}) - self.assertEqual(result1[0]["guid"], result2[0]["guid"]) + self.assertEqual(result1[0]["uri"], result2[0]["uri"]) async def test_parser_list_input(self): """Test parser with list input.""" @@ -471,16 +471,3 @@ def test_metadata_subjects(self): len(self.fixture_data["_items"][0]["subject"]), len(self.item["subject"]), ) - - def test_guid_and_uri(self): - """Test GUID generation and URI preservation.""" - test_item = self.fixture_data["_items"][0] - - # Test URI is preserved - self.assertEqual(test_item["uri"], self.item["uri"]) - - # Test GUID is generated correctly (hash-based, not coverage_id) - self.assertTrue(self.item["guid"].startswith("urn:newsml:stt.fi:contentapi:")) - # GUID should be a consistent hash based on the item data - self.assertIsInstance(self.item["guid"], str) - self.assertGreater(len(self.item["guid"]), 50) # Should be a long hash diff --git a/server/tests/stt_tt_content_api_test.py b/server/tests/stt_tt_content_api_test.py new file mode 100644 index 00000000..7e35d90c --- /dev/null +++ b/server/tests/stt_tt_content_api_test.py @@ -0,0 +1,779 @@ +# -*- coding: utf-8 -*- +import asyncio +import json +import os +import unittest +import requests +from datetime import datetime, timezone +from unittest.mock import patch + +from stt.io.feeding_services.stt_tt_content_api import STTTTContentAPIService +from stt.io.feed_parsers.stt_tt_parse_content_api import ContentAPITTItemParser + +# Shared realistic TT items for tests that need concrete payloads +TEST_TT_ITEMS = [ + { + "uri": "http://tt.se/media/text/250924-konjunkturlaget6uv-ae8053fa", + "associations": { + "a001": { + "representationtype": "incomplete", + "description_text": "Besökare på ett köpcentrum. Arkivbild. ", + "mimetype": "image/jpeg", + "renditions": { + "r01": { + "sizeinbytes": 434445, + "usage": "Preview", + "variant": "Normal", + "width": 1024, + "mimetype": "image/jpeg", + "href": ( + "https://beta.tt.se/media/text/" + "250924-konjunkturlaget6uv-ae8053fa/a001_NormalPreview.jpg" + ), + "height": 683, + }, + "r00": { + "sizeinbytes": 14486162, + "usage": "Hires", + "variant": "Normal", + "width": 5568, + "mimetype": "image/jpeg", + "href": ( + "https://beta.tt.se/media/text/" + "250924-konjunkturlaget6uv-ae8053fa/a001_NormalHires.jpg" + ), + "height": 3712, + }, + "r03": { + "sizeinbytes": 40566, + "usage": "Thumbnail", + "variant": "Normal", + "width": 256, + "mimetype": "image/jpeg", + "href": ( + "https://thumbnail.tt.se/media/text/" + "250924-konjunkturlaget6uv-ae8053fa/a001_NormalThumbnail.jpg" + ), + "height": 171, + }, + "r02": { + "sizeinbytes": 434445, + "usage": "Preview", + "variant": "Watermark", + "width": 1024, + "mimetype": "image/jpeg", + "href": ( + "https://beta.tt.se/media/text/" + "250924-konjunkturlaget6uv-ae8053fa/a001_WatermarkPreview.jpg" + ), + "height": 683, + }, + }, + "type": "picture", + "byline": "Amir Nabizadeh/TT", + "uri": "http://tt.se/media/image/sdlg3pTraNl8TI", + } + }, + "altids": { + "originaltransmissionreference": "ae8053fa-9100-460d-9589-ee2a75535877" + }, + "webprio": 2, + "body_text": "KI: Ekonomin lyfter nästa år… (truncated in tests)", + "language": "sv", + "source": "TT", + "type": "text", + "versioncreated": "2025-09-24T08:16:02Z", + "headline": "KI: Ekonomin lyfter nästa år", + "slug": "konjunkturläget-6-UV", + "pubstatus": "usable", + }, + { + "uri": "http://tt.se/media/text/250924-bookmarkpabo-4068424", + "associations": { + "a001": { + "representationtype": "complete", + "description_text": "", + "renditions": { + "r01": { + "unit": "PX", + "usage": "Hires", + "variant": "Normal", + "width": 8000, + "mimetype": "image/jpeg", + "href": ( + "https://beta.tt.se/media/text/" + "250924-bookmarkpabo-4068424/a001_NormalHires.jpg" + ), + "height": 4500, + }, + "r00": { + "unit": "PX", + "usage": "Thumbnail", + "variant": "Normal", + "width": 512, + "mimetype": "image/jpeg", + "href": ( + "https://thumbnail.tt.se/media/text/" + "250924-bookmarkpabo-4068424/a001_NormalThumbnail.jpg" + ), + "height": 288, + }, + "r03": { + "unit": "PX", + "usage": "Preview", + "variant": "Watermark", + "width": 1024, + "mimetype": "image/jpeg", + "href": ( + "https://beta.tt.se/media/text/" + "250924-bookmarkpabo-4068424/a001_WatermarkPreview.jpg" + ), + "height": 576, + }, + "r02": { + "unit": "PX", + "usage": "Preview", + "variant": "Cropped", + "width": 1024, + "mimetype": "image/jpeg", + "href": ( + "https://beta.tt.se/media/text/" + "250924-bookmarkpabo-4068424/a001_CroppedPreview.jpg" + ), + "height": 1024, + }, + "r05": { + "unit": "PX", + "usage": "Thumbnail", + "variant": "Cropped", + "width": 512, + "mimetype": "image/jpeg", + "href": ( + "https://thumbnail.tt.se/media/text/" + "250924-bookmarkpabo-4068424/a001_CroppedThumbnail.jpg" + ), + "height": 512, + }, + "r04": { + "unit": "PX", + "usage": "Preview", + "variant": "Normal", + "width": 1024, + "mimetype": "image/jpeg", + "href": ( + "https://beta.tt.se/media/text/" + "250924-bookmarkpabo-4068424/a001_NormalPreview.jpg" + ), + "height": 576, + }, + }, + "type": "picture", + "uri": "http://tt.se/media/image/68847ef9304c48267feaaac57dda0fa6", + } + }, + "language": "sv", + "source": "ViaTT", + "type": "text", + "versioncreated": "2025-09-24T10:12:00+02:00", + "headline": "Bookmark på Bokmässan 2025", + "slug": "bookmarkpåbokmässan2025", + "pubstatus": "usable", + }, +] + + +def fixture(filename): + return os.path.join(os.path.dirname(__file__), "fixtures", filename) + + +class MockResponse: + def __init__(self, json_data, status_code=200): + self.json_data = json_data + self.status_code = status_code + self.headers = {"content-type": "application/json"} + self.text = json.dumps(json_data) if json_data else "" + + def json(self): + return self.json_data + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.exceptions.HTTPError(f"HTTP {self.status_code}") + + +class MockResponseWithJsonException: + def __init__(self, json_data=None, status_code=200, json_exc=None): + self._json_data = json_data + self.status_code = status_code + self._json_exc = json_exc + self.headers = {"content-type": "application/json"} + + def json(self): + if self._json_exc: + raise self._json_exc + return self._json_data + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.exceptions.HTTPError(f"HTTP {self.status_code}") + + +class STTContentAPITestCase(unittest.TestCase): + def setUp(self): + self.service = STTTTContentAPIService() + self.parser = ContentAPITTItemParser() + + # Load test fixture + with open(fixture("api/stt_tt_content_api.json")) as _file: + self.fixture_data = json.load(_file) + + def test_instance(self): + """Test service instance creation and basic properties.""" + self.assertEqual("stt_tt_content_api", self.service.NAME) + self.assertEqual("STT TT Content API", self.service.label) + self.assertFalse(self.service.HTTP_AUTH) + + # Check required fields + fields = {field["id"]: field for field in self.service.fields} + self.assertIn("url", fields) + self.assertIn("api_key", fields) + self.assertTrue(fields["url"]["required"]) + self.assertTrue(fields["api_key"]["required"]) + + # Check new pagination fields + self.assertIn("page_size", fields) + self.assertIn("max_pages", fields) + self.assertFalse(fields["page_size"]["required"]) + self.assertFalse(fields["max_pages"]["required"]) + self.assertEqual("50", fields["page_size"]["default"]) + self.assertEqual("200", fields["max_pages"]["default"]) + + def test_headers_helper(self): + """Test the _headers helper method.""" + headers = self.service._headers("test_api_key") + + self.assertEqual("application/json", headers["Accept"]) + self.assertEqual("ApiKey test_api_key", headers["Authorization"]) + + def test_config_validation(self): + """Test configuration validation in _test method.""" + # Test missing URL + provider = {"config": {"api_key": "test"}} + + with self.assertRaises(Exception): + self.service._test(provider) + + # Test missing API key + provider = {"config": {"url": "https://example.com"}} + + with self.assertRaises(Exception): + self.service._test(provider) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_single_page(self, mock_get): + """Test _fetch_tt_data with single page response using fixture data.""" + # Use first 2 items from fixture for testing + test_items = self.fixture_data["hits"][:2] + mock_response_data = { + "hits": test_items, + } + + # First call returns data, subsequent calls return empty + # (simulates single page) + mock_get.side_effect = [ + MockResponse(mock_response_data), # First page with data + MockResponse({"hits": []}), # Second page empty (stops pagination) + ] + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "Bearer TEST_TOKEN", + } + } + + items = self.service._fetch_tt_data(provider, {}) + + # Verify pagination: expect 2 calls (first with data, second empty) + self.assertEqual(2, mock_get.call_count) + + # Check first call has pagination params + first_call_args = mock_get.call_args_list[0] + first_url = first_call_args[0][0] + self.assertIn("s=50", first_url) # page size + self.assertIn("fr=0", first_url) # offset + self.assertEqual( + "ApiKey Bearer TEST_TOKEN", + first_call_args[1]["headers"]["Authorization"], + ) + self.assertEqual("application/json", first_call_args[1]["headers"]["Accept"]) + self.assertEqual(60, first_call_args[1]["timeout"]) + + # Verify items returned + self.assertEqual(2, len(items)) + self.assertEqual(test_items[0]["uri"], items[0]["uri"]) + self.assertEqual(test_items[1]["uri"], items[1]["uri"]) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_hits_format(self, mock_get): + """Test _fetch_tt_data with hits response format.""" + # Test with hits field response format + all_items = self.fixture_data["hits"][:2] + + mock_response_data = {"hits": all_items} + # First call returns data, second call returns empty (simulates single page) + mock_get.side_effect = [ + MockResponse(mock_response_data), # First page with data + MockResponse({"hits": []}), # Second page empty (stops pagination) + ] + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "test_token", # Test without ApiKey prefix + } + } + + items = self.service._fetch_tt_data(provider, {}) + + # Should have made two requests (pagination logic) + self.assertEqual(2, mock_get.call_count) + + # Check request + call_args = mock_get.call_args + self.assertEqual("ApiKey test_token", call_args[1]["headers"]["Authorization"]) + + # Should return all items + self.assertEqual(2, len(items)) + self.assertEqual( + [item["uri"] for item in all_items], + [item["uri"] for item in items], + ) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_different_response_formats(self, mock_get): + """Test _fetch_tt_data with different API response formats.""" + test_items = self.fixture_data["hits"][:2] + + # Test with direct list response + mock_response_data = test_items + + # Simulate pagination: first call returns data, second returns empty + mock_get.side_effect = [ + MockResponse(mock_response_data), # First page with data + MockResponse([]), # Second page empty (stops pagination) + ] + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "Bearer TOKEN123", + } + } + + items = self.service._fetch_tt_data(provider, {}) + + self.assertEqual(2, len(items)) + self.assertEqual(test_items[0]["uri"], items[0]["uri"]) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_adds_trs_with_last_updated(self, mock_get): + """When update contains last_updated, include trs in query.""" + # minimal 1-page flow + mock_get.side_effect = [ + MockResponse({"hits": [TEST_TT_ITEMS[0]]}), + MockResponse({"hits": []}), + ] + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + } + } + update = {"last_updated": "2025-09-24T10:00:00Z"} + + _ = self.service._fetch_tt_data(provider, update) + + # First call URL should contain trs param with the exact timestamp + first_call_args = mock_get.call_args_list[0] + first_url = first_call_args[0][0] + self.assertIn("trs=2025-09-24T10%3A00%3A00Z", first_url) # URL-encoded ':' + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_uses_trs_fallback_since_minutes(self, mock_get): + """When no last_updated, use now - since_minutes as trs.""" + # one page then empty + mock_get.side_effect = [ + MockResponse({"hits": [TEST_TT_ITEMS[0]]}), + MockResponse({"hits": []}), + ] + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + "since_minutes": "120", + } + } + update = {} + + # Freeze datetime.now in the target module + with patch("stt.io.feeding_services.stt_tt_content_api.datetime") as mock_dt: + mock_dt.now.return_value = datetime( + 2025, 9, 25, 12, 0, 0, tzinfo=timezone.utc + ) + mock_dt.fromisoformat.side_effect = datetime.fromisoformat + # timezone is used in code; pass through the real timezone + mock_dt.timezone = timezone + + _ = self.service._fetch_tt_data(provider, update) + + # Expected trs = 2025-09-25T10:00:00Z (12:00 - 120 minutes) + first_call_args = mock_get.call_args_list[0] + first_url = first_call_args[0][0] + self.assertIn("trs=2025-09-25T10%3A00%3A00Z", first_url) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_timeout_configurable(self, mock_get): + """Provider timeout should override default.""" + mock_get.side_effect = [MockResponse({"hits": []})] + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + "timeout": "15", + } + } + update = {} + + _ = self.service._fetch_tt_data(provider, update) + + call_args = mock_get.call_args + self.assertEqual(15, call_args[1]["timeout"]) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_trs_enforced_even_when_legacy_config_disables(self, mock_get): + """Legacy configs with use_trs=False should still yield trs in query.""" + mock_get.side_effect = [MockResponse({"hits": []})] + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + "use_trs": False, + } + } + update = {"last_updated": "2025-09-24T10:00:00Z"} + + _ = self.service._fetch_tt_data(provider, update) + + call_args = mock_get.call_args + url = call_args[0][0] + self.assertIn("trs=2025-09-24T10%3A00%3A00Z", url) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_list_response(self, mock_get): + """Test _fetch_tt_data with direct list response.""" + test_items = self.fixture_data["hits"][:2] + + # Simulate pagination: first call returns data, second returns empty + mock_get.side_effect = [ + MockResponse(test_items), # First page with data + MockResponse([]), # Second page empty (stops pagination) + ] + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "Bearer TOKEN123", + } + } + + items = self.service._fetch_tt_data(provider, {}) + + self.assertEqual(2, len(items)) + self.assertEqual(test_items[0]["uri"], items[0]["uri"]) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_error_handling(self, mock_get): + """Test error handling in _fetch_tt_data.""" + mock_get.return_value = MockResponse({}, status_code=404) + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "Bearer TOKEN123", + } + } + + with self.assertRaises(Exception): + self.service._fetch_tt_data(provider, {}) + + def test_parser_with_fixture_data(self): + """Test parser with real fixture data.""" + test_item = self.fixture_data["hits"][0] + + result = asyncio.run(self.parser.parse(test_item, provider={"config": {}})) + + # Parser should return a list of dicts + self.assertIsInstance(result, list) + self.assertEqual(1, len(result)) + + parsed_item = result[0] + + # Check required fields + self.assertEqual("text", parsed_item["type"]) + self.assertEqual("usable", parsed_item["pubstatus"]) + self.assertIn("uri", parsed_item) + self.assertIn("versioncreated", parsed_item) + + # Check original data is preserved + self.assertEqual(test_item["uri"], parsed_item["uri"]) + self.assertEqual(test_item["headline"], parsed_item["headline"]) + + def test_parser_minimal_item(self): + """Test parser with minimal required data.""" + minimal_item = { + "uri": "http://tt.se/media/text/test-minimal", + "source": "STT", + "type": "text", + "headline": "Test minimal headline", + } + + result = asyncio.run(self.parser.parse(minimal_item, provider={"config": {}})) + self.assertIsInstance(result, list) + self.assertEqual(1, len(result)) + parsed_item = result[0] + + # Should have all required defaults + self.assertEqual("text", parsed_item["type"]) + self.assertEqual("usable", parsed_item["pubstatus"]) + self.assertIn("versioncreated", parsed_item) + self.assertEqual("Test minimal headline", parsed_item["headline"]) + self.assertEqual("", parsed_item["body_html"]) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_update_with_parser_integration(self, mock_get): + """Test _update method with parser integration using fixture data.""" + test_items = self.fixture_data["hits"][:2] + mock_response_data = {"hits": test_items} + + # Simulate pagination: first call returns data, second returns empty + mock_get.side_effect = [ + MockResponse(mock_response_data), # First page with data + MockResponse({"hits": []}), # Second page empty (stops pagination) + ] + + # Mock the parser since it's not registered in test environment + with patch.object(self.service, "get_feed_parser", return_value=self.parser): + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "Bearer TOKEN123", + }, + } + update = {} + + items = asyncio.run(self.service._update(provider, update)) + + # Should have processed all items + self.assertEqual(2, len(items)) + + # Check that items were parsed correctly + for parsed_item in items: + self.assertEqual("text", parsed_item["type"]) + self.assertEqual("usable", parsed_item["pubstatus"]) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_top_level_array(self, mock_get): + test_items = TEST_TT_ITEMS + # Simulate pagination: first call returns data, second returns empty + mock_get.side_effect = [ + MockResponse(json_data=test_items, status_code=200), # First page with data + MockResponse( + json_data=[], status_code=200 + ), # Second page empty (stops pagination) + ] + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + } + } + + items = self.service._fetch_tt_data(provider, {}) + + self.assertEqual(2, len(items)) + self.assertEqual( + [ + "http://tt.se/media/text/250924-konjunkturlaget6uv-ae8053fa", + "http://tt.se/media/text/250924-bookmarkpabo-4068424", + ], + [it["uri"] for it in items], + ) + + self.assertEqual(2, mock_get.call_count) + + # Check the second call (which is what call_args refers to) + second_call_args = mock_get.call_args_list[1] + second_url = second_call_args[0][0] + + # Second call should have pagination offset + self.assertIn(provider["config"]["url"], second_url) + self.assertIn("s=50", second_url) # page_size + self.assertIn("fr=50", second_url) # offset for second page + + # Check headers on second call + self.assertEqual(60, second_call_args[1].get("timeout")) + self.assertIn("headers", second_call_args[1]) + self.assertEqual( + "ApiKey MY_TOKEN", second_call_args[1]["headers"]["Authorization"] + ) + self.assertEqual("application/json", second_call_args[1]["headers"]["Accept"]) + + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_dict_of_id_mapping(self, mock_get): + hits_mapping = { + "x": TEST_TT_ITEMS[0], + "y": TEST_TT_ITEMS[1], + "z": "not a dict", + } + # Simulate pagination: first call returns data, second returns empty + mock_get.side_effect = [ + MockResponse( + json_data={"hits": hits_mapping}, status_code=200 + ), # First page with data + MockResponse( + json_data={"hits": {}}, status_code=200 + ), # Second page empty (stops pagination) + ] + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + } + } + + items = self.service._fetch_tt_data(provider, {}) + + self.assertEqual(2, len(items)) + self.assertEqual( + { + "http://tt.se/media/text/250924-konjunkturlaget6uv-ae8053fa", + "http://tt.se/media/text/250924-bookmarkpabo-4068424", + }, + {item["uri"] for item in items}, + ) + + @patch("superdesk.errors.IngestApiError.apiGeneralError") + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_http_error_raises_ingest_api_error( + self, mock_get, mock_api_error + ): + mock_get.return_value = MockResponse(json_data=None, status_code=500) + mock_api_error.side_effect = lambda ex, provider: RuntimeError(str(ex)) + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + } + } + + with self.assertRaises(requests.exceptions.HTTPError): + self.service._fetch_tt_data(provider, {}) + + # The error is raised directly, not wrapped by mock_api_error + mock_api_error.assert_not_called() + + @patch("superdesk.errors.IngestApiError.apiGeneralError") + @patch.object(STTTTContentAPIService, "_get_with_retry") + def test_fetch_data_json_parse_error_raises_ingest_api_error( + self, mock_get, mock_api_error + ): + mock_get.return_value = MockResponseWithJsonException( + json_exc=ValueError("bad json"), status_code=200 + ) + mock_api_error.side_effect = lambda ex, provider: RuntimeError(str(ex)) + + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + } + } + + with self.assertRaises(RuntimeError) as ctx: + self.service._fetch_tt_data(provider, {}) + + self.assertIn("JSON parse error", str(ctx.exception)) + mock_api_error.assert_called_once() + args, kwargs = mock_api_error.call_args + self.assertIsInstance(args[0], Exception) + self.assertIn("bad json", str(args[0])) + self.assertEqual(provider, args[1]) + + def test_update_flattens_parsed_items(self): + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + } + } + items_to_fetch = [{"a": 1}, {"b": 2}] + with patch.object(self.service, "_fetch_tt_data", return_value=items_to_fetch): + + class DummyParser: + def parse(self, item, provider): + if "a" in item: + return [{"x": 1}] # Parser now returns list + return [{"z": 3}] # Parser now returns list + + # Mock the get_feed_parser method since _update now uses it + with patch.object( + self.service, "get_feed_parser", return_value=DummyParser() + ): + result = asyncio.run(self.service._update(provider, update={})) + + self.assertEqual(2, len(result)) + self.assertEqual([{"x": 1}, {"z": 3}], result) + + @patch("superdesk.errors.ParserError.parseMessageError") + def test_update_parser_exception_wrapped_in_parser_error(self, mock_parse_error): + mock_parse_error.side_effect = lambda ex, provider, data=None: RuntimeError( + "wrapped" + ) + provider = { + "config": { + "url": "https://api.example.com/contentapi/items", + "api_key": "MY_TOKEN", + } + } + items_to_fetch = [{"ok": 1}, {"bad": 2}] + with patch.object(self.service, "_fetch_tt_data", return_value=items_to_fetch): + + class FailingParser: + def parse(self, item, provider): + if "bad" in item: + raise ValueError("boom") + return [{"ok_parsed": True}] # Parser now returns list + + # Mock the get_feed_parser method since _update now uses it + with patch.object( + self.service, "get_feed_parser", return_value=FailingParser() + ): + with self.assertRaises(RuntimeError) as ctx: + asyncio.run(self.service._update(provider, update={})) + + self.assertEqual("wrapped", str(ctx.exception)) + mock_parse_error.assert_called_once() + args, kwargs = mock_parse_error.call_args + self.assertIsInstance(args[0], Exception) + self.assertEqual(provider, args[1]) + self.assertEqual({"bad": 2}, kwargs.get("data")) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/stt_tt_parse_content_api_test.py b/server/tests/stt_tt_parse_content_api_test.py new file mode 100644 index 00000000..f8cb7661 --- /dev/null +++ b/server/tests/stt_tt_parse_content_api_test.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +import asyncio +import json +import os +import unittest + +from stt.io.feed_parsers.stt_tt_parse_content_api import ContentAPITTItemParser + + +def fixture(filename): + return os.path.join(os.path.dirname(__file__), "fixtures", filename) + + +class ContentAPITTItemParserTestCase(unittest.TestCase): + def setUp(self): + self.parser = ContentAPITTItemParser() + + # Load test fixture + with open(fixture("api/stt_tt_content_api.json")) as _file: + self.fixture_data = json.load(_file) + + def test_parser_with_fixture_data(self): + """Test parser with real fixture data.""" + test_item = self.fixture_data["hits"][0] + + result = asyncio.run(self.parser.parse(test_item, provider={"config": {}})) + + self.assertEqual(test_item["uri"], result[0]["uri"]) + + def test_parser_minimal_item(self): + """Test parser with minimal required data.""" + minimal_item = { + "uri": "http://tt.se/media/text/test-minimal", + "source": "STT", + "type": "text", + "headline": "Test minimal headline", + "body_text": "Test content", + } + + result = asyncio.run(self.parser.parse(minimal_item, provider={"config": {}})) + + self.assertEqual(minimal_item["uri"], result[0]["uri"]) + + def test_parser_guid_consistency(self): + """Test that GUID generation is consistent for the same input.""" + test_item = self.fixture_data["hits"][0] + + result1 = asyncio.run(self.parser.parse(test_item, provider={"config": {}})) + result2 = asyncio.run(self.parser.parse(test_item, provider={"config": {}})) + + self.assertEqual(result1[0]["uri"], result2[0]["uri"]) + + +if __name__ == "__main__": + unittest.main()