Skip to content

fix(notifications): four defects a live QA walk turned up - #22

Merged
anilcancakir merged 5 commits into
masterfrom
fix/notification-qa-round
Sep 2, 2026
Merged

fix(notifications): four defects a live QA walk turned up#22
anilcancakir merged 5 commits into
masterfrom
fix/notification-qa-round

Conversation

@anilcancakir

Copy link
Copy Markdown
Contributor

Found by driving the notification system end to end against a real Chrome on a consumer app (uptizm), at desktop and mobile width both. Each fix is pinned by a test that goes red on the pre-fix code; every mutation check is quoted below.

1. BREAKING: a declined delete cost a full page refetch

NotificationsListView.onDelete is now Future<bool> Function(String id)?.

The list is a separately paginated fetch, so a real delete has to be followed by a reload: a row leaving page one pulls one up from page two, and only the server knows which. With no result to read, the row reloaded after every tap, so a host that asks for confirmation (which magic_starter does) spent a full GET /notifications every time somebody declined.

  • true means the row is gone: reload.
  • false means the host chose not to go ahead: nothing is re-read.
  • A throw is a third outcome and deliberately not the same as false: the manager removes the row optimistically and puts it back on failure, so what the server still holds is unknown and the list reloads.

Migration is one line (return true) for a callback that always deletes. The package's own seeded default is already updated.

2. notifications.database.polling_interval was never read

Validated by the CLI, reported by notifications:doctor, shipped in every install stub, ignored by the runtime: NotificationPoller(this) was constructed with no argument on both routes onto it (the explicit start and the realtime-drop fallback), so its own 30-second default always won. A consumer who set 10 got 30 with nothing to say why.

A missing, non-numeric or non-positive value falls back to 30 rather than throwing: this is a timer a consumer wired to its auth state, and a mistyped config value must not take notification delivery down. Zero and negatives are refused specifically because Timer.periodic accepts them and then fires on every event-loop turn.

3. The sms channel rendered as "Sms", in every locale

_channelLabel named mail, database and push and let everything else fall to a helper that raises the machine name's first letter. magic-starter-laravel offers sms in the matrix out of the box, so that fallback was reachable on a default install: three properly localised rows with an untranslated machine name beside them. Seen live on a Turkish screen next to "E-posta", "Uygulama İçi" and "Anlık Bildirim".

The fallback stays: a host can register a channel of its own, and the machine name is the only thing available for it. There is a test for that path too.

4. The delete control had no accessible name

A bare glyph in a WAnchor with no label, so a screen reader announced "button" on every row and an E2E driver had no handle to resolve it by.

Hosts must add two keys

notifications.channel_sms and notifications.delete. Both render as the raw key without them, and both are noted in the CHANGELOG.

Verification

  • flutter test: 595 passed (588 before, 7 new)
  • dart analyze: no issues
  • dart format --output=none --set-exit-if-changed lib test: 0 changed
  • Mutation checks, each turning exactly its own test red and nothing else:
    • reload unconditionally again → a declined delete costs no reload fails
    • drop semanticLabelthe delete control has an accessible name fails
    • NotificationPoller(this) with no interval → comes from config, not from the poller default fails
    • drop the non-positive guard → falls back to 30 seconds when the configured value cannot fire fails
    • drop the 'sms' arm → every offered channel renders a translated label fails, while the unknown-channel fallback test stays green

One note on the accessibility test: it reads the annotation off the widget tree rather than through find.bySemanticsLabel. That finder resolves against renderObject.debugSemantics, which is only populated once the semantics pipeline has run, and it answers "none found" rather than complaining when it has not, so it reported zero whether or not the label was there. That is a green test in reverse, and it is why the assertion is shaped the way it is.

Release note

The onDelete signature is a breaking change on a 0.1.0 package, so this wants 0.2.0, not 0.1.1. magic_starter pins ^0.1.0 and will need its pin moved plus a one-line change in _confirmThenDelete to return the bool (a companion PR follows).

Author: Anılcan Çakır anilcan.cakir@gmail.com

Driven end to end against a real Chrome on a consumer app, then each
one pinned by a test that fails on the pre-fix code.

BREAKING: NotificationsListView.onDelete returns Future<bool>.

1. A declined delete cost a full page refetch. The list is a separately
   paginated fetch, so a real delete has to be followed by a reload; with
   no result to read, the row reloaded after every tap and a host that
   asks for confirmation spent a GET every time somebody said no. true
   reloads, false does not, a throw still reloads because the server's
   own state is then unknown.

