diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index edc07d0..40e1113 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -8,7 +8,6 @@ import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest; import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; -import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/AuditLogServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/AuditLogServiceTest.java new file mode 100644 index 0000000..4ca1dc4 --- /dev/null +++ b/src/test/java/dev/kruhlmann/imgfloat/service/AuditLogServiceTest.java @@ -0,0 +1,111 @@ +package dev.kruhlmann.imgfloat.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry; +import dev.kruhlmann.imgfloat.repository.audit.AuditLogRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.dao.DataAccessResourceFailureException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuditLogServiceTest { + + private AuditLogRepository repository; + private AuditLogService service; + + @BeforeEach + void setup() { + repository = mock(AuditLogRepository.class); + service = new AuditLogService(repository); + } + + @Test + void recordEntryPersistesNormalizedEntry() { + service.recordEntry("BroadCaster", "Actor", "ACTION", "some details"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLogEntry.class); + verify(repository).save(captor.capture()); + AuditLogEntry entry = captor.getValue(); + assertThat(entry.getBroadcaster()).isEqualTo("broadcaster"); + assertThat(entry.getActor()).isEqualTo("actor"); + assertThat(entry.getAction()).isEqualTo("ACTION"); + assertThat(entry.getDetails()).isEqualTo("some details"); + } + + @Test + void recordEntrySkipsBlankBroadcaster() { + service.recordEntry(" ", "actor", "ACTION", "details"); + verify(repository, never()).save(any()); + } + + @Test + void recordEntrySkipsNullBroadcaster() { + service.recordEntry(null, "actor", "ACTION", "details"); + verify(repository, never()).save(any()); + } + + @Test + void recordEntryUsesDefaultActorWhenNull() { + service.recordEntry("broadcaster", null, "ACTION", "details"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLogEntry.class); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().getActor()).isEqualTo("system"); + } + + @Test + void recordEntryUsesDefaultActorWhenBlank() { + service.recordEntry("broadcaster", " ", "ACTION", "details"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditLogEntry.class); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().getActor()).isEqualTo("system"); + } + + @Test + void recordEntryDoesNotThrowWhenRepositoryFails() { + doThrow(new DataAccessResourceFailureException("db down")).when(repository).save(any()); + // Must not propagate + service.recordEntry("broadcaster", "actor", "ACTION", "details"); + } + + @Test + void listEntriesReturnsEmptyPageForBlankBroadcaster() { + Page result = service.listEntries(" ", null, null, null, 0, 20); + assertThat(result.isEmpty()).isTrue(); + verify(repository, never()).searchEntries(any(), any(), any(), any(), any()); + } + + @Test + void listEntriesClampsSizeAndDelegatesToRepository() { + when(repository.searchEntries(any(), any(), any(), any(), any())) + .thenReturn(new PageImpl<>(List.of())); + + service.listEntries("broadcaster", null, null, null, 0, 999); + + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + verify(repository).searchEntries(any(), any(), any(), any(), pageableCaptor.capture()); + assertThat(pageableCaptor.getValue().getPageSize()).isEqualTo(200); + } + + @Test + void deleteEntriesForBroadcasterDelegatesToRepository() { + service.deleteEntriesForBroadcaster("Broadcaster"); + verify(repository).deleteByBroadcaster("broadcaster"); + } + + @Test + void deleteEntriesSkipsBlankBroadcaster() { + service.deleteEntriesForBroadcaster(" "); + verify(repository, never()).deleteByBroadcaster(any()); + } +} diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/AuthorizationServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/AuthorizationServiceTest.java new file mode 100644 index 0000000..94114a7 --- /dev/null +++ b/src/test/java/dev/kruhlmann/imgfloat/service/AuthorizationServiceTest.java @@ -0,0 +1,149 @@ +package dev.kruhlmann.imgfloat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; + +class AuthorizationServiceTest { + + private ChannelDirectoryService channelDirectoryService; + private SystemAdministratorService sysadminService; + private AuthorizationService authorizationService; + private AuthorizationService authorizationServiceSysadminDisabled; + + @BeforeEach + void setup() { + channelDirectoryService = mock(ChannelDirectoryService.class); + sysadminService = mock(SystemAdministratorService.class); + authorizationService = new AuthorizationService(channelDirectoryService, sysadminService, true); + authorizationServiceSysadminDisabled = new AuthorizationService(channelDirectoryService, sysadminService, false); + } + + // --- userMatchesSessionUsernameOrThrowHttpError --- + + @Test + void matchesSessionUsernamePassesWhenEqual() { + // must not throw + authorizationService.userMatchesSessionUsernameOrThrowHttpError("alice", "alice"); + } + + @Test + void matchesSessionUsernameThrowsWhenSessionNull() { + assertThatThrownBy(() -> authorizationService.userMatchesSessionUsernameOrThrowHttpError("alice", null)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("logged in"); + } + + @Test + void matchesSessionUsernameThrowsWhenSubmittedNull() { + assertThatThrownBy(() -> authorizationService.userMatchesSessionUsernameOrThrowHttpError(null, "alice")) + .isInstanceOf(ResponseStatusException.class); + } + + @Test + void matchesSessionUsernameThrowsWhenDifferent() { + assertThatThrownBy(() -> authorizationService.userMatchesSessionUsernameOrThrowHttpError("alice", "bob")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("not this user"); + } + + // --- userIsBroadcaster --- + + @Test + void userIsBroadcasterReturnsTrueWhenEqual() { + assertThat(authorizationService.userIsBroadcaster("alice", "alice")).isTrue(); + } + + @Test + void userIsBroadcasterReturnsFalseWhenDifferent() { + assertThat(authorizationService.userIsBroadcaster("alice", "bob")).isFalse(); + } + + @Test + void userIsBroadcasterReturnsFalseWhenEitherNull() { + assertThat(authorizationService.userIsBroadcaster(null, "alice")).isFalse(); + assertThat(authorizationService.userIsBroadcaster("alice", null)).isFalse(); + } + + // --- userIsChannelAdminForBroadcaster --- + + @Test + void channelAdminCheckDelegatesToChannelDirectoryService() { + when(channelDirectoryService.isAdmin("broadcaster", "alice")).thenReturn(true); + assertThat(authorizationService.userIsChannelAdminForBroadcaster("broadcaster", "alice")).isTrue(); + } + + @Test + void channelAdminCheckReturnsFalseWhenNull() { + assertThat(authorizationService.userIsChannelAdminForBroadcaster(null, "alice")).isFalse(); + assertThat(authorizationService.userIsChannelAdminForBroadcaster("broadcaster", null)).isFalse(); + } + + // --- userIsSystemAdministrator --- + + @Test + void sysadminCheckDelegatesToSysadminService() { + when(sysadminService.isSysadmin("admin")).thenReturn(true); + assertThat(authorizationService.userIsSystemAdministrator("admin")).isTrue(); + } + + @Test + void sysadminCheckReturnsFalseWhenNull() { + assertThat(authorizationService.userIsSystemAdministrator(null)).isFalse(); + } + + // --- userIsBroadcasterOrChannelAdmin --- + + @Test + void allowsWhenBroadcasterMatchesSelf() { + assertThat(authorizationService.userIsBroadcasterOrChannelAdminForBroadcaster("alice", "alice")).isTrue(); + } + + @Test + void allowsChannelAdmin() { + when(channelDirectoryService.isAdmin("broadcaster", "admin")).thenReturn(true); + assertThat(authorizationService.userIsBroadcasterOrChannelAdminForBroadcaster("broadcaster", "admin")).isTrue(); + } + + @Test + void allowsSysadminWhenAccessEnabled() { + when(sysadminService.isSysadmin("sysadmin")).thenReturn(true); + assertThat(authorizationService.userIsBroadcasterOrChannelAdminForBroadcaster("broadcaster", "sysadmin")).isTrue(); + } + + @Test + void deniedSysadminWhenAccessDisabled() { + when(sysadminService.isSysadmin("sysadmin")).thenReturn(true); + when(channelDirectoryService.isAdmin("broadcaster", "sysadmin")).thenReturn(false); + assertThat(authorizationServiceSysadminDisabled.userIsBroadcasterOrChannelAdminForBroadcaster("broadcaster", "sysadmin")).isFalse(); + } + + @Test + void deniesRandomUser() { + when(channelDirectoryService.isAdmin("broadcaster", "random")).thenReturn(false); + when(sysadminService.isSysadmin("random")).thenReturn(false); + assertThat(authorizationService.userIsBroadcasterOrChannelAdminForBroadcaster("broadcaster", "random")).isFalse(); + } + + // --- throw-based wrappers --- + + @Test + void throwsWhenNotBroadcasterOrAdmin() { + when(channelDirectoryService.isAdmin("broadcaster", "random")).thenReturn(false); + when(sysadminService.isSysadmin("random")).thenReturn(false); + assertThatThrownBy( + () -> authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError("broadcaster", "random") + ).isInstanceOf(ResponseStatusException.class); + } + + @Test + void throwsWhenNotSysadmin() { + when(sysadminService.isSysadmin("user")).thenReturn(false); + assertThatThrownBy(() -> authorizationService.userIsSystemAdministratorOrThrowHttpError("user")) + .isInstanceOf(ResponseStatusException.class); + } +} diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/GithubReleaseServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/GithubReleaseServiceTest.java new file mode 100644 index 0000000..9225ef8 --- /dev/null +++ b/src/test/java/dev/kruhlmann/imgfloat/service/GithubReleaseServiceTest.java @@ -0,0 +1,56 @@ +package dev.kruhlmann.imgfloat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class GithubReleaseServiceTest { + + @Test + void returnsDownloadBaseUrlWhenConfigured() { + GithubReleaseService service = new GithubReleaseService("imgfloat", "client", "1.2.3"); + String url = service.getDownloadBaseUrl(); + assertThat(url).startsWith("https://github.com/imgfloat/client/releases/download/v1.2.3/"); + } + + @Test + void stripsLeadingVFromVersion() { + GithubReleaseService service = new GithubReleaseService("imgfloat", "client", "v1.2.3"); + assertThat(service.getDownloadBaseUrl()).contains("/v1.2.3/"); + assertThat(service.getClientReleaseVersion()).isEqualTo("1.2.3"); + } + + @Test + void stripsSnapshotSuffix() { + GithubReleaseService service = new GithubReleaseService("imgfloat", "client", "1.2.3-SNAPSHOT"); + assertThat(service.getClientReleaseVersion()).isEqualTo("1.2.3"); + assertThat(service.getDownloadBaseUrl()).contains("/v1.2.3/"); + } + + @Test + void throwsWhenOwnerMissing() { + GithubReleaseService service = new GithubReleaseService(null, "client", "1.0.0"); + assertThatThrownBy(service::getDownloadBaseUrl) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("GitHub client configuration"); + } + + @Test + void throwsWhenRepoMissing() { + GithubReleaseService service = new GithubReleaseService("imgfloat", null, "1.0.0"); + assertThatThrownBy(service::getDownloadBaseUrl).isInstanceOf(IllegalStateException.class); + } + + @Test + void throwsWhenVersionMissing() { + GithubReleaseService service = new GithubReleaseService("imgfloat", "client", null); + assertThatThrownBy(service::getDownloadBaseUrl).isInstanceOf(IllegalStateException.class); + } + + @Test + void throwsWhenVersionBlank() { + GithubReleaseService service = new GithubReleaseService("imgfloat", "client", " "); + assertThatThrownBy(service::getDownloadBaseUrl).isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoaderTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoaderTest.java new file mode 100644 index 0000000..5947025 --- /dev/null +++ b/src/test/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoaderTest.java @@ -0,0 +1,181 @@ +package dev.kruhlmann.imgfloat.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class MarketplaceScriptSeedLoaderTest { + + @TempDir + Path tempDir; + + private void writeScript(Path dir, String name, String description, String source) throws IOException { + Files.createDirectories(dir); + String metaJson = description != null + ? "{\"name\":\"" + name + "\",\"description\":\"" + description + "\"}" + : "{\"name\":\"" + name + "\"}"; + Files.writeString(dir.resolve("metadata.json"), metaJson); + Files.writeString(dir.resolve("source.js"), source); + } + + @Test + void returnsEmptyListWhenRootPathIsNonExistentDirectory() throws IOException { + // Pass a path that does not exist and is not the fallback doc/marketplace-scripts + Path nonExistent = tempDir.resolve("does-not-exist"); + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(nonExistent.toString()); + assertThat(loader.listEntriesForQuery(null)).isEmpty(); + } + + @Test + void loadsScriptFromDirectory() throws IOException { + Path scriptDir = tempDir.resolve("my-script"); + writeScript(scriptDir, "My Script", "A test script", "console.log('hi');"); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + List entries = loader.listEntriesForQuery(null); + + assertThat(entries).hasSize(1); + assertThat(entries.get(0).id()).isEqualTo("my-script"); + assertThat(entries.get(0).name()).isEqualTo("My Script"); + assertThat(entries.get(0).description()).isEqualTo("A test script"); + } + + @Test + void skipsDirectoryWithMissingMetadata() throws IOException { + Path scriptDir = tempDir.resolve("no-meta"); + Files.createDirectories(scriptDir); + Files.writeString(scriptDir.resolve("source.js"), "/* source */"); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + assertThat(loader.listEntriesForQuery(null)).isEmpty(); + } + + @Test + void skipsDirectoryWithMissingSourceJs() throws IOException { + Path scriptDir = tempDir.resolve("no-source"); + Files.createDirectories(scriptDir); + Files.writeString(scriptDir.resolve("metadata.json"), "{\"name\":\"Test\"}"); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + assertThat(loader.listEntriesForQuery(null)).isEmpty(); + } + + @Test + void skipsDirectoryWithBlankName() throws IOException { + Path scriptDir = tempDir.resolve("blank-name"); + Files.createDirectories(scriptDir); + Files.writeString(scriptDir.resolve("metadata.json"), "{\"name\":\" \"}"); + Files.writeString(scriptDir.resolve("source.js"), "/* source */"); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + assertThat(loader.listEntriesForQuery(null)).isEmpty(); + } + + @Test + void filtersByQueryOnNameAndDescription() throws IOException { + Path dir1 = tempDir.resolve("alpha-script"); + writeScript(dir1, "Alpha Script", "does alpha things", "/* */"); + Path dir2 = tempDir.resolve("beta-script"); + writeScript(dir2, "Beta Script", "does beta things", "/* */"); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + assertThat(loader.listEntriesForQuery("alpha")).hasSize(1); + assertThat(loader.listEntriesForQuery("beta")).hasSize(1); + assertThat(loader.listEntriesForQuery("script")).hasSize(2); + assertThat(loader.listEntriesForQuery("zzznotfound")).isEmpty(); + } + + @Test + void findByIdReturnsCorrectScript() throws IOException { + Path scriptDir = tempDir.resolve("find-me"); + writeScript(scriptDir, "Find Me", null, "/* */"); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + Optional found = loader.findById("find-me"); + assertThat(found).isPresent(); + assertThat(found.get().name()).isEqualTo("Find Me"); + } + + @Test + void findByIdReturnsEmptyForUnknownId() throws IOException { + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + assertThat(loader.findById("does-not-exist")).isEmpty(); + } + + @Test + void loadsLogoWhenPresent() throws IOException { + Path scriptDir = tempDir.resolve("with-logo"); + writeScript(scriptDir, "With Logo", null, "/* */"); + // write a minimal PNG (1x1 white pixel) + byte[] minimalPng = new byte[]{ + (byte)0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, (byte)0x90, 0x77, 0x53, + (byte)0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk + 0x54, 0x08, (byte)0xd7, 0x63, (byte)0xf8, (byte)0xcf, (byte)0xc0, 0x00, + 0x00, 0x00, 0x02, 0x00, 0x01, (byte)0xe2, 0x21, (byte)0xbc, + 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk + 0x44, (byte)0xae, 0x42, 0x60, (byte)0x82 + }; + Files.write(scriptDir.resolve("logo.png"), minimalPng); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + Optional found = loader.findById("with-logo"); + assertThat(found).isPresent(); + assertThat(found.get().loadLogo()).isPresent(); + assertThat(found.get().entry().logoUrl()).isNotNull(); + } + + @Test + void normalizesAllowedDomains() throws IOException { + Path scriptDir = tempDir.resolve("domains-script"); + Files.createDirectories(scriptDir); + Files.writeString(scriptDir.resolve("metadata.json"), + "{\"name\":\"Domains\",\"allowedDomains\":[\"EXAMPLE.COM\",\"api.foo.bar:8080\",\"duplicate.com\",\"duplicate.com\"]}"); + Files.writeString(scriptDir.resolve("source.js"), "/* */"); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + Optional found = loader.findById("domains-script"); + assertThat(found).isPresent(); + List domains = found.get().allowedDomains(); + assertThat(domains).contains("example.com", "api.foo.bar:8080"); + assertThat(domains).doesNotHaveDuplicates(); + assertThat(domains).hasSize(3); // EXAMPLE.COM + api.foo.bar:8080 + duplicate.com (deduped) + } + + @Test + void loadsSourceContent() throws IOException { + Path scriptDir = tempDir.resolve("source-test"); + writeScript(scriptDir, "Source Test", null, "console.log('loaded');"); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + Optional found = loader.findById("source-test"); + assertThat(found).isPresent(); + assertThat(found.get().loadSource()).isPresent(); + assertThat(new String(found.get().loadSource().get().bytes())).contains("loaded"); + } + + @Test + void loadsAttachments() throws IOException { + Path scriptDir = tempDir.resolve("with-attachments"); + writeScript(scriptDir, "With Attachments", null, "/* */"); + Path attachmentsDir = scriptDir.resolve("attachments"); + Files.createDirectories(attachmentsDir); + Files.writeString(attachmentsDir.resolve("data.json"), "{\"key\":\"value\"}"); + + MarketplaceScriptSeedLoader loader = new MarketplaceScriptSeedLoader(tempDir.toString()); + Optional found = loader.findById("with-attachments"); + assertThat(found).isPresent(); + assertThat(found.get().attachments()).hasSize(1); + assertThat(found.get().attachments().get(0).name()).isEqualTo("data.json"); + } +} diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/VersionServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/VersionServiceTest.java new file mode 100644 index 0000000..8f43f83 --- /dev/null +++ b/src/test/java/dev/kruhlmann/imgfloat/service/VersionServiceTest.java @@ -0,0 +1,26 @@ +package dev.kruhlmann.imgfloat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class VersionServiceTest { + + @Test + void returnsVersionWhenPomXmlPresent() { + // VersionService reads pom.xml from the filesystem during development; + // in the test classpath pom.xml is available at the project root. + VersionService service = new VersionService(); + String version = service.getVersion(); + assertThat(version).isNotBlank(); + // Should look like a semver string, not "unknown" or empty + assertThat(version).matches("[0-9]+\\.[0-9]+.*"); + } + + @Test + void versionIsConsistentAcrossCalls() { + VersionService service = new VersionService(); + assertThat(service.getVersion()).isEqualTo(service.getVersion()); + } +}