Stabilize auth flow and profile images

This commit is contained in:
Codex
2026-07-20 13:38:39 +09:00
parent 57caca8dc8
commit 5d3eee7a16
128 changed files with 28860 additions and 1468 deletions
@@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tdc114plus/src/core/actions/contact_launcher.dart';
void main() {
test('buildCallUri normalizes separators and keeps country code', () {
final uri = buildCallUri('+82 10-1234-5678');
expect(uri, isNotNull);
expect(uri.toString(), 'tel:+821012345678');
});
test('buildSmsUri returns null for empty phone number', () {
final uri = buildSmsUri(' - ');
expect(uri, isNull);
});
}
@@ -0,0 +1,72 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
import 'package:tdc114plus/src/features/directory/data/directory_api_client.dart';
void main() {
setUp(() async {
SharedPreferences.setMockInitialValues({});
await const AuthSessionStore().save(
PhoneLoginResponse(
status: 'ok',
token: 'session-token',
expiresAt: DateTime.parse('2026-07-02T12:00:00Z'),
user: const LoginUser(
id: 'user-uuid',
name: 'User One',
phoneNumber: '+821012345678',
tenantId: 'tenant-uuid',
tenantName: 'Hanmac',
tenantSlug: 'hanmac',
),
),
);
});
test('lists employees with bearer token and query filters', () async {
final client = DirectoryApiClient(
httpClient: MockClient((request) async {
expect(request.headers['authorization'], 'Bearer session-token');
expect(request.url.path, '/api/v1/tdc114plus/directory/employees');
expect(request.url.queryParameters['q'], 'kim');
expect(request.url.queryParameters['tenantSlug'], 'hanmac');
return http.Response(
jsonEncode({
'items': [
{
'id': 'user-uuid',
'name': 'User One',
'phoneNumber': '+821012345678',
'phoneDisplay': '010-1234-5678',
'tenantId': 'tenant-uuid',
'tenantName': 'Hanmac',
'tenantSlug': 'hanmac',
'status': 'active',
},
],
'limit': 50,
'offset': 0,
'total': 1,
'nextCursor': '',
}),
200,
);
}),
baseUri: Uri.parse('https://sso.example.test'),
sessionStore: const AuthSessionStore(),
);
final response = await client.listEmployees(
query: 'kim',
tenantSlug: 'hanmac',
);
expect(response.total, 1);
expect(response.items.single.phoneDisplay, '010-1234-5678');
});
}
@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tdc114plus/src/features/directory/presentation/directory_navigation.dart';
import 'package:tdc114plus/src/features/organization/domain/organization_models.dart';
void main() {
const tenants = [
TenantSummary(
id: 'company-a',
name: '총괄기획실/기술',
slug: 'hq-tech',
type: 'COMPANY',
memberCount: 10,
totalMemberCount: 50,
),
TenantSummary(
id: 'company-b',
name: '바른컨설턴트',
slug: 'consulting',
type: 'COMPANY',
memberCount: 5,
totalMemberCount: 20,
),
TenantSummary(
id: 'team-is3',
name: 'IS3',
slug: 'is3',
type: 'ORGANIZATION',
parentId: 'company-a',
memberCount: 3,
totalMemberCount: 3,
),
TenantSummary(
id: 'team-ptc',
name: 'PTC',
slug: 'ptc',
type: 'ORGANIZATION',
parentId: 'company-a',
memberCount: 2,
totalMemberCount: 2,
),
];
test('shows pinned team chip for selected company', () {
final layout = buildTenantNavigationModel(
tenants: tenants,
selectedTenantSlug: 'hq-tech',
pinnedTenantSlug: 'is3',
);
expect(layout.companyTenants.map((tenant) => tenant.slug), [
'hq-tech',
'consulting',
]);
expect(layout.visibleChips.map((tenant) => tenant.slug), [
'hq-tech',
'consulting',
'is3',
]);
});
test('builds breadcrumb and child tenants for selected child team', () {
final layout = buildTenantNavigationModel(
tenants: tenants,
selectedTenantSlug: 'is3',
pinnedTenantSlug: 'is3',
);
expect(layout.selectedTenant?.slug, 'is3');
expect(layout.breadcrumbTenants.map((tenant) => tenant.slug), [
'hq-tech',
'is3',
]);
expect(layout.childTenants, isEmpty);
});
test('shows company drilldown for all selection', () {
final layout = buildTenantNavigationModel(
tenants: tenants,
selectedTenantSlug: 'all',
pinnedTenantSlug: 'is3',
);
expect(layout.selectedTenant, isNull);
expect(layout.childTenants.map((tenant) => tenant.slug), [
'hq-tech',
'consulting',
]);
expect(layout.visibleChips.map((tenant) => tenant.slug), [
'hq-tech',
'consulting',
'is3',
]);
});
test('builds selected tenant subtree slugs for scoped search', () {
final layout = buildTenantNavigationModel(
tenants: tenants,
selectedTenantSlug: 'hq-tech',
pinnedTenantSlug: 'is3',
);
expect(layout.selectedTenantSubtreeSlugs, ['hq-tech', 'is3', 'ptc']);
});
test('resolves pinned team slug from company and department name', () {
final slug = resolvePinnedTenantSlug(
tenants: tenants,
companySlug: 'hq-tech',
departmentName: 'IS3',
);
expect(slug, 'is3');
});
}
@@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tdc114plus/src/features/directory/data/directory_repository.dart';
import 'package:tdc114plus/src/features/directory/domain/employee.dart';
void main() {
test('DirectoryQuery maps all tenant filter to nullable API parameters', () {
const query = DirectoryQuery(query: ' 김하늘 ', tenantSlug: 'all');
expect(query.apiQuery, '김하늘');
expect(query.apiTenantSlug, isNull);
});
test('DirectoryQuery keeps selected tenant slug for API requests', () {
const query = DirectoryQuery(query: '', tenantSlug: 'hanmac');
expect(query.apiQuery, isNull);
expect(query.apiTenantSlug, 'hanmac');
});
test('DirectoryRepository now focuses on employee lists only', () {
const employees = [
Employee(
id: 'user-001',
name: '김하늘',
phoneNumber: '+821012345678',
tenantId: 'tenant-hanmac',
tenantName: '한맥',
tenantSlug: 'hanmac',
),
];
expect(employees.single.name, '김하늘');
expect(employees.single.tenantSlug, 'hanmac');
});
}
@@ -0,0 +1,297 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tdc114plus/src/features/auth/data/auth_session_store.dart';
import 'package:tdc114plus/src/features/auth/domain/auth_models.dart';
import 'package:tdc114plus/src/features/directory/data/profile_image_api_client.dart';
import 'package:tdc114plus/src/features/directory/domain/employee.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('keeps explicit profileImageUrl without auth lookup', () async {
var requestCount = 0;
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
requestCount += 1;
return http.Response('{}', 500);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: const AuthSessionStore(),
);
const employee = Employee(
id: 'user-1',
name: '강경훈',
phoneNumber: '01012345678',
email: 'khkang@samaneng.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
profileImageUrl: 'https://cdn.example.test/khkang.jpg',
);
final imageUrl = await client.resolveImageUrl(employee);
expect(imageUrl, 'https://cdn.example.test/khkang.jpg');
expect(requestCount, 0);
});
test('uses auth profile-image endpoint when explicit profile image is absent', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'app-session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-1',
name: '강경훈',
phoneNumber: '01012345678',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
),
),
);
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
expect(request.url.host, '114-auth.hmac.kr');
expect(request.url.path, '/api/v1/profile-image');
expect(request.url.queryParameters['comp'], 'SAMAN');
expect(request.url.queryParameters['email'], 'khkang@samaneng.com');
expect(request.headers['Authorization'], 'Bearer app-session-token');
return http.Response(
jsonEncode({
'found': true,
'source': 'BARON_UUID_R2',
'imageUrl':
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: store,
);
const employee = Employee(
id: 'bebb832c-052e-493d-b5a9-732518d67685',
name: '강경훈',
phoneNumber: '01012345678',
email: 'khkang@samaneng.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
final imageUrl = await client.resolveImageUrl(employee);
expect(
imageUrl,
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
);
});
test('returns null when employee email is missing', () async {
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
fail('auth lookup should not be sent without email');
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: const AuthSessionStore(),
);
const employee = Employee(
id: 'user-2',
name: '직원',
phoneNumber: '01000000000',
tenantId: 'tenant-2',
tenantName: '한맥',
tenantSlug: 'hanmac',
);
expect(await client.resolveImageUrl(employee), isNull);
});
test('falls back to public uuid image when employee email is missing', () async {
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
fail('auth lookup should not be sent without email');
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: const AuthSessionStore(),
);
const employee = Employee(
id: 'bebb832c-052e-493d-b5a9-732518d67685',
name: '강경훈',
phoneNumber: '01012345678',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
expect(
await client.resolveImageUrl(employee),
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
);
});
test('allows auth lookup without company code when tenant mapping is unknown', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'app-session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-3',
name: '직원',
phoneNumber: '01011112222',
tenantId: 'tenant-3',
tenantName: 'Unknown',
tenantSlug: 'unknown',
),
),
);
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
expect(request.url.queryParameters['comp'], isNull);
expect(request.url.queryParameters['email'], 'person@example.com');
return http.Response(
jsonEncode({'found': false, 'source': 'DEFAULT'}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: store,
);
const employee = Employee(
id: 'user-3',
name: '직원',
phoneNumber: '01011112222',
email: 'person@example.com',
tenantId: 'tenant-3',
tenantName: 'Unknown',
tenantSlug: 'unknown',
);
expect(await client.resolveImageUrl(employee), isNull);
});
test('falls back to public uuid image when auth lookup returns not found', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'app-session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-4',
name: '강경훈',
phoneNumber: '01012345678',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
),
),
);
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
return http.Response(
jsonEncode({'found': false, 'source': 'DEFAULT'}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: store,
);
const employee = Employee(
id: 'bebb832c-052e-493d-b5a9-732518d67685',
name: '강경훈',
phoneNumber: '01012345678',
email: 'khkang@samaneng.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
expect(
await client.resolveImageUrl(employee),
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
);
});
test('uses uuid fallback even when auth not-found result is cached', () async {
const store = AuthSessionStore();
await store.save(
PhoneLoginResponse(
status: 'ok',
token: 'app-session-token',
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
user: const LoginUser(
id: 'user-5',
name: '직원',
phoneNumber: '01012345678',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
),
),
);
var requestCount = 0;
final client = ProfileImageApiClient(
httpClient: MockClient((request) async {
requestCount += 1;
return http.Response(
jsonEncode({'found': false, 'source': 'DEFAULT'}),
200,
headers: {'content-type': 'application/json'},
);
}),
baseUri: Uri.parse('https://114-auth.hmac.kr'),
sessionStore: store,
);
const firstEmployee = Employee(
id: 'legacy-user-id',
name: '직원',
phoneNumber: '01012345678',
email: 'cached-null@example.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
const secondEmployee = Employee(
id: 'bebb832c-052e-493d-b5a9-732518d67685',
name: '직원',
phoneNumber: '01012345678',
email: 'cached-null@example.com',
tenantId: 'tenant-1',
tenantName: '삼안',
tenantSlug: 'saman',
);
expect(await client.resolveImageUrl(firstEmployee), isNull);
expect(
await client.resolveImageUrl(secondEmployee),
'https://baroncs.co.kr/employee_img/bebb832c-052e-493d-b5a9-732518d67685.jpg',
);
expect(requestCount, 1);
});
}