2. notifications.database.polling_interval was never read. The poller was
   constructed with no argument on both routes onto it, so its 30-second
   default always won while the CLI validated the key, doctor reported it
   and every install stub shipped it. Non-positive values are refused:
   Timer.periodic accepts zero and then fires continuously.

3. The sms channel rendered as "Sms", untranslated, in every locale.
   magic-starter-laravel offers sms out of the box, so the machine-name
   fallback was reachable on a default install.

4. The delete control had no accessible name, so a screen reader
   announced a bare "button" on every row.

Hosts must add notifications.channel_sms and notifications.delete.

595 tests pass, analyze clean, format clean.
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/src/facades/notify.dart 33.33% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@kodizm

kodizm Bot commented Sep 2, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

All four fixes look correct and each is pinned by a test; I found nothing that blocks merge, only four minor points.

I read all 8 changed files (nothing was dropped for size), traced the only in-package caller of the new onDelete signature (lib/src/facades/notify.dart:85 via _deleteAndReport) and both NotificationPoller construction sites (lib/src/notification_manager.dart:1758, :1964). stopPolling() nulls _poller, so the ??= re-reads the interval on the next start rather than pinning a stale one, and Config.get<int> type-checks (config_repository.dart:75, current is T) rather than casting, so a non-int value really does fall back instead of throwing, as the description claims.

Minor

doc/architecture/notification-manager.md:371 — maintainability: CLAUDE.md's post-change checklist asks for CHANGELOG.md and README.md/doc/ to be synced. The breaking onDelete contract and the two newly required host keys (notifications.channel_sms, notifications.delete) exist only in the changelog; this doc still describes the row's delete path without the true/false/throw contract, so a host reading the docs gets the old story.

lib/src/notification_manager.dart:1776 — the guard is configured <= 0, but notifications:doctor (lib/src/cli/commands/doctor_command.dart:185) and doc/getting-started/configuration.md:255 both declare 5-600 the valid range. 'polling_interval': 1 now genuinely fires a GET /notifications every second on every client, where before the ignored key accidentally capped it at 30. Clamping to the range the CLI already enforces would make the two agree.

lib/src/notification_manager.dart:1776 — a key that is present but unusable ('30' from a string source, or 30.0) returns null from Config.get<int> and falls to 30 with nothing logged, which is the same silent-ignore shape this PR is fixing. One NotificationLog line on the fallback-when-set path would keep it diagnosable. (The shipped stub uses an int literal, so this is only reachable if a host edits the value.)

test/notification_manager_test.dart:409 — the interval test depends on wall clock: 3 reads expected inside 2400 ms of a 1-second timer, so ~400 ms of slack on a loaded runner, and it adds ~3.6 s to the suite between the two cases. fakeAsync (or asserting on the constructed interval) would be steadier for the same mutation coverage.

Tests

Well covered: the declined/completed reload split, the semantic label, config-driven interval, the non-positive fallback, the sms arm and the still-live unknown-channel fallback each have a case, and the note about find.bySemanticsLabel answering "none found" is right - reading Semantics.properties.label off the tree is the honest assertion here.

Checks I ran

  • flutter pub get - resolved (magic 0.0.9).
  • flutter analyze --no-fatal-infos - No issues found!
  • dart format --output=none --set-exit-if-changed lib test - Formatted 97 files (0 changed)
  • flutter test - 00:57 +595: All tests passed! (matches the claimed 595).
  • flutter test on the three touched test files - 35 passed.
  • Did not run the mutation checks quoted in the description; I cannot edit files, so those remain the author's report.

All four were real.

1. The runtime now enforces the 5 to 600 second polling range that
   notifications:doctor and the configuration docs have always published.
   While the key was ignored it never had to agree with them, and reading
   it without the range removed an accidental safety net: a stub saying 1
   used to be capped at 30 by the bug and would now genuinely fire every
   second. Out of range clamps to the nearest bound rather than falling
   back to the default, so 1 becomes 5 instead of jumping to 30.

2. A value that is present but unusable is logged instead of silently
   substituted. Config.get<int> type-checks rather than casting, so '30'
   or 30.0 reads as absent, and quietly using a different number is the
   same shape of defect this change set is fixing.

3. The interval test no longer sleeps. It was three reads inside 2400 ms
   of a real one-second timer, so about 400 ms of slack on a loaded
   runner and 3.6 s added to the suite. Replaced by six exact cases on
   the new public pollingInterval getter plus one fakeAsync case that
   elapses the clock, which is what pins the WIRING: reverting the
   poller to ignore the getter turns only that case red, so the unit
   cases alone would have missed it.

