mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
test(articles): worker rollup + field-meta + consent-wall + recovery UI (#6,#14)
#6 — Worker test coverage on the deterministic helpers Three new bun-test files in apps/api/src/modules/articles/: - field-meta.test.ts (6 tests): pins down the legacy-vs-F3 fix so it can never regress silently — including the regression check from the live-test-found bug (string vs object compare across both shapes evaluates correctly). - consent-wall.test.ts (8 tests): the heuristic we extracted in #4. German + English vocab, wordcount threshold + the boundary case, case-insensitivity. - import-worker.test.ts (5 tests): countByState rollup. Pins down the consent-wall-counts-as-saved semantics so the progress bar doesn't off-by-one and allTerminal stays correct. Total 19 bun tests, all green. countByState + StateCounts exported (test-only access). #14 — Consent-wall recovery UI in JobDetailView Bulk-import items that hit a cookie-wand land as state='consent-wall' with the teaser saved. Before this commit there was no UX path to "rescue" them other than navigating to the article and re-saving manually. Now: - Job-level hint banner appears when warningCount > 0, explaining the cookie-wand semantics + linking to /articles/settings (where the v2 bookmarklet lives). - Per-item action group on consent-wall rows: "Teaser ansehen" (open existing article) + "Erneut speichern" (deep-link to /articles/add?source=bookmarklet&url=… so the bookmarklet's postMessage handshake has the URL pre-populated). Plan: docs/plans/articles-bulk-import.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f805d9da2
commit
e8774fc233
5 changed files with 222 additions and 3 deletions
47
apps/api/src/modules/articles/consent-wall.test.ts
Normal file
47
apps/api/src/modules/articles/consent-wall.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, it, expect } from 'bun:test';
|
||||
import { looksLikeConsentWall } from './consent-wall';
|
||||
|
||||
describe('looksLikeConsentWall', () => {
|
||||
it('flags short text containing German consent vocabulary', () => {
|
||||
const text =
|
||||
'Cookies zustimmen — Wir und unsere Partner speichern Informationen auf einem Endgerät.';
|
||||
expect(looksLikeConsentWall(text, 14)).toBe(true);
|
||||
});
|
||||
|
||||
it('flags short English consent dialogs', () => {
|
||||
const text = 'Please accept all cookies to continue using this website.';
|
||||
expect(looksLikeConsentWall(text, 9)).toBe(true);
|
||||
});
|
||||
|
||||
it('flags JavaScript-disabled walls', () => {
|
||||
const text = 'JavaScript is disabled. Please enable JavaScript to continue.';
|
||||
expect(looksLikeConsentWall(text, 7)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT flag long articles even if they mention cookies', () => {
|
||||
// Long-form article that happens to mention cookies in body. The
|
||||
// heuristic only fires below the wordcount threshold (300) so a
|
||||
// real article about cookies isn't misclassified.
|
||||
const text = 'cookie consent ' + 'lorem '.repeat(400);
|
||||
expect(looksLikeConsentWall(text, 800)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT flag short text without consent vocabulary', () => {
|
||||
const text = 'A short blog post about hiking trails in the Black Forest.';
|
||||
expect(looksLikeConsentWall(text, 11)).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const text = 'COOKIES ZUSTIMMEN — KLICKE HIER';
|
||||
expect(looksLikeConsentWall(text, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on empty content', () => {
|
||||
expect(looksLikeConsentWall('', 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false at exactly the wordcount threshold (boundary check)', () => {
|
||||
const text = 'cookie consent ' + 'lorem '.repeat(300);
|
||||
expect(looksLikeConsentWall(text, 300)).toBe(false);
|
||||
});
|
||||
});
|
||||
51
apps/api/src/modules/articles/field-meta.test.ts
Normal file
51
apps/api/src/modules/articles/field-meta.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, it, expect } from 'bun:test';
|
||||
import { fieldMetaTime } from './field-meta';
|
||||
|
||||
describe('fieldMetaTime — wire-shape adapter for sync_changes.field_meta', () => {
|
||||
it('passes through legacy plain ISO strings unchanged', () => {
|
||||
expect(fieldMetaTime('2026-04-28T21:14:30.000Z')).toBe('2026-04-28T21:14:30.000Z');
|
||||
});
|
||||
|
||||
it('extracts the `at` field from F3 object stamps', () => {
|
||||
expect(
|
||||
fieldMetaTime({
|
||||
at: '2026-04-28T21:14:30.000Z',
|
||||
actor: { kind: 'system', principalId: 'system:foo', displayName: 'Foo' },
|
||||
origin: 'system',
|
||||
})
|
||||
).toBe('2026-04-28T21:14:30.000Z');
|
||||
});
|
||||
|
||||
it('returns "" for undefined / null (so callers can fall back)', () => {
|
||||
expect(fieldMetaTime(undefined)).toBe('');
|
||||
expect(fieldMetaTime(null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" for malformed objects without an at-string', () => {
|
||||
expect(fieldMetaTime({})).toBe('');
|
||||
expect(fieldMetaTime({ at: 12345 })).toBe('');
|
||||
expect(fieldMetaTime({ at: null })).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" for non-string non-object inputs', () => {
|
||||
expect(fieldMetaTime(42)).toBe('');
|
||||
expect(fieldMetaTime(true)).toBe('');
|
||||
expect(fieldMetaTime([])).toBe('');
|
||||
});
|
||||
|
||||
// Regression: this is the bug that triggered the cross-service fix.
|
||||
// Before fieldMetaTime, a string >= object compare evaluated to false
|
||||
// stably and the older value won. Now both shapes fold to comparable
|
||||
// ISO strings.
|
||||
it('makes string-vs-object comparison work correctly across both shapes', () => {
|
||||
const earlierLegacy = '2026-04-28T21:00:00.000Z';
|
||||
const laterF3 = {
|
||||
at: '2026-04-28T22:00:00.000Z',
|
||||
actor: { kind: 'user', principalId: 'u', displayName: 'Du' },
|
||||
origin: 'user',
|
||||
};
|
||||
// The F3 stamp is later in time, so its normalised form must
|
||||
// compare strictly greater than the legacy stamp.
|
||||
expect(fieldMetaTime(laterF3) > fieldMetaTime(earlierLegacy)).toBe(true);
|
||||
});
|
||||
});
|
||||
80
apps/api/src/modules/articles/import-worker.test.ts
Normal file
80
apps/api/src/modules/articles/import-worker.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, it, expect } from 'bun:test';
|
||||
import { countByState } from './import-worker';
|
||||
import type { ImportItemRow } from './import-projection';
|
||||
|
||||
function item(state: ImportItemRow['state'], idx = 0): ImportItemRow {
|
||||
return {
|
||||
id: `i-${idx}`,
|
||||
userId: 'u-1',
|
||||
spaceId: 'sp-1',
|
||||
jobId: 'j-1',
|
||||
idx,
|
||||
url: `https://example.com/${idx}`,
|
||||
state,
|
||||
articleId: null,
|
||||
warning: null,
|
||||
error: null,
|
||||
attempts: 0,
|
||||
lastAttemptAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('countByState — worker job-counter rollup', () => {
|
||||
it('returns zeros for empty input + allTerminal=false', () => {
|
||||
const c = countByState([]);
|
||||
expect(c).toEqual({
|
||||
saved: 0,
|
||||
duplicate: 0,
|
||||
error: 0,
|
||||
consentWall: 0,
|
||||
cancelled: 0,
|
||||
allTerminal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts each terminal state independently', () => {
|
||||
const c = countByState([
|
||||
item('saved', 0),
|
||||
item('saved', 1),
|
||||
item('duplicate', 2),
|
||||
item('error', 3),
|
||||
item('cancelled', 4),
|
||||
]);
|
||||
expect(c.saved).toBe(2);
|
||||
expect(c.duplicate).toBe(1);
|
||||
expect(c.error).toBe(1);
|
||||
expect(c.cancelled).toBe(1);
|
||||
expect(c.allTerminal).toBe(true);
|
||||
});
|
||||
|
||||
it('treats consent-wall as semantically saved (so progress UI advances)', () => {
|
||||
// One real-saved + two consent-wall = three "saved" from the
|
||||
// user's perspective, but the warning counter tracks the wall hits.
|
||||
const c = countByState([item('saved', 0), item('consent-wall', 1), item('consent-wall', 2)]);
|
||||
expect(c.saved).toBe(3);
|
||||
expect(c.consentWall).toBe(2);
|
||||
expect(c.allTerminal).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag allTerminal when any item is non-terminal', () => {
|
||||
const states: ImportItemRow['state'][] = ['pending', 'extracting', 'extracted'];
|
||||
for (const nonTerminal of states) {
|
||||
const c = countByState([item('saved', 0), item(nonTerminal, 1)]);
|
||||
expect(c.allTerminal).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the saved + consent-wall sum when both are present', () => {
|
||||
// Regression check: saved must include consent-wall items so the
|
||||
// finished-counter UI doesn't off-by-one.
|
||||
const c = countByState([
|
||||
item('saved', 0),
|
||||
item('saved', 1),
|
||||
item('consent-wall', 2),
|
||||
item('error', 3),
|
||||
]);
|
||||
expect(c.saved).toBe(3); // 2 saved + 1 consent-wall
|
||||
expect(c.consentWall).toBe(1);
|
||||
expect(c.error).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -272,7 +272,7 @@ async function processOneJob(job: ImportJobRow): Promise<number> {
|
|||
return claimable.length;
|
||||
}
|
||||
|
||||
interface StateCounts {
|
||||
export interface StateCounts {
|
||||
saved: number;
|
||||
duplicate: number;
|
||||
error: number;
|
||||
|
|
@ -281,7 +281,7 @@ interface StateCounts {
|
|||
allTerminal: boolean;
|
||||
}
|
||||
|
||||
function countByState(items: readonly ImportItemRow[]): StateCounts {
|
||||
export function countByState(items: readonly ImportItemRow[]): StateCounts {
|
||||
let saved = 0;
|
||||
let duplicate = 0;
|
||||
let error = 0;
|
||||
|
|
|
|||
|
|
@ -161,13 +161,34 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{#if j.warningCount > 0}
|
||||
<aside class="consent-hint" role="note">
|
||||
<strong>Cookie-Wand erkannt</strong>: {j.warningCount}
|
||||
{j.warningCount === 1 ? 'Artikel' : 'Artikel'} hat nur den Cookie-Zustimmungs-Dialog gespeichert
|
||||
(der Server sieht keine Cookies). Mit dem
|
||||
<a href="/articles/settings">Browser-HTML-Bookmarklet</a> aus dem Tab in dem du dem Cookie zugestimmt
|
||||
hast überschreibst du den Teaser durch den echten Artikel.
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<ul class="items">
|
||||
{#each items as item (item.id)}
|
||||
{@const pill = statePill(item.state)}
|
||||
<li class="item">
|
||||
<span class="pill {pill.klass}">{pill.label}</span>
|
||||
<span class="url" title={item.url}>{shortUrl(item)}</span>
|
||||
{#if item.articleId && (item.state === 'saved' || item.state === 'consent-wall' || item.state === 'duplicate')}
|
||||
{#if item.state === 'consent-wall' && item.articleId}
|
||||
<span class="action-group">
|
||||
<a class="action" href="/articles/{item.articleId}">Teaser ansehen</a>
|
||||
<a
|
||||
class="action action-rescue"
|
||||
href={`/articles/add?source=bookmarklet&url=${encodeURIComponent(item.url)}`}
|
||||
title="Mit Bookmarklet erneut speichern — überschreibt den Teaser durch den echten Artikel"
|
||||
>
|
||||
Erneut speichern
|
||||
</a>
|
||||
</span>
|
||||
{:else if item.articleId && (item.state === 'saved' || item.state === 'duplicate')}
|
||||
<a class="action" href="/articles/{item.articleId}">Öffnen</a>
|
||||
{:else if item.state === 'error' && item.error}
|
||||
<span class="error-msg" title={item.error}>{item.error}</span>
|
||||
|
|
@ -365,6 +386,26 @@
|
|||
.action:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.action-group {
|
||||
display: inline-flex;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.action-rescue {
|
||||
color: #b45309;
|
||||
}
|
||||
.consent-hint {
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 8%, transparent);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.consent-hint a {
|
||||
color: #b45309;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.error-msg {
|
||||
font-size: 0.78rem;
|
||||
color: #ef4444;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue