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
261 lines
7.2 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|