4. doc/architecture/notification-manager.md carried the old delete story
   and neither of the two new host keys; doc/getting-started/
   configuration.md said the runtime used a 30-second default. Both
   updated, with the true/false/throw contract as a table.

600 tests pass, analyze clean, format clean.
@kodizm

kodizm Bot commented Sep 2, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Incremental re-review of 8f816c2: all four minor points from my last pass are closed, the suite is green at 600, and I have one small doc-accuracy nit left.

Since my last review: the range is now enforced at runtime by clamping to 5-600 (_minPollingSeconds/_maxPollingSeconds, sourced to doctor_command.dart in the doc comment), a set-but-unusable value logs a NotificationLog.warning instead of falling to 30 silently, the two wall-clock interval tests were replaced by six exact unit cases plus one fakeAsync wiring test, and both doc/architecture/notification-manager.md and doc/getting-started/configuration.md now carry the true/false/throw contract and the required host keys. _pollingInterval became public as pollingInterval, which the changelog notes.

I verified the pieces the new code leans on rather than assuming them: Config.has and Config.forget both exist in magic 0.0.9 (facades/config.dart:87, :133), and NotificationLog.warning is guarded by Magic.bound('log') (support/notification_log.dart:45), so the new warning cannot throw in a host with no log bound. Both construction sites now pass the getter (notification_manager.dart:1758, :2013), and the realtime path nulls _poller before the fallback route rebuilds it (:1998), so the fallback really does read fresh config.

Minor

doc/getting-started/configuration.md:263 — maintainability: "The runtime reads this key on every startPolling()" is slightly stronger than the code. _poller ??= ... only re-reads when _poller is null, which stopPolling() and the realtime handoff do but pausePolling() does not, so Config.set(...)pausePolling()startPolling() keeps the interval the poller was built with. "on every start after a stopPolling()" would match notification_manager.dart:1758.

Tests

Well covered, and better than before: absent, in-range, below-floor, zero/negative, above-ceiling and wrong-type each get their own assertion on pollingInterval, and the fakeAsync case pins the wiring the unit cases cannot see (immediate read, then 3 ticks in 16 s of a 5 s timer). The group tearDown forgets the key, so the Config.set inside fakeAsync cannot leak into later tests even if an expect fails first.

Checks I ran

  • flutter pub get - resolved (magic 0.0.9, fake_async 1.3.0 now direct).
  • flutter analyze --no-fatal-infos - No issues found! (ran in 17.0s)
  • dart format --output=none --set-exit-if-changed lib test - Formatted 97 files (0 changed)
  • flutter test - 01:08 +600: All tests passed! (595 before this commit, so 6 net new cases replacing 2).
  • Did not re-run the mutation checks; I cannot edit files, so those remain the author's report.

The review was right that "on every startPolling()" overstated it.
`_poller ??= ...` only re-reads when the poller is null, and only
stopPolling() and the realtime handoff null it. pausePolling() keeps the
instance, so a Config.set between a pause and a resume changes nothing,
which the doc now says outright along with what to call instead.
@kodizm

kodizm Bot commented Sep 2, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Incremental re-review of 85ac5ff: one documentation-only commit that closes the last minor point from my previous pass. No findings.

The only file changed since 8f816c2 is doc/getting-started/configuration.md (+14/-6). It replaces "reads this key on every startPolling()" with the accurate statement that the key is read when the poller is built - first startPolling() and any startPolling() after a stopPolling() - and adds an explicit paragraph that pausePolling()/resumePolling() keep the same instance and therefore the interval it was constructed with.

I checked that against the code rather than taking the wording on trust: stopPolling() nulls _poller (lib/src/notification_manager.dart:1842) while pausePolling() only calls pause() (:1849), and the realtime-drop path does _poller?.stop(); _poller = null; before the reconnect branch rebuilds with _poller ??= NotificationPoller(this, interval: pollingInterval) (:1999, :2013). The doc's parenthetical "that path stops the poller first" is correct. No behavioural change in this commit.

Tests

No test change, and none needed: this commit touches only prose. The interval behaviour it describes is already covered by the six unit cases and the fakeAsync wiring test added in 8f816c2.

Checks I ran

  • git log --oneline - confirmed 85ac5ff is the only commit on top of 8f816c2.
  • Read notification_manager.dart:1838-1870 and :1990-2016 to confirm the doc matches the poller lifecycle.
  • Did not re-run flutter test / analyze / format: no Dart or config file changed since the green run I reported on 8f816c2 (600 passed, no analyzer issues, 0 files reformatted).

