import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:appsok/src/models/jenkins_build.dart'; import 'package:appsok/src/services/jenkins_client.dart'; void main() { const credentials = JenkinsCredentials(username: 'user', apiToken: 'token'); final baseUrl = Uri.parse('https://jenkins.example/'); group('JenkinsJob.fromJson', () { test('parses jenkins jobs from api response', () { final json = { 'name': 'my-app', 'fullName': 'my-app', 'url': 'https://jenkins.example/job/my-app/', 'color': 'blue', '_class': 'hudson.model.FreeStyleProject', }; final job = JenkinsJob.fromJson(json); expect(job.name, 'my-app'); expect(job.fullName, 'my-app'); expect(job.url, Uri.parse('https://jenkins.example/job/my-app/')); expect(job.color, 'blue'); expect(job.isFolder, isFalse); }); test('marks folder jobs from _class', () { final json = { 'name': 'team-folder', 'fullName': 'team-folder', 'url': 'https://jenkins.example/job/team-folder/', '_class': 'com.cloudbees.hudson.plugins.folder.Folder', }; final job = JenkinsJob.fromJson(json); expect(job.name, 'team-folder'); expect(job.isFolder, isTrue); expect(job.color, isNull); }); test('parses job with minimal fields', () { final json = { 'name': 'minimal-job', 'url': 'https://jenkins.example/job/minimal-job/', }; final job = JenkinsJob.fromJson(json); expect(job.name, 'minimal-job'); expect(job.fullName, isNull); expect(job.color, isNull); expect(job.isFolder, isFalse); }); }); group('JenkinsBuild.fromJson', () { test( 'parses build metadata, requested user, parameters, and artifacts', () { final build = JenkinsBuild.fromJson({ 'number': 42, 'url': 'https://jenkins.example/job/app/42/', 'timestamp': 1760000000000, 'result': 'SUCCESS', 'actions': [ { 'causes': [ {'userName': 'Ada Lovelace', 'userId': 'ada'}, ], 'parameters': [ {'name': 'BRANCH_NAME', 'value': 'feature/install'}, {'name': 'FLAVOR', 'value': 'qa'}, ], }, ], 'artifacts': [ {'fileName': 'app-qa.apk', 'relativePath': 'outputs/app-qa.apk'}, ], }, jobName: 'android-app'); expect(build.number, 42); expect(build.jobName, 'android-app'); expect(build.result, 'SUCCESS'); expect(build.isRunning, isFalse); expect(build.requestedBy, 'Ada Lovelace'); expect(build.branch, 'feature/install'); expect(build.flavor, 'qa'); expect(build.artifacts.single.fileName, 'app-qa.apk'); expect(build.apkArtifacts.single.relativePath, 'outputs/app-qa.apk'); }, ); test('falls back from cause trigger source to commit author', () { final upstreamBuild = JenkinsBuild.fromJson({ 'number': 41, 'url': 'https://jenkins.example/job/app/41/', 'result': null, 'actions': [ { 'causes': [ { 'shortDescription': 'Started by upstream project smoke', 'upstreamProject': 'smoke', 'upstreamBuild': 77, }, ], }, ], 'changeSet': { 'items': [ { 'author': {'fullName': 'Grace Hopper'}, }, ], }, 'artifacts': [], }, jobName: 'android-app'); final commitBuild = JenkinsBuild.fromJson({ 'number': 40, 'url': 'https://jenkins.example/job/app/40/', 'result': 'FAILURE', 'actions': [], 'changeSet': { 'items': [ { 'author': {'id': 'hopper'}, }, ], }, 'artifacts': [], }, jobName: 'android-app'); expect(upstreamBuild.isRunning, isTrue); expect(upstreamBuild.requestedBy, 'Started by upstream project smoke'); expect(commitBuild.requestedBy, 'hopper'); }); }); group('BuildArtifact', () { test('detects apk artifacts case-insensitively', () { final artifact = BuildArtifact( fileName: 'APP-QA.APK', relativePath: 'outputs/app-qa.APK', ); final mapping = BuildArtifact( fileName: 'mapping.txt', relativePath: 'outputs/mapping.txt', ); expect(artifact.isApk, isTrue); expect(mapping.isApk, isFalse); }); }); group('JenkinsClient.fetchJobs', () { test('fetchJobs requests root api json with job tree', () async { Uri? capturedUri; Map? capturedHeaders; final client = JenkinsClient( client: MockClient((request) async { capturedUri = request.url; capturedHeaders = request.headers; return http.Response( jsonEncode({ 'jobs': [ { 'name': 'app-release', 'fullName': 'app-release', 'url': 'https://jenkins.example/job/app-release/', 'color': 'blue', '_class': 'hudson.model.FreeStyleProject', }, ], }), 200, ); }), ); final jobs = await client.fetchJobs( baseUrl: baseUrl, credentials: credentials, ); expect(jobs, hasLength(1)); expect(jobs.first.name, 'app-release'); expect(capturedUri?.path, contains('api/json')); expect( capturedUri?.queryParameters['tree'], 'jobs[name,fullName,url,color,_class]', ); final expectedAuth = 'Basic ${base64Encode(utf8.encode('user:token'))}'; expect(capturedHeaders?['Authorization'], expectedAuth); }); test('fetchJobs returns empty list when jobs key is missing', () async { final client = JenkinsClient( client: MockClient( (_) async => http.Response(jsonEncode({'_class': 'hudson.model.Hudson'}), 200), ), ); final jobs = await client.fetchJobs( baseUrl: baseUrl, credentials: credentials, ); expect(jobs, isEmpty); }); test('fetchJobs returns empty list when jobs is empty array', () async { final client = JenkinsClient( client: MockClient( (_) async => http.Response(jsonEncode({'jobs': []}), 200), ), ); final jobs = await client.fetchJobs( baseUrl: baseUrl, credentials: credentials, ); expect(jobs, isEmpty); }); test( 'fetchJobs throws JenkinsClientException on forbidden response', () async { final client = JenkinsClient( client: MockClient((_) async => http.Response('Forbidden', 403)), ); expect( () => client.fetchJobs(baseUrl: baseUrl, credentials: credentials), throwsA( isA().having( (e) => e.statusCode, 'statusCode', 403, ), ), ); }, ); }); group('JenkinsClient.downloadArtifactStream', () { final artifactUrl = Uri.parse('https://jenkins.example/artifact/app.apk'); test( 'downloadArtifactStream emits progress while reading chunks', () async { final chunk1 = List.filled(1024, 1); final chunk2 = List.filled(512, 2); final client = JenkinsClient( client: MockClient.streaming((request, _) async { final controller = StreamController>(); scheduleMicrotask(() async { controller.add(chunk1); controller.add(chunk2); await controller.close(); }); return http.StreamedResponse( controller.stream, 200, contentLength: chunk1.length + chunk2.length, ); }), ); final task = client.downloadArtifactStream( artifactUrl: artifactUrl, credentials: credentials, ); final events = await task.events.toList(); expect(events, hasLength(3)); expect(events[0].receivedBytes, chunk1.length); expect(events[0].chunkBytes, chunk1); expect(events[0].isComplete, isFalse); expect(events[1].receivedBytes, chunk1.length + chunk2.length); expect(events[1].chunkBytes, chunk2); expect(events[1].isComplete, isFalse); expect(events[2].isComplete, isTrue); expect(events[2].chunkBytes, isEmpty); expect(events[2].receivedBytes, chunk1.length + chunk2.length); expect(events[2].totalBytes, chunk1.length + chunk2.length); }, ); test('downloadArtifactStream reports forbidden response', () async { final client = JenkinsClient( client: MockClient.streaming( (req, body) async => http.StreamedResponse( Stream.value(utf8.encode('Forbidden')), 403, ), ), ); final task = client.downloadArtifactStream( artifactUrl: artifactUrl, credentials: credentials, ); expect( task.events.toList(), throwsA( isA().having( (e) => e.statusCode, 'statusCode', 403, ), ), ); }); test('downloadArtifactStream can be cancelled before completion', () async { final firstChunkCompleter = Completer(); final client = JenkinsClient( client: MockClient.streaming((request, _) async { final controller = StreamController>(); controller.onListen = () async { controller.add(List.filled(512, 1)); await firstChunkCompleter.future; if (!controller.isClosed) { controller.add(List.filled(512, 2)); await controller.close(); } }; return http.StreamedResponse(controller.stream, 200); }), ); final task = client.downloadArtifactStream( artifactUrl: artifactUrl, credentials: credentials, ); final received = []; final doneCompleter = Completer(); task.events.listen( (e) { received.add(e); if (received.length == 1) { task.cancel(); firstChunkCompleter.complete(); } }, onDone: () => doneCompleter.complete(), onError: (_) => doneCompleter.complete(), cancelOnError: false, ); await doneCompleter.future; expect(received, isNotEmpty); expect(received.any((e) => e.isComplete), isFalse); }); }); group('JenkinsClient.fetchCurrentUser', () { test('fetchCurrentUser requests whoAmI api with auth header', () async { Uri? capturedUri; Map? capturedHeaders; final client = JenkinsClient( client: MockClient((request) async { capturedUri = request.url; capturedHeaders = request.headers; return http.Response( jsonEncode({'id': 'ada', 'fullName': 'Ada Lovelace'}), 200, ); }), ); final user = await client.fetchCurrentUser( baseUrl: baseUrl, credentials: credentials, ); expect(user.id, 'ada'); expect(user.displayName, 'Ada Lovelace'); expect(capturedUri?.path, contains('whoAmI/api/json')); final expectedAuth = 'Basic ${base64Encode(utf8.encode('user:token'))}'; expect(capturedHeaders?['Authorization'], expectedAuth); }); test('fetchCurrentUser parses id and display name with fallback', () async { final client = JenkinsClient( client: MockClient( (_) async => http.Response(jsonEncode({'id': 'grace', 'fullName': ''}), 200), ), ); final user = await client.fetchCurrentUser( baseUrl: baseUrl, credentials: credentials, ); expect(user.id, 'grace'); expect(user.displayName, 'grace'); }); test( 'fetchCurrentUser falls back to name when fullName is blank', () async { final client = JenkinsClient( client: MockClient( (_) async => http.Response( jsonEncode({ 'id': 'grace', 'fullName': ' ', 'name': 'Grace Hopper', }), 200, ), ), ); final user = await client.fetchCurrentUser( baseUrl: baseUrl, credentials: credentials, ); expect(user.id, 'grace'); expect(user.displayName, 'Grace Hopper'); }, ); test( 'fetchCurrentUser throws JenkinsClientException on forbidden response', () async { final client = JenkinsClient( client: MockClient((_) async => http.Response('Forbidden', 403)), ); expect( () => client.fetchCurrentUser( baseUrl: baseUrl, credentials: credentials, ), throwsA( isA().having( (e) => e.statusCode, 'statusCode', 403, ), ), ); }, ); test('fetchCurrentUser rejects anonymous whoAmI response', () async { final client = JenkinsClient( client: MockClient( (_) async => http.Response( jsonEncode({ 'anonymous': true, 'id': 'anonymous', 'fullName': 'anonymous', }), 200, ), ), ); expect( () => client.fetchCurrentUser(baseUrl: baseUrl, credentials: credentials), throwsA( isA().having( (e) => e.statusCode, 'statusCode', 401, ), ), ); }); test('fetchCurrentUser rejects blank whoAmI id', () async { final client = JenkinsClient( client: MockClient( (_) async => http.Response(jsonEncode({'anonymous': false, 'id': ' '}), 200), ), ); expect( () => client.fetchCurrentUser(baseUrl: baseUrl, credentials: credentials), throwsA( isA().having( (e) => e.statusCode, 'statusCode', 401, ), ), ); }); }); group('JenkinsClient.fetchRecentBuilds', () { test( 'requests build tree and returns only builds with apk artifacts', () async { Uri? capturedUri; Map? capturedHeaders; final jobUrl = Uri.parse('https://jenkins.example/job/android-app/'); final client = JenkinsClient( client: MockClient((request) async { capturedUri = request.url; capturedHeaders = request.headers; return http.Response( jsonEncode({ 'builds': [ { 'number': 12, 'url': 'https://jenkins.example/job/android-app/12/', 'timestamp': 1760000000000, 'result': 'SUCCESS', 'actions': [ { 'causes': [ {'userName': 'Ada Lovelace', 'userId': 'ada'}, ], 'parameters': [ {'name': 'branch', 'value': 'main'}, {'name': 'flavor', 'value': 'qa'}, ], }, ], 'artifacts': [ { 'fileName': 'android-app-qa.apk', 'relativePath': 'outputs/android-app-qa.apk', }, { 'fileName': 'mapping.txt', 'relativePath': 'outputs/mapping.txt', }, ], }, { 'number': 11, 'url': 'https://jenkins.example/job/android-app/11/', 'timestamp': 1750000000000, 'result': 'FAILURE', 'actions': [], 'artifacts': [ { 'fileName': 'mapping.txt', 'relativePath': 'outputs/mapping.txt', }, ], }, ], }), 200, ); }), ); final builds = await client.fetchRecentBuilds( jobUrl: jobUrl, jobName: 'android-app', credentials: credentials, ); expect(builds, hasLength(1)); expect(builds.single.number, 12); expect(builds.single.requestedBy, 'Ada Lovelace'); expect(builds.single.result, 'SUCCESS'); expect(builds.single.branch, 'main'); expect(builds.single.flavor, 'qa'); expect(builds.single.artifacts, hasLength(2)); expect( builds.single.apkArtifacts.single.fileName, 'android-app-qa.apk', ); expect(capturedUri?.path, '/job/android-app/api/json'); expect( capturedUri?.queryParameters['tree'], 'builds[number,url,timestamp,result,actions[parameters[name,value],causes[userName,userId,shortDescription,upstreamProject,upstreamBuild]],changeSet[items[author[fullName,id],authorEmail]],artifacts[fileName,relativePath]]', ); final expectedAuth = 'Basic ${base64Encode(utf8.encode('user:token'))}'; expect(capturedHeaders?['Authorization'], expectedAuth); }, ); }); group('JenkinsClientException', () { test('toString redacts credential-like values', () { final apiToken = ['api', 'token', 'value'].join('-'); final password = ['pass', 'word', 'value'].join('-'); final basic = base64Encode(utf8.encode('user:$apiToken')); final exception = JenkinsClientException( statusCode: 500, message: 'Authorization: Basic $basic apiToken=$apiToken ' 'password="$password" ' 'https://jenkins.example/me?token=$apiToken', ); final text = exception.toString(); expect(text, contains('JenkinsClientException(500)')); expect(text, contains('apiToken=[redacted]')); expect(text, contains('password="[redacted]"')); expect(text, contains('?token=[redacted]')); expect(text, isNot(contains(apiToken))); expect(text, isNot(contains(password))); expect(text, isNot(contains(basic))); }); }); }