Skip to content

Commit 54ffff4

Browse files
committed
test: add regression-focused coverage for qr and history use cases
1 parent 26def36 commit 54ffff4

4 files changed

Lines changed: 202 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { ClearHistoryUseCase } from '@/services/usecases/history/ClearHistoryUseCase';
2+
import { DeleteHistoryItemUseCase } from '@/services/usecases/history/DeleteHistoryItemUseCase';
3+
import { GetHistoryUseCase } from '@/services/usecases/history/GetHistoryUseCase';
4+
import { SaveGeneratedEntryUseCase } from '@/services/usecases/history/SaveGeneratedEntryUseCase';
5+
import { SaveScanEntryUseCase } from '@/services/usecases/history/SaveScanEntryUseCase';
6+
import type { IHistoryRepository } from '@/repositories/contracts/IHistoryRepository';
7+
import type { HistoryEntry } from '@/types';
8+
9+
class InMemoryHistoryRepository implements IHistoryRepository {
10+
public items: HistoryEntry[] = [];
11+
12+
async getAll(): Promise<HistoryEntry[]> {
13+
return [...this.items];
14+
}
15+
16+
async getLatest(limit: number): Promise<HistoryEntry[]> {
17+
return [...this.items].slice(0, limit);
18+
}
19+
20+
async add(entry: HistoryEntry): Promise<void> {
21+
this.items.unshift(entry);
22+
}
23+
24+
async deleteById(id: string): Promise<void> {
25+
this.items = this.items.filter((item) => item.id !== id);
26+
}
27+
28+
async clear(): Promise<void> {
29+
this.items = [];
30+
}
31+
32+
async clearByKind(kind: HistoryEntry['kind']): Promise<void> {
33+
this.items = this.items.filter((item) => item.kind !== kind);
34+
}
35+
}
36+
37+
describe('history use-cases integration', () => {
38+
test('save, read, delete and clear operations work together', async () => {
39+
const repo = new InMemoryHistoryRepository();
40+
const saveScan = new SaveScanEntryUseCase(repo);
41+
const saveGenerated = new SaveGeneratedEntryUseCase(repo);
42+
const getHistory = new GetHistoryUseCase(repo);
43+
const deleteOne = new DeleteHistoryItemUseCase(repo);
44+
const clearHistory = new ClearHistoryUseCase(repo);
45+
46+
await saveScan.execute({
47+
label: 'example.com',
48+
payload: { raw: 'https://example.com', type: 'url' },
49+
});
50+
51+
await saveGenerated.execute({
52+
payload: { raw: 'mailto:test@company.com', type: 'email' },
53+
});
54+
55+
const all = await getHistory.execute();
56+
expect(all).toHaveLength(2);
57+
expect(all[0].kind).toBe('generated');
58+
expect(all[1].kind).toBe('scan');
59+
60+
await deleteOne.execute(all[0].id);
61+
const afterDelete = await getHistory.execute();
62+
expect(afterDelete).toHaveLength(1);
63+
64+
await clearHistory.execute('scan');
65+
const afterKindClear = await getHistory.execute();
66+
expect(afterKindClear).toHaveLength(0);
67+
});
68+
69+
test('getLatest delegates limit path', async () => {
70+
const repo = new InMemoryHistoryRepository();
71+
const saveScan = new SaveScanEntryUseCase(repo);
72+
const getHistory = new GetHistoryUseCase(repo);
73+
74+
await saveScan.execute({
75+
label: 'one',
76+
payload: { raw: 'https://1.com', type: 'url' },
77+
});
78+
await saveScan.execute({
79+
label: 'two',
80+
payload: { raw: 'https://2.com', type: 'url' },
81+
});
82+
83+
const latestOne = await getHistory.execute(1);
84+
expect(latestOne).toHaveLength(1);
85+
expect(latestOne[0].label).toBe('two');
86+
});
87+
});

tests/unit/qrPayloadRules.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
detectPayloadType,
3+
isValidForCreateType,
4+
normalizeForCreateType,
5+
stripActionPrefix,
6+
} from '@/services/domain/qrPayloadRules';
7+
8+
describe('qrPayloadRules', () => {
9+
test.each([
10+
['https://example.com', 'url'],
11+
['http://example.com', 'url'],
12+
['mailto:test@example.com', 'email'],
13+
['tel:+48123123123', 'phone'],
14+
['WIFI:T:WPA;S:Office;P:secret;;', 'wifi'],
15+
['plain text', 'text'],
16+
[' ', 'unknown'],
17+
] as const)('detectPayloadType(%s) -> %s', (raw, expected) => {
18+
expect(detectPayloadType(raw)).toBe(expected);
19+
});
20+
21+
test('normalizeForCreateType adds missing URL scheme', () => {
22+
expect(normalizeForCreateType('example.com', 'url')).toBe('https://example.com');
23+
});
24+
25+
test('normalizeForCreateType keeps existing prefixes', () => {
26+
expect(normalizeForCreateType('mailto:a@b.com', 'email')).toBe('mailto:a@b.com');
27+
expect(normalizeForCreateType('tel:+48123456789', 'phone')).toBe('tel:+48123456789');
28+
});
29+
30+
test('stripActionPrefix removes only known prefixes', () => {
31+
expect(stripActionPrefix('mailto:test@company.com', 'email')).toBe('test@company.com');
32+
expect(stripActionPrefix('tel:+48123456789', 'phone')).toBe('+48123456789');
33+
expect(stripActionPrefix('https://example.com', 'url')).toBe('https://example.com');
34+
});
35+
36+
test.each([
37+
['url', 'example.com', true],
38+
['url', 'not a url', false],
39+
['email', 'a@b.com', true],
40+
['email', 'mailto:a@b.com', true],
41+
['email', 'invalid@domain', false],
42+
['phone', '+48 123 456 789', true],
43+
['phone', '12345', false],
44+
['text', 'anything', true],
45+
] as const)('isValidForCreateType(%s, %s) -> %s', (type, value, expected) => {
46+
expect(isValidForCreateType(value, type)).toBe(expected);
47+
});
48+
});

