worldhopper/test/models/models_test.dart
Felipe M. 58a562abe8
feat: refactor server software abstraction with Kavita REST API integration
Rename all OPDS-prefixed models to generic names (Server, Feed, Entry,
Link, StreamLink), expand the ServerSoftware interface to cover all
server interactions, and implement a full Kavita REST API client that
replaces the OPDS delegation.

- Rename OPDS* models to generic names across ~60 files
- Add database migrations 13 (opds_id → entry_id) and 14 (unify credentials)
- Create OPDSServerSoftware wrapping existing OPDS services
- Create KavitaApiClient for direct Kavita REST API calls
- Create KavitaFeedMapper to convert Kavita JSON to Feed/Entry models
- Rewrite KavitaServerSoftware to use native API (no OPDS delegation)
- Unify server credentials (remove softwareUsername/softwarePassword)
- Simplify add/edit server UI to single auth section
- Add test connection button to server add/edit screens
- Add progress indicator to PublicationCard using local and server data
- Eliminate softwareType branching in reader screens
- Add EntryProgress and fetchEntryProgress to ServerSoftware interface
- Fix Entry.acquisitionLink crash on empty links
- Add 59 new tests covering models, services, and providers
2026-04-06 17:46:48 +02:00

261 lines
7.2 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:worldhopper/models/entry.dart';
import 'package:worldhopper/models/feed.dart';
import 'package:worldhopper/models/link.dart';
import 'package:worldhopper/models/server.dart';
import 'package:worldhopper/models/stream_link.dart';
import 'package:worldhopper/services/server_software/server_software_type.dart';
void main() {
group('Server', () {
test('can be constructed with required fields', () {
final server = Server(
id: 's1',
name: 'Test Server',
url: 'https://example.com/opds',
createdAt: DateTime(2024, 1, 1),
);
expect(server.id, 's1');
expect(server.name, 'Test Server');
expect(server.url, 'https://example.com/opds');
expect(server.softwareType, isNull);
});
test('requiresAuth returns true when credentials present', () {
final server = Server(
id: 's1',
name: 'Test',
url: 'https://example.com',
username: 'user',
password: 'pass',
createdAt: DateTime(2024),
);
expect(server.requiresAuth, isTrue);
});
test('requiresAuth returns false when no credentials', () {
final server = Server(
id: 's1',
name: 'Test',
url: 'https://example.com',
createdAt: DateTime(2024),
);
expect(server.requiresAuth, isFalse);
});
test('toDatabase and fromDatabase roundtrip', () {
final original = Server(
id: 's1',
name: 'Test',
url: 'https://example.com',
username: 'user',
password: 'pass',
createdAt: DateTime(2024, 1, 1),
softwareType: ServerSoftwareType.kavita,
);
final row = original.toDatabase();
final restored = Server.fromDatabase(row);
expect(restored.id, original.id);
expect(restored.name, original.name);
expect(restored.url, original.url);
expect(restored.username, original.username);
expect(restored.password, original.password);
expect(restored.softwareType, ServerSoftwareType.kavita);
});
test('JSON roundtrip preserves all fields', () {
final original = Server(
id: 's1',
name: 'Test',
url: 'https://example.com',
createdAt: DateTime(2024, 1, 1),
);
final json = original.toJson();
final restored = Server.fromJson(json);
expect(restored, original);
});
});
group('Entry', () {
test('isNavigation detects subsection links', () {
const entry = Entry(
id: 'e1',
title: 'A Collection',
links: [Link(rel: 'subsection', href: '/collection/1')],
);
expect(entry.isNavigation, isTrue);
});
test('isNavigation is false for acquisition entries', () {
const entry = Entry(
id: 'e1',
title: 'A Book',
links: [
Link(
rel: 'http://opds-spec.org/acquisition',
href: '/book.epub',
type: 'application/epub+zip',
),
],
);
expect(entry.isNavigation, isFalse);
});
test('hasStreamLink returns true when stream link present', () {
const entry = Entry(
id: 'e1',
title: 'Comic',
streamLink: StreamLink(
href: 'https://example.com/page/{pageNumber}',
type: 'image/jpeg',
pageCount: 20,
),
);
expect(entry.hasStreamLink, isTrue);
expect(entry.isImageStream, isTrue);
});
test('isEpub detects EPUB acquisition link', () {
const entry = Entry(
id: 'e1',
title: 'Book',
links: [
Link(
rel: 'http://opds-spec.org/acquisition',
href: '/book.epub',
type: 'application/epub+zip',
),
],
);
expect(entry.isEpub, isTrue);
});
test('JSON roundtrip preserves basic fields', () {
const original = Entry(
id: 'e1',
title: 'Test Entry',
authors: ['Author One'],
categories: ['Fiction'],
);
final json = original.toJson();
final restored = Entry.fromJson(json);
expect(restored.id, original.id);
expect(restored.title, original.title);
expect(restored.authors, original.authors);
expect(restored.categories, original.categories);
});
});
group('Feed', () {
test('isNavigationFeed detects navigation entries', () {
const feed = Feed(
id: 'f1',
title: 'Root',
entries: [
Entry(
id: 'e1',
title: 'Library',
links: [Link(rel: 'subsection', href: '/lib')],
),
],
);
expect(feed.isNavigationFeed, isTrue);
expect(feed.isAcquisitionFeed, isFalse);
});
test('isAcquisitionFeed when no navigation entries', () {
const feed = Feed(
id: 'f1',
title: 'Books',
entries: [
Entry(
id: 'e1',
title: 'Book 1',
links: [
Link(
rel: 'http://opds-spec.org/acquisition',
href: '/book.epub',
),
],
),
],
);
expect(feed.isAcquisitionFeed, isTrue);
expect(feed.isNavigationFeed, isFalse);
});
test('nextLink returns pagination link', () {
const feed = Feed(
id: 'f1',
title: 'Books',
links: [
Link(rel: 'next', href: '/page/2'),
Link(rel: 'self', href: '/page/1'),
],
);
expect(feed.nextLink, isNotNull);
expect(feed.nextLink!.href, '/page/2');
});
test('nextLink returns null when not present', () {
const feed = Feed(
id: 'f1',
title: 'Books',
links: [Link(rel: 'self', href: '/page/1')],
);
expect(feed.nextLink, isNull);
});
});
group('StreamLink', () {
test('getPageUrl replaces pageNumber placeholder', () {
const link = StreamLink(
href: 'https://example.com/page/{pageNumber}',
type: 'image/jpeg',
pageCount: 50,
);
expect(link.getPageUrl(5), 'https://example.com/page/5');
});
test('getPageUrl replaces both pageNumber and maxWidth', () {
const link = StreamLink(
href: 'https://example.com/page/{pageNumber}?maxWidth={maxWidth}',
type: 'image/jpeg',
pageCount: 50,
);
expect(
link.getPageUrl(3, maxWidth: 1200),
'https://example.com/page/3?maxWidth=1200',
);
});
test('supportsMaxWidth detects maxWidth placeholder', () {
const withMax = StreamLink(
href: 'https://example.com/{pageNumber}?maxWidth={maxWidth}',
type: 'image/jpeg',
pageCount: 10,
);
const withoutMax = StreamLink(
href: 'https://example.com/{pageNumber}',
type: 'image/jpeg',
pageCount: 10,
);
expect(withMax.supportsMaxWidth, isTrue);
expect(withoutMax.supportsMaxWidth, isFalse);
});
});
group('Link', () {
test('JSON roundtrip', () {
const original = Link(
rel: 'acquisition',
href: '/book.epub',
type: 'application/epub+zip',
title: 'Download',
);
final json = original.toJson();
final restored = Link.fromJson(json);
expect(restored, original);
});
});
}