code_work_spawner/test/issue_tracker_client_test.dart

284 lines
12 KiB
Dart

import 'dart:io';
import 'package:code_work_spawner/code_work_spawner.dart';
import 'package:github/github.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'test_support.dart';
void main() {
/// ```gherkin
/// Feature: Issue tracker payload mapping
///
/// As a maintainer extending tracker providers
/// I want issue summaries to serialize and deserialize consistently
/// So that stored tracker state can be reused safely across provider-specific API calls
/// ```
group('GitHubIssueSummary.fromJson', () {
/// ```gherkin
/// Scenario: Preserve repository slugs for repository-backed trackers
/// Given a GitHub issue summary created from a repository-backed tracker payload
/// When the summary is serialized and read back through fromJson
/// Then the repository slug remains available on the deserialized summary
/// And requiredRepoSlug still returns the original owner and repository
/// ```
test('Preserve repository slugs for repository-backed trackers', () {
// Given a GitHub issue summary created from a repository-backed tracker payload.
final original = GitHubIssueSummary.fromRepositoryJson(
RepositorySlug('owner', 'sample'),
<String, dynamic>{
'number': 12,
'title': 'Need architecture help',
'body': 'Please review the API layering.',
'state': 'open',
'html_url': 'https://example.test/issues/12',
'updated_at': '2026-04-04T12:00:00Z',
'user': <String, dynamic>{'login': 'reporter'},
'labels': <Map<String, String>>[
<String, String>{'name': 'help'},
],
},
);
// When the summary is serialized and read back through fromJson.
final restored = GitHubIssueSummary.fromJson(
original.toJson().cast<String, dynamic>(),
);
// Then the repository slug remains available on the deserialized summary.
expect(restored.repoSlug, isNotNull);
// And requiredRepoSlug still returns the original owner and repository.
expect(restored.requiredRepoSlug.owner, 'owner');
expect(restored.requiredRepoSlug.name, 'sample');
});
/// ```gherkin
/// Scenario: Preserve GitLab project paths without forcing owner/repo slugs
/// Given a GitLab issue summary created from a namespaced GitLab project payload
/// When the summary is serialized and read back through fromJson
/// Then the original GitLab project path remains available on the deserialized summary
/// And the deserialized summary does not invent a two-segment repository slug
/// ```
test('Preserve GitLab project paths without forcing owner/repo slugs', () {
// Given a GitLab issue summary created from a namespaced GitLab project payload.
final original = GitHubIssueSummary.fromGitLabJson(
'group/subgroup/sample',
<String, dynamic>{
'iid': 12,
'title': 'Need GitLab support',
'description': 'Please add glab support.',
'state': 'opened',
'web_url':
'https://gitlab.example.test/group/subgroup/sample/issues/12',
'updated_at': '2026-04-04T12:00:00Z',
'author': <String, dynamic>{'username': 'reporter'},
'labels': <String>['enhancement'],
},
);
// When the summary is serialized and read back through fromJson.
final restored = GitHubIssueSummary.fromJson(
original.toJson().cast<String, dynamic>(),
);
// Then the original GitLab project path remains available on the deserialized summary.
expect(restored.trackerProject, 'group/subgroup/sample');
// And the deserialized summary does not invent a two-segment repository slug.
expect(restored.repoSlug, isNull);
});
});
/// ```gherkin
/// Feature: GitLab tracker client integration
///
/// As a maintainer adding GitLab issue tracking
/// I want the tracker client to talk to glab and normalize GitLab payloads
/// So that polling, thread loading, and comment posting work like the existing providers
/// ```
group('IssueTrackerClient GitLab', () {
/// ```gherkin
/// Scenario: Fetch GitLab issues, comments, and login through glab
/// Given a fake glab executable with one GitLab issue and one note
/// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment
/// Then the GitLab issue payload is normalized into the shared issue summary model
/// And GitLab note URLs fall back to the issue note anchor when the API omits a web URL
/// ```
test('Fetch GitLab issues, comments, and login through glab', () async {
// Given a fake glab executable with one GitLab issue and one note.
final sandbox = await Directory.systemTemp.createTemp('cws-glab-');
final glabScript = File(p.join(sandbox.path, 'glab'));
final postedBodyFile = File(p.join(sandbox.path, 'posted-body.txt'));
await writeFakeGlabScript(
glabScript: glabScript,
issueListResponsesByRepo: <String, List<Map<String, Object?>>>{
'group/subgroup/sample': <Map<String, Object?>>[
<String, Object?>{
'iid': 12,
'title': 'Need GitLab support',
'description': 'Please add glab support.',
'state': 'opened',
'web_url':
'https://gitlab.example.test/group/subgroup/sample/issues/12',
'updated_at': '2026-04-04T12:00:00Z',
'author': <String, Object?>{'username': 'reporter'},
'labels': <String>['enhancement'],
},
],
},
commentResponsesByRepo: <String, Map<int, List<Map<String, Object?>>>>{
'group/subgroup/sample': <int, List<Map<String, Object?>>>{
12: <Map<String, Object?>>[
<String, Object?>{
'id': 501,
'body': 'Working on it.',
'created_at': '2026-04-04T12:05:00Z',
'updated_at': '2026-04-04T12:06:00Z',
'author': <String, Object?>{'username': 'maintainer'},
},
],
},
},
postedBodyFile: postedBodyFile,
viewerLogin: 'glab-user',
);
final client = IssueTrackerClient(glabCommand: glabScript.path);
// When the tracker client fetches updates, loads the thread, resolves the login, and posts a comment.
final issues = await client.fetchUpdatedIssuesForRepos(
provider: IssueTrackerProvider.gitlab,
trackerProjects: const <String>['group/subgroup/sample'],
since: null,
);
final thread = await client.fetchThread(
provider: IssueTrackerProvider.gitlab,
trackerProject: 'group/subgroup/sample',
issue: issues.single,
);
final login = await client.getAuthenticatedLogin(
provider: IssueTrackerProvider.gitlab,
);
final posted = await client.createIssueComment(
provider: IssueTrackerProvider.gitlab,
trackerProject: 'group/subgroup/sample',
issueNumber: 12,
body: 'Reply from glab',
);
// Then the GitLab issue payload is normalized into the shared issue summary model.
expect(client.trackerCliName(IssueTrackerProvider.gitlab), 'glab');
expect(login, 'glab-user');
expect(issues.single.trackerProject, 'group/subgroup/sample');
expect(issues.single.repoSlug, isNull);
expect(issues.single.number, 12);
expect(issues.single.state, 'opened');
expect(issues.single.labels, ['enhancement']);
// And GitLab note URLs fall back to the issue note anchor when the API omits a web URL.
expect(thread.comments.single.url, endsWith('/issues/12#note_501'));
expect(await postedBodyFile.readAsString(), 'Reply from glab');
expect(posted.id, 901);
expect(posted.url, endsWith('/issues/12#note_901'));
expect(posted.body, 'posted');
});
});
/// ```gherkin
/// Feature: Gitea tracker client integration
///
/// As a maintainer debugging Gitea tracker failures
/// I want tea-specific API and webhook errors to preserve the original cause
/// So that misconfigured repos and tea output changes are easy to trace
/// ```
group('IssueTrackerClient Gitea', () {
/// ```gherkin
/// Scenario: Read a webhook id from tea plain-text success output
/// Given a fake tea executable whose webhook create command prints plain text instead of JSON
/// When the tracker client creates a Gitea webhook
/// Then it still extracts and returns the created webhook id
/// ```
test('Read a webhook id from tea plain-text success output', () async {
// Given a fake tea executable whose webhook create command prints plain text instead of JSON.
final sandbox = await Directory.systemTemp.createTemp('cws-tea-webhook-');
final teaScript = File(p.join(sandbox.path, 'tea'));
await teaScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
if [[ "\$1" == "webhooks" && "\$2" == "create" ]]; then
printf '%s\n' 'Webhook created successfully (ID: 40)'
exit 0
fi
echo "unexpected tea args: \$*" >&2
exit 1
''');
await makeScriptExecutable(teaScript);
final client = IssueTrackerClient(teaCommand: teaScript.path);
// When the tracker client creates a Gitea webhook.
final webhookId = await client.createGiteaIssueWebhook(
repo: RepositorySlug('owner', 'sample'),
url: 'https://gosmee.example.test/sample',
);
// Then it still extracts and returns the created webhook id.
expect(webhookId, 40);
});
/// ```gherkin
/// Scenario: Surface Gitea API error details when issue listing returns an error object
/// Given a fake tea executable whose issue list request returns a Gitea error object
/// When the tracker client fetches updated Gitea issues
/// Then it throws a format error that includes the repo and API error details
/// ```
test(
'Surface Gitea API error details when issue listing returns an error object',
() async {
// Given a fake tea executable whose issue list request returns a Gitea error object.
final sandbox = await Directory.systemTemp.createTemp('cws-tea-api-');
final teaScript = File(p.join(sandbox.path, 'tea'));
await teaScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues?state=open"* ]]; then
cat <<'JSON'
{"errors":["user redirect does not exist [name: owner]"],"message":"GetUserByName","url":"https://gitea.example.test/api/swagger"}
JSON
exit 0
fi
echo "unexpected tea args: \$*" >&2
exit 1
''');
await makeScriptExecutable(teaScript);
final client = IssueTrackerClient(teaCommand: teaScript.path);
// When the tracker client fetches updated Gitea issues.
final future = client.fetchUpdatedIssuesForRepos(
provider: IssueTrackerProvider.gitea,
trackerProjects: const <String>['owner/sample'],
since: null,
);
// Then it throws a format error that includes the repo and API error details.
await expectLater(
future,
throwsA(
isA<FormatException>().having(
(error) => error.message,
'message',
allOf(
contains('owner/sample'),
contains('GetUserByName'),
contains('user redirect does not exist'),
),
),
),
);
},
);
});
}