Parse transferable days from FK instead of deriving them
The scraper never read "överlåtbara dagar" from Försäkringskassan. It only visited ansok-och-planera-foraldrapenning#/valjdagar and derived the reserved/transferable split in build_snapshot, assuming transferable days are spent before reserved ones. On real data that gave 49 for the user and 0 for the partner where FK states 129 and 71. The assumption cannot be repaired: reserved-first, transferable-first, proportional and cap-based models were all checked against real figures and none reproduces FK's accounting (reserved-first fits the partner but is 10 days off for the user). FK publishes the real numbers per parent per tier on the min-foraldrapenning page, which the scraper had never visited. - Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction, summing sjukpenningnivå + lägstanivå per parent. The totals are not literals on the page. Parse only direct-child <p> elements: the first group embeds a tooltip with decoy "45 dagar"/"90 dagar" text. - build_snapshot accepts the reported value and derives reserved days from it; the old arithmetic remains only as a fallback. An implausible FK value degrades to that fallback rather than failing the fetch. - Record provenance in transferable_days_reported (migration 006) and badge derived values as estimates in the dashboard breakdown. Drop the footnote's claim that at most 150 days are transferable — real data contradicts it as a per-family figure. - Select the child's section by name when FK lists several; never guess. Verified against a live BankID scrape: scraped 240/101/139 with 129 transferable, deduced partner 240/169/71 with 71, both flagged as reported. Incidental fixes found along the way: - The fetch router never forwarded the partner's transferable figure to persist_partner_deduction, so the partner's stored snapshot would have kept the flawed split even once the parse landed. - 'deduced' snapshots were labelled "Manually entered" by a two-way ternary over a three-value union. Replaced with a compiler-checked exhaustive map. - Alembic resolved its database from alembic.ini alone, ignoring APP_SQLITE_PATH, so migrations silently targeted the developer's real database even when pointed elsewhere. env.py now resolves it, preferring -x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value. - backend/captures/ was not ignored: the root pattern was anchored as /captures/ despite a comment saying scraper dumps must never be tracked. - CLAUDE.md quoted test counts and coverage percentages that had gone stale, which made a correct verification report look suspect. Gate thresholds kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ backend/debug
|
||||
# Scraper page captures and browser-verification screenshots contain real
|
||||
# personal data from Försäkringskassan — never track them. Tests use the
|
||||
# synthetic fixture in backend/tests/fixtures/ instead.
|
||||
/captures/
|
||||
captures/
|
||||
/test.html
|
||||
|
||||
# C extensions
|
||||
|
||||
@@ -114,7 +114,7 @@ uv run ruff check . # lint
|
||||
uv run ruff format --check . # format check
|
||||
uv run pytest # tests + coverage (config in pyproject.toml)
|
||||
```
|
||||
Gate: `--cov-fail-under=80` (total now ~84%), climbing toward **90**. Never lower it; raise only as coverage grows.
|
||||
Gate: `--cov-fail-under=80`, climbing toward **90**. Never lower it; raise only as coverage grows.
|
||||
|
||||
**Frontend** (from `frontend/`):
|
||||
```bash
|
||||
@@ -123,7 +123,7 @@ npm run lint # ESLint, zero warnings
|
||||
npm run coverage # Vitest with coverage
|
||||
```
|
||||
Gates (baseline measured after including `src/pages/**`): lines 96 | functions 91 | branches 92 | statements 96.
|
||||
A regression ratchet, not a target. Current: 97.26% lines/statements, 93.05% functions, 93.51% branches — above thresholds. Never lower gates to pass.
|
||||
A regression ratchet, not a target. Verify current coverage is above these thresholds; never lower gates to pass.
|
||||
|
||||
### WHO RUNS VERIFICATION (cost rule)
|
||||
|
||||
@@ -136,7 +136,7 @@ These checks are mechanical: fixed commands, judgement limited to reading pass/f
|
||||
Applies to full-suite runs: `uv run pytest`, `uv run ruff check .`, `uv run ruff format --check .`, `npm run typecheck`, `npm run lint`, `npm run coverage`, `npx vitest run`, `uv run alembic upgrade head`.
|
||||
Does **not** apply to quick mid-task diagnostics (reading one file, a `git grep`, one focused test) — delegating those costs more than it saves. The rule targets long whole-suite passes.
|
||||
|
||||
**Brief the Haiku verifier with:** exact commands, directory to run from, expected baseline (e.g. "currently 361 passed / 0 failed — must not regress"), and an instruction to paste **raw output verbatim**, failures included, not a summary. "All green" without output is useless — several agents have reported gates passing that were failing.
|
||||
**Brief the Haiku verifier with:** exact commands, directory to run from, and an instruction to report exact pass/fail counts and coverage percentages from their run, then compare them against the current branch state to verify no regression; require **raw output verbatim**, failures included, not a summary. "All green" without output is useless — several agents have reported gates passing that were failing.
|
||||
|
||||
### MODEL TIERS
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
# Last-resort fallback only. migrations/env.py's _resolve_url() overrides this
|
||||
# from the app's own APP_SQLITE_PATH setting (or an `-x sqlalchemy.url=...`
|
||||
# argument) whenever either is available — this value is used only if neither is.
|
||||
sqlalchemy.url = sqlite:///./foraldraledig.db
|
||||
|
||||
[loggers]
|
||||
|
||||
@@ -130,6 +130,7 @@ CREATE TABLE IF NOT EXISTS parental_leave_fetch (
|
||||
tier_data TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
transferable_days_reported INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE
|
||||
|
||||
@@ -42,3 +42,8 @@ class ParentalLeaveFetch:
|
||||
tier_data: TierData
|
||||
created_at: datetime | None = field(default=None)
|
||||
updated_at: datetime | None = field(default=None)
|
||||
# True when `transferable_days_left` is FK's own reported figure (parsed
|
||||
# from the "Dagar ni kan föra över" panel); False when it was derived by
|
||||
# `app.services.leave_snapshot.build_snapshot`'s transferable-first
|
||||
# fallback, including when only `reserved_days_left` was supplied.
|
||||
transferable_days_reported: bool = field(default=False)
|
||||
|
||||
@@ -64,6 +64,7 @@ class ParentalLeaveFetchResponse(BaseModel):
|
||||
reserved_days_total: int
|
||||
reserved_days_left: int
|
||||
transferable_days_left: int
|
||||
transferable_days_reported: bool
|
||||
dubbeldagar_left: int
|
||||
tier_data: TierDataResponse
|
||||
created_at: datetime | None
|
||||
@@ -84,6 +85,7 @@ class ParentalLeaveFetchResponse(BaseModel):
|
||||
reserved_days_total=fetch.reserved_days_total,
|
||||
reserved_days_left=fetch.reserved_days_left,
|
||||
transferable_days_left=fetch.transferable_days_left,
|
||||
transferable_days_reported=fetch.transferable_days_reported,
|
||||
dubbeldagar_left=fetch.dubbeldagar_left,
|
||||
tier_data=TierDataResponse.from_entity(fetch.tier_data),
|
||||
created_at=fetch.created_at,
|
||||
|
||||
@@ -45,6 +45,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository):
|
||||
tier_data=tier_data,
|
||||
created_at=datetime.fromisoformat(row["created_at"]),
|
||||
updated_at=datetime.fromisoformat(row["updated_at"]),
|
||||
transferable_days_reported=bool(row["transferable_days_reported"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -69,8 +70,9 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository):
|
||||
INSERT INTO parental_leave_fetch
|
||||
(id, personal_data_uuid, workspace_id, child_id, source, fetched_at,
|
||||
total_days, days_used, days_left, reserved_days_total, reserved_days_left,
|
||||
transferable_days_left, dubbeldagar_left, tier_data, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
transferable_days_left, dubbeldagar_left, tier_data, created_at, updated_at,
|
||||
transferable_days_reported)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
str(item.id),
|
||||
@@ -89,6 +91,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository):
|
||||
self._tier_data_to_json(item.tier_data),
|
||||
item.created_at.isoformat(),
|
||||
item.updated_at.isoformat(),
|
||||
int(item.transferable_days_reported),
|
||||
),
|
||||
)
|
||||
await commit(self._conn)
|
||||
@@ -137,7 +140,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository):
|
||||
UPDATE parental_leave_fetch
|
||||
SET days_used = ?, days_left = ?, reserved_days_left = ?,
|
||||
transferable_days_left = ?, dubbeldagar_left = ?,
|
||||
tier_data = ?, updated_at = ?
|
||||
tier_data = ?, updated_at = ?, transferable_days_reported = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
@@ -148,6 +151,7 @@ class SQLiteParentalLeaveRepository(ParentalLeaveRepository):
|
||||
item.dubbeldagar_left,
|
||||
self._tier_data_to_json(item.tier_data),
|
||||
item.updated_at.isoformat(),
|
||||
int(item.transferable_days_reported),
|
||||
str(id),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -165,6 +165,7 @@ async def start_fetch(
|
||||
if payload.child_id is not None:
|
||||
child = await scope.check_child(payload.child_id)
|
||||
child_id = child.id
|
||||
child_name = child.name
|
||||
else:
|
||||
children = await child_repo.list_by_workspace(workspace_id)
|
||||
if not children:
|
||||
@@ -173,6 +174,7 @@ async def start_fetch(
|
||||
"No child in workspace — add one before fetching.",
|
||||
)
|
||||
child_id = children[0].id
|
||||
child_name = children[0].name
|
||||
|
||||
# Capture the repos for the background task. The connection lives for the
|
||||
# app's lifetime (app.state.db_connection), so using it from the
|
||||
@@ -197,6 +199,7 @@ async def start_fetch(
|
||||
sjukpenning_left=partner.sjukpenning_left,
|
||||
lagsta_niva_left=partner.lagsta_niva_left,
|
||||
dubbeldagar_left=partner.dubbeldagar_left,
|
||||
transferable_days_left=partner.transferable_days_left,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("partner-deduction persist failed, session unaffected: %s", exc)
|
||||
@@ -207,6 +210,7 @@ async def start_fetch(
|
||||
child_id=child_id,
|
||||
owner_user_id=current_user.id,
|
||||
persist_callback=_persist,
|
||||
child_name=child_name,
|
||||
)
|
||||
return _StartResponse(
|
||||
session_id=session_id,
|
||||
|
||||
@@ -33,7 +33,7 @@ import logging
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
@@ -43,6 +43,7 @@ from app.config import settings
|
||||
from app.entities.parental_leave import ParentalLeaveFetch
|
||||
from app.services.fetch_data_service import FetchDataService
|
||||
from app.services.leave_snapshot import build_snapshot
|
||||
from app.utils.errors import ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,6 +62,14 @@ POST_LOGIN_URL_FRAGMENT = "ansok-och-planera-foraldrapenning#/"
|
||||
# The data page we navigate to after clicking Ansök
|
||||
DATA_PAGE_URL_FRAGMENT = "ansok-och-planera-foraldrapenning#/valjdagar"
|
||||
|
||||
# The "Min föräldrapenning" e-service page. Reports the transferable-days
|
||||
# split ("Dagar ni kan föra över") directly, per-tier, for both parents —
|
||||
# figures that cannot be reconstructed from #/valjdagar alone (see
|
||||
# `_extract_transferable_days`'s docstring).
|
||||
MIN_FP_URL = (
|
||||
"https://www.forsakringskassan.se/privatperson/mina-sidor/alla-e-tjanster/min-foraldrapenning"
|
||||
)
|
||||
|
||||
|
||||
class _SEL:
|
||||
# Login page
|
||||
@@ -100,6 +109,15 @@ class _SEL:
|
||||
FAMILY_DISCRETE = FAMILY + " .visa-dagar__discrete"
|
||||
FAMILY_NIVA_STATISTIK = FAMILY + " .visa-dagar__desktop .niva-statistik__dagar span"
|
||||
|
||||
# Min föräldrapenning page — "Dagar ni kan föra över" panel (per child).
|
||||
# See `MIN_FP_URL`'s comment and `_extract_transferable_days`.
|
||||
MIN_FP_BARN_SEKTION = ".min-fp .barn-sektion"
|
||||
BARN_RUBRIK = ".barn-rubrik strong"
|
||||
PANEL_FORA_OVER_DAGAR = ".panel-fora-over-dagar"
|
||||
FORA_OVER_GROUP = ".fora-over-group"
|
||||
MINFP_LABEL = "h4.minfp-label"
|
||||
EXPANDABLE_BUTTON = ".expandable-paragraph__button"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Family-panel summary and partner deduction
|
||||
@@ -125,6 +143,33 @@ class PartnerDeduction:
|
||||
sjukpenning_left: int
|
||||
lagsta_niva_left: int
|
||||
dubbeldagar_left: int
|
||||
transferable_days_left: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransferableDays:
|
||||
"""FK's own reported "Dagar ni kan föra över" split, per tier and parent.
|
||||
|
||||
Read from the `min-foraldrapenning` e-service page (see `MIN_FP_URL`),
|
||||
not derived — this is the source of truth `_apply_transferable` feeds
|
||||
back into `build_snapshot` as `transferable_days_left`. Partner fields
|
||||
are `None` for a solo parent (no "Annan förälder" group on the page).
|
||||
"""
|
||||
|
||||
mine_sjukpenning: int
|
||||
mine_lagsta_niva: int
|
||||
partner_sjukpenning: int | None = None
|
||||
partner_lagsta_niva: int | None = None
|
||||
|
||||
@property
|
||||
def mine_total(self) -> int:
|
||||
return self.mine_sjukpenning + self.mine_lagsta_niva
|
||||
|
||||
@property
|
||||
def partner_total(self) -> int | None:
|
||||
if self.partner_sjukpenning is None or self.partner_lagsta_niva is None:
|
||||
return None
|
||||
return self.partner_sjukpenning + self.partner_lagsta_niva
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -146,6 +191,13 @@ class _SessionState:
|
||||
# treat a mismatched owner exactly like an unknown session_id — never a
|
||||
# 403, so a foreign session_id can't be distinguished from a made-up one.
|
||||
owner_user_id: UUID
|
||||
# The child's display name, when the caller had it loaded already (see
|
||||
# `start_session`). Used only to pick the right `.barn-sektion` on the
|
||||
# min-foraldrapenning page when a workspace has more than one child —
|
||||
# see `_extract_transferable_days`. `None` disables that feature for
|
||||
# multi-child workspaces (never guesses a section) but never affects
|
||||
# the caller's own #/valjdagar extraction.
|
||||
child_name: str | None = None
|
||||
status: SessionStatus = "launching"
|
||||
qr_data: bytes | None = None # raw PNG bytes; never persisted to disk/DB
|
||||
result: ParentalLeaveFetch | None = None
|
||||
@@ -210,6 +262,7 @@ class FKScraperService(FetchDataService):
|
||||
persist_callback: (
|
||||
Callable[[ParentalLeaveFetch, PartnerDeduction | None], Awaitable[None]] | None
|
||||
) = None,
|
||||
child_name: str | None = None,
|
||||
) -> str:
|
||||
"""Launch a new headless browser session. Returns the session_id.
|
||||
|
||||
@@ -218,6 +271,11 @@ class FKScraperService(FetchDataService):
|
||||
`owner_user_id` binds the session to the caller that started it — see
|
||||
`_SessionState.owner_user_id`.
|
||||
|
||||
`child_name`, if the caller already has the `Child` loaded, is used
|
||||
only to pick the right section on the min-foraldrapenning page when
|
||||
a workspace has more than one child — see `_extract_transferable_days`.
|
||||
The scraper never looks it up itself (no repository access from here).
|
||||
|
||||
At most one live (non-terminal) session per `owner_user_id` — starting
|
||||
a new one cancels any session already in flight for the same caller
|
||||
(see F5: unbounded growth of `_sessions` per owner).
|
||||
@@ -237,6 +295,7 @@ class FKScraperService(FetchDataService):
|
||||
personal_data_uuid=personal_data_uuid,
|
||||
child_id=child_id,
|
||||
owner_user_id=owner_user_id,
|
||||
child_name=child_name,
|
||||
status="launching",
|
||||
persist_callback=persist_callback,
|
||||
)
|
||||
@@ -572,6 +631,24 @@ async def _do_extract(page, state: _SessionState) -> None:
|
||||
state.family = None
|
||||
state.partner_deduction = None
|
||||
|
||||
# "Dagar ni kan föra över" panel on the min-foraldrapenning page -> FK's
|
||||
# own reported transferable-days split, replacing the transferable-first
|
||||
# assumption in `data`/`state.partner_deduction`. Best-effort only, and
|
||||
# must run after the family-panel block above: the family panel only
|
||||
# exists on #/valjdagar, and this navigates away from it.
|
||||
try:
|
||||
transferable = await _extract_transferable_days(page, state, state.child_name)
|
||||
if transferable is not None:
|
||||
data = _apply_transferable(data, transferable)
|
||||
if state.partner_deduction is not None and transferable.partner_total is not None:
|
||||
state.partner_deduction = replace(
|
||||
state.partner_deduction, transferable_days_left=transferable.partner_total
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
"[%s] transferable-days parse skipped: %s", state.session_id, type(exc).__name__
|
||||
)
|
||||
|
||||
if state.persist_callback is not None:
|
||||
try:
|
||||
await state.persist_callback(data, state.partner_deduction)
|
||||
@@ -795,3 +872,201 @@ def _deduce_partner(mine: ParentalLeaveFetch, family: FamilySummary) -> PartnerD
|
||||
lagsta_niva_left=lagsta_left,
|
||||
dubbeldagar_left=mine.dubbeldagar_left,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transferable days ("Dagar ni kan föra över") — FK's own reported split
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TRANSFERABLE_RE = re.compile(r"(\d+)\s+dagar?\s+på\s+(sjukpenningnivå|lägstanivå)")
|
||||
|
||||
|
||||
async def _extract_transferable_days(
|
||||
page, state: _SessionState, child_name: str | None
|
||||
) -> TransferableDays | None:
|
||||
"""Read the "Dagar ni kan föra över" panel on the min-foraldrapenning
|
||||
e-service page and return FK's own reported transferable-days split.
|
||||
|
||||
This figure cannot be reconstructed from #/valjdagar (see
|
||||
`app.services.leave_snapshot.build_snapshot`'s module docstring) — FK's
|
||||
own accounting there is not reproducible by any plausible model, so it
|
||||
must be read off this second page directly.
|
||||
|
||||
Fully defensive by design, mirroring `_extract_family_summary`: any
|
||||
missing element, unmatched child, or unexpected text is "no transferable
|
||||
data available" — logged at INFO and returned as None — never raised.
|
||||
The caller's own `data` (already validated) must be unaffected either way.
|
||||
"""
|
||||
try:
|
||||
logger.debug("[%s] navigating to min-foraldrapenning page", state.session_id)
|
||||
await page.goto(MIN_FP_URL, wait_until="domcontentloaded")
|
||||
|
||||
logger.debug("[%s] waiting for transfer panel", state.session_id)
|
||||
await page.wait_for_selector(_SEL.PANEL_FORA_OVER_DAGAR, timeout=30_000)
|
||||
|
||||
# The panel mounts before the async fetch lands — wait for real
|
||||
# per-tier figures rather than trusting first paint (mirrors the
|
||||
# #/valjdagar hydration guard in `_do_extract`).
|
||||
logger.debug("[%s] waiting for transfer panel data to hydrate", state.session_id)
|
||||
await page.wait_for_function(
|
||||
"""(sel) => {
|
||||
const els = document.querySelectorAll(sel);
|
||||
for (const el of els) {
|
||||
if (/\\d+\\s+dagar?\\s+på/.test(el.textContent)) return true;
|
||||
}
|
||||
return false;
|
||||
}""",
|
||||
arg=_SEL.FORA_OVER_GROUP + " p",
|
||||
timeout=30_000,
|
||||
)
|
||||
logger.debug("[%s] transfer panel data hydrated", state.session_id)
|
||||
|
||||
sections = page.locator(_SEL.MIN_FP_BARN_SEKTION)
|
||||
section_count = await sections.count()
|
||||
if section_count == 0:
|
||||
logger.info(
|
||||
"[%s] no barn-sektion found on min-foraldrapenning page; skipping "
|
||||
"transferable-days parse",
|
||||
state.session_id,
|
||||
)
|
||||
return None
|
||||
|
||||
if section_count == 1:
|
||||
chosen = sections.first
|
||||
else:
|
||||
if not child_name or not child_name.strip():
|
||||
logger.info(
|
||||
"[%s] multiple children on min-foraldrapenning page and no "
|
||||
"child_name to match; skipping transferable-days parse",
|
||||
state.session_id,
|
||||
)
|
||||
return None
|
||||
target = child_name.strip().split()[0].casefold()
|
||||
chosen = None
|
||||
for i in range(section_count):
|
||||
section = sections.nth(i)
|
||||
rubrik_loc = section.locator(_SEL.BARN_RUBRIK)
|
||||
if await rubrik_loc.count() == 0:
|
||||
continue
|
||||
rubrik_text = (await rubrik_loc.first.inner_text(timeout=2_000)).strip().casefold()
|
||||
if rubrik_text == target:
|
||||
chosen = section
|
||||
break
|
||||
if chosen is None:
|
||||
logger.info(
|
||||
"[%s] no barn-sektion matched child_name; skipping transferable-days parse",
|
||||
state.session_id,
|
||||
)
|
||||
return None
|
||||
|
||||
panel = chosen.locator(_SEL.PANEL_FORA_OVER_DAGAR)
|
||||
if await panel.count() == 0:
|
||||
logger.info(
|
||||
"[%s] chosen barn-sektion has no transfer panel; skipping transferable-days parse",
|
||||
state.session_id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Content is present in the DOM even when the accordion is collapsed,
|
||||
# so text reads work as-is. Still click through as insurance against
|
||||
# FK switching to lazy rendering — a same-page toggle, never a dialog.
|
||||
button = panel.locator(_SEL.EXPANDABLE_BUTTON)
|
||||
if await button.count() > 0:
|
||||
aria_expanded = await button.first.get_attribute("aria-expanded")
|
||||
if aria_expanded == "false":
|
||||
logger.debug("[%s] expanding collapsed transfer panel", state.session_id)
|
||||
await button.first.click(timeout=5_000)
|
||||
|
||||
groups = panel.locator(_SEL.FORA_OVER_GROUP)
|
||||
group_count = await groups.count()
|
||||
|
||||
mine_sjuk: int | None = None
|
||||
mine_lagsta: int | None = None
|
||||
partner_sjuk: int | None = None
|
||||
partner_lagsta: int | None = None
|
||||
|
||||
for i in range(group_count):
|
||||
group = groups.nth(i)
|
||||
label_loc = group.locator(_SEL.MINFP_LABEL)
|
||||
if await label_loc.count() == 0:
|
||||
continue
|
||||
label_text = (await label_loc.first.inner_text(timeout=2_000)).strip()
|
||||
|
||||
# Direct children only — the tooltip inside "Du kan föra över"
|
||||
# holds decoy "45 dagar"/"90 dagar" text that a descendant
|
||||
# selector would also pick up.
|
||||
p_texts = await group.locator(":scope > p").all_inner_texts()
|
||||
sjuk: int | None = None
|
||||
lagsta: int | None = None
|
||||
for t in p_texts:
|
||||
m = _TRANSFERABLE_RE.search(t)
|
||||
if not m:
|
||||
continue
|
||||
value = int(m.group(1))
|
||||
if m.group(2) == "sjukpenningnivå":
|
||||
sjuk = value
|
||||
else:
|
||||
lagsta = value
|
||||
|
||||
if label_text.startswith("Du kan"):
|
||||
mine_sjuk, mine_lagsta = sjuk, lagsta
|
||||
elif label_text.startswith("Annan förälder"):
|
||||
partner_sjuk, partner_lagsta = sjuk, lagsta
|
||||
|
||||
if mine_sjuk is None or mine_lagsta is None:
|
||||
logger.info(
|
||||
"[%s] 'Du kan föra över' group missing or incomplete; skipping "
|
||||
"transferable-days parse",
|
||||
state.session_id,
|
||||
)
|
||||
return None
|
||||
|
||||
return TransferableDays(
|
||||
mine_sjukpenning=mine_sjuk,
|
||||
mine_lagsta_niva=mine_lagsta,
|
||||
partner_sjukpenning=partner_sjuk,
|
||||
partner_lagsta_niva=partner_lagsta,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
"[%s] transferable-days panel parsing failed (%s); skipping",
|
||||
state.session_id,
|
||||
type(exc).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _apply_transferable(
|
||||
data: ParentalLeaveFetch, transferable: TransferableDays
|
||||
) -> ParentalLeaveFetch:
|
||||
"""Re-run `build_snapshot` with FK's own reported transferable-days
|
||||
figure in place of the transferable-first assumption used when `data`
|
||||
was first built.
|
||||
|
||||
An FK-reported figure that fails `build_snapshot`'s validation (should
|
||||
never happen, but FK's markup is not a contract) degrades to the
|
||||
original `data` unchanged rather than failing the whole fetch.
|
||||
"""
|
||||
try:
|
||||
return build_snapshot(
|
||||
personal_data_uuid=data.personal_data_uuid,
|
||||
workspace_id=data.workspace_id,
|
||||
child_id=data.child_id,
|
||||
source=data.source,
|
||||
total_days=data.total_days,
|
||||
sjukpenning_left=data.tier_data.sjukpenning_left,
|
||||
lagsta_niva_left=data.tier_data.lagsta_niva_left,
|
||||
dubbeldagar_left=data.dubbeldagar_left,
|
||||
transferable_days_left=transferable.mine_total,
|
||||
fetched_at=data.fetched_at,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
logger.warning(
|
||||
"FK-reported transferable_days_left=%d implausible against derived "
|
||||
"days_left=%d (child_id=%s); keeping derived reserved/transferable split: %s",
|
||||
transferable.mine_total,
|
||||
data.days_left,
|
||||
data.child_id,
|
||||
exc,
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -118,6 +118,7 @@ async def persist_partner_deduction(
|
||||
sjukpenning_left: int,
|
||||
lagsta_niva_left: int,
|
||||
dubbeldagar_left: int,
|
||||
transferable_days_left: int | None = None,
|
||||
) -> None:
|
||||
"""Persist a 'deduced' snapshot for the caller's partner, if there is one.
|
||||
|
||||
@@ -126,7 +127,10 @@ async def persist_partner_deduction(
|
||||
`app.scrapers.fk_scraper._deduce_partner`). Takes plain fields rather
|
||||
than the scraper's `PartnerDeduction` dataclass to avoid a router/scraper
|
||||
import cycle (`fk_scraper` already imports from this module) — the
|
||||
router unpacks it before calling in.
|
||||
router unpacks it before calling in. `transferable_days_left`, when
|
||||
known (FK's own reported figure for the partner), is forwarded to
|
||||
`build_snapshot` so the partner's reserved/transferable split doesn't
|
||||
fall back to the transferable-first assumption either.
|
||||
|
||||
A no-op (logged, never raised) unless the workspace has exactly one
|
||||
other non-deleted member: with zero, there's no one to persist for; with
|
||||
@@ -155,6 +159,7 @@ async def persist_partner_deduction(
|
||||
sjukpenning_left=sjukpenning_left,
|
||||
lagsta_niva_left=lagsta_niva_left,
|
||||
dubbeldagar_left=dubbeldagar_left,
|
||||
transferable_days_left=transferable_days_left,
|
||||
)
|
||||
await parental_leave_repo.create(fetch)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -9,6 +9,13 @@ itself — see PLANNING_ENGINE_SPEC.md and the "Bug A" note in
|
||||
`app.utils.tip_predicates.default_tier_pool_share` for what happens when two
|
||||
call sites disagree.
|
||||
|
||||
FK's `min-foraldrapenning` e-service page does report the
|
||||
reserved/transferable split directly, in its "Dagar ni kan föra över" panel
|
||||
— when a caller has that figure (a scraper parse, or a detailed manual
|
||||
entry) it should be passed as `transferable_days_left`. The
|
||||
"transferable-first" arithmetic below is only a fallback for when that
|
||||
figure isn't available.
|
||||
|
||||
Domain: a child has `rules.total_days_per_child` days total (390
|
||||
sjukpenningnivå + 90 lägstanivå). Split across two parents each gets half
|
||||
(195 + 45 = 240); `rules.reserved_days_per_parent` (90) of each parent's
|
||||
@@ -40,16 +47,29 @@ def build_snapshot(
|
||||
lagsta_niva_left: int,
|
||||
dubbeldagar_left: int,
|
||||
reserved_days_left: int | None = None,
|
||||
transferable_days_left: int | None = None,
|
||||
fetched_at: datetime | None = None,
|
||||
) -> ParentalLeaveFetch:
|
||||
"""Derive and validate a full `ParentalLeaveFetch` snapshot.
|
||||
|
||||
`reserved_days_left`, when omitted, is derived assuming transferable
|
||||
(non-reserved) days are always consumed before reserved ones — the same
|
||||
assumption the FK scraper's page reading has always made, since FK's UI
|
||||
doesn't expose the reserved/transferable split directly. When the caller
|
||||
already knows the reserved split (e.g. a future, more detailed manual
|
||||
form), passing it directly skips that assumption.
|
||||
`reserved_days_left` and `transferable_days_left` are mutually
|
||||
exclusive — passing both raises `ValidationError`, since each one alone
|
||||
fully determines the other (`transferable_days_left = days_left -
|
||||
reserved_days_left`).
|
||||
|
||||
`transferable_days_left` is FK's own reported figure, read off the
|
||||
"Dagar ni kan föra över" panel on the `min-foraldrapenning` e-service
|
||||
page. When it is supplied, `reserved_days_left` is computed from it
|
||||
(`days_left - transferable_days_left`) and checked by the same
|
||||
validators below as any other value.
|
||||
|
||||
When neither is supplied, `reserved_days_left` falls back to an
|
||||
assumption — transferable (non-reserved) days are always consumed
|
||||
before reserved ones — used only because FK's own figure wasn't
|
||||
available (manual entry with no split known, or a failed parse of that
|
||||
panel). When the caller already knows the reserved split directly
|
||||
(e.g. a future, more detailed manual form), passing `reserved_days_left`
|
||||
skips that assumption.
|
||||
"""
|
||||
if source not in _VALID_SOURCES:
|
||||
raise ValidationError(
|
||||
@@ -57,6 +77,17 @@ def build_snapshot(
|
||||
message_key="parentalLeave.invalidSource",
|
||||
data={"source": source},
|
||||
)
|
||||
if reserved_days_left is not None and transferable_days_left is not None:
|
||||
raise ValidationError(
|
||||
"reserved_days_left and transferable_days_left are mutually exclusive "
|
||||
"— each one alone determines the other",
|
||||
message_key="parentalLeave.conflictingTransferableOverride",
|
||||
data={
|
||||
"reserved_days_left": reserved_days_left,
|
||||
"transferable_days_left": transferable_days_left,
|
||||
},
|
||||
)
|
||||
transferable_days_reported = transferable_days_left is not None
|
||||
|
||||
rules = settings.rules
|
||||
|
||||
@@ -75,18 +106,22 @@ def build_snapshot(
|
||||
lagsta_niva_used = lagsta_niva_total - lagsta_niva_left
|
||||
|
||||
# -- Reserved / transferable split --
|
||||
# Reserved days cannot be transferred to the other parent; the
|
||||
# long-standing assumption (matches FK's own "spend transferable first")
|
||||
# is that transferable days are used up before any reserved day is.
|
||||
# Reserved days cannot be transferred to the other parent. FK reports
|
||||
# the transferable figure directly (see docstring); when it isn't
|
||||
# available, fall back to the long-standing assumption (matches FK's
|
||||
# own "spend transferable first") that transferable days are used up
|
||||
# before any reserved day is.
|
||||
reserved_days_total = rules.reserved_days_per_parent
|
||||
if reserved_days_left is None:
|
||||
if transferable_days_left is not None:
|
||||
reserved_days_left = days_left - transferable_days_left
|
||||
elif reserved_days_left is not None:
|
||||
transferable_days_left = days_left - reserved_days_left
|
||||
else:
|
||||
transferable_total = total_days - reserved_days_total
|
||||
transferable_used = min(days_used, transferable_total)
|
||||
reserved_used = days_used - transferable_used
|
||||
reserved_days_left = reserved_days_total - reserved_used
|
||||
transferable_days_left = transferable_total - transferable_used
|
||||
else:
|
||||
transferable_days_left = days_left - reserved_days_left
|
||||
|
||||
# -- Validate before returning --
|
||||
if days_left < 0:
|
||||
@@ -180,6 +215,7 @@ def build_snapshot(
|
||||
reserved_days_total=reserved_days_total,
|
||||
reserved_days_left=reserved_days_left,
|
||||
transferable_days_left=transferable_days_left,
|
||||
transferable_days_reported=transferable_days_reported,
|
||||
dubbeldagar_left=dubbeldagar_left,
|
||||
tier_data=tier_data,
|
||||
created_at=now,
|
||||
|
||||
@@ -7,20 +7,49 @@ layer stays independent of any Python model objects.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
from typing import Any
|
||||
|
||||
from alembic import context
|
||||
|
||||
from migrations.url_resolution import coerce_sqlite_async, resolve_database_url
|
||||
|
||||
# Load the alembic.ini logging config without disabling existing loggers —
|
||||
# when running inside the FastAPI app the app's own loggers must not be reset.
|
||||
if context.config.config_file_name is not None:
|
||||
fileConfig(context.config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
logger = logging.getLogger("alembic.env")
|
||||
|
||||
|
||||
def _resolve_url() -> str:
|
||||
"""Resolve the database URL for this migration run.
|
||||
|
||||
This is the SINGLE place both the offline and online paths get their URL
|
||||
from — `alembic.ini`'s `sqlalchemy.url` is deliberately never read
|
||||
directly anywhere else, so Alembic can never silently target a different
|
||||
database than the one the running app actually uses.
|
||||
|
||||
The actual resolution order/logic lives in `migrations.url_resolution`
|
||||
(kept separate so it is unit-testable without an active Alembic
|
||||
context); this just wires Alembic's `-x` args and ini value into it.
|
||||
"""
|
||||
x_args = context.get_x_argument(as_dictionary=True)
|
||||
ini_url = context.config.get_main_option("sqlalchemy.url")
|
||||
url = resolve_database_url(x_args, ini_url)
|
||||
if not url:
|
||||
raise RuntimeError(
|
||||
"Could not resolve a database URL: no -x sqlalchemy.url, no "
|
||||
"APP_SQLITE_PATH-derived setting, and no alembic.ini sqlalchemy.url."
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Emit SQL to stdout (used by `alembic upgrade --sql`)."""
|
||||
url = context.config.get_main_option("sqlalchemy.url")
|
||||
url = _resolve_url()
|
||||
logger.info("Resolved database URL (offline mode): %s", url)
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=None,
|
||||
@@ -41,10 +70,8 @@ async def run_migrations_online() -> None:
|
||||
"""Run migrations against a live DB connection."""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
url = context.config.get_main_option("sqlalchemy.url")
|
||||
# Coerce sqlite:// → sqlite+aiosqlite:// for async support.
|
||||
if url and url.startswith("sqlite:///"):
|
||||
url = url.replace("sqlite:///", "sqlite+aiosqlite:///", 1)
|
||||
url = coerce_sqlite_async(_resolve_url())
|
||||
logger.info("Resolved database URL (online mode): %s", url)
|
||||
|
||||
connectable = create_async_engine(url)
|
||||
async with connectable.connect() as connection:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Pure database-URL resolution logic for Alembic migrations.
|
||||
|
||||
Kept separate from `env.py` so it is importable and unit-testable on its own:
|
||||
`env.py` has import-time side effects tied to an active Alembic
|
||||
`MigrationContext` (it calls `alembic.context.config...` at module scope) and
|
||||
cannot be imported outside of an actual `alembic` invocation. This module has
|
||||
no such dependency — it only takes plain values in and returns plain values
|
||||
out.
|
||||
|
||||
Resolution order implemented by `resolve_database_url` (first match wins):
|
||||
1. An explicit `-x sqlalchemy.url=...` argument — a documented way to point a
|
||||
one-off Alembic run at a scratch database without touching the environment.
|
||||
2. `APP_SQLITE_PATH` read directly from `os.environ` — but only when it is
|
||||
genuinely present and non-empty. This deliberately does NOT go through
|
||||
`app.config.Settings`: `Settings.sqlite_path` has a default
|
||||
(`"./foraldraledig.db"`), so a `Settings()` instance can never tell "the
|
||||
env var was set to the default's value" apart from "the env var was never
|
||||
set at all" — the default always wins, which made step 3 below dead code
|
||||
and meant a `sqlalchemy.url` set programmatically on the Alembic `Config`
|
||||
(e.g. `cfg.set_main_option(...)` in a test) was silently ignored in favour
|
||||
of whatever `./foraldraledig.db` resolved to, including the developer's
|
||||
real local database. Reading `os.environ` directly is the only way to
|
||||
distinguish "explicitly set" from "defaulted".
|
||||
Only the real `APP_SQLITE_PATH` environment variable counts here — a value
|
||||
from an `.env` file is deliberately NOT treated as "explicitly set" (this
|
||||
resolver never loads `.env`). `.env` is a developer convenience with
|
||||
quietly-applied defaults, exactly the kind of implicit source that caused
|
||||
this bug; requiring a real env var keeps "what database does this
|
||||
migration run touch" answerable by looking at the process environment
|
||||
alone, and it means an `.env` file can never silently retarget migrations
|
||||
for anyone running Alembic (locally or in CI) who does not also have that
|
||||
exact environment variable exported.
|
||||
3. The `sqlalchemy.url` value from `alembic.ini` — or from
|
||||
`Config.set_main_option("sqlalchemy.url", ...)`, which is indistinguishable
|
||||
from `alembic.ini` by the time it reaches `context.config.get_main_option`
|
||||
— as the fallback when neither of the above applies.
|
||||
|
||||
This is the single place that decides which database Alembic touches — never
|
||||
read `alembic.ini`'s `sqlalchemy.url` directly anywhere else, so migrations
|
||||
can never silently target a different database than the one the running app
|
||||
actually uses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def resolve_database_url(x_args: dict[str, str], ini_url: str | None) -> str | None:
|
||||
"""Resolve the database URL from -x args, a real APP_SQLITE_PATH env var,
|
||||
then the ini fallback."""
|
||||
explicit_url = x_args.get("sqlalchemy.url")
|
||||
if explicit_url:
|
||||
return explicit_url
|
||||
|
||||
sqlite_path = os.environ.get("APP_SQLITE_PATH")
|
||||
if sqlite_path:
|
||||
return f"sqlite:///{sqlite_path}"
|
||||
|
||||
return ini_url
|
||||
|
||||
|
||||
def coerce_sqlite_async(url: str) -> str:
|
||||
"""sqlite:/// → sqlite+aiosqlite:/// so the async engine can use it."""
|
||||
if url.startswith("sqlite:///"):
|
||||
return url.replace("sqlite:///", "sqlite+aiosqlite:///", 1)
|
||||
return url
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Add parental_leave_fetch.transferable_days_reported.
|
||||
|
||||
Tracks provenance of `transferable_days_left`: True when it is FK's own
|
||||
reported figure (parsed from the "Dagar ni kan föra över" panel on the
|
||||
`min-foraldrapenning` e-service page, or a detailed manual entry that
|
||||
supplied it directly); False when `app.services.leave_snapshot.build_snapshot`
|
||||
derived it via the transferable-first fallback (manual entry with no split
|
||||
known, or a failed parse of that panel).
|
||||
|
||||
Existing rows default to false, which is accurate — every snapshot persisted
|
||||
before this migration was derived via the fallback, never FK-reported.
|
||||
|
||||
A plain `ADD COLUMN` suffices here (like 003's `take_pl_on_red_days`, unlike
|
||||
002's CHECK-constraint change which needed a full table rebuild) since
|
||||
SQLite supports adding a NOT NULL column with a constant DEFAULT directly.
|
||||
|
||||
Must stay byte-for-byte equivalent to the `parental_leave_fetch` table in
|
||||
`app/db/connection.py`'s `_FULL_SCHEMA` —
|
||||
`tests/integration/test_schema_parity.py` asserts exactly that.
|
||||
|
||||
Revision ID: 006
|
||||
Revises: 005
|
||||
Create Date: 2026-09-20
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "006"
|
||||
down_revision: str | None = "005"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE parental_leave_fetch "
|
||||
"ADD COLUMN transferable_days_reported INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE parental_leave_fetch DROP COLUMN transferable_days_reported")
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Synthetic stand-in for a captured FK "min-foraldrapenning" e-service page
|
||||
(https://www.forsakringskassan.se/privatperson/mina-sidor/alla-e-tjanster/min-foraldrapenning).
|
||||
|
||||
This is NOT a real Försäkringskassan page dump — it is a hand-built,
|
||||
minimal HTML fragment that reproduces only the DOM structure and CSS
|
||||
selectors that `app/scrapers/fk_scraper.py::_extract_transferable_days`
|
||||
reads (see `_SEL.MIN_FP_*` / `_SEL.BARN_RUBRIK` / `_SEL.PANEL_FORA_OVER_DAGAR`
|
||||
/ `_SEL.FORA_OVER_GROUP` / `_SEL.MINFP_LABEL` / `_SEL.EXPANDABLE_BUTTON` in
|
||||
that module). All names, day counts, and amounts below are fictitious (see
|
||||
F1 of the scraper/config/ops security audit — real captures never belong
|
||||
in the repo; `captures/` is gitignored).
|
||||
|
||||
The tooltip block under "Du kan föra över" deliberately repeats the
|
||||
fixed "45 dagar"/"90 dagar" boilerplate text FK shows on the real page
|
||||
(how many days each guardian may transfer to another *person*, not the
|
||||
other parent) so tests genuinely exercise `:scope > p` scoping rather
|
||||
than passing by luck on a fixture with no decoy text.
|
||||
-->
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Min föräldrapenning - Försäkringskassan (synthetic fixture)</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-fp">
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header">
|
||||
<h2 class="barn-rubrik"><strong>Testbarn</strong></h2>
|
||||
</div>
|
||||
|
||||
<div class="panel-fora-over-dagar">
|
||||
<div class="expandable-paragraph expandable-paragraph--open">
|
||||
<h3 class="expandable-paragraph__heading heading--h4">
|
||||
<button type="button" class="expandable-paragraph__button" aria-expanded="true"
|
||||
aria-controls="fkui-vue-element-0002">
|
||||
<span class="expandable-paragraph__icon"></span>
|
||||
Dagar ni kan föra över
|
||||
</button>
|
||||
</h3>
|
||||
<div id="fkui-vue-element-0002" class="expandable-paragraph__container"
|
||||
style="overflow: hidden;">
|
||||
<div class="expandable-paragraph__content">
|
||||
|
||||
<div class="fora-over-group">
|
||||
<div class="tooltip__container">
|
||||
<h4 class="minfp-label">Du kan föra över</h4>
|
||||
<button class="tooltip__button" type="button" aria-label="Mer information">?</button>
|
||||
</div>
|
||||
<div class="tooltip">
|
||||
<div class="tooltip__bubble">
|
||||
<h4 class="tooltip__header">Vem kan du föra över dagar till?</h4>
|
||||
<div class="tooltip__body">
|
||||
<div>
|
||||
<strong>Förälder</strong>
|
||||
<div>Du kan föra över alla dina dagar till barnets andra förälder.</div>
|
||||
<p><strong>Annan person</strong></p>
|
||||
<div>Du kan föra över dagar till en annan person som är folkbokförd med barnet.</div>
|
||||
<p>Varje vårdnadshavare kan föra över 45 dagar till andra personer.
|
||||
Om du har ensam vårdnad kan du föra över 90 dagar till andra personer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>84 dagar på sjukpenningnivå</p>
|
||||
<p>45 dagar på lägstanivå</p>
|
||||
</div>
|
||||
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Annan förälder kan föra över</h4>
|
||||
<p>26 dagar på sjukpenningnivå</p>
|
||||
<p>45 dagar på lägstanivå</p>
|
||||
</div>
|
||||
|
||||
<!---->
|
||||
<div class="reserverade-dagar">
|
||||
<h4 class="heading--h6">Reserverade dagar</h4>
|
||||
<p>Vissa dagar är reserverade och går inte att föra över till någon annan.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -320,11 +320,13 @@ class TestScraperEndpointsEnabled:
|
||||
child_id: UUID,
|
||||
persist_callback: Any,
|
||||
owner_user_id: UUID,
|
||||
child_name: str | None = None,
|
||||
) -> str:
|
||||
captured["workspace_id"] = workspace_id
|
||||
captured["personal_data_uuid"] = personal_data_uuid
|
||||
captured["child_id"] = child_id
|
||||
captured["owner_user_id"] = owner_user_id
|
||||
captured["child_name"] = child_name
|
||||
return "fake-session-id"
|
||||
|
||||
monkeypatch.setattr(fetch_data_router._scraper_service, "start_session", fake_start_session)
|
||||
@@ -410,6 +412,7 @@ class TestPersistCallbackPartnerDeduction:
|
||||
child_id: UUID,
|
||||
persist_callback: Any,
|
||||
owner_user_id: UUID,
|
||||
child_name: str | None = None,
|
||||
) -> str:
|
||||
captured["persist_callback"] = persist_callback
|
||||
return "fake-session-id"
|
||||
|
||||
@@ -27,9 +27,13 @@ from app.scrapers.fk_scraper import (
|
||||
FamilySummary,
|
||||
FKScraperService,
|
||||
PartnerDeduction,
|
||||
TransferableDays,
|
||||
_apply_transferable,
|
||||
_deduce_partner,
|
||||
_do_extract,
|
||||
_extract_family_summary,
|
||||
_extract_transferable_days,
|
||||
_SEL,
|
||||
_SessionState,
|
||||
_set_status,
|
||||
_sessions,
|
||||
@@ -642,6 +646,578 @@ class TestDeducePartner:
|
||||
assert deduction.lagsta_niva_left >= 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transferable days ("Dagar ni kan föra över") -> min-foraldrapenning page
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Small balanced-<div> HTML toolkit for the mock page below. Regex alone
|
||||
# (as `_make_mock_page` above uses) can't safely slice `.barn-sektion` or
|
||||
# `.fora-over-group`, since they contain further nested <div>s (the tooltip,
|
||||
# in particular) — a naive ".*?</div>" would stop at the first nested close.
|
||||
|
||||
|
||||
def _balanced_div_slice(html: str, open_tag_start: int) -> str:
|
||||
"""From the index of a '<div' opening tag, return that div's full outer
|
||||
HTML (opening tag through its matching closing tag), counting nested
|
||||
<div>/</div> pairs."""
|
||||
depth = 0
|
||||
for m in re.finditer(r"<div\b[^>]*>|</div\s*>", html[open_tag_start:], re.IGNORECASE):
|
||||
if m.group(0).lower().startswith("</div"):
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return html[open_tag_start : open_tag_start + m.end()]
|
||||
else:
|
||||
depth += 1
|
||||
raise ValueError("unbalanced <div> in fixture")
|
||||
|
||||
|
||||
def _find_divs_by_class(html: str, class_name: str) -> list[str]:
|
||||
"""Balanced outer-HTML slices for every <div class="...class_name...">
|
||||
in document order (non-overlapping — resumes the search after each
|
||||
match's own closing tag, so nested divs of the same class, unused here,
|
||||
aren't double-counted)."""
|
||||
pattern = re.compile(rf'<div\b[^>]*\bclass="[^"]*\b{re.escape(class_name)}\b[^"]*"[^>]*>')
|
||||
results: list[str] = []
|
||||
pos = 0
|
||||
while True:
|
||||
m = pattern.search(html, pos)
|
||||
if not m:
|
||||
break
|
||||
sl = _balanced_div_slice(html, m.start())
|
||||
results.append(sl)
|
||||
pos = m.start() + len(sl)
|
||||
return results
|
||||
|
||||
|
||||
def _find_barn_rubrik_text(section_html: str) -> str | None:
|
||||
idx = section_html.find("barn-rubrik")
|
||||
if idx == -1:
|
||||
return None
|
||||
m = re.search(r"<strong>(.*?)</strong>", section_html[idx:], re.DOTALL)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def _direct_child_p_texts(div_slice: str) -> list[str]:
|
||||
"""Text of <p> elements that are direct children of div_slice's own root
|
||||
element — i.e. not nested inside any further <div> — mirroring
|
||||
Playwright's `:scope > p`."""
|
||||
open_m = re.match(r"<div\b[^>]*>", div_slice, re.IGNORECASE)
|
||||
inner = div_slice[open_m.end() : -len("</div>")]
|
||||
texts: list[str] = []
|
||||
depth = 0
|
||||
for m in re.finditer(r"<(/?)(div|p)\b[^>]*>", inner, re.IGNORECASE):
|
||||
closing = m.group(1) == "/"
|
||||
tag = m.group(2).lower()
|
||||
if tag == "div":
|
||||
depth += -1 if closing else 1
|
||||
continue
|
||||
if tag == "p" and not closing and depth == 0:
|
||||
end_m = re.search(r"</p\s*>", inner[m.end() :], re.IGNORECASE)
|
||||
if end_m:
|
||||
raw = inner[m.end() : m.end() + end_m.start()]
|
||||
texts.append(re.sub(r"<[^>]+>", "", raw).strip())
|
||||
return texts
|
||||
|
||||
|
||||
def _make_min_fp_mock_page(html: str):
|
||||
"""Build a minimal async mock Page for the min-foraldrapenning "Dagar ni
|
||||
kan föra över" panel selectors. Balanced-<div> slicing + regex over the
|
||||
fixture HTML, not a real browser — see `_find_divs_by_class` above.
|
||||
"""
|
||||
min_fp_slices = _find_divs_by_class(html, "min-fp")
|
||||
min_fp_html = min_fp_slices[0] if min_fp_slices else ""
|
||||
barn_sektioner = _find_divs_by_class(min_fp_html, "barn-sektion") if min_fp_html else []
|
||||
|
||||
def _make_group_locator(group_html: str) -> MagicMock:
|
||||
loc = MagicMock()
|
||||
|
||||
def sub_locator(sel: str) -> MagicMock:
|
||||
if sel == _SEL.MINFP_LABEL:
|
||||
m = re.search(
|
||||
r'<h4\b[^>]*class="[^"]*minfp-label[^"]*"[^>]*>(.*?)</h4>',
|
||||
group_html,
|
||||
re.DOTALL,
|
||||
)
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=1 if m else 0)
|
||||
if m:
|
||||
text = re.sub(r"<[^>]+>", "", m.group(1)).strip()
|
||||
sub.first.inner_text = AsyncMock(return_value=text)
|
||||
return sub
|
||||
if sel == ":scope > p":
|
||||
sub = MagicMock()
|
||||
sub.all_inner_texts = AsyncMock(return_value=_direct_child_p_texts(group_html))
|
||||
return sub
|
||||
raise AssertionError(f"unexpected selector on group locator: {sel!r}")
|
||||
|
||||
loc.locator.side_effect = sub_locator
|
||||
return loc
|
||||
|
||||
def _make_panel_locator(panel_html: str) -> MagicMock:
|
||||
loc = MagicMock()
|
||||
loc.count = AsyncMock(return_value=1)
|
||||
|
||||
def sub_locator(sel: str) -> MagicMock:
|
||||
if sel == _SEL.EXPANDABLE_BUTTON:
|
||||
m = re.search(
|
||||
r'<button\b[^>]*class="[^"]*expandable-paragraph__button[^"]*"[^>]*>',
|
||||
panel_html,
|
||||
)
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=1 if m else 0)
|
||||
if m:
|
||||
aria_m = re.search(r'aria-expanded="(true|false)"', m.group(0))
|
||||
sub.first.get_attribute = AsyncMock(
|
||||
return_value=aria_m.group(1) if aria_m else None
|
||||
)
|
||||
sub.first.click = AsyncMock()
|
||||
return sub
|
||||
if sel == _SEL.FORA_OVER_GROUP:
|
||||
groups = _find_divs_by_class(panel_html, "fora-over-group")
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=len(groups))
|
||||
sub.nth.side_effect = lambda i: _make_group_locator(groups[i])
|
||||
return sub
|
||||
raise AssertionError(f"unexpected selector on panel locator: {sel!r}")
|
||||
|
||||
loc.locator.side_effect = sub_locator
|
||||
return loc
|
||||
|
||||
def _make_section_locator(section_html: str) -> MagicMock:
|
||||
loc = MagicMock()
|
||||
|
||||
def sub_locator(sel: str) -> MagicMock:
|
||||
if sel == _SEL.BARN_RUBRIK:
|
||||
text = _find_barn_rubrik_text(section_html)
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=1 if text is not None else 0)
|
||||
sub.first.inner_text = AsyncMock(return_value=text or "")
|
||||
return sub
|
||||
if sel == _SEL.PANEL_FORA_OVER_DAGAR:
|
||||
panels = _find_divs_by_class(section_html, "panel-fora-over-dagar")
|
||||
sub = MagicMock()
|
||||
sub.count = AsyncMock(return_value=len(panels))
|
||||
if panels:
|
||||
panel_loc = _make_panel_locator(panels[0])
|
||||
sub.locator.side_effect = panel_loc.locator.side_effect
|
||||
return sub
|
||||
raise AssertionError(f"unexpected selector on section locator: {sel!r}")
|
||||
|
||||
loc.locator.side_effect = sub_locator
|
||||
return loc
|
||||
|
||||
def _make_sections_root_locator() -> MagicMock:
|
||||
loc = MagicMock()
|
||||
loc.count = AsyncMock(return_value=len(barn_sektioner))
|
||||
if barn_sektioner:
|
||||
loc.first = _make_section_locator(barn_sektioner[0])
|
||||
loc.nth.side_effect = lambda i: _make_section_locator(barn_sektioner[i])
|
||||
return loc
|
||||
|
||||
page = MagicMock()
|
||||
page.goto = AsyncMock()
|
||||
page.wait_for_selector = AsyncMock()
|
||||
page.wait_for_function = AsyncMock()
|
||||
|
||||
def locator_factory(sel: str) -> MagicMock:
|
||||
if sel == _SEL.MIN_FP_BARN_SEKTION:
|
||||
return _make_sections_root_locator()
|
||||
raise AssertionError(f"unexpected top-level selector: {sel!r}")
|
||||
|
||||
page.locator.side_effect = locator_factory
|
||||
return page
|
||||
|
||||
|
||||
_MULTI_CHILD_MIN_FP_HTML = """
|
||||
<div class="min-fp">
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
|
||||
<div class="panel-fora-over-dagar">
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Du kan föra över</h4>
|
||||
<p>84 dagar på sjukpenningnivå</p>
|
||||
<p>45 dagar på lägstanivå</p>
|
||||
</div>
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Annan förälder kan föra över</h4>
|
||||
<p>26 dagar på sjukpenningnivå</p>
|
||||
<p>45 dagar på lägstanivå</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Andrabarn</strong></h2></div>
|
||||
<div class="panel-fora-over-dagar">
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Du kan föra över</h4>
|
||||
<p>10 dagar på sjukpenningnivå</p>
|
||||
<p>5 dagar på lägstanivå</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
_SOLO_PARENT_MIN_FP_HTML = """
|
||||
<div class="min-fp">
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
|
||||
<div class="panel-fora-over-dagar">
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Du kan föra över</h4>
|
||||
<p>84 dagar på sjukpenningnivå</p>
|
||||
<p>45 dagar på lägstanivå</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
_NO_TRANSFER_PANEL_MIN_FP_HTML = """
|
||||
<div class="min-fp">
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
|
||||
<div class="some-other-panel"></div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
_INCOMPLETE_MINE_MIN_FP_HTML = """
|
||||
<div class="min-fp">
|
||||
<div class="barn-sektion">
|
||||
<div class="barn-header"><h2 class="barn-rubrik"><strong>Testbarn</strong></h2></div>
|
||||
<div class="panel-fora-over-dagar">
|
||||
<div class="fora-over-group">
|
||||
<h4 class="minfp-label">Du kan föra över</h4>
|
||||
<p>84 dagar på sjukpenningnivå</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _min_fp_state() -> _SessionState:
|
||||
return _SessionState(
|
||||
session_id="min-fp-test",
|
||||
workspace_id=uuid.uuid4(),
|
||||
personal_data_uuid=uuid.uuid4(),
|
||||
child_id=uuid.uuid4(),
|
||||
owner_user_id=uuid.uuid4(),
|
||||
)
|
||||
|
||||
|
||||
class TestExtractTransferableDays:
|
||||
"""`_extract_transferable_days` reads FK's own "Dagar ni kan föra över"
|
||||
figures off the min-foraldrapenning page — the fix for the bug where
|
||||
`transferable_days_left` could never be reconstructed from #/valjdagar
|
||||
alone (see facts.md: FK reports 129/71, the old derivation gave 49/0)."""
|
||||
|
||||
async def test_both_groups_parsed_golden_regression(self) -> None:
|
||||
"""Pinning regression: mine 84+45=129, partner 26+45=71 — the exact
|
||||
`facts.md` figures FK itself reports, which the old arithmetic
|
||||
derivation could never reproduce."""
|
||||
html = _load_fixture("fk_min_foraldrapenning_synthetic.html")
|
||||
page = _make_min_fp_mock_page(html)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_total == 129
|
||||
assert result.partner_total == 71
|
||||
|
||||
async def test_tooltip_decoys_ignored(self) -> None:
|
||||
"""The tooltip's decoy "45 dagar"/"90 dagar" text must not leak into
|
||||
`mine_sjukpenning`/`mine_lagsta_niva` via a non-scoped read."""
|
||||
html = _load_fixture("fk_min_foraldrapenning_synthetic.html")
|
||||
page = _make_min_fp_mock_page(html)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_sjukpenning == 84
|
||||
assert result.mine_lagsta_niva == 45
|
||||
|
||||
async def test_collapsed_accordion_still_yields_correct_numbers(self) -> None:
|
||||
html = _load_fixture("fk_min_foraldrapenning_synthetic.html").replace(
|
||||
'aria-expanded="true"', 'aria-expanded="false"'
|
||||
)
|
||||
page = _make_min_fp_mock_page(html)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_total == 129
|
||||
assert result.partner_total == 71
|
||||
|
||||
async def test_panel_absent_returns_none(self) -> None:
|
||||
page = _make_min_fp_mock_page(_NO_TRANSFER_PANEL_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_single_section_used_without_name_match(self) -> None:
|
||||
"""A single `.barn-sektion` is used as-is — no `child_name` needed."""
|
||||
page = _make_min_fp_mock_page(_SOLO_PARENT_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), None)
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_total == 129
|
||||
|
||||
async def test_multi_child_correct_section_chosen_by_name(self) -> None:
|
||||
page = _make_min_fp_mock_page(_MULTI_CHILD_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Andrabarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.mine_sjukpenning == 10
|
||||
assert result.mine_lagsta_niva == 5
|
||||
|
||||
async def test_multi_child_unmatched_name_returns_none(self) -> None:
|
||||
page = _make_min_fp_mock_page(_MULTI_CHILD_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Okänt Barn")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_multi_child_no_name_returns_none(self) -> None:
|
||||
"""Never guess a section when there's more than one and no name to match."""
|
||||
page = _make_min_fp_mock_page(_MULTI_CHILD_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), None)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_solo_parent_partner_fields_none(self) -> None:
|
||||
page = _make_min_fp_mock_page(_SOLO_PARENT_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is not None
|
||||
assert result.partner_sjukpenning is None
|
||||
assert result.partner_lagsta_niva is None
|
||||
assert result.partner_total is None
|
||||
|
||||
async def test_incomplete_mine_group_returns_none(self) -> None:
|
||||
page = _make_min_fp_mock_page(_INCOMPLETE_MINE_MIN_FP_HTML)
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_exception_during_parse_returns_none(self) -> None:
|
||||
"""Any unexpected failure (e.g. a locator raising) degrades to None
|
||||
rather than propagating — mirrors `_extract_family_summary`."""
|
||||
page = _make_min_fp_mock_page(_load_fixture("fk_min_foraldrapenning_synthetic.html"))
|
||||
page.wait_for_selector = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
result = await _extract_transferable_days(page, _min_fp_state(), "Testbarn")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestApplyTransferable:
|
||||
"""`_apply_transferable` re-derives `data` with FK's own reported split."""
|
||||
|
||||
def _mine(self, **overrides: object) -> ParentalLeaveFetch:
|
||||
now = datetime.now()
|
||||
base = dict(
|
||||
id=uuid.uuid4(),
|
||||
personal_data_uuid=uuid.uuid4(),
|
||||
workspace_id=uuid.uuid4(),
|
||||
child_id=uuid.uuid4(),
|
||||
source="scraped",
|
||||
fetched_at=now,
|
||||
total_days=240,
|
||||
days_used=140,
|
||||
days_left=100,
|
||||
reserved_days_total=90,
|
||||
reserved_days_left=90,
|
||||
transferable_days_left=10,
|
||||
dubbeldagar_left=20,
|
||||
tier_data=TierData(
|
||||
sjukpenning_total=195,
|
||||
sjukpenning_used=135,
|
||||
sjukpenning_left=60,
|
||||
lagsta_niva_total=45,
|
||||
lagsta_niva_used=5,
|
||||
lagsta_niva_left=40,
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
base.update(overrides)
|
||||
return ParentalLeaveFetch(**base)
|
||||
|
||||
def test_valid_transferable_updates_reserved_split(self) -> None:
|
||||
mine = self._mine()
|
||||
transferable = TransferableDays(mine_sjukpenning=50, mine_lagsta_niva=10)
|
||||
|
||||
result = _apply_transferable(mine, transferable)
|
||||
|
||||
assert result.transferable_days_left == 60
|
||||
assert result.reserved_days_left == 40 # days_left(100) - transferable(60)
|
||||
assert result.transferable_days_reported is True
|
||||
|
||||
def test_implausible_transferable_returns_original_unchanged(self) -> None:
|
||||
"""transferable_days_left > days_left is impossible — build_snapshot
|
||||
raises ValidationError (reserved_days_left would go negative), and
|
||||
the caller's original estimate must survive unchanged, never raise."""
|
||||
mine = self._mine()
|
||||
transferable = TransferableDays(mine_sjukpenning=500, mine_lagsta_niva=500)
|
||||
|
||||
result = _apply_transferable(mine, transferable)
|
||||
|
||||
assert result is mine
|
||||
|
||||
|
||||
class TestDoExtractTransferableIntegration:
|
||||
"""`_do_extract`'s new transferable-days block, end to end.
|
||||
|
||||
`_mine`'s figures mirror `facts.md` exactly: days_left=94+45=139, and the
|
||||
old transferable-first derivation (no FK figure available) computes
|
||||
transferable_days_left=49 — the bug this feature fixes (FK itself
|
||||
reports 129). The family fixture below likewise mirrors facts.md's
|
||||
"210 av 480 dagar kvar totalt" / "Annan förälder: 71 dagar kvar".
|
||||
"""
|
||||
|
||||
_FAMILY_HTML = """
|
||||
<div data-test="forDigOchAnnanForalder">
|
||||
<div class="visa-dagar" data-test="body">
|
||||
<span class="visa-dagar__dagar-kvar">210</span>
|
||||
<span class="visa-dagar__discrete">av 480 dagar kvar efter ansökan</span>
|
||||
<div class="visa-dagar__desktop">
|
||||
<span class="niva-statistik__dagar"><span>(120 dagar)</span></span>
|
||||
<span class="niva-statistik__dagar"><span>(90 dagar)</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
_sessions.clear()
|
||||
|
||||
def teardown_method(self) -> None:
|
||||
_sessions.clear()
|
||||
|
||||
def _mine(self, **overrides: object) -> ParentalLeaveFetch:
|
||||
now = datetime.now()
|
||||
base = dict(
|
||||
id=uuid.uuid4(),
|
||||
personal_data_uuid=uuid.uuid4(),
|
||||
workspace_id=uuid.uuid4(),
|
||||
child_id=uuid.uuid4(),
|
||||
source="scraped",
|
||||
fetched_at=now,
|
||||
total_days=240,
|
||||
days_used=101,
|
||||
days_left=139,
|
||||
reserved_days_total=90,
|
||||
reserved_days_left=90,
|
||||
transferable_days_left=49,
|
||||
dubbeldagar_left=39,
|
||||
tier_data=TierData(
|
||||
sjukpenning_total=195,
|
||||
sjukpenning_used=101,
|
||||
sjukpenning_left=94,
|
||||
lagsta_niva_total=45,
|
||||
lagsta_niva_used=0,
|
||||
lagsta_niva_left=45,
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
base.update(overrides)
|
||||
return ParentalLeaveFetch(**base)
|
||||
|
||||
async def _run(
|
||||
self,
|
||||
mine: ParentalLeaveFetch,
|
||||
min_fp_html: str,
|
||||
family_html: str = "",
|
||||
child_name: str | None = "Testbarn",
|
||||
) -> _SessionState:
|
||||
sid = str(uuid.uuid4())
|
||||
state = _SessionState(
|
||||
session_id=sid,
|
||||
workspace_id=uuid.uuid4(),
|
||||
personal_data_uuid=uuid.uuid4(),
|
||||
child_id=uuid.uuid4(),
|
||||
owner_user_id=uuid.uuid4(),
|
||||
child_name=child_name,
|
||||
)
|
||||
_sessions[sid] = state
|
||||
|
||||
min_fp_page = _make_min_fp_mock_page(min_fp_html)
|
||||
family_page = _make_mock_page(family_html)
|
||||
|
||||
page = MagicMock()
|
||||
page.wait_for_selector = AsyncMock()
|
||||
page.wait_for_function = AsyncMock()
|
||||
page.goto = min_fp_page.goto
|
||||
|
||||
def locator_factory(sel: str) -> MagicMock:
|
||||
if sel == _SEL.MIN_FP_BARN_SEKTION:
|
||||
return min_fp_page.locator(sel)
|
||||
if sel.startswith(_SEL.FAMILY):
|
||||
return family_page.locator(sel)
|
||||
loc = MagicMock()
|
||||
loc.first.click = AsyncMock()
|
||||
loc.count = AsyncMock(return_value=0)
|
||||
return loc
|
||||
|
||||
page.locator.side_effect = locator_factory
|
||||
|
||||
with patch(
|
||||
"app.scrapers.fk_scraper._extract_parental_leave", new_callable=AsyncMock
|
||||
) as mock_extract:
|
||||
mock_extract.return_value = mine
|
||||
await _do_extract(page, state)
|
||||
|
||||
return state
|
||||
|
||||
async def test_transferable_days_applied_to_result(self) -> None:
|
||||
"""Golden regression via `_do_extract`: the persisted result carries
|
||||
FK's own 129, not the old broken derivation's 49."""
|
||||
mine = self._mine()
|
||||
state = await self._run(mine, _load_fixture("fk_min_foraldrapenning_synthetic.html"))
|
||||
|
||||
assert state.status == "done"
|
||||
assert state.result is not None
|
||||
assert state.result.transferable_days_left == 129
|
||||
assert state.result.reserved_days_left == 10 # 139 - 129
|
||||
|
||||
async def test_partner_deduction_picks_up_transferable_days(self) -> None:
|
||||
mine = self._mine()
|
||||
state = await self._run(
|
||||
mine,
|
||||
_load_fixture("fk_min_foraldrapenning_synthetic.html"),
|
||||
family_html=self._FAMILY_HTML,
|
||||
)
|
||||
|
||||
assert state.partner_deduction is not None
|
||||
assert state.partner_deduction.days_left == 71
|
||||
assert state.partner_deduction.transferable_days_left == 71
|
||||
|
||||
async def test_exception_in_block_leaves_data_untouched(self) -> None:
|
||||
"""Mirrors `test_absent_family_panel_does_not_affect_main_extraction`:
|
||||
a failure in the new block must never affect the caller's own,
|
||||
already-validated `data`."""
|
||||
mine = self._mine()
|
||||
with patch(
|
||||
"app.scrapers.fk_scraper._extract_transferable_days",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
state = await self._run(mine, _load_fixture("fk_min_foraldrapenning_synthetic.html"))
|
||||
|
||||
assert state.status == "done"
|
||||
assert state.result is mine
|
||||
assert state.result.transferable_days_left == 49 # unchanged from the original estimate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -728,6 +1304,7 @@ class TestSessionStateMachine:
|
||||
"sjukpenning_left": 130,
|
||||
"lagsta_niva_left": 20,
|
||||
"dubbeldagar_left": 20,
|
||||
"transferable_days_left": None,
|
||||
}
|
||||
|
||||
async def test_poll_done_partner_deduction_is_none_when_absent(self) -> None:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for `migrations.url_resolution` — the Alembic database-URL resolver.
|
||||
|
||||
Regression coverage for the bug where `resolve_database_url` read
|
||||
`app.config.Settings().sqlite_path` (which always has a default value) ahead
|
||||
of the Alembic `ini_url`, so `ini_url` — including a `sqlalchemy.url` set
|
||||
programmatically via `Config.set_main_option(...)`, as integration tests do —
|
||||
was unreachable dead code and migrations always ran against
|
||||
`./foraldraledig.db` regardless of what the caller asked for. See
|
||||
`migrations/env.py`'s `_resolve_url` for how this plugs into Alembic.
|
||||
"""
|
||||
|
||||
from migrations.url_resolution import coerce_sqlite_async, resolve_database_url
|
||||
|
||||
|
||||
class TestResolveDatabaseUrl:
|
||||
def test_app_sqlite_path_set_wins_over_ini_url(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("APP_SQLITE_PATH", "/tmp/scratch/other.db")
|
||||
|
||||
url = resolve_database_url(x_args={}, ini_url="sqlite:///./foraldraledig.db")
|
||||
|
||||
assert url == "sqlite:////tmp/scratch/other.db"
|
||||
|
||||
def test_app_sqlite_path_unset_falls_back_to_ini_url(self, monkeypatch) -> None:
|
||||
monkeypatch.delenv("APP_SQLITE_PATH", raising=False)
|
||||
|
||||
url = resolve_database_url(x_args={}, ini_url="sqlite:///./foraldraledig.db")
|
||||
|
||||
assert url == "sqlite:///./foraldraledig.db"
|
||||
|
||||
def test_app_sqlite_path_empty_string_treated_as_unset(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("APP_SQLITE_PATH", "")
|
||||
|
||||
url = resolve_database_url(x_args={}, ini_url="sqlite:///./ini-fallback.db")
|
||||
|
||||
assert url == "sqlite:///./ini-fallback.db"
|
||||
|
||||
def test_x_argument_wins_over_env_and_ini(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("APP_SQLITE_PATH", "/tmp/scratch/other.db")
|
||||
|
||||
url = resolve_database_url(
|
||||
x_args={"sqlalchemy.url": "sqlite:///./explicit-scratch.db"},
|
||||
ini_url="sqlite:///./foraldraledig.db",
|
||||
)
|
||||
|
||||
assert url == "sqlite:///./explicit-scratch.db"
|
||||
|
||||
def test_ini_url_used_when_env_var_unset(self, monkeypatch) -> None:
|
||||
monkeypatch.delenv("APP_SQLITE_PATH", raising=False)
|
||||
|
||||
url = resolve_database_url(x_args={}, ini_url="sqlite:///./ini-fallback.db")
|
||||
|
||||
assert url == "sqlite:///./ini-fallback.db"
|
||||
|
||||
def test_regression_programmatic_ini_url_wins_over_app_default(self, monkeypatch) -> None:
|
||||
"""The exact real-world scenario that regressed: a caller sets
|
||||
`sqlalchemy.url` programmatically on the Alembic `Config` (as
|
||||
`tests/integration/test_migration_004_backfill.py` does) and no
|
||||
`APP_SQLITE_PATH` is set. This must resolve to that ini value, NOT to
|
||||
`app.config.Settings`'s default of `./foraldraledig.db`.
|
||||
"""
|
||||
monkeypatch.delenv("APP_SQLITE_PATH", raising=False)
|
||||
|
||||
url = resolve_database_url(x_args={}, ini_url="sqlite:////tmp/whatever.db")
|
||||
|
||||
assert url == "sqlite:////tmp/whatever.db"
|
||||
assert url != "sqlite:///./foraldraledig.db"
|
||||
|
||||
|
||||
class TestCoerceSqliteAsync:
|
||||
def test_sqlite_url_coerced_to_aiosqlite(self) -> None:
|
||||
assert (
|
||||
coerce_sqlite_async("sqlite:///./foraldraledig.db")
|
||||
== "sqlite+aiosqlite:///./foraldraledig.db"
|
||||
)
|
||||
|
||||
def test_resolved_env_url_is_coerced(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("APP_SQLITE_PATH", "/tmp/scratch/other.db")
|
||||
|
||||
url = coerce_sqlite_async(resolve_database_url(x_args={}, ini_url=None))
|
||||
|
||||
assert url == "sqlite+aiosqlite:////tmp/scratch/other.db"
|
||||
|
||||
def test_non_sqlite_url_left_untouched(self) -> None:
|
||||
postgres_url = "postgresql+asyncpg://user:pass@host/db"
|
||||
assert coerce_sqlite_async(postgres_url) == postgres_url
|
||||
@@ -120,6 +120,101 @@ class TestReservedTransferableSplit:
|
||||
assert fetch.transferable_days_left == 0
|
||||
|
||||
|
||||
class TestTransferableDaysReported:
|
||||
"""`transferable_days_left` as FK's own reported figure (vs. the
|
||||
transferable-first fallback) — ground-truth numbers from real FK data,
|
||||
see the task that added this: user 129 reported -> reserved 10; partner
|
||||
71 reported -> reserved 0.
|
||||
"""
|
||||
|
||||
def test_fk_reported_transferable_user_figures(self) -> None:
|
||||
fetch = build_snapshot(
|
||||
**_ids(),
|
||||
source="scraped",
|
||||
total_days=240,
|
||||
sjukpenning_left=94,
|
||||
lagsta_niva_left=45,
|
||||
dubbeldagar_left=0,
|
||||
transferable_days_left=129,
|
||||
)
|
||||
assert fetch.days_left == 139
|
||||
assert fetch.reserved_days_left == 10
|
||||
assert fetch.transferable_days_left == 129
|
||||
assert fetch.transferable_days_reported is True
|
||||
|
||||
def test_fk_reported_transferable_partner_figures(self) -> None:
|
||||
fetch = build_snapshot(
|
||||
**_ids(),
|
||||
source="scraped",
|
||||
total_days=240,
|
||||
sjukpenning_left=26,
|
||||
lagsta_niva_left=45,
|
||||
dubbeldagar_left=0,
|
||||
transferable_days_left=71,
|
||||
)
|
||||
assert fetch.days_left == 71
|
||||
assert fetch.reserved_days_left == 0
|
||||
assert fetch.transferable_days_left == 71
|
||||
assert fetch.transferable_days_reported is True
|
||||
|
||||
def test_both_reserved_and_transferable_overrides_raises(self) -> None:
|
||||
with pytest.raises(ValidationError, match="mutually exclusive") as exc_info:
|
||||
build_snapshot(
|
||||
**_ids(),
|
||||
source="manual",
|
||||
total_days=240,
|
||||
sjukpenning_left=94,
|
||||
lagsta_niva_left=45,
|
||||
dubbeldagar_left=0,
|
||||
reserved_days_left=10,
|
||||
transferable_days_left=129,
|
||||
)
|
||||
assert exc_info.value.message_key == "parentalLeave.conflictingTransferableOverride"
|
||||
|
||||
def test_implausible_reported_transferable_raises(self) -> None:
|
||||
"""days_left is 139; a reported transferable of 200 would make
|
||||
reserved_days_left negative (139 - 200 = -61) — the existing
|
||||
negative-reserved-days validator must catch it."""
|
||||
with pytest.raises(ValidationError, match="negative") as exc_info:
|
||||
build_snapshot(
|
||||
**_ids(),
|
||||
source="scraped",
|
||||
total_days=240,
|
||||
sjukpenning_left=94,
|
||||
lagsta_niva_left=45,
|
||||
dubbeldagar_left=0,
|
||||
transferable_days_left=200,
|
||||
)
|
||||
assert exc_info.value.message_key == "parentalLeave.negativeReservedDaysLeft"
|
||||
|
||||
def test_derived_fallback_sets_transferable_days_reported_false(self) -> None:
|
||||
fetch = build_snapshot(
|
||||
**_ids(),
|
||||
source="manual",
|
||||
total_days=240,
|
||||
sjukpenning_left=60,
|
||||
lagsta_niva_left=40,
|
||||
dubbeldagar_left=60,
|
||||
)
|
||||
assert fetch.reserved_days_left == 90
|
||||
assert fetch.transferable_days_left == 10
|
||||
assert fetch.transferable_days_reported is False
|
||||
|
||||
def test_explicit_reserved_days_left_also_not_reported(self) -> None:
|
||||
"""A caller-supplied `reserved_days_left` is a user estimate too —
|
||||
only `transferable_days_left` counts as FK-reported."""
|
||||
fetch = build_snapshot(
|
||||
**_ids(),
|
||||
source="manual",
|
||||
total_days=240,
|
||||
sjukpenning_left=150,
|
||||
lagsta_niva_left=20,
|
||||
dubbeldagar_left=60,
|
||||
reserved_days_left=60,
|
||||
)
|
||||
assert fetch.transferable_days_reported is False
|
||||
|
||||
|
||||
class TestNegativeAndOverTotalGuards:
|
||||
def test_negative_sjukpenning_left_raises(self) -> None:
|
||||
with pytest.raises(ValidationError, match="negative"):
|
||||
|
||||
@@ -53,8 +53,25 @@ describe('ParentalLeaveBreakdown', () => {
|
||||
render(<ParentalLeaveBreakdown parents={[alex, samWithout]} />)
|
||||
const notes = screen.getAllByTestId('reserved-days-note')
|
||||
expect(notes).toHaveLength(1)
|
||||
expect(notes[0]).toHaveTextContent(`${LEAVE_RULES.reservedDaysPerParent} av varje förälders dagar är reserverade`)
|
||||
expect(notes[0]).toHaveTextContent(`max ${LEAVE_RULES.transferableDaysPerParent} dagar kan överlåtas`)
|
||||
expect(notes[0]).toHaveTextContent(
|
||||
`${LEAVE_RULES.reservedDaysPerParent} av varje förälders dagar är reserverade och kan inte överlåtas till den andra föräldern.`,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the transferable-days value plain when it was reported by FK', () => {
|
||||
render(<ParentalLeaveBreakdown parents={[alex]} />)
|
||||
const cell = screen.getByTestId('breakdown-transferable-pd-a')
|
||||
expect(cell).toHaveTextContent('50')
|
||||
expect(cell.querySelector('.badge')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the transferable-days value as an estimate when it was derived, not reported by FK', () => {
|
||||
const derived = { ...alex, data: { ...alexData, transferableDaysReported: false } }
|
||||
render(<ParentalLeaveBreakdown parents={[derived]} />)
|
||||
const cell = screen.getByTestId('breakdown-transferable-pd-a')
|
||||
expect(cell).toHaveTextContent('Antaget')
|
||||
expect(cell).toHaveTextContent('50')
|
||||
expect(cell.querySelector('.badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the scraped source and date per parent', () => {
|
||||
@@ -67,6 +84,13 @@ describe('ParentalLeaveBreakdown', () => {
|
||||
expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('Manuellt angiven')
|
||||
})
|
||||
|
||||
it('shows the deduced source label', () => {
|
||||
render(<ParentalLeaveBreakdown parents={[{ ...alex, data: { ...alexData, source: 'deduced' } }]} />)
|
||||
const provenance = screen.getByTestId('breakdown-provenance-pd-a')
|
||||
expect(provenance).toHaveTextContent('Härlett från Försäkringskassan')
|
||||
expect(provenance).not.toHaveTextContent('Manuellt angiven')
|
||||
})
|
||||
|
||||
it('formats the provenance date using the en-US locale when the language is English', () => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
vi.stubGlobal('localStorage', {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
import type { ParentalLeaveFetch } from '@/types'
|
||||
import type { ParentalLeaveFetch, ParentalLeaveSource } from '@/types'
|
||||
import { LEAVE_RULES } from '@/utils/constants'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
|
||||
/** One parent's balance snapshot — `data` is `null` when nothing has been
|
||||
* fetched or entered for them yet. */
|
||||
@@ -15,6 +15,13 @@ export interface ParentalLeaveBreakdownProps {
|
||||
parents: ParentalLeaveBreakdownParent[]
|
||||
}
|
||||
|
||||
/** Map parental leave source values to their i18n translation keys. */
|
||||
const SOURCE_LABEL_KEYS: Record<ParentalLeaveSource, TranslationKey> = {
|
||||
scraped: 'parentalLeaveBreakdown.sourceScraped',
|
||||
manual: 'parentalLeaveBreakdown.sourceManual',
|
||||
deduced: 'parentalLeaveBreakdown.sourceDeduced',
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw balance snapshot per parent (one column each): what is left of
|
||||
* each tier, transferable and double days, and where the figures came from.
|
||||
@@ -74,7 +81,18 @@ export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps)
|
||||
</tr>
|
||||
<tr data-testid="breakdown-transferable">
|
||||
<th scope="row">{t('parentalLeaveBreakdown.transferableDays')}</th>
|
||||
{parents.map((parent) => cell(parent, 'breakdown-transferable', (d) => d.transferableDaysLeft))}
|
||||
{parents.map((parent) =>
|
||||
cell(parent, 'breakdown-transferable', (d) => (
|
||||
<>
|
||||
{!d.transferableDaysReported && (
|
||||
<span className="badge badge--muted parental-leave-breakdown__badge">
|
||||
{t('parentalLeaveBreakdown.estimatedBadge')}
|
||||
</span>
|
||||
)}
|
||||
{d.transferableDaysLeft}
|
||||
</>
|
||||
)),
|
||||
)}
|
||||
</tr>
|
||||
<tr data-testid="breakdown-dubbeldagar">
|
||||
<th scope="row">{t('parentalLeaveBreakdown.dubbeldagarLeft')}</th>
|
||||
@@ -88,11 +106,7 @@ export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps)
|
||||
<li key={parent.personalDataUuid} data-testid={`breakdown-provenance-${parent.personalDataUuid}`}>
|
||||
<span className="parental-leave-breakdown__provenance-name">{parent.name}</span>{' '}
|
||||
{parent.data
|
||||
? `${
|
||||
parent.data.source === 'scraped'
|
||||
? t('parentalLeaveBreakdown.sourceScraped')
|
||||
: t('parentalLeaveBreakdown.sourceManual')
|
||||
} ${formatDate(parent.data.fetchedAt)}`
|
||||
? `${t(SOURCE_LABEL_KEYS[parent.data.source])} ${formatDate(parent.data.fetchedAt)}`
|
||||
: t('parentalLeaveBreakdown.noBalance')}
|
||||
</li>
|
||||
))}
|
||||
@@ -101,7 +115,6 @@ export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps)
|
||||
<p className="parental-leave-breakdown__reserved-note" data-testid="reserved-days-note">
|
||||
{t('parentalLeaveBreakdown.reservedNote', {
|
||||
reserved: LEAVE_RULES.reservedDaysPerParent,
|
||||
transferable: LEAVE_RULES.transferableDaysPerParent,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -106,11 +106,7 @@ describe('LanguageContext', () => {
|
||||
})
|
||||
|
||||
it('interpolates multiple params', () => {
|
||||
expect(
|
||||
translate('en', 'parentalLeaveBreakdown.reservedNote', { reserved: 90, transferable: 45 }),
|
||||
).toBe(
|
||||
'90 of each parent\'s days are reserved and cannot be transferred to the other parent (up to 45 days can be transferred).',
|
||||
)
|
||||
expect(translate('en', 'balance.statusLine', { left: 90, total: 240 })).toBe('90 of 240 days left')
|
||||
})
|
||||
|
||||
it('leaves an unmatched placeholder untouched', () => {
|
||||
|
||||
@@ -25,11 +25,13 @@ export const dashboard = {
|
||||
'overviewMetrics.defaultTitle': 'Overview',
|
||||
'parentalLeaveBreakdown.dubbeldagarLeft': 'Double days left',
|
||||
'parentalLeaveBreakdown.empty': 'No balances fetched yet.',
|
||||
'parentalLeaveBreakdown.estimatedBadge': 'Assumed',
|
||||
'parentalLeaveBreakdown.lagstaNiva': 'Minimum rate',
|
||||
'parentalLeaveBreakdown.leftOfTotal': '{left} of {total}',
|
||||
'parentalLeaveBreakdown.noBalance': 'No balance yet',
|
||||
'parentalLeaveBreakdown.reservedNote': '{reserved} of each parent\'s days are reserved and cannot be transferred to the other parent (up to {transferable} days can be transferred).',
|
||||
'parentalLeaveBreakdown.reservedNote': '{reserved} of each parent\'s days are reserved and cannot be transferred to the other parent.',
|
||||
'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå (income-based)',
|
||||
'parentalLeaveBreakdown.sourceDeduced': 'Derived from Försäkringskassan',
|
||||
'parentalLeaveBreakdown.sourceManual': 'Manually entered',
|
||||
'parentalLeaveBreakdown.sourceScraped': 'Fetched from Försäkringskassan',
|
||||
'parentalLeaveBreakdown.transferableDays': 'Transferable days',
|
||||
|
||||
@@ -25,11 +25,13 @@ export const dashboard = {
|
||||
'overviewMetrics.defaultTitle': 'Översikt',
|
||||
'parentalLeaveBreakdown.dubbeldagarLeft': 'Dubbeldagar kvar',
|
||||
'parentalLeaveBreakdown.empty': 'Inga saldon hämtade än.',
|
||||
'parentalLeaveBreakdown.estimatedBadge': 'Antaget',
|
||||
'parentalLeaveBreakdown.lagstaNiva': 'Lägstanivå',
|
||||
'parentalLeaveBreakdown.leftOfTotal': '{left} av {total}',
|
||||
'parentalLeaveBreakdown.noBalance': 'Inget saldo än',
|
||||
'parentalLeaveBreakdown.reservedNote': '{reserved} av varje förälders dagar är reserverade och kan inte överlåtas till den andra föräldern (max {transferable} dagar kan överlåtas).',
|
||||
'parentalLeaveBreakdown.reservedNote': '{reserved} av varje förälders dagar är reserverade och kan inte överlåtas till den andra föräldern.',
|
||||
'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå',
|
||||
'parentalLeaveBreakdown.sourceDeduced': 'Härlett från Försäkringskassan',
|
||||
'parentalLeaveBreakdown.sourceManual': 'Manuellt angiven',
|
||||
'parentalLeaveBreakdown.sourceScraped': 'Hämtad från Försäkringskassan',
|
||||
'parentalLeaveBreakdown.transferableDays': 'Överlåtbara dagar',
|
||||
|
||||
@@ -117,6 +117,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
|
||||
reserved_days_total: 90,
|
||||
reserved_days_left: 80,
|
||||
transferable_days_left: 100,
|
||||
transferable_days_reported: false,
|
||||
dubbeldagar_left: 60,
|
||||
tier_data: {
|
||||
sjukpenning_total: 390,
|
||||
@@ -149,6 +150,7 @@ describe('API response mappers (snake_case → camelCase)', () => {
|
||||
expect(result.reservedDaysTotal).toBe(90)
|
||||
expect(result.reservedDaysLeft).toBe(80)
|
||||
expect(result.transferableDaysLeft).toBe(100)
|
||||
expect(result.transferableDaysReported).toBe(false)
|
||||
expect(result.dubbeldagarLeft).toBe(60)
|
||||
expect(result.createdAt).toBe('2026-06-14T17:09:47')
|
||||
expect(result.updatedAt).toBe('2026-06-14T17:09:47')
|
||||
@@ -1068,6 +1070,7 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
reserved_days_total: 90,
|
||||
reserved_days_left: 90,
|
||||
transferable_days_left: 100,
|
||||
transferable_days_reported: false,
|
||||
dubbeldagar_left: 60,
|
||||
tier_data: {
|
||||
sjukpenning_total: 390,
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface RawParentalLeaveFetch {
|
||||
reserved_days_total: number
|
||||
reserved_days_left: number
|
||||
transferable_days_left: number
|
||||
transferable_days_reported: boolean
|
||||
dubbeldagar_left: number
|
||||
tier_data: RawTierData
|
||||
created_at: string
|
||||
@@ -103,6 +104,7 @@ function toParentalLeaveFetch(raw: RawParentalLeaveFetch): ParentalLeaveFetch {
|
||||
reservedDaysTotal: raw.reserved_days_total,
|
||||
reservedDaysLeft: raw.reserved_days_left,
|
||||
transferableDaysLeft: raw.transferable_days_left,
|
||||
transferableDaysReported: raw.transferable_days_reported,
|
||||
dubbeldagarLeft: raw.dubbeldagar_left,
|
||||
tierData: toTierData(raw.tier_data),
|
||||
createdAt: raw.created_at,
|
||||
|
||||
@@ -603,6 +603,10 @@
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.parental-leave-breakdown__badge {
|
||||
margin-right: var(--sp-1);
|
||||
}
|
||||
|
||||
.parental-leave-breakdown__reserved-note {
|
||||
margin: 0;
|
||||
max-width: 64ch;
|
||||
|
||||
@@ -127,7 +127,7 @@ export interface ParentalLeaveFetch {
|
||||
personalDataUuid: string
|
||||
workspaceId: string
|
||||
childId: string
|
||||
source: 'scraped' | 'manual' | 'deduced'
|
||||
source: ParentalLeaveSource
|
||||
fetchedAt: string
|
||||
totalDays: number
|
||||
daysUsed: number
|
||||
@@ -135,6 +135,12 @@ export interface ParentalLeaveFetch {
|
||||
reservedDaysTotal: number
|
||||
reservedDaysLeft: number
|
||||
transferableDaysLeft: number
|
||||
// True when `transferableDaysLeft` came straight from Försäkringskassan's
|
||||
// own figure (a scraped snapshot); false when it's derived from the
|
||||
// statutory reserved-days assumption (a manually-entered balance) — see
|
||||
// `LEAVE_RULES.transferableDaysPerParent`. The UI must not present the
|
||||
// derived figure as fact.
|
||||
transferableDaysReported: boolean
|
||||
dubbeldagarLeft: number
|
||||
tierData: TierData
|
||||
createdAt: string
|
||||
@@ -201,6 +207,8 @@ export interface PlannedDayExclusion {
|
||||
// avoid colliding with the `CalendarDay` component.
|
||||
export type CalendarDayType = 'red_day' | 'klamdag' | 'weekend' | 'working_day'
|
||||
|
||||
export type ParentalLeaveSource = 'scraped' | 'manual' | 'deduced'
|
||||
|
||||
export interface CalendarDayEntry {
|
||||
date: string // ISO-8601 date
|
||||
/** Exact holiday name (may be the combined "X; Söndag" form); null unless dayType === 'red_day'. */
|
||||
|
||||
Vendored
+1
@@ -117,6 +117,7 @@ export function makeParentalLeaveFetch(overrides: Partial<ParentalLeaveFetch> =
|
||||
reservedDaysTotal: 90,
|
||||
reservedDaysLeft: 70,
|
||||
transferableDaysLeft: 50,
|
||||
transferableDaysReported: true,
|
||||
dubbeldagarLeft: 55,
|
||||
tierData: makeTierData(),
|
||||
createdAt: '2026-05-01T10:00:00',
|
||||
|
||||
Reference in New Issue
Block a user