636 lines
19 KiB
Dart
636 lines
19 KiB
Dart
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<String, String>? 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<JenkinsClientException>().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<int>.filled(1024, 1);
|
|
final chunk2 = List<int>.filled(512, 2);
|
|
|
|
final client = JenkinsClient(
|
|
client: MockClient.streaming((request, _) async {
|
|
final controller = StreamController<List<int>>();
|
|
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<JenkinsClientException>().having(
|
|
(e) => e.statusCode,
|
|
'statusCode',
|
|
403,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('downloadArtifactStream can be cancelled before completion', () async {
|
|
final firstChunkCompleter = Completer<void>();
|
|
|
|
final client = JenkinsClient(
|
|
client: MockClient.streaming((request, _) async {
|
|
final controller = StreamController<List<int>>();
|
|
controller.onListen = () async {
|
|
controller.add(List<int>.filled(512, 1));
|
|
await firstChunkCompleter.future;
|
|
if (!controller.isClosed) {
|
|
controller.add(List<int>.filled(512, 2));
|
|
await controller.close();
|
|
}
|
|
};
|
|
return http.StreamedResponse(controller.stream, 200);
|
|
}),
|
|
);
|
|
|
|
final task = client.downloadArtifactStream(
|
|
artifactUrl: artifactUrl,
|
|
credentials: credentials,
|
|
);
|
|
|
|
final received = <DownloadProgressEvent>[];
|
|
final doneCompleter = Completer<void>();
|
|
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<String, String>? 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<JenkinsClientException>().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<JenkinsClientException>().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<JenkinsClientException>().having(
|
|
(e) => e.statusCode,
|
|
'statusCode',
|
|
401,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('JenkinsClient.fetchRecentBuilds', () {
|
|
test(
|
|
'requests build tree and returns only builds with apk artifacts',
|
|
() async {
|
|
Uri? capturedUri;
|
|
Map<String, String>? 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)));
|
|
});
|
|
});
|
|
}
|