Add syncing with external tools

This commit is contained in:
TheOtherP 2025-09-18 12:36:28 +02:00
parent 717e2f12ab
commit 9e4ceb6403
19 changed files with 781 additions and 50 deletions

View File

@ -2,7 +2,24 @@
"permissions": {
"allow": [
"Read(//c/Users/strat/IdeaProjects/nzbhydra2/**)",
"Bash(mvn:*)"
"Bash(mvn:*)",
"mcp__intellij__execute_run_configuration",
"Bash(npm install)",
"Bash(npx playwright install:*)",
"Bash(npx playwright test:*)",
"Bash(timeout 60s npx playwright test external-tools.spec.ts --grep \"should open external tool configuration modal with presets\")",
"Bash(timeout 120s npx playwright test external-tools.spec.ts --grep \"should open Sonarr preset configuration modal\")",
"Bash(find:*)",
"Bash(timeout 300s npx playwright test external-tools.spec.ts)",
"Bash(timeout 120s npx playwright test external-tools.spec.ts --grep \"should toggle sync on config change setting\")",
"Bash(timeout 120s npx playwright test external-tools.spec.ts --grep \"should navigate to External Tools configuration\")",
"Bash(timeout 120s npx playwright test external-tools.spec.ts --grep \"should trigger manual sync all\")",
"Bash(timeout 120s npx playwright test external-tools.spec.ts --grep \"should save external tool configuration\" --headed)",
"mcp__intellij__get_run_configurations",
"Bash(timeout 120s npx playwright test external-tools.spec.ts --grep \"should save external tool configuration\")",
"Bash(timeout 120s npx playwright test external-tools.spec.ts --grep \"should edit existing external tool\")",
"Bash(timeout 120s npx playwright test external-tools.spec.ts --grep \"should delete external tool\")",
"Read(//c/Users/strat/IdeaProjects/**)"
],
"deny": [],
"ask": []

View File

@ -102,6 +102,7 @@ public class BaseConfigHandler {
baseConfig.setGenericStorage(newConfig.getGenericStorage());
baseConfig.setNotificationConfig(newConfig.getNotificationConfig());
baseConfig.setEmby(newConfig.getEmby());
baseConfig.setExternalTools(newConfig.getExternalTools());
if (fireConfigChangedEvent) {

View File

@ -252,7 +252,7 @@ public class ExternalToolsSyncService {
if (oldIndexer == null) {
// New indexer
changed.add(newIndexer.getName());
} else if (!oldIndexer.equals(newIndexer)) {
} else if (!IndexerConfig.isIndexerEquals(oldIndexer, newIndexer)) {
// Modified indexer
changed.add(newIndexer.getName());
}

View File

@ -1,11 +1,14 @@
package org.nzbhydra.externaltools;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.nzbhydra.config.ConfigProvider;
import org.nzbhydra.config.ConfigReaderWriter;
import org.nzbhydra.config.indexer.IndexerConfig;
import org.nzbhydra.config.indexer.SearchModuleType;
import org.nzbhydra.indexers.IndexerRepository;
import org.nzbhydra.web.UrlCalculator;
import org.nzbhydra.webaccess.WebAccess;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@ -17,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.net.URI;
import java.util.List;
@RestController
@ -35,6 +39,8 @@ public class ExternalToolsWeb {
private final ConfigReaderWriter configReaderWriter = new ConfigReaderWriter();
@Autowired
private ExternalToolsSyncService externalToolsSyncService;
@Autowired
private WebAccess webAccess;
@Secured({"ROLE_ADMIN"})
@RequestMapping(value = "/internalapi/externalTools/getDialogInfo", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)
@ -68,23 +74,39 @@ public class ExternalToolsWeb {
@RequestMapping(value = "/internalapi/externalTools/testConnection", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ConnectionTestResult testConnection(@RequestBody AddRequest addRequest) {
try {
// Just test the connection without actually syncing
boolean success = externalTools.addNzbhydraAsIndexer(buildTestRequest(addRequest));
return new ConnectionTestResult(success, success ? "Connection successful" : "Connection failed");
return testSimpleConnection(addRequest.getXdarrHost(), addRequest.getXdarrApiKey());
} catch (Exception e) {
return new ConnectionTestResult(false, "Error: " + e.getMessage());
}
}
private AddRequest buildTestRequest(AddRequest request) {
// Create a test request that only checks connection without modifying anything
AddRequest testRequest = new AddRequest();
testRequest.setExternalTool(request.getExternalTool());
testRequest.setXdarrHost(request.getXdarrHost());
testRequest.setXdarrApiKey(request.getXdarrApiKey());
testRequest.setAddType(AddRequest.AddType.DELETE_ONLY); // Only delete, don't add
testRequest.setNzbhydraName("TEST_CONNECTION_" + System.currentTimeMillis());
return testRequest;
private ConnectionTestResult testSimpleConnection(String host, String apiKey) {
try {
// Remove trailing slash if present
String cleanHost = host.endsWith("/") ? host.substring(0, host.length() - 1) : host;
// Build the API URL: http://host/api?apikey=key
String apiUrl = cleanHost + "/api?apikey=" + apiKey;
logger.debug("Testing connection to: {}", apiUrl);
// Make the API call
String response = webAccess.callUrl(URI.create(apiUrl).toString());
// Parse response as JSON and check for "current" key
ObjectMapper mapper = new ObjectMapper();
JsonNode jsonNode = mapper.readTree(response);
if (jsonNode.has("current")) {
return new ConnectionTestResult(true, "Connection successful");
} else {
return new ConnectionTestResult(false, "Invalid response: missing 'current' field");
}
} catch (Exception e) {
logger.debug("Connection test failed", e);
return new ConnectionTestResult(false, "Connection failed: " + e.getMessage());
}
}
public static class ConnectionTestResult {

View File

@ -177,6 +177,32 @@ angular.module('nzbhydraApp').config(["$stateProvider", "$urlRouterProvider", "$
}
}
})
.state("root.config.externalTools", {
url: "/externalTools",
views: {
'container@': {
templateUrl: "static/html/states/config.html",
controller: "ConfigController",
resolve: {
loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {
return loginRequired($q, $timeout, $state, HydraAuthService, "admin")
}],
config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {
return ConfigService.get();
}],
safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {
return ConfigService.getSafe();
}],
activeTab: [function () {
return 5;
}],
$title: ["$stateParams", function ($stateParams) {
return "Config (External Tools)"
}]
}
}
}
})
.state("root.config.indexers", {
url: "/indexers",
views: {
@ -194,7 +220,7 @@ angular.module('nzbhydraApp').config(["$stateProvider", "$urlRouterProvider", "$
return ConfigService.getSafe();
}],
activeTab: [function () {
return 5;
return 6;
}],
$title: ["$stateParams", function ($stateParams) {
return "Config (Indexers)"
@ -220,7 +246,7 @@ angular.module('nzbhydraApp').config(["$stateProvider", "$urlRouterProvider", "$
return ConfigService.getSafe();
}],
activeTab: [function () {
return 6;
return 7;
}],
$title: ["$stateParams", function ($stateParams) {
return "Config (Notifications)"
@ -5036,7 +5062,7 @@ angular
}
];
function _showBox(model, parentModel, isInitial, callback) {
function _showBox(model, parentModel, isInitial, form, callback) {
var modalInstance = $uibModal.open({
templateUrl: 'static/html/config/external-tool-config-box.html',
controller: 'ExternalToolConfigBoxInstanceController',
@ -5063,7 +5089,7 @@ angular
modalInstance.result.then(function (returnedModel) {
$scope.form.$setDirty(true);
form.$setDirty(true);
if (angular.isDefined(callback)) {
callback(true, returnedModel);
}
@ -5075,7 +5101,7 @@ angular
}
function showBox(model, parentModel) {
$scope._showBox(model, parentModel, false)
$scope._showBox(model, parentModel, false, $scope.form)
}
$scope.syncAll = function () {
@ -5108,7 +5134,8 @@ angular
enableInteractiveSearch: true,
useHydraPriorities: true,
priority: 25,
nzbhydraName: "NZBHydra2"
nzbhydraName: "NZBHydra2",
nzbhydraHost: "http://host.docker.internal:5076"
});
if (angular.isDefined(preset)) {
_.extend(model, preset);
@ -5116,7 +5143,7 @@ angular
$scope.isInitial = true;
$scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) {
$scope._showBox(model, entriesCollection, true, $scope.form, function (isSubmitted, returnedModel) {
if (isSubmitted) {
entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model);
}
@ -5254,6 +5281,17 @@ angular
}
});
fieldset.push({
key: 'nzbhydraHost',
type: 'horizontalInput',
templateOptions: {
type: 'text',
label: 'NZBHydra Host',
help: 'NZBHydra URL that the external tool can reach (use host.docker.internal for Docker containers)',
required: true
}
});
fieldset.push({
key: 'configureForUsenet',
type: 'horizontalSwitch',
@ -5473,7 +5511,10 @@ angular.module('nzbhydraApp').controller('ExternalToolConfigBoxInstanceControlle
$scope.obSubmit = function () {
if ($scope.form.$valid) {
checkConnection().then(function () {
$uibModalInstance.close($scope.model);
// When adding/editing a specific external tool, block and show results
syncToExternalTool().then(function () {
$uibModalInstance.close($scope.model);
});
});
} else {
growl.error("Config invalid. Please check your settings.");
@ -5574,6 +5615,54 @@ angular.module('nzbhydraApp').controller('ExternalToolConfigBoxInstanceControlle
return deferred.promise;
}
function syncToExternalTool() {
var deferred = $q.defer();
$scope.spinnerActive = true;
blockUI.start("Configuring NZBHydra in " + model.type + "...");
var syncRequest = {
externalTool: model.type === 'SONARR' ? 'Sonarr' :
model.type === 'RADARR' ? 'Radarr' :
model.type === 'LIDARR' ? 'Lidarr' : 'Readarr',
xdarrHost: model.host,
xdarrApiKey: model.apiKey,
addType: model.syncType === 'SINGLE' ? 'SINGLE' : 'PER_INDEXER',
nzbhydraName: model.nzbhydraName,
nzbhydraHost: model.nzbhydraHost,
configureForUsenet: model.configureForUsenet,
configureForTorrents: model.configureForTorrents,
enableRss: model.enableRss,
enableAutomaticSearch: model.enableAutomaticSearch,
enableInteractiveSearch: model.enableInteractiveSearch,
enableCategories: true,
categories: model.categories || '',
additionalParameters: model.additionalParameters
};
$http.post("internalapi/externalTools/configure", syncRequest).then(
function (response) {
blockUI.reset();
$scope.spinnerActive = false;
if (response.data === true) {
growl.success("Successfully configured NZBHydra in " + model.type);
deferred.resolve();
} else {
growl.error("Failed to configure NZBHydra in " + model.type);
deferred.reject();
}
},
function (error) {
blockUI.reset();
$scope.spinnerActive = false;
growl.error("Error configuring NZBHydra in " + model.type + ": " + (error.data ? error.data : "Unknown error"));
deferred.reject();
}
);
return deferred.promise;
}
$scope.$on("modal.closing", function (targetScope, reason) {
if (reason === "backdrop click") {
$scope.reset($scope);
@ -9520,6 +9609,14 @@ function ConfigController($scope, $http, activeTab, ConfigService, config, Downl
fields: $scope.fields.downloading,
options: {}
},
{
active: false,
state: 'root.config.externalTools',
name: 'External Tools',
model: ConfigModel.externalTools,
fields: $scope.fields.externalTools,
options: {}
},
{
active: false,
state: 'root.config.indexers',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
<!--
~ (C) Copyright 2023 TheOtherP (theotherp@posteo.net)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<span class="config-box">
<div class="modal-header">
<h3 class="modal-title">External Tool Configuration</h3>
</div>
<div class="modal-body">
<formly-form fields="fields"
model="model"
form="form"
options="options"
>
</formly-form>
</div>
<div class="modal-footer">
<button class="btn btn-danger pull-left" ng-click="deleteEntry()" ng-if="!isInitial">Delete</button>
<button class="btn btn-warning" ng-click="reset()">Reset</button>
<button class="btn btn-info" ng-click="testConnection()" ng-if="model.host && model.apiKey">Test connection</button>
<button class="btn btn-default" ng-click="cancel()">Cancel</button>
<button class="btn btn-success has-spinner" ng-class="{'active': spinnerActive}" ng-click="obSubmit()">
<span class="spinner"><i class="fa fa-refresh fa-spin"></i></span>
OK
</button>
</div>
</span>

View File

@ -0,0 +1,67 @@
<!--
~ (C) Copyright 2023 TheOtherP (theotherp@posteo.net)
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<div class="row" style="margin-top: 20px;">
<div class="col-md-12">
<button type="button" class="btn btn-info" ng-click="syncAll()">
<span class="glyphicon glyphicon-refresh" style="margin-right: 10px"></span>Sync All Now
</button>
</div>
</div>
<div class="row" style="margin-top: 30px;">
<div class="btn-group" style="margin-bottom: 30px; margin-left: auto; margin-right: auto; float:none !important;">
<button type="button" class="btn btn-info dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="glyphicon glyphicon-plus" style="margin-right: 10px"></span>Add external tool
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="preset in presets">
<a ng-click="addEntry(model.externalTools, preset)">{{ preset.name }}</a>
</li>
<li role="separator" class="divider"></li>
<li>
<a ng-click="addEntry(model.externalTools)">Custom</a>
</li>
</ul>
</div>
</div>
<div ng-if="model.externalTools.length === 0" class="row">
<div class="col-md-12 text-center" style="margin-top: 30px;">
<h4>No external tools configured</h4>
<p>Use the "Add external tool" button above to configure Sonarr, Radarr, Lidarr, or Readarr instances.</p>
</div>
</div>
<div ng-repeat="entry in model.externalTools | orderBy: ['name'] track by $index">
<div class="row">
<div style="margin-left: auto; margin-right: auto; float:none !important;">
<div style="margin-bottom: 30px;">
<form class="form-inline">
<button ng-click="showBox(entry, model.externalTools)" class="btn btn-secondary indexer-button indexer-input btn-default" style="margin-right: 10px;">
{{ entry.name }}
</button>
<span style="margin-right: 10px;">{{ entry.host }}</span>
<button ng-click="model.externalTools.splice($index, 1)" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-remove"></span>
</button>
</form>
</div>
</div>
</div>
</div>

View File

@ -16,24 +16,6 @@
<input type="checkbox" ng-model="showAdvanced" class="ng-untouched ng-valid ng-not-empty ng-dirty" aria-invalid="false"></div>
</div>
<div class="btn-group">
<button type="button" class="btn config-button btn-default" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
uib-tooltip="Allows you to automatically configure NZBHydra2 as an indexer in an external tool."
tooltip-placement="top"
tooltip-trigger="mouseenter"
>Configure NZBHydra in...
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li>
<a ng-click="configureIn('Lidarr')">Lidarr</a>
<a ng-click="configureIn('Sonarr')">Sonarr</a>
<a ng-click="configureIn('Radarr')">Radarr</a>
<a ng-click="configureIn('Readarr')">Readarr</a>
</li>
</ul>
</div>
<button ng-click="apiHelp()" class="btn config-button config-api-button btn-default">API?</button>
<button ng-click="submit()" class="btn config-button" ng-class="{'btn-info': isSavingNeeded(), 'pulse2': isSavingNeeded(), 'btn-success': !isSavingNeeded()}">

View File

@ -232,6 +232,14 @@ function ConfigController($scope, $http, activeTab, ConfigService, config, Downl
fields: $scope.fields.downloading,
options: {}
},
{
active: false,
state: 'root.config.externalTools',
name: 'External Tools',
model: ConfigModel.externalTools,
fields: $scope.fields.externalTools,
options: {}
},
{
active: false,
state: 'root.config.indexers',

View File

@ -56,7 +56,7 @@ angular
}
];
function _showBox(model, parentModel, isInitial, callback) {
function _showBox(model, parentModel, isInitial, form, callback) {
var modalInstance = $uibModal.open({
templateUrl: 'static/html/config/external-tool-config-box.html',
controller: 'ExternalToolConfigBoxInstanceController',
@ -83,7 +83,7 @@ angular
modalInstance.result.then(function (returnedModel) {
$scope.form.$setDirty(true);
form.$setDirty(true);
if (angular.isDefined(callback)) {
callback(true, returnedModel);
}
@ -95,7 +95,7 @@ angular
}
function showBox(model, parentModel) {
$scope._showBox(model, parentModel, false)
$scope._showBox(model, parentModel, false, $scope.form)
}
$scope.syncAll = function () {
@ -128,7 +128,8 @@ angular
enableInteractiveSearch: true,
useHydraPriorities: true,
priority: 25,
nzbhydraName: "NZBHydra2"
nzbhydraName: "NZBHydra2",
nzbhydraHost: "http://host.docker.internal:5076"
});
if (angular.isDefined(preset)) {
_.extend(model, preset);
@ -136,7 +137,7 @@ angular
$scope.isInitial = true;
$scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) {
$scope._showBox(model, entriesCollection, true, $scope.form, function (isSubmitted, returnedModel) {
if (isSubmitted) {
entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model);
}
@ -274,6 +275,17 @@ angular
}
});
fieldset.push({
key: 'nzbhydraHost',
type: 'horizontalInput',
templateOptions: {
type: 'text',
label: 'NZBHydra Host',
help: 'NZBHydra URL that the external tool can reach (use host.docker.internal for Docker containers)',
required: true
}
});
fieldset.push({
key: 'configureForUsenet',
type: 'horizontalSwitch',
@ -493,7 +505,10 @@ angular.module('nzbhydraApp').controller('ExternalToolConfigBoxInstanceControlle
$scope.obSubmit = function () {
if ($scope.form.$valid) {
checkConnection().then(function () {
$uibModalInstance.close($scope.model);
// When adding/editing a specific external tool, block and show results
syncToExternalTool().then(function () {
$uibModalInstance.close($scope.model);
});
});
} else {
growl.error("Config invalid. Please check your settings.");
@ -594,6 +609,54 @@ angular.module('nzbhydraApp').controller('ExternalToolConfigBoxInstanceControlle
return deferred.promise;
}
function syncToExternalTool() {
var deferred = $q.defer();
$scope.spinnerActive = true;
blockUI.start("Configuring NZBHydra in " + model.type + "...");
var syncRequest = {
externalTool: model.type === 'SONARR' ? 'Sonarr' :
model.type === 'RADARR' ? 'Radarr' :
model.type === 'LIDARR' ? 'Lidarr' : 'Readarr',
xdarrHost: model.host,
xdarrApiKey: model.apiKey,
addType: model.syncType === 'SINGLE' ? 'SINGLE' : 'PER_INDEXER',
nzbhydraName: model.nzbhydraName,
nzbhydraHost: model.nzbhydraHost,
configureForUsenet: model.configureForUsenet,
configureForTorrents: model.configureForTorrents,
enableRss: model.enableRss,
enableAutomaticSearch: model.enableAutomaticSearch,
enableInteractiveSearch: model.enableInteractiveSearch,
enableCategories: true,
categories: model.categories || '',
additionalParameters: model.additionalParameters
};
$http.post("internalapi/externalTools/configure", syncRequest).then(
function (response) {
blockUI.reset();
$scope.spinnerActive = false;
if (response.data === true) {
growl.success("Successfully configured NZBHydra in " + model.type);
deferred.resolve();
} else {
growl.error("Failed to configure NZBHydra in " + model.type);
deferred.reject();
}
},
function (error) {
blockUI.reset();
$scope.spinnerActive = false;
growl.error("Error configuring NZBHydra in " + model.type + ": " + (error.data ? error.data : "Unknown error"));
deferred.reject();
}
);
return deferred.promise;
}
$scope.$on("modal.closing", function (targetScope, reason) {
if (reason === "backdrop click") {
$scope.reset($scope);

View File

@ -177,6 +177,32 @@ angular.module('nzbhydraApp').config(function ($stateProvider, $urlRouterProvide
}
}
})
.state("root.config.externalTools", {
url: "/externalTools",
views: {
'container@': {
templateUrl: "static/html/states/config.html",
controller: "ConfigController",
resolve: {
loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {
return loginRequired($q, $timeout, $state, HydraAuthService, "admin")
}],
config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {
return ConfigService.get();
}],
safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {
return ConfigService.getSafe();
}],
activeTab: [function () {
return 5;
}],
$title: function ($stateParams) {
return "Config (External Tools)"
}
}
}
}
})
.state("root.config.indexers", {
url: "/indexers",
views: {
@ -194,7 +220,7 @@ angular.module('nzbhydraApp').config(function ($stateProvider, $urlRouterProvide
return ConfigService.getSafe();
}],
activeTab: [function () {
return 5;
return 6;
}],
$title: function ($stateParams) {
return "Config (Indexers)"
@ -220,7 +246,7 @@ angular.module('nzbhydraApp').config(function ($stateProvider, $urlRouterProvide
return ConfigService.getSafe();
}],
activeTab: [function () {
return 6;
return 7;
}],
$title: function ($stateParams) {
return "Config (Notifications)"

View File

@ -48,6 +48,7 @@ public class ExternalToolConfig {
private boolean enabled = true;
private SyncType syncType = AddRequest.AddType.SINGLE.name().equals("SINGLE") ? SyncType.SINGLE : SyncType.PER_INDEXER;
private String nzbhydraName = "NZBHydra2";
private String nzbhydraHost = "http://host.docker.internal:5076";
// Sync settings
private boolean configureForUsenet = true;

3
tests/.gitignore vendored
View File

@ -27,3 +27,6 @@ nbdist/
logs/
cacerts
system/data
system/node_modules
system/playwright-report
system/test-results

96
tests/system/package-lock.json generated Normal file
View File

@ -0,0 +1,96 @@
{
"name": "nzbhydra2-system-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nzbhydra2-system-tests",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "20.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

15
tests/system/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "nzbhydra2-system-tests",
"version": "1.0.0",
"description": "System tests for NZBHydra2",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"playwright:install": "playwright install"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.0.0"
}
}

View File

@ -0,0 +1,27 @@
import {defineConfig, devices} from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html", {open: "never", outputFolder: "playwright-report"}], ["list"]],
use: {
baseURL: "http://127.0.0.1:5076",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: {...devices["Desktop Chrome"]},
},
],
webServer: {
command: "echo \"NZBHydra should be running on port 5076\"",
url: "http://127.0.0.1:5076",
reuseExistingServer: !process.env.CI,
},
});

View File

@ -0,0 +1,265 @@
import {expect, test} from "@playwright/test";
test.describe("External Tools Configuration", () => {
test.beforeEach(async ({page}) => {
// Navigate to the config page
await page.goto("/");
// Wait for the page to load
await page.waitForLoadState("networkidle");
});
test("should display External Tools tab in configuration", async ({page}) => {
// Navigate to config page
await page.click("a[href=\"/config/main\"]");
await page.waitForSelector(".nav-tabs");
// Check that External Tools tab exists
await expect(page.locator(".nav-tabs").locator("text=External Tools")).toBeVisible();
});
test("should navigate to External Tools configuration", async ({page}) => {
// Navigate to External Tools config
await page.goto("/config/externalTools");
await page.waitForLoadState("networkidle");
// Check that the sync settings are visible
await expect(page.locator(".bootstrap-switch-id-formly_1_horizontalSwitch_syncOnConfigChange_0")).toBeVisible();
// Check that the "Add external tool" button is present
await expect(page.locator("button:has-text(\"Add external tool\")")).toBeVisible();
});
test("should show empty state when no external tools configured", async ({page}) => {
await page.goto("/config/externalTools");
await page.waitForSelector(".form-horizontal");
// Check that empty state message is shown
await expect(page.locator("text=No external tools configured")).toBeVisible();
});
test("should open external tool configuration modal with presets", async ({page}) => {
await page.goto("/config/externalTools");
await page.waitForSelector("button:has-text(\"Add external tool\")");
// Click the dropdown button
await page.click("button:has-text(\"Add external tool\")");
// Check that preset options are available in the dropdown menu
await expect(page.locator(".dropdown-menu a[ng-click=\"addEntry(model.externalTools, preset)\"]:has-text(\"Sonarr\")")).toBeVisible();
await expect(page.locator(".dropdown-menu a[ng-click=\"addEntry(model.externalTools, preset)\"]:has-text(\"Radarr\")")).toBeVisible();
await expect(page.locator(".dropdown-menu a[ng-click=\"addEntry(model.externalTools, preset)\"]:has-text(\"Lidarr\")")).toBeVisible();
await expect(page.locator(".dropdown-menu a[ng-click=\"addEntry(model.externalTools, preset)\"]:has-text(\"Readarr\")")).toBeVisible();
await expect(page.locator(".dropdown-menu a[ng-click=\"addEntry(model.externalTools)\"]:has-text(\"Custom\")")).toBeVisible();
});
test("should open Sonarr preset configuration modal", async ({page}) => {
await page.goto("/config/externalTools");
await page.waitForSelector("button:has-text(\"Add external tool\")");
// Click dropdown and select Sonarr
await page.click("button:has-text(\"Add external tool\")");
await page.click(".dropdown-menu a[ng-click=\"addEntry(model.externalTools, preset)\"]:has-text(\"Sonarr\")");
// Wait for modal to open
await page.waitForSelector(".modal-title:has-text(\"External Tool Configuration\")");
// Check that the modal is opened with Sonarr preset values
await expect(page.locator(".modal-title")).toContainText("External Tool Configuration");
await expect(page.locator("input[id*=\"name\"]")).toHaveValue("Sonarr");
await expect(page.locator("input[id*=\"host\"]")).toHaveValue("http://localhost:8989");
await expect(page.locator("input[id*=\"categories\"]")).toHaveValue("5030,5040");
// Check that switches are in correct state
await expect(page.locator("input[id*=\"enabled\"]")).toBeChecked();
await expect(page.locator("input[id*=\"configureForUsenet\"]")).toBeChecked();
await expect(page.locator("input[id*=\"enableRss\"]")).toBeChecked();
await expect(page.locator("input[id*=\"enableAutomaticSearch\"]")).toBeChecked();
await expect(page.locator("input[id*=\"enableInteractiveSearch\"]")).toBeChecked();
// Close modal
await page.click("button:has-text(\"Cancel\")");
});
test("should validate required fields in external tool configuration", async ({page}) => {
await page.goto("/config/externalTools");
await page.waitForSelector("button:has-text(\"Add external tool\")");
// Open custom configuration
await page.click("button:has-text(\"Add external tool\")");
await page.click("a:has-text(\"Custom\")");
// Wait for modal
await page.waitForSelector(".modal-title:has-text(\"External Tool Configuration\")");
// Clear required fields
await page.fill("input[id*=\"name\"]", "");
await page.fill("input[id*=\"host\"]", "");
// Try to submit
await page.click("button:has-text(\"OK\")");
// Check that validation errors are shown
await expect(page.locator(".has-error").first()).toBeVisible();
// Close modal
await page.click("button:has-text(\"Cancel\")");
});
test("should save external tool configuration", async ({page}) => {
await page.goto("/config/externalTools");
await page.waitForSelector("button:has-text(\"Add external tool\")");
// Open Radarr preset
await page.click("button:has-text(\"Add external tool\")");
await page.click(".dropdown-menu a[ng-click=\"addEntry(model.externalTools, preset)\"]:has-text(\"Radarr\")");
// Wait for modal
await page.waitForSelector(".modal-title:has-text(\"External Tool Configuration\")");
// Fill in API key
await page.fill("input[id*=\"apiKey\"]", "766c3461d6fe44cf83cea3e3c16b5428");
// Submit form
await page.click("button:has-text(\"OK\")");
// Wait for modal to close and tool to appear in list
await page.waitForSelector(".btn:has-text(\"Radarr (RADARR)\")");
// Wait for the form to stabilize before saving
await page.waitForTimeout(1000);
// Save the configuration to persist the changes
await page.click("button:has-text(\"Save\")", {force: true});
// Check that the tool appears in the list
await expect(page.locator(".btn:has-text(\"Radarr (RADARR)\")")).toBeVisible();
await expect(page.locator("text=http://localhost:7878")).toBeVisible();
// Check that delete button is present
await expect(page.locator(".btn-danger .glyphicon-remove")).toBeVisible();
});
test("should test connection to external tool", async ({page}) => {
await page.goto("/config/externalTools");
await page.waitForSelector("button:has-text(\"Add external tool\")");
// Open Sonarr preset
await page.click("button:has-text(\"Add external tool\")");
await page.click(".dropdown-menu a[ng-click=\"addEntry(model.externalTools, preset)\"]:has-text(\"Sonarr\")");
// Wait for modal
await page.waitForSelector(".modal-title:has-text(\"External Tool Configuration\")");
// Fill in API key
await page.fill("input[id*=\"apiKey\"]", "52a631c9cab346bca59c32bfffdd2669");
// Click test connection button
await page.click("button:has-text(\"Test connection\")");
// Wait for response (should show error since we're not actually running Sonarr)
await page.waitForSelector(".growl-message", {timeout: 10000});
// Close modal
await page.click("button:has-text(\"Cancel\")");
});
test("should trigger manual sync all", async ({page}) => {
await page.goto("/config/externalTools");
await page.waitForSelector("button:has-text(\"Sync All Now\")");
// Click sync all button
await page.click("button:has-text(\"Sync All Now\")");
// Wait for notification (should show some message about sync)
await page.waitForSelector(".growl-message", {timeout: 5000});
// Should show a message about syncing (even if no tools configured)
await expect(page.locator(".growl-message").first()).toBeVisible();
});
test("should toggle sync on config change setting", async ({page}) => {
await page.goto("/config/externalTools");
await page.waitForSelector(".bootstrap-switch-id-formly_1_horizontalSwitch_syncOnConfigChange_0");
// Get current state from the hidden checkbox
const isChecked = await page.locator("input#formly_1_horizontalSwitch_syncOnConfigChange_0").isChecked();
// Toggle by clicking the bootstrap switch wrapper
await page.click(".bootstrap-switch-id-formly_1_horizontalSwitch_syncOnConfigChange_0");
// Verify state changed
const newState = await page.locator("input#formly_1_horizontalSwitch_syncOnConfigChange_0").isChecked();
expect(newState).toBe(!isChecked);
});
test("should edit existing external tool", async ({page}) => {
// First add a tool
await page.goto("/config/externalTools");
await page.waitForSelector("button:has-text(\"Add external tool\")");
await page.click("button:has-text(\"Add external tool\")");
await page.click(".dropdown-menu a[ng-click=\"addEntry(model.externalTools, preset)\"]:has-text(\"Radarr\")");
await page.waitForSelector(".modal-title:has-text(\"External Tool Configuration\")");
await page.fill("input[id*=\"apiKey\"]", "766c3461d6fe44cf83cea3e3c16b5428");
await page.click("button:has-text(\"OK\")");
// Wait for tool to appear
await page.waitForSelector(".btn:has-text(\"Radarr (RADARR)\")");
// Save the configuration first
await page.click("button:has-text(\"Save\")", {force: true});
// Click on the tool to edit it
await page.click(".btn:has-text(\"Radarr (RADARR)\")");
// Wait for modal to open in edit mode
await page.waitForSelector(".modal-title:has-text(\"External Tool Configuration\")");
// Check that delete button is present (indicates edit mode)
await expect(page.locator("button:has-text(\"Delete\")")).toBeVisible();
// Modify the name
await page.fill("input[id*=\"name\"]", "My Radarr Instance");
// Save changes
await page.click("button:has-text(\"OK\")");
// Save the configuration again
await page.click("button:has-text(\"Save\")", {force: true});
// Verify the name changed
await page.waitForSelector(".btn:has-text(\"My Radarr Instance (RADARR)\")");
await expect(page.locator(".btn:has-text(\"My Radarr Instance (RADARR)\")")).toBeVisible();
});
test("should delete external tool", async ({page}) => {
// First add a tool
await page.goto("/config/externalTools");
await page.waitForSelector("button:has-text(\"Add external tool\")");
await page.click("button:has-text(\"Add external tool\")");
await page.click(".dropdown-menu a[ng-click=\"addEntry(model.externalTools, preset)\"]:has-text(\"Radarr\")");
await page.waitForSelector(".modal-title:has-text(\"External Tool Configuration\")");
await page.fill("input[id*=\"apiKey\"]", "766c3461d6fe44cf83cea3e3c16b5428");
await page.click("button:has-text(\"OK\")");
// Wait for tool to appear
await page.waitForSelector(".btn:has-text(\"Radarr (RADARR)\")");
// Save the configuration first
await page.click("button:has-text(\"Save\")", {force: true});
// Wait for save to complete
await page.waitForTimeout(2000);
// Click delete button
await page.click(".btn-danger .glyphicon-remove", {force: true});
// Save the configuration after deletion
await page.click("button:has-text(\"Save\")", {force: true});
// Verify tool is removed from list
await expect(page.locator(".btn:has-text(\"Radarr (RADARR)\")")).not.toBeVisible();
await expect(page.locator("text=No external tools configured")).toBeVisible();
});
});