Migrate not found errors to NotFoundException

This commit is contained in:
René Pfeuffer
2018-08-22 09:35:13 +02:00
parent 5ed6cc4a53
commit 2761edb9f6
11 changed files with 50 additions and 36 deletions

View File

@@ -3,6 +3,7 @@ package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import com.webcohesion.enunciate.metadata.rs.TypeHint;
import sonia.scm.NotFoundException;
import sonia.scm.group.Group; import sonia.scm.group.Group;
import sonia.scm.group.GroupManager; import sonia.scm.group.GroupManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -50,7 +51,7 @@ public class GroupResource {
@ResponseCode(code = 404, condition = "not found, no group with the specified id/name available"), @ResponseCode(code = 404, condition = "not found, no group with the specified id/name available"),
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
}) })
public Response get(@PathParam("id") String id) { public Response get(@PathParam("id") String id) throws NotFoundException {
return adapter.get(id, groupToGroupDtoMapper::map); return adapter.get(id, groupToGroupDtoMapper::map);
} }
@@ -95,7 +96,7 @@ public class GroupResource {
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
}) })
@TypeHint(TypeHint.NO_CONTENT.class) @TypeHint(TypeHint.NO_CONTENT.class)
public Response update(@PathParam("id") String name, GroupDto groupDto) { public Response update(@PathParam("id") String name, GroupDto groupDto) throws NotFoundException {
return adapter.update(name, existing -> dtoToGroupMapper.map(groupDto)); return adapter.update(name, existing -> dtoToGroupMapper.map(groupDto));
} }
} }

View File