…elog with the reason

Three points from an independent review, all real.

1. pollingInterval is PUBLIC and logged on read, so a consumer surfacing
   it in a build() would write a line per frame, and the realtime
   handoff rebuilds the poller on every socket drop, so a flapping
   connection repeated the same warning for as long as it flapped and
   buried the incident it was flapping over. The getter is now pure; a
   private resolver returns the interval and, separately, whatever is
   wrong with it, and the construction path issues that at most once per
   manager.

2. The polling-interval change is a behaviour change on upgrade and was
   filed only under Fixed, which is not where somebody planning an
   upgrade looks. It now leads Breaking Changes, naming both directions:
   an app configured at 5 issues six times the requests it did
   yesterday, and an app configured at 3600 is clamped to 600, so its
   bell can be ten minutes stale where it used to be thirty seconds.

3. The onDelete entry led with the avoided HTTP request, which is a
   consequence rather than the reason and invites the obvious objection.
   It now leads with the reason: onDelete is an opaque host callback, so
   a host may delete by any route and never touch deleteNotification,
   and the return value is the only channel that can answer whether the
   row left.

601 tests, analyze clean, format clean. A getter that logs again, and
dropping the once-only flag, each turn the new case red.
@kodizm

kodizm Bot commented Sep 2, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Incremental re-review of 01c5fb9: the getter is now side-effect free and the warning moved to poller construction; suite green at 601. One minor point.

Since 85ac5ff: pollingInterval delegates to a pure _resolvePollingInterval() returning (interval, warning), _pollingIntervalForPoller() emits the warning at most once per manager and is used at both construction sites (lib/src/notification_manager.dart:1758, :2045), and the changelog gains a Breaking entry for the key now taking effect. I checked that the resolution logic itself is unchanged by the refactor - absent, unusable-but-present, clamp and in-range all return the same durations as before, which the six existing unit cases still assert.

Minor

lib/src/notification_manager.dart:1827 — maintainability: _warnedAboutPollingInterval is one flag for two distinct warnings, so once either has fired the other never will for the life of the manager. Concretely: a host that starts polling with '30' (wrong type, warns), then does Config.set(key, 1)stopPolling()startPolling(), silently gets the 5-second clamp with no line. Narrow - it needs a runtime config change - and de-duplicating per warning text (or per resolved warning value) would close it.

Tests

The new case is the right shape: ten getter reads assert assertNothingLogged(), then warnings are counted by content (contains('Clamping')) rather than by total, so the Http.fake immediate read cannot pass or fail it by accident, and the stop/start pair pins the once-only guard. The suppression-across-different-warnings path above is the one branch of the flag with no coverage.

Checks I ran

  • flutter analyze --no-fatal-infos - No issues found! (ran in 15.5s)
  • dart format --output=none --set-exit-if-changed lib test - Formatted 97 files (0 changed)
  • flutter test - 01:06 +601: All tests passed! (600 before this commit, so 1 net new case).
  • flutter test test/notification_manager_test.dart - 25 passed, including the getter is pure, and the warning is issued once.
  • Did not run the mutation checks; I cannot edit files, so those remain the author's report.

The review caught that one boolean was suppressing two distinct
warnings: once either had fired the other never would for the life of
the manager. Concretely, a host starting with a wrong-typed '30' (which
warns), then setting the key to 1 and restarting polling, took the
five-second clamp silently.

De-duplicated on the warning TEXT instead. The repeat this guard exists
to stop is the SAME line on every socket flap, and comparing the text
stops exactly that while leaving a different reason free to speak.

602 tests. Reverting to the single-flag behaviour turns the new case
red, and nothing else.
@kodizm

kodizm Bot commented Sep 2, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Incremental re-review of 8c36f81: the last minor point from my previous pass is closed, suite green at 602. No findings.

Since 01c5fb9, only two files changed: the bool _warnedAboutPollingInterval flag becomes String? _lastPollingIntervalWarning, de-duplicated on the warning text, plus one test. I checked the two behaviours that matter against lib/src/notification_manager.dart:1841-1876: the two warning strings are distinct by construction (Ignoring … vs Clamping …), so the wrong-type → clamp transition really does get through, and a repeated identical resolution still emits once, which is the flapping-socket case the doc comment names. Comparing on the full text (which interpolates the configured value) also means 1 → 5 and 2 → 5 each warn once rather than the second being swallowed as "same problem"; that reads as the intended behaviour, not a defect.

The only nuance left is that an A → B → A config sequence would re-emit A, since only the last warning is remembered. That is the correct answer for a real config change, so I am not filing it.

