diff --git a/src/main/java/teammates/common/datatransfer/DataBundle.java b/src/main/java/teammates/common/datatransfer/DataBundle.java index bb3a081827c..59b3ab524ef 100644 --- a/src/main/java/teammates/common/datatransfer/DataBundle.java +++ b/src/main/java/teammates/common/datatransfer/DataBundle.java @@ -13,6 +13,7 @@ import teammates.storage.entity.FeedbackSessionLog; import teammates.storage.entity.Institute; import teammates.storage.entity.Instructor; +import teammates.storage.entity.MagicLink; import teammates.storage.entity.Notification; import teammates.storage.entity.ReadNotification; import teammates.storage.entity.ResponseInstructorComment; @@ -42,6 +43,7 @@ public class DataBundle { public Map feedbackResponses = new LinkedHashMap<>(); public Map responseInstructorComments = new LinkedHashMap<>(); public Map feedbackSessionLogs = new LinkedHashMap<>(); + public Map magicLinks = new LinkedHashMap<>(); public Map notifications = new LinkedHashMap<>(); public Map readNotifications = new LinkedHashMap<>(); } diff --git a/src/main/java/teammates/common/util/HibernateUtil.java b/src/main/java/teammates/common/util/HibernateUtil.java index 6ca66cb56e7..86ba8d33570 100644 --- a/src/main/java/teammates/common/util/HibernateUtil.java +++ b/src/main/java/teammates/common/util/HibernateUtil.java @@ -30,6 +30,7 @@ import teammates.storage.entity.InstructorCoursePrivilege; import teammates.storage.entity.InstructorSectionPrivilege; import teammates.storage.entity.InstructorSessionPrivilege; +import teammates.storage.entity.MagicLink; import teammates.storage.entity.Notification; import teammates.storage.entity.ReadNotification; import teammates.storage.entity.ResponseInstructorComment; @@ -67,6 +68,7 @@ public final class HibernateUtil { Course.class, FeedbackSession.class, Account.class, + MagicLink.class, Notification.class, ReadNotification.class, User.class, diff --git a/src/main/java/teammates/logic/core/DataBundleLogic.java b/src/main/java/teammates/logic/core/DataBundleLogic.java index 3317d478a72..86df2dd819f 100644 --- a/src/main/java/teammates/logic/core/DataBundleLogic.java +++ b/src/main/java/teammates/logic/core/DataBundleLogic.java @@ -31,6 +31,7 @@ import teammates.storage.entity.InstructorCoursePrivilege; import teammates.storage.entity.InstructorSectionPrivilege; import teammates.storage.entity.InstructorSessionPrivilege; +import teammates.storage.entity.MagicLink; import teammates.storage.entity.Notification; import teammates.storage.entity.ReadNotification; import teammates.storage.entity.ResponseGiver; @@ -114,6 +115,7 @@ public static DataBundle deserializeDataBundle(String jsonString, String seed) { Collection responses = dataBundle.feedbackResponses.values(); Collection responseComments = dataBundle.responseInstructorComments.values(); Collection deadlineExtensions = dataBundle.deadlineExtensions.values(); + Collection magicLinks = dataBundle.magicLinks.values(); Collection notifications = dataBundle.notifications.values(); Collection readNotifications = dataBundle.readNotifications.values(); @@ -274,6 +276,11 @@ public static DataBundle deserializeDataBundle(String jsonString, String seed) { notificationsMap.put(placeholderId, notification); } + for (MagicLink magicLink : magicLinks) { + UUID placeholderId = magicLink.getId(); + magicLink.setId(generateId(placeholderId, seed)); + } + for (ReadNotification readNotification : readNotifications) { UUID placeholderId = readNotification.getId(); readNotification.setId(generateId(placeholderId, seed)); @@ -418,10 +425,12 @@ public DataBundle persistDataBundle(DataBundle dataBundle) Collection responses = dataBundle.feedbackResponses.values(); Collection responseComments = dataBundle.responseInstructorComments.values(); Collection deadlineExtensions = dataBundle.deadlineExtensions.values(); + Collection magicLinks = dataBundle.magicLinks.values(); Collection notifications = dataBundle.notifications.values(); Collection readNotifications = dataBundle.readNotifications.values(); persistEntities(institutes); + persistEntities(magicLinks); persistEntities(notifications); persistEntities(accounts); persistEntities(accountVerificationRequests); diff --git a/src/main/java/teammates/storage/api/MagicLinksDb.java b/src/main/java/teammates/storage/api/MagicLinksDb.java new file mode 100644 index 00000000000..35674beab27 --- /dev/null +++ b/src/main/java/teammates/storage/api/MagicLinksDb.java @@ -0,0 +1,54 @@ +package teammates.storage.api; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; + +import teammates.common.util.HibernateUtil; +import teammates.storage.entity.MagicLink; + +/** + * Handles CRUD operations for magic links. + * + * @see MagicLink + */ +public final class MagicLinksDb { + private static final MagicLinksDb instance = new MagicLinksDb(); + + private MagicLinksDb() { + // prevent initialization + } + + public static MagicLinksDb inst() { + return instance; + } + + /** + * Persists a MagicLink. + */ + public MagicLink persistMagicLink(MagicLink magicLink) { + HibernateUtil.persist(magicLink); + return magicLink; + } + + /** + * Returns a MagicLink with the given token hash or null if it does not exist. + */ + public MagicLink getMagicLinkByTokenHash(String tokenHash) { + CriteriaBuilder cb = HibernateUtil.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(MagicLink.class); + Root root = cr.from(MagicLink.class); + + cr.select(root).where(cb.equal(root.get("tokenHash"), tokenHash)); + + return HibernateUtil.createQuery(cr).getResultStream().findFirst().orElse(null); + } + + /** + * Deletes a MagicLink. + */ + public void deleteMagicLink(MagicLink magicLink) { + HibernateUtil.remove(magicLink); + } + +} diff --git a/src/test/java/teammates/storage/api/MagicLinksDbTest.java b/src/test/java/teammates/storage/api/MagicLinksDbTest.java new file mode 100644 index 00000000000..f1b1eb7a1e6 --- /dev/null +++ b/src/test/java/teammates/storage/api/MagicLinksDbTest.java @@ -0,0 +1,65 @@ +package teammates.storage.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.testng.annotations.Test; + +import teammates.storage.entity.MagicLink; +import teammates.test.GroupNames; + +/** + * Tests for {@link MagicLinksDb}. + */ +public class MagicLinksDbTest extends BaseDbTestcase { + private final MagicLinksDb magicLinksDb = MagicLinksDb.inst(); + + @Test(groups = GroupNames.DB) + public void persistMagicLink_magicLinkDoesNotExist_magicLinkIsInserted() { + var magicLinkRef = given.magicLink("magic-link", + ml -> ml.email("insert@example.com").tokenHash("insert-token-hash")); + MagicLink magicLink = given.getDataBundle().magicLinks.get(magicLinkRef.alias()); + + MagicLink actual = inTransaction(() -> magicLinksDb.persistMagicLink(magicLink)); + + assertEquals(magicLinkRef.id(), actual.getId()); + assertEquals("insert@example.com", actual.getEmail()); + assertEquals("insert-token-hash", actual.getTokenHash()); + verifyPresentInDatabase(MagicLink.class, magicLinkRef.id()); + } + + @Test(groups = GroupNames.DB) + public void getMagicLinkByTokenHash_magicLinkExists_returnsMagicLink() { + var magicLink = given.magicLink("magic-link", + ml -> ml.email("lookup@example.com").tokenHash("lookup-token-hash")); + persistGivenData(given); + + MagicLink actual = inTransaction(() -> magicLinksDb.getMagicLinkByTokenHash("lookup-token-hash")); + + assertNotNull(actual); + assertEquals(magicLink.id(), actual.getId()); + } + + @Test(groups = GroupNames.DB) + public void getMagicLinkByTokenHash_magicLinkDoesNotExist_returnsNull() { + given.magicLink("magic-link", + ml -> ml.email("missing-lookup@example.com").tokenHash("existing-token-hash")); + persistGivenData(given); + + MagicLink actual = inTransaction(() -> magicLinksDb.getMagicLinkByTokenHash("non-existent-token-hash")); + + assertNull(actual); + } + + @Test(groups = GroupNames.DB) + public void deleteMagicLink_magicLinkExists_magicLinkIsRemoved() { + var magicLink = given.magicLink("magic-link", + ml -> ml.email("delete@example.com").tokenHash("delete-token-hash")); + persistGivenData(given); + + inTransaction(() -> magicLinksDb.deleteMagicLink(magicLinksDb.getMagicLinkByTokenHash("delete-token-hash"))); + + verifyAbsentInDatabase(MagicLink.class, magicLink.id()); + } +} diff --git a/src/test/java/teammates/test/BaseTestCaseWithDatabaseAccess.java b/src/test/java/teammates/test/BaseTestCaseWithDatabaseAccess.java index 97cd4be8dae..4dc4f94a361 100644 --- a/src/test/java/teammates/test/BaseTestCaseWithDatabaseAccess.java +++ b/src/test/java/teammates/test/BaseTestCaseWithDatabaseAccess.java @@ -178,6 +178,7 @@ private void clearDatabase() { feedback_sessions, institutes, instructors, + magic_links, notifications, read_notifications, response_instructor_comments, diff --git a/src/test/java/teammates/test/scenariobuilder/GivenData.java b/src/test/java/teammates/test/scenariobuilder/GivenData.java index 052b150489d..95b79c37e55 100644 --- a/src/test/java/teammates/test/scenariobuilder/GivenData.java +++ b/src/test/java/teammates/test/scenariobuilder/GivenData.java @@ -18,6 +18,7 @@ import teammates.storage.entity.FeedbackSessionLog; import teammates.storage.entity.Institute; import teammates.storage.entity.Instructor; +import teammates.storage.entity.MagicLink; import teammates.storage.entity.Notification; import teammates.storage.entity.ReadNotification; import teammates.storage.entity.ResponseInstructorComment; @@ -382,6 +383,25 @@ public NotificationRef notification(String alias, Consumer op return new NotificationRef(notification.getId(), alias); } + /** + * Creates a magic link with default values. + */ + public MagicLinkRef magicLink(String alias) { + return magicLink(alias, ml -> { + }); + } + + /** + * Creates a magic link and applies the provided options to customize it. + */ + public MagicLinkRef magicLink(String alias, Consumer options) { + GivenMagicLink magicLinkData = new GivenMagicLink(this, uuid(alias)); + options.accept(magicLinkData); + MagicLink magicLink = magicLinkData.build(); + registerEntity(alias, magicLink, dataBundle.magicLinks); + return new MagicLinkRef(magicLink.getId(), alias, magicLink.getEmail(), magicLink.getTokenHash()); + } + /** * Creates a read notification with default values. */ @@ -570,6 +590,16 @@ public record DeadlineExtensionRef(UUID id, String alias) {} */ public record FeedbackSessionLogRef(UUID id, String alias) {} + /** + * Reference to a magic link created by GivenData. + * + * @param id generated entity ID + * @param alias GivenData alias + * @param email email address + * @param tokenHash token hash + */ + public record MagicLinkRef(UUID id, String alias, String email, String tokenHash) {} + /** * Reference to a notification created by GivenData. * diff --git a/src/test/java/teammates/test/scenariobuilder/GivenMagicLink.java b/src/test/java/teammates/test/scenariobuilder/GivenMagicLink.java new file mode 100644 index 00000000000..59d3b88a230 --- /dev/null +++ b/src/test/java/teammates/test/scenariobuilder/GivenMagicLink.java @@ -0,0 +1,54 @@ +package teammates.test.scenariobuilder; + +import java.time.Instant; +import java.util.UUID; + +import teammates.storage.entity.MagicLink; + +/** + * Builder for MagicLink entities used in test scenarios. + */ +public final class GivenMagicLink extends GivenBase { + public GivenMagicLink(GivenData given, UUID magicLinkId) { + super(given); + this.entity = defaultMagicLink(magicLinkId); + } + + /** + * Sets the email for the magic link. + */ + public GivenMagicLink email(String email) { + entity.setEmail(email); + return this; + } + + /** + * Sets the token hash for the magic link. + */ + public GivenMagicLink tokenHash(String tokenHash) { + entity.setTokenHash(tokenHash); + return this; + } + + /** + * Sets the expiry time for the magic link. + */ + public GivenMagicLink expiresAt(Instant expiresAt) { + entity.setExpiresAt(expiresAt); + return this; + } + + @Override + void ensureConsistent() { + // No mandatory relationships + } + + private MagicLink defaultMagicLink(UUID magicLinkId) { + MagicLink magicLink = new MagicLink( + magicLinkId.toString() + "@teammates.tmt", + "token-hash:" + magicLinkId.toString(), + Instant.now()); + magicLink.setId(magicLinkId); + return magicLink; + } +}