@@ -4,6 +4,7 @@ import de.otto.edison.hal.HalRepresentation;
import sonia.scm.AlreadyExistsException; import sonia.scm.AlreadyExistsException;
import sonia.scm.Manager; import sonia.scm.Manager;
import sonia.scm.ModelObject; import sonia.scm.ModelObject;
import sonia.scm.NotFoundException;
import sonia.scm.PageResult; import sonia.scm.PageResult;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
@@ -31,11 +32,11 @@ class IdResourceManagerAdapter<MODEL_OBJECT extends ModelObject,
collectionAdapter = new CollectionResourceManagerAdapter<>(manager, type); collectionAdapter = new CollectionResourceManagerAdapter<>(manager, type);
} }
Response get(String id, Function<MODEL_OBJECT, DTO> mapToDto) { Response get(String id, Function<MODEL_OBJECT, DTO> mapToDto) throws NotFoundException {
return singleAdapter.get(loadBy(id), mapToDto); return singleAdapter.get(loadBy(id), mapToDto);
} }
public Response update(String id, Function<MODEL_OBJECT, MODEL_OBJECT> applyChanges) { public Response update(String id, Function<MODEL_OBJECT, MODEL_OBJECT> applyChanges) throws NotFoundException {
return singleAdapter.update( return singleAdapter.update(
loadBy(id), loadBy(id),
applyChanges, applyChanges,

View File

@@ -4,6 +4,7 @@ import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import com.webcohesion.enunciate.metadata.rs.TypeHint;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import sonia.scm.NotFoundException;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.user.UserManager; import sonia.scm.user.UserManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -46,7 +47,7 @@ public class MeResource {
@ResponseCode(code = 401, condition = "not authenticated / invalid credentials"), @ResponseCode(code = 401, condition = "not authenticated / invalid credentials"),
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
}) })
public Response get(@Context Request request, @Context UriInfo uriInfo) { public Response get(@Context Request request, @Context UriInfo uriInfo) throws NotFoundException {
String id = (String) SecurityUtils.getSubject().getPrincipals().getPrimaryPrincipal(); String id = (String) SecurityUtils.getSubject().getPrincipals().getPrimaryPrincipal();
return adapter.get(id, userToDtoMapper::map); return adapter.get(id, userToDtoMapper::map);

View File

@@ -3,6 +3,7 @@ package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import com.webcohesion.enunciate.metadata.rs.TypeHint;
import sonia.scm.NotFoundException;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryIsNotArchivedException; import sonia.scm.repository.RepositoryIsNotArchivedException;
@@ -77,7 +78,7 @@ public class RepositoryResource {
@ResponseCode(code = 404, condition = "not found, no repository with the specified name available in the namespace"), @ResponseCode(code = 404, condition = "not found, no repository with the specified name available in the namespace"),
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
}) })
public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name) { public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name) throws NotFoundException {
return adapter.get(loadBy(namespace, name), repositoryToDtoMapper::map); return adapter.get(loadBy(namespace, name), repositoryToDtoMapper::map);
} }
@@ -124,7 +125,7 @@ public class RepositoryResource {
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
}) })
@TypeHint(TypeHint.NO_CONTENT.class) @TypeHint(TypeHint.NO_CONTENT.class)
public Response update(@PathParam("namespace") String namespace, @PathParam("name") String name, RepositoryDto repositoryDto) { public Response update(@PathParam("namespace") String namespace, @PathParam("name") String name, RepositoryDto repositoryDto) throws NotFoundException {
return adapter.update( return adapter.update(
loadBy(namespace, name), loadBy(namespace, name),
existing -> dtoToRepositoryMapper.map(repositoryDto, existing.getId()), existing -> dtoToRepositoryMapper.map(repositoryDto, existing.getId()),

View File

@@ -3,6 +3,7 @@ package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.HalRepresentation;
import sonia.scm.Manager; import sonia.scm.Manager;
import sonia.scm.ModelObject; import sonia.scm.ModelObject;
import sonia.scm.NotFoundException;
import sonia.scm.api.rest.resources.AbstractManagerResource; import sonia.scm.api.rest.resources.AbstractManagerResource;
import javax.ws.rs.core.GenericEntity; import javax.ws.rs.core.GenericEntity;
@@ -44,28 +45,25 @@ class SingleResourceManagerAdapter<MODEL_OBJECT extends ModelObject,
* Reads the model object for the given id, transforms it to a dto and returns a corresponding http response. * Reads the model object for the given id, transforms it to a dto and returns a corresponding http response.
* This handles all corner cases, eg. no matching object for the id or missing privileges. * This handles all corner cases, eg. no matching object for the id or missing privileges.
*/ */
Response get(Supplier<Optional<MODEL_OBJECT>> reader, Function<MODEL_OBJECT, DTO> mapToDto) { Response get(Supplier<Optional<MODEL_OBJECT>> reader, Function<MODEL_OBJECT, DTO> mapToDto) throws NotFoundException {
return reader.get() return reader.get()
.map(mapToDto) .map(mapToDto)
.map(Response::ok) .map(Response::ok)
.map(Response.ResponseBuilder::build) .map(Response.ResponseBuilder::build)
.orElse(Response.status(Response.Status.NOT_FOUND).build()); .orElseThrow(NotFoundException::new);
} }
/** /**
* Update the model object for the given id according to the given function and returns a corresponding http response. * Update the model object for the given id according to the given function and returns a corresponding http response.
* This handles all corner cases, eg. no matching object for the id or missing privileges. * This handles all corner cases, eg. no matching object for the id or missing privileges.
*/ */
public Response update(Supplier<Optional<MODEL_OBJECT>> reader, Function<MODEL_OBJECT, MODEL_OBJECT> applyChanges, Predicate<MODEL_OBJECT> hasSameKey) { public Response update(Supplier<Optional<MODEL_OBJECT>> reader, Function<MODEL_OBJECT, MODEL_OBJECT> applyChanges, Predicate<MODEL_OBJECT> hasSameKey) throws NotFoundException {
Optional<MODEL_OBJECT> existingModelObject = reader.get(); MODEL_OBJECT existingModelObject = reader.get().orElseThrow(NotFoundException::new);
if (!existingModelObject.isPresent()) { MODEL_OBJECT changedModelObject = applyChanges.apply(existingModelObject);
return Response.status(Response.Status.NOT_FOUND).build();
}
MODEL_OBJECT changedModelObject = applyChanges.apply(existingModelObject.get());
if (!hasSameKey.test(changedModelObject)) { if (!hasSameKey.test(changedModelObject)) {
return Response.status(BAD_REQUEST).entity("illegal change of id").build(); return Response.status(BAD_REQUEST).entity("illegal change of id").build();
} }
return update(getId(existingModelObject.get()), changedModelObject); return update(getId(existingModelObject), changedModelObject);
} }
public Response delete(Supplier<Optional<MODEL_OBJECT>> reader) { public Response delete(Supplier<Optional<MODEL_OBJECT>> reader) {

View File

@@ -3,6 +3,7 @@ package sonia.scm.api.v2.resources;
import com.webcohesion.enunciate.metadata.rs.ResponseCode; import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes; import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint; import com.webcohesion.enunciate.metadata.rs.TypeHint;
import sonia.scm.NotFoundException;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.user.UserManager; import sonia.scm.user.UserManager;
import sonia.scm.web.VndMediaType; import sonia.scm.web.VndMediaType;
@@ -50,7 +51,7 @@ public class UserResource {
@ResponseCode(code = 404, condition = "not found, no user with the specified id/name available"), @ResponseCode(code = 404, condition = "not found, no user with the specified id/name available"),
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
}) })
public Response get(@PathParam("id") String id) { public Response get(@PathParam("id") String id) throws NotFoundException {
return adapter.get(id, userToDtoMapper::map); return adapter.get(id, userToDtoMapper::map);
} }
@@ -95,7 +96,7 @@ public class UserResource {
@ResponseCode(code = 500, condition = "internal server error") @ResponseCode(code = 500, condition = "internal server error")
}) })
@TypeHint(TypeHint.NO_CONTENT.class) @TypeHint(TypeHint.NO_CONTENT.class)
public Response update(@PathParam("id") String name, UserDto userDto) { public Response update(@PathParam("id") String name, UserDto userDto) throws NotFoundException {
return adapter.update(name, existing -> dtoToUserMapper.map(userDto, existing.getPassword())); return adapter.update(name, existing -> dtoToUserMapper.map(userDto, existing.getPassword()));
} }
} }

