feat(programmer): day sheet providers + controller
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -37,6 +37,9 @@ class DaySheetSnapshot {
|
||||
'rows': rows.map((r) => r.toJson()).toList(),
|
||||
};
|
||||
|
||||
/// Alias for [toJson]; used by the approval RPC payload.
|
||||
Map<String, dynamic> toMap() => toJson();
|
||||
|
||||
factory DaySheetSnapshot.fromJson(Map<String, dynamic> j) {
|
||||
final rowsList = (j['rows'] as List? ?? [])
|
||||
.map((r) => DaySheetRow.fromJson(r as Map<String, dynamic>))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/// A single audit-trail event on a programmer day sheet.
|
||||
class ProgrammerDaySheetEvent {
|
||||
final String id;
|
||||
final String sheetId;
|
||||
final String kind;
|
||||
final String? actorId;
|
||||
final String? body;
|
||||
final List<String> flaggedTaskIds;
|
||||
final DateTime createdAt;
|
||||
|
||||
const ProgrammerDaySheetEvent({
|
||||
required this.id,
|
||||
required this.sheetId,
|
||||
required this.kind,
|
||||
this.actorId,
|
||||
this.body,
|
||||
required this.flaggedTaskIds,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory ProgrammerDaySheetEvent.fromMap(Map<String, dynamic> m) {
|
||||
return ProgrammerDaySheetEvent(
|
||||
id: m['id'] as String,
|
||||
sheetId: m['sheet_id'] as String,
|
||||
kind: m['kind'] as String,
|
||||
actorId: m['actor_id'] as String?,
|
||||
body: m['body'] as String?,
|
||||
flaggedTaskIds: List<String>.from(m['flagged_task_ids'] as List? ?? []),
|
||||
createdAt: DateTime.parse(m['created_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/programmer_day_sheet.model.dart';
|
||||
import '../models/programmer_day_sheet_event.model.dart';
|
||||
import '../utils/app_time.dart';
|
||||
import '../utils/programmer_daily_time.dart';
|
||||
import 'notifications_provider.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Callback type for sending push notifications.
|
||||
typedef PushFn = Future<void> Function({
|
||||
required List<String> userIds,
|
||||
required String title,
|
||||
required String body,
|
||||
Map<String, dynamic>? data,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum DaySheetError {
|
||||
alreadyReviewed,
|
||||
alreadySubmitted,
|
||||
notAuthorized,
|
||||
remarksRequired,
|
||||
bodyRequired,
|
||||
workDateLocked,
|
||||
invalidSnapshot,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class DaySheetActionException implements Exception {
|
||||
const DaySheetActionException(this.code);
|
||||
|
||||
final DaySheetError code;
|
||||
|
||||
String get message {
|
||||
switch (code) {
|
||||
case DaySheetError.alreadyReviewed:
|
||||
return 'This sheet was already reviewed.';
|
||||
case DaySheetError.alreadySubmitted:
|
||||
return 'This day sheet was already submitted.';
|
||||
case DaySheetError.notAuthorized:
|
||||
return 'You are not allowed to do that.';
|
||||
case DaySheetError.remarksRequired:
|
||||
return 'Remarks are required to disapprove.';
|
||||
case DaySheetError.bodyRequired:
|
||||
return 'Please write a justification.';
|
||||
case DaySheetError.workDateLocked:
|
||||
return 'That day is locked — you can only add entries to pending or disapproved days.';
|
||||
case DaySheetError.invalidSnapshot:
|
||||
return 'Could not approve: the sheet data was invalid.';
|
||||
case DaySheetError.unknown:
|
||||
return 'Something went wrong. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'DaySheetActionException(${code.name}): $message';
|
||||
}
|
||||
|
||||
/// Maps an RPC error to a [DaySheetError] by substring-matching the lowercased
|
||||
/// error string against the known database error codes.
|
||||
DaySheetError mapDaySheetError(Object error) {
|
||||
final s = error.toString().toLowerCase();
|
||||
if (s.contains('not_authorized')) return DaySheetError.notAuthorized;
|
||||
if (s.contains('already_reviewed')) return DaySheetError.alreadyReviewed;
|
||||
if (s.contains('already_submitted')) return DaySheetError.alreadySubmitted;
|
||||
if (s.contains('remarks_required')) return DaySheetError.remarksRequired;
|
||||
if (s.contains('body_required')) return DaySheetError.bodyRequired;
|
||||
if (s.contains('work_date_locked')) return DaySheetError.workDateLocked;
|
||||
if (s.contains('invalid_snapshot')) return DaySheetError.invalidSnapshot;
|
||||
return DaySheetError.unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class BulkApproveResult {
|
||||
const BulkApproveResult({
|
||||
required this.approvedIds,
|
||||
required this.skippedIds,
|
||||
});
|
||||
|
||||
final List<String> approvedIds;
|
||||
final List<String> skippedIds;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class DaySheetsController {
|
||||
DaySheetsController(this._client, {required PushFn push}) : _push = push;
|
||||
|
||||
final dynamic _client;
|
||||
final PushFn _push;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Submit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<String> submit(DateTime dayKey) async {
|
||||
try {
|
||||
final dateStr = formatDayKey(dayKey);
|
||||
final result = await _client.rpc(
|
||||
'submit_day_sheet',
|
||||
params: {'p_date': dateStr},
|
||||
) as Map<String, dynamic>;
|
||||
|
||||
final sheetId = result['sheet_id'] as String;
|
||||
final workDate = parseDayKey(result['work_date'] as String);
|
||||
final programmerName = result['programmer_name'] as String;
|
||||
final notifyUserIds =
|
||||
List<String>.from(result['notify_user_ids'] as List? ?? []);
|
||||
|
||||
if (notifyUserIds.isNotEmpty) {
|
||||
try {
|
||||
await _push(
|
||||
userIds: notifyUserIds,
|
||||
title: 'Day sheet submitted',
|
||||
body:
|
||||
'$programmerName submitted their day sheet for ${AppTime.formatDate(workDate)}',
|
||||
data: {
|
||||
'type': 'day_sheet_submitted',
|
||||
'navigate_to': '/programmer-tasks/day-sheets/$sheetId',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DaySheetsController.submit push error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return sheetId;
|
||||
} catch (e) {
|
||||
if (e is DaySheetActionException) rethrow;
|
||||
throw DaySheetActionException(mapDaySheetError(e));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Disapprove
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> disapprove({
|
||||
required String sheetId,
|
||||
required String remarks,
|
||||
required List<String> flaggedTaskIds,
|
||||
}) async {
|
||||
if (remarks.trim().isEmpty) {
|
||||
throw DaySheetActionException(DaySheetError.remarksRequired);
|
||||
}
|
||||
try {
|
||||
final result = await _client.rpc(
|
||||
'disapprove_day_sheet',
|
||||
params: {
|
||||
'p_sheet': sheetId,
|
||||
'p_remarks': remarks.trim(),
|
||||
'p_flagged_task_ids': flaggedTaskIds,
|
||||
},
|
||||
);
|
||||
|
||||
if (result is Map<String, dynamic>) {
|
||||
final programmerId = result['programmer_id'] as String?;
|
||||
final rawDate = result['work_date'] as String?;
|
||||
if (programmerId != null && rawDate != null) {
|
||||
final workDate = parseDayKey(rawDate);
|
||||
final truncated = remarks.trim().length > 80
|
||||
? '${remarks.trim().substring(0, 80)}…'
|
||||
: remarks.trim();
|
||||
try {
|
||||
await _push(
|
||||
userIds: [programmerId],
|
||||
title: 'Day sheet disapproved',
|
||||
body:
|
||||
'Your day sheet for ${AppTime.formatDate(workDate)} needs changes: $truncated',
|
||||
data: {
|
||||
'type': 'day_sheet_disapproved',
|
||||
'navigate_to': '/programmer-tasks/day-sheets/$sheetId',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DaySheetsController.disapprove push error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is DaySheetActionException) rethrow;
|
||||
throw DaySheetActionException(mapDaySheetError(e));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Justify
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<void> justify({
|
||||
required String sheetId,
|
||||
required String body,
|
||||
}) async {
|
||||
if (body.trim().isEmpty) {
|
||||
throw DaySheetActionException(DaySheetError.bodyRequired);
|
||||
}
|
||||
try {
|
||||
final result = await _client.rpc(
|
||||
'justify_day_sheet',
|
||||
params: {
|
||||
'p_sheet': sheetId,
|
||||
'p_body': body.trim(),
|
||||
},
|
||||
);
|
||||
|
||||
if (result is Map<String, dynamic>) {
|
||||
final notifyUserIds =
|
||||
List<String>.from(result['notify_user_ids'] as List? ?? []);
|
||||
final programmerName = result['programmer_name'] as String?;
|
||||
final rawDate = result['work_date'] as String?;
|
||||
if (notifyUserIds.isNotEmpty &&
|
||||
programmerName != null &&
|
||||
rawDate != null) {
|
||||
final workDate = parseDayKey(rawDate);
|
||||
try {
|
||||
await _push(
|
||||
userIds: notifyUserIds,
|
||||
title: 'Day sheet resubmitted',
|
||||
body:
|
||||
'$programmerName justified their day sheet for ${AppTime.formatDate(workDate)}',
|
||||
data: {
|
||||
'type': 'day_sheet_resubmitted',
|
||||
'navigate_to': '/programmer-tasks/day-sheets/$sheetId',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DaySheetsController.justify push error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e is DaySheetActionException) rethrow;
|
||||
throw DaySheetActionException(mapDaySheetError(e));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Approve (bulk)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Future<BulkApproveResult> approve(
|
||||
List<({String sheetId, DaySheetSnapshot snapshot})> items,
|
||||
) async {
|
||||
try {
|
||||
final params = items
|
||||
.map((e) => {
|
||||
'sheet_id': e.sheetId,
|
||||
'snapshot': e.snapshot.toMap(),
|
||||
})
|
||||
.toList();
|
||||
|
||||
final result = await _client.rpc(
|
||||
'approve_day_sheets',
|
||||
params: {'p_items': params},
|
||||
) as Map<String, dynamic>;
|
||||
|
||||
final approved = List<Map<String, dynamic>>.from(
|
||||
(result['approved'] as List? ?? []).cast<Map<String, dynamic>>(),
|
||||
);
|
||||
final skippedIds =
|
||||
List<String>.from(result['skipped_ids'] as List? ?? []);
|
||||
|
||||
for (final item in approved) {
|
||||
final approvedSheetId = item['sheet_id'] as String;
|
||||
final programmerId = item['programmer_id'] as String?;
|
||||
final rawDate = item['work_date'] as String?;
|
||||
if (programmerId != null && rawDate != null) {
|
||||
final workDate = parseDayKey(rawDate);
|
||||
try {
|
||||
await _push(
|
||||
userIds: [programmerId],
|
||||
title: 'Day sheet approved',
|
||||
body:
|
||||
'Your day sheet for ${AppTime.formatDate(workDate)} was approved',
|
||||
data: {
|
||||
'type': 'day_sheet_approved',
|
||||
'navigate_to':
|
||||
'/programmer-tasks/day-sheets/$approvedSheetId',
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DaySheetsController.approve push error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return BulkApproveResult(
|
||||
approvedIds: approved.map((e) => e['sheet_id'] as String).toList(),
|
||||
skippedIds: skippedIds,
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is DaySheetActionException) rethrow;
|
||||
throw DaySheetActionException(mapDaySheetError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Riverpod providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final daySheetsControllerProvider = Provider<DaySheetsController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final notifications = ref.read(notificationsControllerProvider);
|
||||
return DaySheetsController(
|
||||
client,
|
||||
push: notifications.sendPush,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Day sheets for the currently authenticated programmer, newest first.
|
||||
final myDaySheetsProvider =
|
||||
StreamProvider<List<ProgrammerDaySheet>>((ref) {
|
||||
final userId = ref.watch(currentUserIdProvider);
|
||||
if (userId == null) return const Stream.empty();
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('programmer_day_sheets')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('programmer_id', userId)
|
||||
.order('work_date', ascending: false)
|
||||
.map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList());
|
||||
});
|
||||
|
||||
/// Day sheets for a specific user (admin view), newest first.
|
||||
final daySheetsForUserProvider =
|
||||
StreamProvider.family<List<ProgrammerDaySheet>, String>((ref, userId) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('programmer_day_sheets')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('programmer_id', userId)
|
||||
.order('work_date', ascending: false)
|
||||
.map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList());
|
||||
});
|
||||
|
||||
/// All day sheets visible to the current user (RLS-scoped), newest first.
|
||||
final allDaySheetsProvider =
|
||||
StreamProvider<List<ProgrammerDaySheet>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('programmer_day_sheets')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('work_date', ascending: false)
|
||||
.map((rows) => rows.map(ProgrammerDaySheet.fromMap).toList());
|
||||
});
|
||||
|
||||
/// A single day sheet by its ID, or null if not found.
|
||||
final daySheetByIdProvider =
|
||||
StreamProvider.family<ProgrammerDaySheet?, String>((ref, id) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('programmer_day_sheets')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('id', id)
|
||||
.map((rows) => rows.isEmpty ? null : ProgrammerDaySheet.fromMap(rows.first));
|
||||
});
|
||||
|
||||
/// Events for a day sheet, ordered by creation time ascending.
|
||||
final daySheetEventsProvider =
|
||||
StreamProvider.family<List<ProgrammerDaySheetEvent>, String>((ref, sheetId) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('programmer_day_sheet_events')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('sheet_id', sheetId)
|
||||
.order('created_at', ascending: true)
|
||||
.map((rows) => rows.map(ProgrammerDaySheetEvent.fromMap).toList());
|
||||
});
|
||||
@@ -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