web: migrate health notifications to js_interop; add browser hook
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled

- Replace dart:js with package:js in health_check_service_web.dart\n- Implement showHealthCheckNotification in web/index.html\n- Pin js dependency to ^0.6.7 for flutter_secure_storage_web compatibility

auth: harden AuthInterceptor + tests

- Allow overrideAuthRepository injection for testing\n- Normalize imports to package: paths\n- Add unit test covering token attach, 401→refresh→retry, and failure path\n- Add integration test skeleton gated by env vars

ui/data: map User.companyName to list column

- Add companyName to domain User\n- Map UserDto.company?.name\n- Render companyName in user_list

cleanup: remove legacy equipment table + unused code; minor warnings

- Remove _buildFlexibleTable and unused helpers\n- Remove unused zipcode details and cache retry constant\n- Fix null-aware and non-null assertions\n- Address child-last warnings in administrator dialog

docs: update AGENTS.md session context
This commit is contained in:
JiWoong Sul
2025-09-08 17:39:00 +09:00
parent 519e1883a3
commit 655d473413
55 changed files with 2729 additions and 4968 deletions

View File

@@ -0,0 +1,216 @@
import 'package:dartz/dartz.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/core/constants/api_endpoints.dart';
import 'package:superport/core/errors/failures.dart';
import 'package:superport/data/datasources/remote/interceptors/auth_interceptor.dart';
import 'package:superport/data/models/auth/refresh_token_request.dart';
import 'package:superport/data/models/auth/login_request.dart';
import 'package:superport/data/models/auth/login_response.dart';
import 'package:superport/data/models/auth/auth_user.dart';
import 'package:superport/data/models/auth/token_response.dart';
import 'package:superport/domain/repositories/auth_repository.dart';
class _InMemoryAuthRepository implements AuthRepository {
String? _accessToken;
String? _refreshToken;
bool cleared = false;
_InMemoryAuthRepository({String? accessToken, String? refreshToken})
: _accessToken = accessToken,
_refreshToken = refreshToken;
@override
Future<Either<Failure, String?>> getStoredAccessToken() async => Right(_accessToken);
@override
Future<Either<Failure, String?>> getStoredRefreshToken() async => Right(_refreshToken);
@override
Future<Either<Failure, TokenResponse>> refreshToken(RefreshTokenRequest refreshRequest) async {
if (refreshRequest.refreshToken != _refreshToken || _refreshToken == null) {
return Left(ServerFailure(message: 'Invalid refresh token'));
}
// Issue new tokens
_accessToken = 'NEW_TOKEN';
_refreshToken = 'NEW_REFRESH';
return Right(TokenResponse(
accessToken: _accessToken!,
refreshToken: _refreshToken!,
tokenType: 'Bearer',
expiresIn: 3600,
));
}
@override
Future<Either<Failure, void>> clearLocalSession() async {
cleared = true;
_accessToken = null;
_refreshToken = null;
return const Right(null);
}
// Unused in these tests
@override
Future<Either<Failure, void>> changePassword(String currentPassword, String newPassword) async =>
const Right(null);
@override
Future<Either<Failure, void>> requestPasswordReset(String email) async => const Right(null);
@override
Future<Either<Failure, bool>> isAuthenticated() async => const Right(true);
@override
Future<Either<Failure, void>> logout() async => const Right(null);
@override
Future<Either<Failure, bool>> validateSession() async => const Right(true);
@override
Future<Either<Failure, LoginResponse>> login(LoginRequest loginRequest) async =>
Left(ServerFailure(message: 'not used in test'));
@override
Future<Either<Failure, AuthUser>> getCurrentUser() async =>
Left(ServerFailure(message: 'not used in test'));
}
/// Interceptor that terminates requests without hitting network.
class _TerminatorInterceptor extends Interceptor {
RequestOptions? lastOptions;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
lastOptions = options;
// Default success response (200)
handler.resolve(Response(requestOptions: options, statusCode: 200, data: {'ok': true}));
}
}
/// Interceptor that rejects with 401 unless it sees the expected token.
class _UnauthorizedThenOkInterceptor extends Interceptor {
final String requiredBearer;
_UnauthorizedThenOkInterceptor(this.requiredBearer);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final auth = options.headers['Authorization'];
// Debug: record current auth header on the test interceptor
// ignore: avoid_print
print('Test UnauthorizedThenOkInterceptor saw Authorization: ' + (auth?.toString() ?? 'NULL'));
if (auth == 'Bearer $requiredBearer') {
handler.resolve(Response(requestOptions: options, statusCode: 200, data: {'ok': true}));
} else {
handler.reject(DioException(
requestOptions: options,
response: Response(requestOptions: options, statusCode: 401),
type: DioExceptionType.badResponse,
message: 'Unauthorized',
));
}
}
}
/// Simple HTTP adapter that returns 200 when Authorization matches,
/// otherwise returns 401. This is used to exercise `dio.fetch(...)` retry path.
class _AuthTestAdapter implements HttpClientAdapter {
@override
void close({bool force = false}) {}
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future? cancelFuture,
) async {
final auth = options.headers['Authorization'];
// Login endpoint always returns 200
if (options.path == ApiEndpoints.login) {
return ResponseBody.fromString('{"ok":true}', 200, headers: {});
}
if (auth == 'Bearer NEW_TOKEN') {
return ResponseBody.fromString('{"ok":true}', 200, headers: {});
}
return ResponseBody.fromString('', 401, headers: {});
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('AuthInterceptor', () {
late GetIt sl;
setUp(() {
sl = GetIt.instance;
sl.reset(dispose: true);
});
tearDown(() {
sl.reset(dispose: true);
});
test('attaches Authorization header for protected endpoints', () async {
final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: 'REFRESH');
sl.registerLazySingleton<AuthRepository>(() => repo);
final dio = Dio(BaseOptions(baseUrl: 'https://example.com'));
final terminator = _TerminatorInterceptor();
dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo));
dio.interceptors.add(terminator);
final res = await dio.get(ApiEndpoints.users);
expect(res.statusCode, 200);
expect(terminator.lastOptions, isNotNull);
expect(terminator.lastOptions!.headers['Authorization'], 'Bearer OLD_TOKEN');
});
test('skips Authorization header for auth endpoints', () async {
final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: 'REFRESH');
sl.registerLazySingleton<AuthRepository>(() => repo);
final dio = Dio(BaseOptions(baseUrl: 'https://example.com'));
final terminator = _TerminatorInterceptor();
dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo));
dio.interceptors.add(terminator);
final res = await dio.get(ApiEndpoints.login);
expect(res.statusCode, 200);
expect(terminator.lastOptions, isNotNull);
expect(terminator.lastOptions!.headers.containsKey('Authorization'), isFalse);
});
test('on 401, refresh token and retry succeeds', () async {
final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: 'REFRESH');
sl.registerLazySingleton<AuthRepository>(() => repo);
final dio = Dio(BaseOptions(baseUrl: 'https://example.com'));
dio.httpClientAdapter = _AuthTestAdapter();
dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo));
final res = await dio.get(ApiEndpoints.users);
expect(res.statusCode, 200);
// ensure repo now holds new token
final tokenEither = await repo.getStoredAccessToken();
expect(tokenEither.getOrElse(() => null), 'NEW_TOKEN');
});
test('on 401 and refresh fails, session cleared and error bubbles', () async {
// Repo with no refresh token (will fail refresh)
final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: null);
sl.registerLazySingleton<AuthRepository>(() => repo);
// Verify registration exists
expect(GetIt.instance.isRegistered<AuthRepository>(), true);
final dio = Dio(BaseOptions(baseUrl: 'https://example.com'));
dio.httpClientAdapter = _AuthTestAdapter();
dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo));
DioException? caught;
try {
await dio.get(ApiEndpoints.users);
} on DioException catch (e) {
caught = e;
}
expect(caught, isNotNull);
expect(caught!.response?.statusCode, 401);
expect(repo.cleared, isTrue);
});
});
}