View File

@@ -0,0 +1,17 @@
package sonia.scm.api.v2.resources;
import org.jboss.resteasy.core.Dispatcher;
import org.jboss.resteasy.mock.MockDispatcherFactory;
import sonia.scm.api.rest.AlreadyExistsExceptionMapper;
import sonia.scm.api.rest.AuthorizationExceptionMapper;
public class DispatcherMock {
public static Dispatcher createDispatcher(Object resource) {
Dispatcher dispatcher = MockDispatcherFactory.createDispatcher();
dispatcher.getRegistry().addSingletonResource(resource);
dispatcher.getProviderFactory().registerProvider(NotFoundExceptionMapper.class);
dispatcher.getProviderFactory().registerProvider(AlreadyExistsExceptionMapper.class);
dispatcher.getProviderFactory().registerProvider(AuthorizationExceptionMapper.class);
return dispatcher;
}
}

View File

@@ -4,7 +4,6 @@ import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware; import com.github.sdorra.shiro.SubjectAware;
import com.google.common.io.Resources; import com.google.common.io.Resources;
import org.jboss.resteasy.core.Dispatcher; import org.jboss.resteasy.core.Dispatcher;
import org.jboss.resteasy.mock.MockDispatcherFactory;
import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse; import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.Before; import org.junit.Before;
@@ -34,6 +33,7 @@ import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks; import static org.mockito.MockitoAnnotations.initMocks;
import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher;
@SubjectAware( @SubjectAware(
username = "trillian", username = "trillian",
@@ -45,7 +45,7 @@ public class GroupRootResourceTest {
@Rule @Rule
public ShiroRule shiro = new ShiroRule(); public ShiroRule shiro = new ShiroRule();
private Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); private Dispatcher dispatcher;
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/")); private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
@@ -73,7 +73,7 @@ public class GroupRootResourceTest {
GroupResource groupResource = new GroupResource(groupManager, groupToDtoMapper, dtoToGroupMapper); GroupResource groupResource = new GroupResource(groupManager, groupToDtoMapper, dtoToGroupMapper);
GroupRootResource groupRootResource = new GroupRootResource(MockProvider.of(groupCollectionResource), MockProvider.of(groupResource)); GroupRootResource groupRootResource = new GroupRootResource(MockProvider.of(groupCollectionResource), MockProvider.of(groupResource));
dispatcher.getRegistry().addSingletonResource(groupRootResource); dispatcher = createDispatcher(groupRootResource);
} }
@Test @Test

View File

@@ -8,7 +8,6 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.authz.AuthorizationException;
import org.assertj.core.util.Lists; import org.assertj.core.util.Lists;
import org.jboss.resteasy.core.Dispatcher; import org.jboss.resteasy.core.Dispatcher;
import org.jboss.resteasy.mock.MockDispatcherFactory;
import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse; import org.jboss.resteasy.mock.MockHttpResponse;
import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpRequest;
@@ -20,8 +19,6 @@ import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory; import org.junit.jupiter.api.TestFactory;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import sonia.scm.api.rest.AlreadyExistsExceptionMapper;
import sonia.scm.api.rest.AuthorizationExceptionMapper;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.Permission; import sonia.scm.repository.Permission;
import sonia.scm.repository.PermissionType; import sonia.scm.repository.PermissionType;
@@ -47,6 +44,7 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks; import static org.mockito.MockitoAnnotations.initMocks;
import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher;
@Slf4j @Slf4j
public class PermissionRootResourceTest { public class PermissionRootResourceTest {
@@ -88,7 +86,7 @@ public class PermissionRootResourceTest {
.content(PERMISSION_TEST_PAYLOAD) .content(PERMISSION_TEST_PAYLOAD)
.path(PATH_OF_ONE_PERMISSION); .path(PATH_OF_ONE_PERMISSION);
private final Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); private Dispatcher dispatcher;
@Mock @Mock
private RepositoryManager repositoryManager; private RepositoryManager repositoryManager;
@@ -111,11 +109,7 @@ public class PermissionRootResourceTest {
permissionRootResource = new PermissionRootResource(permissionDtoToPermissionMapper, permissionToPermissionDtoMapper, resourceLinks, repositoryManager); permissionRootResource = new PermissionRootResource(permissionDtoToPermissionMapper, permissionToPermissionDtoMapper, resourceLinks, repositoryManager);
RepositoryRootResource repositoryRootResource = new RepositoryRootResource(MockProvider RepositoryRootResource repositoryRootResource = new RepositoryRootResource(MockProvider
.of(new RepositoryResource(null, null, null, null, null, null, null,null, MockProvider.of(permissionRootResource))), null); .of(new RepositoryResource(null, null, null, null, null, null, null,null, MockProvider.of(permissionRootResource))), null);
dispatcher.getRegistry().addSingletonResource(repositoryRootResource); dispatcher = createDispatcher(repositoryRootResource);
dispatcher.getProviderFactory().registerProvider(NotFoundExceptionMapper.class);
dispatcher.getProviderFactory().registerProvider(NotFoundExceptionMapper.class);
dispatcher.getProviderFactory().registerProvider(AlreadyExistsExceptionMapper.class);
dispatcher.getProviderFactory().registerProvider(AuthorizationExceptionMapper.class);
} }
@TestFactory @TestFactory

View File

@@ -4,7 +4,6 @@ import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware; import com.github.sdorra.shiro.SubjectAware;
import com.google.common.io.Resources; import com.google.common.io.Resources;
import org.jboss.resteasy.core.Dispatcher; import org.jboss.resteasy.core.Dispatcher;
import org.jboss.resteasy.mock.MockDispatcherFactory;
import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse; import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.Before; import org.junit.Before;
@@ -43,6 +42,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks; import static org.mockito.MockitoAnnotations.initMocks;
import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher;
@SubjectAware( @SubjectAware(
username = "trillian", username = "trillian",
@@ -51,7 +51,7 @@ import static org.mockito.MockitoAnnotations.initMocks;
) )
public class RepositoryRootResourceTest { public class RepositoryRootResourceTest {
private final Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); private Dispatcher dispatcher;
@Rule @Rule
public ShiroRule shiro = new ShiroRule(); public ShiroRule shiro = new ShiroRule();
@@ -77,7 +77,7 @@ public class RepositoryRootResourceTest {
RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper = new RepositoryCollectionToDtoMapper(repositoryToDtoMapper, resourceLinks); RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper = new RepositoryCollectionToDtoMapper(repositoryToDtoMapper, resourceLinks);
RepositoryCollectionResource repositoryCollectionResource = new RepositoryCollectionResource(repositoryManager, repositoryCollectionToDtoMapper, dtoToRepositoryMapper, resourceLinks); RepositoryCollectionResource repositoryCollectionResource = new RepositoryCollectionResource(repositoryManager, repositoryCollectionToDtoMapper, dtoToRepositoryMapper, resourceLinks);
RepositoryRootResource repositoryRootResource = new RepositoryRootResource(MockProvider.of(repositoryResource), MockProvider.of(repositoryCollectionResource)); RepositoryRootResource repositoryRootResource = new RepositoryRootResource(MockProvider.of(repositoryResource), MockProvider.of(repositoryCollectionResource));
dispatcher.getRegistry().addSingletonResource(repositoryRootResource); dispatcher = createDispatcher(repositoryRootResource);
} }
@Test @Test

View File

@@ -5,7 +5,6 @@ import com.github.sdorra.shiro.SubjectAware;
import com.google.common.io.Resources; import com.google.common.io.Resources;
import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authc.credential.PasswordService;
import org.jboss.resteasy.core.Dispatcher; import org.jboss.resteasy.core.Dispatcher;
import org.jboss.resteasy.mock.MockDispatcherFactory;
import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse; import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.Before; import org.junit.Before;
@@ -35,6 +34,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.initMocks; import static org.mockito.MockitoAnnotations.initMocks;
import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher;
@SubjectAware( @SubjectAware(
username = "trillian", username = "trillian",
@@ -46,7 +46,7 @@ public class UserRootResourceTest {
@Rule @Rule
public ShiroRule shiro = new ShiroRule(); public ShiroRule shiro = new ShiroRule();
private Dispatcher dispatcher = MockDispatcherFactory.createDispatcher(); private Dispatcher dispatcher;
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/")); private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
@@ -76,7 +76,7 @@ public class UserRootResourceTest {
UserRootResource userRootResource = new UserRootResource(MockProvider.of(userCollectionResource), UserRootResource userRootResource = new UserRootResource(MockProvider.of(userCollectionResource),
MockProvider.of(userResource)); MockProvider.of(userResource));
dispatcher.getRegistry().addSingletonResource(userRootResource); dispatcher = createDispatcher(userRootResource);
} }
@Test @Test