tests/unit/qrUsecases.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { BuildQrPreviewUseCase } from '@/services/usecases/qr/BuildQrPreview';
2+
import { ValidateQrInputUseCase } from '@/services/usecases/qr/ValidateQrInput';
3+
4+
describe('QR create use-cases', () => {
5+
const validate = new ValidateQrInputUseCase();
6+
const buildPreview = new BuildQrPreviewUseCase();
7+
8+
test('validate input reports empty and too_long errors', () => {
9+
const empty = validate.execute(' ', 'text');
10+
expect(empty.isValid).toBe(false);
11+
expect(empty.reason).toBe('empty');
12+
13+
const tooLong = validate.execute('a'.repeat(2049), 'text');
14+
expect(tooLong.isValid).toBe(false);
15+
expect(tooLong.reason).toBe('too_long');
16+
});
17+
18+
test('validate input reports url/email/phone reasons', () => {
19+
expect(validate.execute('x', 'url').reason).toBe('invalid_url');
20+
expect(validate.execute('x@x', 'email').reason).toBe('invalid_email');
21+
expect(validate.execute('123', 'phone').reason).toBe('invalid_phone');
22+
});
23+
24+
test('build preview returns encoded external QR URL', () => {
25+
const preview = buildPreview.execute('https://example.com/path?a=1&b=2');
26+
expect(preview.sourceUrl).toContain('https://api.qrserver.com/v1/create-qr-code/');
27+
expect(preview.sourceUrl).toContain('data=https%3A%2F%2Fexample.com%2Fpath%3Fa%3D1%26b%3D2');
28+
expect(preview.payload.type).toBe('url');
29+
});
30+
31+
test('build preview uses fallback for empty input', () => {
32+
const preview = buildPreview.execute(' ');
33+
expect(preview.payload.raw).toBe('https://example.com');
34+
});
35+
});

tests/unit/scannerUsecases.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { BuildScanResultViewModelUseCase } from '@/services/usecases/scanner/BuildScanResultViewModel';
2+
import { ResolveScannedActionUseCase } from '@/services/usecases/scanner/ResolveScannedAction';
3+
import type { QrPayload } from '@/types';
4+
5+
describe('scanner use-cases', () => {
6+
const resolveAction = new ResolveScannedActionUseCase();
7+
const buildViewModel = new BuildScanResultViewModelUseCase();
8+
9+
test.each([
10+
[{ raw: 'https://example.com', type: 'url' }, 'open_url'],
11+
[{ raw: 'mailto:test@example.com', type: 'email' }, 'compose_email'],
12+
[{ raw: 'tel:+48123456789', type: 'phone' }, 'call_phone'],
13+
[{ raw: 'WIFI:T:WPA;S:Net;P:pass;;', type: 'wifi' }, 'copy_text'],
14+
[{ raw: 'hello', type: 'text' }, 'copy_text'],
15+
[{ raw: '', type: 'unknown' }, 'unsupported'],
16+
] as const)('resolveAction for %o', (payload, expectedKind) => {
17+
expect(resolveAction.execute(payload as QrPayload).kind).toBe(expectedKind);
18+
});
19+
20+
test('url view model extracts domain', () => {
21+
const vm = buildViewModel.execute({ raw: 'https://sub.example.com/p/a/t/h', type: 'url' });
22+
expect(vm.kicker).toBe('Link detected');
23+
expect(vm.title).toBe('sub.example.com');
24+
});
25+
26+
test('email and phone view model remove action prefixes', () => {
27+
const email = buildViewModel.execute({ raw: 'mailto:test@company.com', type: 'email' });
28+
const phone = buildViewModel.execute({ raw: 'tel:+48123456789', type: 'phone' });
29+
expect(email.subtitle).toBe('test@company.com');
30+
expect(phone.subtitle).toBe('+48123456789');
31+
});
32+
});

0 commit comments

Comments
 (0)