feat(programmer): day sheet providers + controller
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet_event.model.dart';
|
||||
import 'package:tasq/providers/programmer_day_sheets_provider.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake push recorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _PushCall {
|
||||
final List<String> userIds;
|
||||
final String title;
|
||||
final String body;
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
_PushCall({
|
||||
required this.userIds,
|
||||
required this.title,
|
||||
required this.body,
|
||||
this.data,
|
||||
});
|
||||
}
|
||||
|
||||
class _RecordingPush {
|
||||
final calls = <_PushCall>[];
|
||||
|
||||
Future<void> call({
|
||||
required List<String> userIds,
|
||||
required String title,
|
||||
required String body,
|
||||
Map<String, dynamic>? data,
|
||||
}) async {
|
||||
calls.add(_PushCall(userIds: userIds, title: title, body: body, data: data));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake Supabase-like client — only exposes rpc()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _RpcCall {
|
||||
final String name;
|
||||
final Map<String, dynamic>? params;
|
||||
_RpcCall(this.name, this.params);
|
||||
}
|
||||
|
||||
class _FakeClient {
|
||||
final calls = <_RpcCall>[];
|
||||
Map<String, dynamic>? _nextResult;
|
||||
Object? _nextError;
|
||||
|
||||
void setResult(Map<String, dynamic> result) => _nextResult = result;
|
||||
void setError(Object error) => _nextError = error;
|
||||
|
||||
Future<dynamic> rpc(String name, {Map<String, dynamic>? params}) async {
|
||||
calls.add(_RpcCall(name, params));
|
||||
if (_nextError != null) {
|
||||
final err = _nextError!;
|
||||
_nextError = null;
|
||||
throw err;
|
||||
}
|
||||
final result = _nextResult;
|
||||
_nextResult = null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('DaySheetsController', () {
|
||||
late _FakeClient fake;
|
||||
late _RecordingPush push;
|
||||
late DaySheetsController controller;
|
||||
|
||||
setUp(() {
|
||||
fake = _FakeClient();
|
||||
push = _RecordingPush();
|
||||
controller = DaySheetsController(fake, push: push.call);
|
||||
});
|
||||
|
||||
test('submit calls submit_day_sheet with p_date and pushes admins', () async {
|
||||
fake.setResult({
|
||||
'sheet_id': 's1',
|
||||
'work_date': '2026-09-28',
|
||||
'programmer_name': 'Paola Ross',
|
||||
'notify_user_ids': ['adm'],
|
||||
});
|
||||
|
||||
final sheetId = await controller.submit(DateTime.utc(2026, 9, 28));
|
||||
|
||||
expect(sheetId, 's1');
|
||||
expect(fake.calls.single.name, 'submit_day_sheet');
|
||||
expect(fake.calls.single.params, {'p_date': '2026-09-28'});
|
||||
expect(push.calls.single.title, 'Day sheet submitted');
|
||||
expect(
|
||||
push.calls.single.body,
|
||||
'Paola Ross submitted their day sheet for Sep 28, 2026',
|
||||
);
|
||||
});
|
||||
|
||||
test('blank remarks fail fast without an rpc call', () async {
|
||||
await expectLater(
|
||||
controller.disapprove(
|
||||
sheetId: 'sh1',
|
||||
remarks: ' ',
|
||||
flaggedTaskIds: [],
|
||||
),
|
||||
throwsA(
|
||||
isA<DaySheetActionException>().having(
|
||||
(e) => e.code,
|
||||
'code',
|
||||
DaySheetError.remarksRequired,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(fake.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('already_reviewed maps to a friendly message', () async {
|
||||
fake.setError(Exception('already_reviewed'));
|
||||
|
||||
DaySheetActionException? caught;
|
||||
try {
|
||||
await controller.submit(DateTime.utc(2026, 9, 28));
|
||||
} on DaySheetActionException catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught, isNotNull);
|
||||
expect(caught!.code, DaySheetError.alreadyReviewed);
|
||||
expect(caught.message, 'This sheet was already reviewed.');
|
||||
});
|
||||
|
||||
test('approve reports approved and skipped ids and pushes per approved sheet',
|
||||
() async {
|
||||
fake.setResult({
|
||||
'approved': [
|
||||
{
|
||||
'sheet_id': 'sh1',
|
||||
'work_date': '2026-09-28',
|
||||
'programmer_id': 'prog1',
|
||||
},
|
||||
{
|
||||
'sheet_id': 'sh2',
|
||||
'work_date': '2026-09-27',
|
||||
'programmer_id': 'prog2',
|
||||
},
|
||||
],
|
||||
'skipped_ids': ['sh3'],
|
||||
});
|
||||
|
||||
final snapshot = DaySheetSnapshot(totalSeconds: 3600, rows: []);
|
||||
final items = [
|
||||
(sheetId: 'sh1', snapshot: snapshot),
|
||||
(sheetId: 'sh2', snapshot: snapshot),
|
||||
(sheetId: 'sh3', snapshot: snapshot),
|
||||
];
|
||||
|
||||
final result = await controller.approve(items);
|
||||
|
||||
expect(result.approvedIds, ['sh1', 'sh2']);
|
||||
expect(result.skippedIds, ['sh3']);
|
||||
expect(push.calls.length, 2);
|
||||
expect(push.calls[0].title, 'Day sheet approved');
|
||||
expect(
|
||||
push.calls[0].body,
|
||||
'Your day sheet for Sep 28, 2026 was approved',
|
||||
);
|
||||
expect(
|
||||
push.calls[1].body,
|
||||
'Your day sheet for Sep 27, 2026 was approved',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failing push does not fail the action', () async {
|
||||
fake.setResult({
|
||||
'sheet_id': 's1',
|
||||
'work_date': '2026-09-28',
|
||||
'programmer_name': 'Paola Ross',
|
||||
'notify_user_ids': ['adm'],
|
||||
});
|
||||
|
||||
final throwingController = DaySheetsController(
|
||||
fake,
|
||||
push: ({
|
||||
required List<String> userIds,
|
||||
required String title,
|
||||
required String body,
|
||||
Map<String, dynamic>? data,
|
||||
}) async {
|
||||
throw Exception('push failed');
|
||||
},
|
||||
);
|
||||
|
||||
// Should succeed even though push throws.
|
||||
final sheetId = await throwingController.submit(DateTime.utc(2026, 9, 28));
|
||||
expect(sheetId, 's1');
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('mapDaySheetError', () {
|
||||
test('maps known substrings to the correct error code', () {
|
||||
expect(
|
||||
mapDaySheetError(Exception('not_authorized')),
|
||||
DaySheetError.notAuthorized,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('already_reviewed')),
|
||||
DaySheetError.alreadyReviewed,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('already_submitted')),
|
||||
DaySheetError.alreadySubmitted,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('remarks_required')),
|
||||
DaySheetError.remarksRequired,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('body_required')),
|
||||
DaySheetError.bodyRequired,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('work_date_locked')),
|
||||
DaySheetError.workDateLocked,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('invalid_snapshot')),
|
||||
DaySheetError.invalidSnapshot,
|
||||
);
|
||||
expect(
|
||||
mapDaySheetError(Exception('something else entirely')),
|
||||
DaySheetError.unknown,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('DaySheetActionException.message', () {
|
||||
test('returns exact strings for every error code', () {
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.alreadyReviewed).message,
|
||||
'This sheet was already reviewed.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.alreadySubmitted).message,
|
||||
'This day sheet was already submitted.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.notAuthorized).message,
|
||||
'You are not allowed to do that.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.remarksRequired).message,
|
||||
'Remarks are required to disapprove.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.bodyRequired).message,
|
||||
'Please write a justification.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.workDateLocked).message,
|
||||
'That day is locked — you can only add entries to pending or disapproved days.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.invalidSnapshot).message,
|
||||
'Could not approve: the sheet data was invalid.',
|
||||
);
|
||||
expect(
|
||||
DaySheetActionException(DaySheetError.unknown).message,
|
||||
'Something went wrong. Please try again.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
group('ProgrammerDaySheetEvent', () {
|
||||
test('fromMap parses all fields correctly', () {
|
||||
final event = ProgrammerDaySheetEvent.fromMap({
|
||||
'id': 'ev1',
|
||||
'sheet_id': 'sh1',
|
||||
'kind': 'submitted',
|
||||
'actor_id': 'u1',
|
||||
'body': 'some text',
|
||||
'flagged_task_ids': ['t1', 't2'],
|
||||
'created_at': '2026-09-28T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(event.id, 'ev1');
|
||||
expect(event.sheetId, 'sh1');
|
||||
expect(event.kind, 'submitted');
|
||||
expect(event.actorId, 'u1');
|
||||
expect(event.body, 'some text');
|
||||
expect(event.flaggedTaskIds, ['t1', 't2']);
|
||||
expect(event.createdAt, DateTime.utc(2026, 9, 28, 10, 0, 0));
|
||||
});
|
||||
|
||||
test('fromMap handles null optional fields', () {
|
||||
final event = ProgrammerDaySheetEvent.fromMap({
|
||||
'id': 'ev2',
|
||||
'sheet_id': 'sh2',
|
||||
'kind': 'approved',
|
||||
'created_at': '2026-09-28T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(event.actorId, isNull);
|
||||
expect(event.body, isNull);
|
||||
expect(event.flaggedTaskIds, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user