Tests

a second, different warning is not suppressed by the first pins exactly the branch I flagged last time: wrong-typed value warns once, then a stop/start with an out-of-range value produces the clamp line while the first is not repeated, counted by content (contains('Ignoring') / contains('Clamping')) so the Http.fake immediate read cannot skew it. That leaves no uncovered branch of the new de-dup.

Checks I ran

  • flutter analyze --no-fatal-infos - No issues found! (ran in 15.5s)
  • dart format --output=none --set-exit-if-changed lib test - Formatted 97 files (0 changed)
  • flutter test - +602: All tests passed! (601 before this commit, so 1 net new case).
  • flutter test test/notification_manager_test.dart - 26 passed, including the new case.
  • Did not run the mutation checks; I cannot edit files, so those remain the author's report.

@anilcancakir

Copy link
Copy Markdown
Contributor Author

Held deliberately, not stalled. Review is clean and the gate is CLEAN; this waits on a release step rather than on anything in the diff.

Merging this alone puts a compile error on magic_starter's default branch. Measured rather than reasoned, by pointing magic_starter main's override at this branch and analysing:

error - src/routes/notification_routes.dart:85:39 - The argument type
'Future<void> Function(String)' can't be assigned to the parameter type
'Future<bool> Function(String)?'

uptizm inherits it, because it resolves both packages by path and compiles magic_starter from source. So the window between this merging and magic_starter#122 landing is a window where two default branches cannot build, and only a publish can close it, since #122's pin is ^0.2.0.

Sequence when the release happens, each step verified before the next:

  1. Merge this PR.
  2. chore(release): 0.2.0 on magic_notifications, then the annotated tag that fires the OIDC publish. 0.2.0, not 0.1.1: the Breaking Changes block carries the onDelete signature and the polling interval now taking effect, and pub's ^0.1.0 would admit a 0.1.1 into every consumer that pinned it.
  3. Confirm 0.2.0 is live on pub.
  4. Mark fix(notifications): answer whether the confirmed delete went ahead magic_starter#122 ready. Its published CI job is the only gate that can catch an unresolvable pin, and it cannot go green before step 3.
  5. Merge #122, release magic_starter, then move consumer pins.

One thing NOT to do in the meantime: relax #122's pin back to ^0.1.0 to get it green early. Its lib/ does compile against the published signature (I checked), but its tests do not, because they read view.onDelete's return type. That route costs a temporary cast or three weakened assertions plus a PR to undo them, which is more moving parts than waiting.

@anilcancakir
anilcancakir merged commit ba5c92e into master Sep 2, 2026
4 checks passed
@anilcancakir
anilcancakir deleted the fix/notification-qa-round branch September 2, 2026 21:17
@anilcancakir anilcancakir mentioned this pull request Sep 2, 2026
anilcancakir added a commit that referenced this pull request Sep 2, 2026
Cuts 0.2.0, carrying the delete-affordance repair merged in #21 and the
QA round merged in #22.

Minor rather than patch: three breaking changes, listed in CHANGELOG.md.
Two are API shape and one is behaviour that changes on upgrade with no
code edit at all.

  - NotificationsListView.onDelete becomes Future<bool>. A host passing
    a Future<void> callback no longer compiles. The widget had no way to
    learn whether the row left, and the list is a separately paginated
    fetch, so it reloaded after every tap: a host that asks for
    confirmation spent a GET /notifications every time somebody
    declined.
  - deleteNotification rethrows a failed request instead of completing
    normally, so a caller can finally tell a delete that worked from one
    that did not.
  - notifications.database.polling_interval now takes effect. It was
    validated by the CLI, reported by notifications:doctor and shipped
    in every install stub while the runtime read nobody, so every
    install effectively polled every 30 seconds. Both directions move on
    upgrade: an app configured at 5 issues six times the requests, and
    one configured at 3600 is clamped to 600.

Two host keys are newly required, notifications.channel_sms and
notifications.delete; both render as the raw key without them and both
are called out in the changelog.

Six version sites, swept by SHAPE rather than by the old number, which
is the same lesson 0.1.0 recorded: doc/getting-started/installation.md
needed no change last time because it was already ahead, so a grep for
the current version finds the wrong set. The provider constant is a
sixth site the release command's own table does not list, and every
previous release has bumped it. The ^0.0.1 in
test/cli/commands/uninstall_getter_test.dart is deliberately untouched:
it is fixture data for a temp app's pubspec, not a version site.

602 tests, analyze clean, format clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant