appsok/test/jenkins_client_test.dart
toki 688e907574 feat: jenkins credential milestone - app wiring & session foundation
- Move artifact-browser milestone to archive
- Add Jenkins artifact session service
- Add token store tests
- Update app wiring for Jenkins credential feature
- Update builds page and Jenkins client
- Add code review and plan documents for G06
2026-06-11 09:01:26 +09:00

563 lines
17 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,
),
),
);
});
});
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);
},
);
});
}