From b0caed30bc305f5f124ba5dfc9d57e7c367876e7 Mon Sep 17 00:00:00 2001 From: Boubaker Khanfir Date: Mon, 19 Oct 2026 12:20:46 +0100 Subject: [PATCH 1/2] fix: Pin a database error while saving a video call as a call error - EXO-91299 Symptom: when the database failed inside an EntityManager call made while saving a video call, such as reading the call or a participant before writing it, the failure left the service as an UndeclaredThrowableException instead of the service's own call error (a StorageException naming the step, wrapped in an InvalidCallException when starting, joining, leaving, stopping or removing a call), and the code that tolerates a storage error, such as removing a call whose owner no longer exists while reading it, failed the whole read instead. A failure at commit was already reported as a call storage error. Cause: the kernel DAOs' EntityManager proxy delivered the EntityManager's exception wrapped in an UndeclaredThrowableException, which none of the service's storage catches recognises. It is fixed in portal's EntityManagerHolder. Fix: a test runs adding a participant or a guest, updating the invitees or the participants, creating a started group call, starting, joining, leaving, updating, stopping and removing a call through the real DAOs, the EntityManager proxy and the ExoTransactional aspect, with only the persistence engine behind them mocked. It raises each failure where a database failure leaves an EntityManager call (the read that precedes the write, or the persist of an invitation that flushes) and checks it is reported as the operation's error. It also checks that reading a call whose owner no longer exists tolerates a failed removal, and that a failure at commit, when adding a participant, is still reported as a storage error. Co-Authored-By: Claude Opus 5.5 --- ...ebConferencingServiceStorageErrorTest.java | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceStorageErrorTest.java diff --git a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceStorageErrorTest.java b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceStorageErrorTest.java new file mode 100644 index 000000000..fc91d4e6f --- /dev/null +++ b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceStorageErrorTest.java @@ -0,0 +1,355 @@ +/* + * Copyright (C) 2026 eXo Platform SAS. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.exoplatform.webconferencing; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.exoplatform.commons.api.settings.SettingService; +import org.exoplatform.commons.persistence.impl.EntityManagerService; +import org.exoplatform.container.ExoContainer; +import org.exoplatform.container.ExoContainerContext; +import org.exoplatform.container.xml.InitParams; +import org.exoplatform.container.xml.PropertiesParam; +import org.exoplatform.services.cms.link.LinkManager; +import org.exoplatform.services.jcr.RepositoryService; +import org.exoplatform.services.jcr.ext.app.SessionProviderService; +import org.exoplatform.services.jcr.ext.hierarchy.NodeHierarchyCreator; +import org.exoplatform.services.listener.ListenerService; +import org.exoplatform.services.organization.OrganizationService; +import org.exoplatform.services.security.Authenticator; +import org.exoplatform.services.security.IdentityRegistry; +import org.exoplatform.social.core.manager.IdentityManager; +import org.exoplatform.upload.UploadService; +import org.exoplatform.wcm.ext.component.document.service.ShareDocumentService; +import org.exoplatform.web.security.codec.CodecInitializer; +import org.exoplatform.webconferencing.dao.CallDAO; +import org.exoplatform.webconferencing.dao.InviteDAO; +import org.exoplatform.webconferencing.dao.OriginDAO; +import org.exoplatform.webconferencing.dao.ParticipantDAO; +import org.exoplatform.webconferencing.dao.StorageException; +import org.exoplatform.webconferencing.domain.CallEntity; +import org.exoplatform.webconferencing.domain.InviteEntity; +import org.exoplatform.webconferencing.domain.ParticipantEntity; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityTransaction; +import jakarta.persistence.PersistenceException; +import jakarta.persistence.RollbackException; +import jakarta.persistence.TypedQuery; + +/** + * A database failure while saving a call is reported as that call operation's + * error. The calls run through the real DAOs, the kernel's EntityManager proxy + * they get from GenericDAOJPAImpl and the woven ExoTransactional aspect: only + * the persistence engine behind them is mocked (the EntityManager, its + * transaction and the container that provides them), so a failure reaches the + * service in the form the platform delivers it. Each failure is raised where a + * database failure leaves an EntityManager call: the entities have assigned + * ids, so their writes run at commit, and the call that reaches the database + * through the proxy is the read that precedes the write, or a persist that + * flushes. + */ +@ExtendWith(MockitoExtension.class) +class WebConferencingServiceStorageErrorTest { + + private static final String CALL_ID = "p2p_john_mary"; + + private static final String PROVIDER = "jitsi"; + + private static final UserInfo JOHN = new UserInfo("john", "John", "Smith"); + + private static final UserInfo MARY = new UserInfo("mary", "Mary", "Williams"); + + @Mock + private ExoContainer container; + + @Mock + private EntityManagerService entityManagerService; + + @Mock + private EntityManager entityManager; + + @Mock + private EntityTransaction transaction; + + private WebConferencingService service; + + @BeforeEach + void setUp() { + lenient().when(container.getComponentInstanceOfType(EntityManagerService.class)).thenReturn(entityManagerService); + lenient().when(entityManagerService.getEntityManager()).thenReturn(entityManager); + lenient().when(entityManager.getTransaction()).thenReturn(transaction); + AtomicBoolean active = new AtomicBoolean(); + lenient().when(transaction.isActive()).thenAnswer(invocation -> active.get()); + lenient().doAnswer(invocation -> { + active.set(true); + return null; + }).when(transaction).begin(); + lenient().doAnswer(invocation -> { + active.set(false); + return null; + }).when(transaction).commit(); + lenient().doAnswer(invocation -> { + active.set(false); + return null; + }).when(transaction).rollback(); + ExoContainerContext.setCurrentContainer(container); + + InitParams initParams = mock(InitParams.class); + when(initParams.getPropertiesParam(anyString())).thenReturn(new PropertiesParam()); + service = spy(new WebConferencingService(mock(OrganizationService.class), + mock(IdentityManager.class), + mock(ListenerService.class), + mock(SettingService.class), + new CallDAO(), + new ParticipantDAO(), + new OriginDAO(), + new InviteDAO(), + mock(UploadService.class), + mock(RepositoryService.class), + mock(SessionProviderService.class), + mock(NodeHierarchyCreator.class), + mock(IdentityRegistry.class), + mock(Authenticator.class), + mock(ShareDocumentService.class), + initParams, + mock(LinkManager.class), + mock(CodecInitializer.class))); + } + + @AfterEach + void tearDown() { + ExoContainerContext.setCurrentContainer(null); + } + + @Test + void addParticipantReportsAFailedReadAsAStorageError() throws Exception { + doReturn(MARY).when(service).userInfo(MARY.getId()); + PersistenceException failure = failOnFind(ParticipantEntity.class); + + StorageException error = assertThrows(StorageException.class, () -> service.addParticipant(CALL_ID, MARY.getId())); + assertSame(failure, error.getCause()); + } + + @Test + void addGuestReportsAFailedReadAsAStorageError() throws Exception { + doReturn(null).when(service).userInfo("guest@acme.com"); + doReturn(newCall()).when(service).getCall(CALL_ID); + PersistenceException failure = failOnFind(ParticipantEntity.class); + + StorageException error = assertThrows(StorageException.class, () -> service.addGuest(CALL_ID, "guest@acme.com")); + assertSame(failure, error.getCause()); + } + + /** + * Inviting again an identity whose invitation the update has just deleted + * persists over a deleted key, which makes Hibernate flush: a database failure + * then leaves the persist. + */ + @Test + void updateInvitesReportsAFailedSaveAsAStorageError() throws Exception { + doReturn(newCall()).when(service).getCall(CALL_ID); + returnOnQuery("WebConfInvite.findCallInvites", + InviteEntity.class, + List.of(new InviteEntity(CALL_ID, MARY.getId(), UserInfo.TYPE_NAME, "invitation"))); + PersistenceException failure = new PersistenceException("Database unavailable"); + doThrow(failure).when(entityManager).persist(any(InviteEntity.class)); + + StorageException error = assertThrows(StorageException.class, + () -> service.updateInvites(CALL_ID, + List.of(new InvitedIdentity(MARY.getId(), + UserInfo.TYPE_NAME)))); + assertSame(failure, error.getCause()); + } + + @Test + void updateParticipantsReportsAFailedReadAsAStorageError() throws Exception { + doReturn(MARY).when(service).userInfo(MARY.getId()); + PersistenceException failure = failOnFind(CallEntity.class); + + StorageException error = assertThrows(StorageException.class, + () -> service.updateParticipants(CALL_ID, List.of(MARY.getId()))); + assertSame(failure, error.getCause()); + } + + @Test + void startCallReportsAFailedReadAsAnInvalidCall() throws Exception { + doReturn(newCall()).when(service).getCall(CALL_ID); + doReturn(JOHN.getId()).when(service).currentUserId(); + PersistenceException failure = failOnFind(CallEntity.class); + + InvalidCallException error = assertThrows(InvalidCallException.class, () -> service.startCall(CALL_ID, "client")); + assertSame(failure, error.getCause().getCause()); + } + + @Test + void stopCallReportsAFailedReadAsAnInvalidCall() throws Exception { + doReturn(newCall()).when(service).getCall(CALL_ID); + doReturn(JOHN.getId()).when(service).currentUserId(); + PersistenceException failure = failOnFind(CallEntity.class); + + InvalidCallException error = assertThrows(InvalidCallException.class, () -> service.stopCall(CALL_ID, false)); + assertSame(failure, error.getCause().getCause()); + } + + @Test + void removeCallReportsAFailedReadAsAnInvalidCall() throws Exception { + doReturn(newCall()).when(service).getCall(CALL_ID); + doReturn(JOHN.getId()).when(service).currentUserId(); + PersistenceException failure = failOnFind(CallEntity.class); + + InvalidCallException error = assertThrows(InvalidCallException.class, () -> service.stopCall(CALL_ID, true)); + assertSame(failure, error.getCause().getCause()); + } + + @Test + void joinCallReportsAFailedReadAsAnInvalidCall() throws Exception { + CallInfo call = newCall(); + call.setState(CallState.STARTED); + doReturn(call).when(service).getCall(CALL_ID); + PersistenceException failure = failOnFind(ParticipantEntity.class); + + InvalidCallException error = assertThrows(InvalidCallException.class, + () -> service.joinCall(CALL_ID, MARY.getId(), "client")); + assertSame(failure, error.getCause().getCause()); + } + + @Test + void leaveCallReportsAFailedReadAsAnInvalidCall() throws Exception { + CallInfo call = newCall(); + call.setState(CallState.STARTED); + call.addParticipant(new GuestInfo("guest@acme.com")); + doReturn(call).when(service).getCall(CALL_ID); + PersistenceException failure = failOnFind(ParticipantEntity.class); + + InvalidCallException error = assertThrows(InvalidCallException.class, + () -> service.leaveCall(CALL_ID, "guest@acme.com", null)); + assertSame(failure, error.getCause().getCause()); + } + + @Test + void updateCallReportsAFailedReadAsAStorageError() throws Exception { + doReturn(newCall()).when(service).getCall(CALL_ID); + PersistenceException failure = failOnFind(CallEntity.class); + + StorageException error = assertThrows(StorageException.class, + () -> service.updateCall(CALL_ID, + null, + null, + "John and Mary weekly", + null, + null, + null, + null, + null)); + assertSame(failure, error.getCause()); + } + + /** + * A group call created started saves its members as participants, reading + * each one first. + */ + @Test + void createCallReportsAFailedReadAsAStorageError() throws Exception { + WebConferencingService.RoomInfo room = service.new RoomInfo("room", "Weekly sync"); + room.addMember(new UserInfo(MARY.getId(), MARY.getFirstName(), MARY.getLastName())); + CallInfo call = new CallInfo("g_room", "Weekly sync", room, PROVIDER); + call.setState(CallState.STARTED); + PersistenceException failure = failOnFind(ParticipantEntity.class); + + StorageException error = assertThrows(StorageException.class, () -> service.createCall(call)); + assertSame(failure, error.getCause()); + } + + /** + * Reading a call whose owner no longer exists removes the call, and a failed + * removal leaves the call unread instead of failing the read. + */ + @Test + void getCallOfAMissingOwnerToleratesAFailedRemoval() throws Exception { + CallEntity entity = new CallEntity(); + entity.setId(CALL_ID); + entity.setOwnerId(JOHN.getId()); + entity.setOwnerType(WebConferencingService.OWNER_TYPE_USER); + entity.setProviderType(PROVIDER); + when(entityManager.find(CallEntity.class, CALL_ID)).thenReturn(entity) + .thenThrow(new PersistenceException("Database unavailable")); + doReturn(null).when(service).userInfo(JOHN.getId()); + + assertNull(service.getCall(CALL_ID)); + } + + /** + * A failure at commit is raised by the transaction the aspect ends, outside + * the EntityManager proxy. + */ + @Test + void addParticipantReportsAFailedCommitAsAStorageError() throws Exception { + doReturn(MARY).when(service).userInfo(MARY.getId()); + RollbackException failure = new RollbackException("Commit failed"); + doThrow(failure).when(transaction).commit(); + + StorageException error = assertThrows(StorageException.class, () -> service.addParticipant(CALL_ID, MARY.getId())); + assertSame(failure, error.getCause()); + } + + private CallInfo newCall() { + CallInfo call = new CallInfo(CALL_ID, "John and Mary", JOHN, PROVIDER); + call.addParticipant(new UserInfo(JOHN.getId(), JOHN.getFirstName(), JOHN.getLastName())); + call.addParticipant(new UserInfo(MARY.getId(), MARY.getFirstName(), MARY.getLastName())); + return call; + } + + private PersistenceException failOnFind(Class entityType) { + PersistenceException failure = new PersistenceException("Database unavailable"); + when(entityManager.find(eq(entityType), any())).thenThrow(failure); + return failure; + } + + @SuppressWarnings("unchecked") + private void returnOnQuery(String name, Class type, List result) { + TypedQuery query = mock(TypedQuery.class); + when(entityManager.createNamedQuery(name, type)).thenReturn(query); + when(query.setParameter("callId", CALL_ID)).thenReturn(query); + when(query.getResultList()).thenReturn(result); + } + +} From a0c905d1224f542223952e388d0bc431f93705c9 Mon Sep 17 00:00:00 2001 From: Boubaker Khanfir Date: Tue, 20 Oct 2026 06:59:53 +0100 Subject: [PATCH 2/2] fix: Fail the call operations' read of the call in their storage error tests - EXO-91299 Starting, stopping, removing, joining, leaving and updating a call read the call through getCall before anything else, so within a request that read is the one that reaches the database. Their tests fail it and check the InvalidCallException wrapping its StorageException. A member who joined a space after its call started has no participant row, and joining the call reads that row for the first time in the request: its test checks joinCall's own InvalidCallException. Creating a call checks that no call has its id: its test checks that the call is still created after a failure there. The test describes what it mocks: the container, the EntityManagerService, the EntityManager and its transaction, and the methods stubbed on the service's spy. Co-Authored-By: Claude Opus 5.5 --- ...ebConferencingServiceStorageErrorTest.java | 176 ++++++++++++------ 1 file changed, 118 insertions(+), 58 deletions(-) diff --git a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceStorageErrorTest.java b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceStorageErrorTest.java index fc91d4e6f..36b94e88a 100644 --- a/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceStorageErrorTest.java +++ b/services/src/test/java/org/exoplatform/webconferencing/WebConferencingServiceStorageErrorTest.java @@ -18,7 +18,8 @@ */ package org.exoplatform.webconferencing; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -30,6 +31,7 @@ import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; @@ -57,6 +59,7 @@ import org.exoplatform.services.security.Authenticator; import org.exoplatform.services.security.IdentityRegistry; import org.exoplatform.social.core.manager.IdentityManager; +import org.exoplatform.social.core.space.model.Space; import org.exoplatform.upload.UploadService; import org.exoplatform.wcm.ext.component.document.service.ShareDocumentService; import org.exoplatform.web.security.codec.CodecInitializer; @@ -77,15 +80,22 @@ /** * A database failure while saving a call is reported as that call operation's - * error. The calls run through the real DAOs, the kernel's EntityManager proxy - * they get from GenericDAOJPAImpl and the woven ExoTransactional aspect: only - * the persistence engine behind them is mocked (the EntityManager, its - * transaction and the container that provides them), so a failure reaches the - * service in the form the platform delivers it. Each failure is raised where a - * database failure leaves an EntityManager call: the entities have assigned - * ids, so their writes run at commit, and the call that reaches the database - * through the proxy is the read that precedes the write, or a persist that - * flushes. + * error, except a failure of the check that no call already has the id of the + * call being created, which is logged while the creation goes on. The calls run + * through the real DAOs, the kernel's EntityManager proxy they get from + * GenericDAOJPAImpl and the woven ExoTransactional aspect, so a failure reaches + * the service in the form the platform delivers it. The container, the + * EntityManagerService, the EntityManager and its transaction are mocked. The + * service is a spy whose userInfo, spaceInfo and getProvider return a user + * (none for an external guest), a space or a provider without reading them, and + * whose getCall, in the guest and invitation tests, returns a call without + * reading it. Each failure but the commit's is raised on an EntityManager call + * that reaches the database within a request: the read of the call an + * operation starts with, the check that no call has the id of the one being + * created, the read of a participant the request has not loaded yet, or a + * persist that flushes. The entities have assigned ids, so their writes run at + * commit, and a later read of an entity the request has already loaded is + * answered by the persistence context. */ @ExtendWith(MockitoExtension.class) class WebConferencingServiceStorageErrorTest { @@ -210,77 +220,115 @@ void updateParticipantsReportsAFailedReadAsAStorageError() throws Exception { assertSame(failure, error.getCause()); } + /** + * Starting, stopping, removing, joining, leaving and updating a call read the + * call first: that read reaches the database unless the request has read the + * call before. + */ @Test - void startCallReportsAFailedReadAsAnInvalidCall() throws Exception { - doReturn(newCall()).when(service).getCall(CALL_ID); - doReturn(JOHN.getId()).when(service).currentUserId(); + void startCallReportsAFailedReadOfTheCallAsAnInvalidCall() { PersistenceException failure = failOnFind(CallEntity.class); InvalidCallException error = assertThrows(InvalidCallException.class, () -> service.startCall(CALL_ID, "client")); + assertInstanceOf(StorageException.class, error.getCause()); assertSame(failure, error.getCause().getCause()); } @Test - void stopCallReportsAFailedReadAsAnInvalidCall() throws Exception { - doReturn(newCall()).when(service).getCall(CALL_ID); - doReturn(JOHN.getId()).when(service).currentUserId(); + void stopCallReportsAFailedReadOfTheCallAsAnInvalidCall() { PersistenceException failure = failOnFind(CallEntity.class); InvalidCallException error = assertThrows(InvalidCallException.class, () -> service.stopCall(CALL_ID, false)); + assertInstanceOf(StorageException.class, error.getCause()); assertSame(failure, error.getCause().getCause()); } @Test - void removeCallReportsAFailedReadAsAnInvalidCall() throws Exception { - doReturn(newCall()).when(service).getCall(CALL_ID); - doReturn(JOHN.getId()).when(service).currentUserId(); + void removeCallReportsAFailedReadOfTheCallAsAnInvalidCall() { PersistenceException failure = failOnFind(CallEntity.class); InvalidCallException error = assertThrows(InvalidCallException.class, () -> service.stopCall(CALL_ID, true)); + assertInstanceOf(StorageException.class, error.getCause()); assertSame(failure, error.getCause().getCause()); } @Test - void joinCallReportsAFailedReadAsAnInvalidCall() throws Exception { - CallInfo call = newCall(); - call.setState(CallState.STARTED); - doReturn(call).when(service).getCall(CALL_ID); - PersistenceException failure = failOnFind(ParticipantEntity.class); + void joinCallReportsAFailedReadOfTheCallAsAnInvalidCall() { + PersistenceException failure = failOnFind(CallEntity.class); InvalidCallException error = assertThrows(InvalidCallException.class, () -> service.joinCall(CALL_ID, MARY.getId(), "client")); + assertInstanceOf(StorageException.class, error.getCause()); assertSame(failure, error.getCause().getCause()); } + /** + * A member who joined the space after its call started has no participant + * row: joining the call reads that row for the first time in the request. + */ @Test - void leaveCallReportsAFailedReadAsAnInvalidCall() throws Exception { - CallInfo call = newCall(); - call.setState(CallState.STARTED); - call.addParticipant(new GuestInfo("guest@acme.com")); - doReturn(call).when(service).getCall(CALL_ID); + void joinCallOfANewSpaceMemberReportsAFailedReadOfItsParticipantAsAnInvalidCall() throws Exception { + CallEntity savedCall = new CallEntity(); + savedCall.setId("g_weekly"); + savedCall.setTitle("Weekly sync"); + savedCall.setProviderType(PROVIDER); + savedCall.setOwnerId("weekly"); + savedCall.setOwnerType(WebConferencingService.OWNER_TYPE_SPACE); + savedCall.setIsGroup(true); + savedCall.setState(CallState.STARTED); + when(entityManager.find(eq(CallEntity.class), eq("g_weekly"))).thenReturn(savedCall); + ParticipantEntity joined = new ParticipantEntity(); + joined.setId(JOHN.getId()); + joined.setCallId("g_weekly"); + joined.setType(UserInfo.TYPE_NAME); + joined.setState(UserState.JOINED); + returnOnQuery("WebConfCall.findCallParts", ParticipantEntity.class, List.of(joined), "g_weekly"); + returnOnQuery("WebConfInvite.findCallInvites", + InviteEntity.class, + List.of(new InviteEntity("g_weekly", JOHN.getId(), UserInfo.TYPE_NAME, "invitation")), + "g_weekly"); + doReturn(new UserInfo(JOHN.getId(), JOHN.getFirstName(), JOHN.getLastName())).when(service).userInfo(JOHN.getId()); + Space socialSpace = new Space(); + socialSpace.setPrettyName("weekly"); + socialSpace.setDisplayName("Weekly"); + WebConferencingService.SpaceInfo space = service.new SpaceInfo(socialSpace); + space.addMember(new UserInfo(JOHN.getId(), JOHN.getFirstName(), JOHN.getLastName())); + space.addMember(new UserInfo(MARY.getId(), MARY.getFirstName(), MARY.getLastName())); + doReturn(space).when(service).spaceInfo("weekly", "g_weekly"); PersistenceException failure = failOnFind(ParticipantEntity.class); InvalidCallException error = assertThrows(InvalidCallException.class, - () -> service.leaveCall(CALL_ID, "guest@acme.com", null)); + () -> service.joinCall("g_weekly", MARY.getId(), "client")); + assertInstanceOf(StorageException.class, error.getCause()); assertSame(failure, error.getCause().getCause()); } @Test - void updateCallReportsAFailedReadAsAStorageError() throws Exception { - doReturn(newCall()).when(service).getCall(CALL_ID); + void leaveCallReportsAFailedReadOfTheCallAsAnInvalidCall() { PersistenceException failure = failOnFind(CallEntity.class); - StorageException error = assertThrows(StorageException.class, - () -> service.updateCall(CALL_ID, - null, - null, - "John and Mary weekly", - null, - null, - null, - null, - null)); - assertSame(failure, error.getCause()); + InvalidCallException error = assertThrows(InvalidCallException.class, + () -> service.leaveCall(CALL_ID, MARY.getId(), "client")); + assertInstanceOf(StorageException.class, error.getCause()); + assertSame(failure, error.getCause().getCause()); + } + + @Test + void updateCallReportsAFailedReadOfTheCallAsAnInvalidCall() { + PersistenceException failure = failOnFind(CallEntity.class); + + InvalidCallException error = assertThrows(InvalidCallException.class, + () -> service.updateCall(CALL_ID, + null, + null, + "John and Mary weekly", + null, + null, + null, + null, + null)); + assertInstanceOf(StorageException.class, error.getCause()); + assertSame(failure, error.getCause().getCause()); } /** @@ -300,21 +348,29 @@ void createCallReportsAFailedReadAsAStorageError() throws Exception { } /** - * Reading a call whose owner no longer exists removes the call, and a failed - * removal leaves the call unread instead of failing the read. + * Creating a call checks that no call already has its id, a read of a call + * the request has not loaded: a failure there is logged, and the call is + * created. */ @Test - void getCallOfAMissingOwnerToleratesAFailedRemoval() throws Exception { - CallEntity entity = new CallEntity(); - entity.setId(CALL_ID); - entity.setOwnerId(JOHN.getId()); - entity.setOwnerType(WebConferencingService.OWNER_TYPE_USER); - entity.setProviderType(PROVIDER); - when(entityManager.find(CallEntity.class, CALL_ID)).thenReturn(entity) - .thenThrow(new PersistenceException("Database unavailable")); - doReturn(null).when(service).userInfo(JOHN.getId()); - - assertNull(service.getCall(CALL_ID)); + void createCallToleratesAFailedCheckOfAnExistingCall() throws Exception { + doReturn(JOHN).when(service).userInfo(JOHN.getId()); + doReturn(MARY).when(service).userInfo(MARY.getId()); + doReturn(mock(CallProvider.class)).when(service).getProvider(PROVIDER); + failOnFind(CallEntity.class); + + CallInfo call = service.createCall(CALL_ID, + JOHN.getId(), + WebConferencingService.OWNER_TYPE_USER, + "John and Mary", + PROVIDER, + List.of(JOHN.getId(), MARY.getId()), + null, + false, + null, + null); + assertEquals(CALL_ID, call.getId()); + verify(entityManager).persist(any(CallEntity.class)); } /** @@ -344,11 +400,15 @@ private PersistenceException failOnFind(Class entityType) { return failure; } - @SuppressWarnings("unchecked") private void returnOnQuery(String name, Class type, List result) { + returnOnQuery(name, type, result, CALL_ID); + } + + @SuppressWarnings("unchecked") + private void returnOnQuery(String name, Class type, List result, String callId) { TypedQuery query = mock(TypedQuery.class); when(entityManager.createNamedQuery(name, type)).thenReturn(query); - when(query.setParameter("callId", CALL_ID)).thenReturn(query); + when(query.setParameter("callId", callId)).thenReturn(query); when(query.getResultList()).thenReturn(result); }