bottleneckcrm/mobile/test/unit/org_modules_test.dart

401 lines
13 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:bottle_crm/data/models/auth_response.dart';
import 'package:bottle_crm/data/models/org_module.dart';
import 'package:bottle_crm/providers/auth_provider.dart';
import 'package:bottle_crm/providers/org_modules_provider.dart';
import 'package:bottle_crm/services/api_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
class _FakeClient extends http.BaseClient {
int status = 200;
String body = '{}';
final List<http.BaseRequest> sent = [];
final List<String> bodies = [];
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
sent.add(request);
bodies.add(utf8.decode(await request.finalize().toBytes()));
return http.StreamedResponse(
Stream.value(utf8.encode(body)),
status,
request: request,
);
}
}
const _groups = {
'deals': 'sell',
'leads': 'sell',
'goals': 'sell',
'tasks': 'serve',
'tickets': 'serve',
'knowledge_base': 'serve',
'documents': 'serve',
'invoices': 'bill',
'timesheet': 'bill',
};
Map<String, dynamic> _row(
String key, {
bool enabled = true,
bool? available,
String? needs,
String? label,
}) => {
'key': key,
'label': label ?? defaultModuleLabels[key],
'group': _groups[key],
'covers': 'Covers $key.',
'stops': 'Stops $key.',
'enabled': enabled,
'available': available ?? enabled,
'needs': needs,
};
Map<String, dynamic> _json({
Set<String> off = const {},
List<String>? order,
bool canEdit = true,
}) => {
'modules': [
for (final k in order ?? _groups.keys)
_row(
k,
enabled: !off.contains(k),
needs: k == 'goals' ? 'deals' : (k == 'timesheet' ? 'tickets' : null),
),
],
'can_edit': canEdit,
};
OrgModulesAnswer _answer({Set<String> off = const {}, List<String>? order}) =>
OrgModulesAnswer.fromJson(
_json(off: off, order: order),
orgId: 'a',
);
class _GateClient extends _FakeClient {
final Completer<void> gate;
_GateClient(this.gate);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
await gate.future;
return super.send(request);
}
}
class _OrgSwitch extends Notifier<Organization?> {
@override
Organization? build() => null;
void set(Organization? next) => state = next;
}
void main() {
late _FakeClient client;
late ProviderContainer container;
setUp(() {
client = _FakeClient();
ApiService().setClientForTesting(client);
container = ProviderContainer();
});
tearDown(() => container.dispose());
group('OrgModulesAnswer', () {
test(
'reads the nine rows in order with label, group, covers, stops and needs',
() {
final a = _answer();
expect(a.modules.map((m) => m.key), _groups.keys.toList());
final goals = a.modules[2];
expect(goals.label, 'Goals');
expect(goals.group, 'sell');
expect(goals.covers, 'Covers goals.');
expect(goals.stops, 'Stops goals.');
expect(goals.needs, 'deals');
expect(a.modules.first.needs, isNull);
expect(a.canEdit, isTrue);
expect(a.orgId, 'a');
},
);
test('survives missing and wrongly typed keys without throwing', () {
expect(OrgModulesAnswer.fromJson({}).modules, isEmpty);
expect(OrgModulesAnswer.fromJson({'modules': 'x'}).modules, isEmpty);
final a = OrgModulesAnswer.fromJson({
'modules': [
'junk',
{'label': 'no key'},
{'key': 7},
{'key': 'leads', 'label': 3, 'enabled': 'yes', 'needs': 4},
],
'can_edit': 'true',
});
expect(a.modules.single.key, 'leads');
expect(a.modules.single.label, 'Leads');
expect(a.modules.single.enabled, isTrue);
expect(a.modules.single.needs, isNull);
expect(a.canEdit, isFalse);
});
test(
'reads a module as on when available, when it has no row, and when the answer is for another org',
() {
final a = OrgModulesAnswer.fromJson({
'modules': [
_row('deals', enabled: false),
_row('goals', available: false, needs: 'deals'),
_row('leads'),
],
}, orgId: 'a');
expect(a.isOn('deals'), isFalse);
expect(a.isOn('goals'), isFalse);
expect(a.isOn('leads'), isTrue);
expect(a.isOn('tickets'), isTrue);
expect(a.productsOn, isTrue);
expect(a.labelOf('deals'), 'Pipeline');
expect(a.labelOf('tickets'), 'Tickets');
final value = AsyncData<OrgModulesAnswer?>(a);
expect(moduleIsOn(value, 'a', 'deals'), isFalse);
expect(moduleIsOn(value, 'b', 'deals'), isTrue);
expect(moduleIsOn(const AsyncData(null), 'a', 'deals'), isTrue);
expect(moduleIsOn(const AsyncLoading(), 'a', 'deals'), isTrue);
expect(
moduleIsOn(
AsyncError(Exception('x'), StackTrace.empty),
'a',
'deals',
),
isTrue,
);
},
);
test('productsOn is true while either deals or invoices is on', () {
expect(_answer(off: {'deals'}).productsOn, isTrue);
expect(_answer(off: {'deals', 'invoices'}).productsOn, isFalse);
});
test('switches one module and keeps the served order', () {
final a = _answer(
off: {'tickets'},
order: [
'goals',
'leads',
'deals',
'documents',
'tasks',
'tickets',
'knowledge_base',
'timesheet',
'invoices',
],
);
final order = [for (final m in a.modules) m.key];
final off = a.switchBody('leads', false)!;
expect(off.off, ['leads', 'tickets']);
expect(off.order, order);
final on = a.switchBody('tickets', true)!;
expect(on.off, isEmpty);
expect(on.order, order);
expect(a.switchBody('crm', true), isNull);
});
test('moves within a group and stops at the ends', () {
final a = _answer(off: {'goals'});
final up = a.moveBody('leads', 'up')!;
expect(up.order.take(3), ['leads', 'deals', 'goals']);
expect(up.order.skip(3), a.modules.skip(3).map((m) => m.key));
expect(up.off, ['goals']);
expect(a.moveBody('leads', 'down')!.order.take(3), [
'deals',
'goals',
'leads',
]);
expect(a.moveBody('deals', 'up'), isNull);
expect(a.moveBody('goals', 'down'), isNull);
expect(a.moveBody('tasks', 'up'), isNull);
expect(a.moveBody('documents', 'down'), isNull);
expect(a.moveBody('invoices', 'up'), isNull);
expect(a.moveBody('timesheet', 'up')!.order.skip(7), [
'timesheet',
'invoices',
]);
expect(a.moveBody('crm', 'up'), isNull);
expect(a.moveBody('leads', 'sideways'), isNull);
});
});
group('orgModulesProvider', () {
Future<OrgModulesAnswer?> read() {
container.listen(orgModulesProvider, (_, _) {});
return container.read(orgModulesProvider.future);
}
test(
'loads for the selected org, reads 404 as null and 500 as an error',
() async {
final org = NotifierProvider<_OrgSwitch, Organization?>(_OrgSwitch.new);
final scoped = ProviderContainer(
overrides: [
selectedOrgProvider.overrideWith((ref) => ref.watch(org)),
],
);
addTearDown(scoped.dispose);
scoped
.read(org.notifier)
.set(Organization.fromJson({'id': 'a', 'name': 'A'}));
client.body = jsonEncode(_json(off: {'deals'}));
scoped.listen(orgModulesProvider, (_, _) {});
final answer = await scoped.read(orgModulesProvider.future);
expect(answer!.orgId, 'a');
expect(client.sent.single.url.path, '/api/org/modules/');
expect(scoped.read(moduleOnProvider('deals')), isFalse);
expect(scoped.read(moduleOnProvider('leads')), isTrue);
expect(scoped.read(productsOnProvider), isTrue);
expect(scoped.read(shownModulesProvider), isNotNull);
client.status = 404;
scoped.invalidate(orgModulesProvider);
expect(await scoped.read(orgModulesProvider.future), isNull);
expect(scoped.read(shownModulesProvider), isNull);
expect(scoped.read(moduleOnProvider('deals')), isTrue);
client.status = 500;
client.body = '{}';
scoped.invalidate(orgModulesProvider);
await expectLater(
scoped.read(orgModulesProvider.future),
throwsA(isA<Exception>()),
);
expect(scoped.read(shownModulesProvider), isNull);
expect(scoped.read(moduleOnProvider('deals')), isTrue);
},
);
test(
'a reload for the same org keeps the off module off while loading',
() async {
client.body = jsonEncode(_json(off: {'deals'}));
container.read(selectedOrgProvider);
await read();
final orgId = container.read(orgModulesProvider).value!.orgId;
final gate = Completer<void>();
final slow = _GateClient(gate)..body = client.body;
ApiService().setClientForTesting(slow);
container.invalidate(orgModulesProvider);
container.read(orgModulesProvider);
expect(container.read(orgModulesProvider).isLoading, isTrue);
expect(
moduleIsOn(container.read(orgModulesProvider), orgId, 'deals'),
isFalse,
);
gate.complete();
await container.read(orgModulesProvider.future);
},
);
test('an error after a successful load keeps the last answer', () async {
client.body = jsonEncode(_json(off: {'deals'}));
await read();
final orgId = container.read(orgModulesProvider).value!.orgId;
client.status = 500;
client.body = '{}';
container.invalidate(orgModulesProvider);
await expectLater(
container.read(orgModulesProvider.future),
throwsA(isA<Exception>()),
);
final value = container.read(orgModulesProvider);
expect(value.hasError, isTrue);
expect(moduleIsOn(value, orgId, 'deals'), isFalse);
});
test(
'an org switch during a reload reads as all on until the new org answers',
() async {
final org = NotifierProvider<_OrgSwitch, Organization?>(_OrgSwitch.new);
final scoped = ProviderContainer(
overrides: [
selectedOrgProvider.overrideWith((ref) => ref.watch(org)),
],
);
addTearDown(scoped.dispose);
scoped
.read(org.notifier)
.set(Organization.fromJson({'id': 'a', 'name': 'A'}));
client.body = jsonEncode(_json(off: {'deals'}));
scoped.listen(orgModulesProvider, (_, _) {});
await scoped.read(orgModulesProvider.future);
expect(scoped.read(moduleOnProvider('deals')), isFalse);
final gate = Completer<void>();
ApiService().setClientForTesting(_GateClient(gate)..body = client.body);
scoped
.read(org.notifier)
.set(Organization.fromJson({'id': 'b', 'name': 'B'}));
scoped.read(orgModulesProvider);
expect(scoped.read(orgModulesProvider).isLoading, isTrue);
expect(scoped.read(moduleOnProvider('deals')), isTrue);
expect(scoped.read(shownModulesProvider), isNull);
gate.complete();
await scoped.read(orgModulesProvider.future);
expect(scoped.read(moduleOnProvider('deals')), isFalse);
},
);
test(
'save returns every refusal, off first, and keeps the state',
() async {
client.body = jsonEncode(_json());
final before = await read();
client.status = 400;
client.body = jsonEncode({
'order': ['Name each module once.'],
'off': ['Goals needs Pipeline.', 'Tickets still receives email.'],
});
final notifier = container.read(orgModulesProvider.notifier);
final errors = await notifier.save(off: ['deals'], order: ['leads']);
expect(errors, [
'Goals needs Pipeline.',
'Tickets still receives email.',
'Name each module once.',
]);
expect(jsonDecode(client.bodies.last), {
'off': ['deals'],
'order': ['leads'],
});
expect(client.sent.last.method, 'PUT');
expect(container.read(orgModulesProvider).value, same(before));
client.status = 500;
client.body = '{}';
final other = await notifier.save(off: [], order: []);
expect(other, hasLength(1));
expect(other.single, isNotEmpty);
expect(container.read(orgModulesProvider).value, same(before));
},
);
test('save takes the new answer as its state', () async {
client.body = jsonEncode(_json());
await read();
client.body = jsonEncode(_json(off: {'tickets'}));
final errors = await container
.read(orgModulesProvider.notifier)
.save(off: ['tickets'], order: []);
expect(errors, isEmpty);
final now = container.read(orgModulesProvider).value!;
expect(now.isOn('tickets'), isFalse);
expect(now.isOn('leads'), isTrue);
});
});
}