diff --git a/helpmehelpapplication/src/main/java/ntnu/systemutvikling/team6/database/Readers/UserSelect.java b/helpmehelpapplication/src/main/java/ntnu/systemutvikling/team6/database/Readers/UserSelect.java index b62ebae..36d2f0b 100644 --- a/helpmehelpapplication/src/main/java/ntnu/systemutvikling/team6/database/Readers/UserSelect.java +++ b/helpmehelpapplication/src/main/java/ntnu/systemutvikling/team6/database/Readers/UserSelect.java @@ -8,13 +8,49 @@ import ntnu.systemutvikling.team6.models.user.*; import ntnu.systemutvikling.team6.security.PasswordHasher; +/** + * Data access class responsible for reading user-related data from the database. + * + *
Provides methods to retrieve individual users (by credentials or UUID), + * all users, a user's settings, and a user's inbox. Queries use LEFT JOINs + * across the {@code User}, {@code Settings}, and {@code Messages} tables to + * assemble fully populated {@link User} objects in a single round trip where + * possible.
+ */ + public class UserSelect { + /** The database connection used for all queries in this class. */ private final DatabaseConnection connection; + /** + * Constructs a new {@code UserSelect} with the given database connection. + * + * @param connection the {@link DatabaseConnection} to use for executing queries; + * must not be {@code null} + */ public UserSelect(DatabaseConnection connection) { this.connection = connection; } + /** + * Retrieves a single {@link User} from the database matching the given username + * and password. + * + *The password is hashed via {@link PasswordHasher} before being compared + * against the stored value. If a matching user is found, their {@link Settings} + * (when present) and {@link Inbox} (including any {@link Message} objects) are + * also populated. Returns {@code null} if no matching user is found.
+ * + *Note: the current SQL query compares both parameters against + * {@code user_password}; the {@code user_name} column is not yet included in the + * WHERE clause, which may be a bug.
+ * + * @param username the plain-text username to look up + * @param password the plain-text password; hashed internally before the query runs + * @return the matching {@link User} with settings and inbox populated, + * or {@code null} if no match is found + * @throws RuntimeException if a {@link SQLException} occurs while executing the query + */ public User getUserFromDBUsernameAndPassword(String username, String password) { PasswordHasher hasher = new PasswordHasher(); String hashedpassword = hasher.getHashPassword(password); @@ -83,6 +119,18 @@ public User getUserFromDBUsernameAndPassword(String username, String password) { return user; } + /** + * Retrieves a single {@link User} from the database by their UUID. + * + *The returned user is fully populated with {@link Settings} (when present) + * and an {@link Inbox} containing any associated {@link Message} objects. + * Returns {@code null} if no user with the given UUID exists.
+ * + * @param user_id the UUID string of the user to retrieve; must not be {@code null} + * @return the matching {@link User} with settings and inbox populated, + * or {@code null} if no user is found + * @throws RuntimeException if a {@link SQLException} occurs while executing the query + */ public User getUserFromDBUuid(String user_id) { User user = null; Connection conn = null; @@ -146,6 +194,18 @@ public User getUserFromDBUuid(String user_id) { return user; } + /** + * Retrieves all users from the database, each fully populated with their + * {@link Settings} and {@link Inbox}. + * + *The query LEFT JOINs {@code User}, {@code Settings}, and {@code Messages}. + * Multiple rows for the same user UUID (due to multiple messages) are collapsed + * into a single {@link User} object with all messages appended to its inbox.
+ * + * @return a {@link UserRegistry} containing all users found in the database; + * never {@code null}, but may be empty if no users exist + * @throws RuntimeException if a {@link SQLException} occurs while executing the query + */ public UserRegistry getUsersFromDB() { UserRegistry registry = new UserRegistry(); Connection conn = null; @@ -211,6 +271,17 @@ public UserRegistry getUsersFromDB() { return registry; } + /** + * Retrieves the {@link Settings} for a specific user by their UUID. + * + *At most one row is fetched (via {@code setMaxRows(1)}). Returns {@code null} + * if no settings row exists for the given user.
+ * + * @param user_id the UUID string of the user whose settings should be retrieved; + * must not be {@code null} + * @return the user's {@link Settings}, or {@code null} if none are found + * @throws RuntimeException if a {@link SQLException} occurs while executing the query + */ public Settings getSettingsForUser(String user_id) { Settings settings = null; Connection conn = null; @@ -243,6 +314,19 @@ public Settings getSettingsForUser(String user_id) { return settings; } + /** + * Retrieves the {@link Inbox} for a specific user by their UUID, populated with + * all of their {@link Message} objects. + * + *Returns an empty {@link Inbox} (never {@code null}) if no messages exist + * for the given user.
+ * + * @param user_id the UUID string of the user whose inbox should be retrieved; + * must not be {@code null} + * @return an {@link Inbox} containing all messages for the user; + * empty if no messages are found + * @throws RuntimeException if a {@link SQLException} occurs while executing the query + */ public Inbox getInboxForUser(String user_id) { Inbox inbox = new Inbox(); Connection conn = null; diff --git a/helpmehelpapplication/src/test/java/ntnu/systemutvikling/team6/database/Readers/UserSelectTest.java b/helpmehelpapplication/src/test/java/ntnu/systemutvikling/team6/database/Readers/UserSelectTest.java new file mode 100644 index 0000000..b736459 --- /dev/null +++ b/helpmehelpapplication/src/test/java/ntnu/systemutvikling/team6/database/Readers/UserSelectTest.java @@ -0,0 +1,406 @@ +package ntnu.systemutvikling.team6.database.Readers; + +import ntnu.systemutvikling.team6.database.DatabaseConnection; +import ntnu.systemutvikling.team6.models.UserRegistry; +import ntnu.systemutvikling.team6.models.user.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.sql.*; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link UserSelect}. + * + *Uses Mockito to mock the entire JDBC stack ({@link DatabaseConnection}, + * {@link Connection}, {@link Statement}, {@link PreparedStatement}, {@link ResultSet}) + * so that no real database connection is required.
+ */ +@ExtendWith(MockitoExtension.class) +class UserSelectTest { + + @Mock private DatabaseConnection mockDatabaseConnection; + @Mock private Connection mockConnection; + @Mock private Statement mockStatement; + @Mock private PreparedStatement mockPreparedStatement; + @Mock private ResultSet mockResultSet; + + private static final String USER_UUID = "user-uuid-1"; + private static final String CHARITY_UUID = UUID.randomUUID().toString(); + private static final String MESSAGE_UUID = "msg-uuid-1"; + + private UserSelect userSelect; + + @BeforeEach + void setUp() { + userSelect = new UserSelect(mockDatabaseConnection); + } + + // ------------------------------------------------------------------------- + // getUserFromDBUuid + // ------------------------------------------------------------------------- + + @Test + @DisplayName("getUserFromDBUuid – no matching row returns null") + void getUserFromDBUuid_noRow_returnsNull() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + User result = userSelect.getUserFromDBUuid(USER_UUID); + + assertNull(result, "Should return null when no user is found"); + } + + @Test + @DisplayName("getUserFromDBUuid – single row without settings returns User with null settings") + void getUserFromDBUuid_noSettings_userSettingsNull() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + + when(mockResultSet.next()).thenReturn(true, false); + stubCoreUserColumns(); + when(mockResultSet.getString("isAnonymous")).thenReturn(null); + when(mockResultSet.getString("UUID_message")).thenReturn(null); + + User result = userSelect.getUserFromDBUuid(USER_UUID); + + assertNotNull(result); + assertNull(result.getSettings(), "Settings should be null when isAnonymous is null"); + assertNotNull(result.getInbox(), "Inbox should always be initialised"); + } + + @Test + @DisplayName("getUserFromDBUuid – single row with settings populates Settings correctly") + void getUserFromDBUuid_withSettings_settingsPopulated() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + + when(mockResultSet.next()).thenReturn(true, false); + stubCoreUserColumns(); + stubSettingsColumns(false, "ENGLISH", true); + when(mockResultSet.getString("UUID_message")).thenReturn(null); + + User result = userSelect.getUserFromDBUuid(USER_UUID); + + assertNotNull(result.getSettings()); + assertFalse(result.getSettings().isAnonymous()); + assertEquals(Language.ENGLISH, result.getSettings().getLanguage()); + assertTrue(result.getSettings().isLightmode()); + } + + @Test + @DisplayName("getUserFromDBUuid – row with a message adds it to the inbox") + void getUserFromDBUuid_withMessage_messageAddedToInbox() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + + when(mockResultSet.next()).thenReturn(true, false); + stubCoreUserColumns(); + when(mockResultSet.getString("isAnonymous")).thenReturn(null); + stubMessageColumns(); + + User result = userSelect.getUserFromDBUuid(USER_UUID); + + assertEquals(1, result.getInbox().getMessages().size(), + "Inbox should contain exactly one message"); + } + + @Test + @DisplayName("getUserFromDBUuid – two rows for same UUID adds two messages, one User") + void getUserFromDBUuid_twoRowsSameUuid_oneUserTwoMessages() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + + when(mockResultSet.next()).thenReturn(true, true, false); + when(mockResultSet.getString("UUID_User")).thenReturn(USER_UUID); + when(mockResultSet.getString("user_name")).thenReturn("Alice"); + when(mockResultSet.getString("user_email")).thenReturn("alice@example.com"); + when(mockResultSet.getString("user_password")).thenReturn("hashedpw"); + when(mockResultSet.getString("role")).thenReturn("USER"); + when(mockResultSet.getString("isAnonymous")).thenReturn(null); + when(mockResultSet.getString("UUID_message")).thenReturn("msg-1", "msg-2"); + when(mockResultSet.getString("message_title")).thenReturn("Title 1", "Title 2"); + when(mockResultSet.getString("sender_charity_id")).thenReturn(CHARITY_UUID); + when(mockResultSet.getString("message_content")).thenReturn("Content"); + when(mockResultSet.getString("message_date")).thenReturn("2024-04-01"); + + User result = userSelect.getUserFromDBUuid(USER_UUID); + + assertEquals(2, result.getInbox().getMessages().size()); + } + + @Test + @DisplayName("getUserFromDBUuid – UUID is bound to PreparedStatement parameter 1") + void getUserFromDBUuid_uuidBoundCorrectly() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + userSelect.getUserFromDBUuid(USER_UUID); + + verify(mockPreparedStatement).setString(1, USER_UUID); + } + + @Test + @DisplayName("getUserFromDBUuid – SQLException is wrapped in RuntimeException") + void getUserFromDBUuid_sqlException_throwsRuntimeException() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenThrow(new SQLException("DB error")); + + assertThrows(RuntimeException.class, () -> userSelect.getUserFromDBUuid(USER_UUID)); + } + + // ------------------------------------------------------------------------- + // getUsersFromDB + // ------------------------------------------------------------------------- + + @Test + @DisplayName("getUsersFromDB – empty result set returns empty registry") + void getUsersFromDB_emptyResultSet_returnsEmptyRegistry() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.createStatement()).thenReturn(mockStatement); + when(mockStatement.executeQuery(anyString())).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + UserRegistry registry = userSelect.getUsersFromDB(); + + assertNotNull(registry); + assertTrue(registry.getUsers().isEmpty()); + } + + @Test + @DisplayName("getUsersFromDB – two distinct UUIDs produce two User objects") + void getUsersFromDB_twoDistinctUuids_twoUsersInRegistry() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.createStatement()).thenReturn(mockStatement); + when(mockStatement.executeQuery(anyString())).thenReturn(mockResultSet); + + when(mockResultSet.next()).thenReturn(true, true, false); + when(mockResultSet.getString("UUID_User")).thenReturn("uuid-A", "uuid-B"); + when(mockResultSet.getString("user_name")).thenReturn("Alice", "Bob"); + when(mockResultSet.getString("user_email")).thenReturn("a@x.com", "b@x.com"); + when(mockResultSet.getString("user_password")).thenReturn("pw1", "pw2"); + when(mockResultSet.getString("role")).thenReturn("USER", "ADMIN"); + when(mockResultSet.getString("isAnonymous")).thenReturn(null); + when(mockResultSet.getString("UUID_message")).thenReturn(null); + + UserRegistry registry = userSelect.getUsersFromDB(); + + assertEquals(2, registry.getUsers().size()); + } + + @Test + @DisplayName("getUsersFromDB – same UUID across two rows deduplicates to one User") + void getUsersFromDB_sameUuidTwoRows_oneUserWithTwoMessages() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.createStatement()).thenReturn(mockStatement); + when(mockStatement.executeQuery(anyString())).thenReturn(mockResultSet); + + when(mockResultSet.next()).thenReturn(true, true, false); + when(mockResultSet.getString("UUID_User")).thenReturn(USER_UUID); + when(mockResultSet.getString("user_name")).thenReturn("Alice"); + when(mockResultSet.getString("user_email")).thenReturn("alice@example.com"); + when(mockResultSet.getString("user_password")).thenReturn("hashedpw"); + when(mockResultSet.getString("role")).thenReturn("USER"); + when(mockResultSet.getString("isAnonymous")).thenReturn(null); + when(mockResultSet.getString("UUID_message")).thenReturn("msg-1", "msg-2"); + when(mockResultSet.getString("message_title")).thenReturn("T1", "T2"); + when(mockResultSet.getString("sender_charity_id")).thenReturn(CHARITY_UUID); + when(mockResultSet.getString("message_content")).thenReturn("Body"); + when(mockResultSet.getString("message_date")).thenReturn("2024-05-01"); + + UserRegistry registry = userSelect.getUsersFromDB(); + + assertEquals(1, registry.getUsers().size(), "Same UUID should not produce duplicate users"); + assertEquals(2, registry.getUsers().get(0).getInbox().getMessages().size()); + } + + @Test + @DisplayName("getUsersFromDB – SQLException is wrapped in RuntimeException") + void getUsersFromDB_sqlException_throwsRuntimeException() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.createStatement()).thenThrow(new SQLException("DB error")); + + assertThrows(RuntimeException.class, () -> userSelect.getUsersFromDB()); + } + + // ------------------------------------------------------------------------- + // getSettingsForUser + // ------------------------------------------------------------------------- + + @Test + @DisplayName("getSettingsForUser – no row returns null") + void getSettingsForUser_noRow_returnsNull() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + Settings result = userSelect.getSettingsForUser(USER_UUID); + + assertNull(result, "Should return null when no settings row exists"); + } + + @Test + @DisplayName("getSettingsForUser – matching row returns populated Settings") + void getSettingsForUser_matchingRow_returnsSettings() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + + when(mockResultSet.next()).thenReturn(true, false); + stubSettingsColumns(true, "NORWEGIAN", false); + + Settings result = userSelect.getSettingsForUser(USER_UUID); + + assertNotNull(result); + assertTrue(result.isAnonymous()); + assertEquals(Language.NORWEGIAN, result.getLanguage()); + assertFalse(result.isLightmode()); + } + + @Test + @DisplayName("getSettingsForUser – UUID is bound to PreparedStatement and maxRows set to 1") + void getSettingsForUser_correctBindingAndMaxRows() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + userSelect.getSettingsForUser(USER_UUID); + + verify(mockPreparedStatement).setString(1, USER_UUID); + verify(mockPreparedStatement).setMaxRows(1); + } + + @Test + @DisplayName("getSettingsForUser – SQLException is wrapped in RuntimeException") + void getSettingsForUser_sqlException_throwsRuntimeException() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenThrow(new SQLException("DB error")); + + assertThrows(RuntimeException.class, () -> userSelect.getSettingsForUser(USER_UUID)); + } + + // ------------------------------------------------------------------------- + // getInboxForUser + // ------------------------------------------------------------------------- + + @Test + @DisplayName("getInboxForUser – no messages returns empty Inbox (never null)") + void getInboxForUser_noMessages_returnsEmptyInbox() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + Inbox result = userSelect.getInboxForUser(USER_UUID); + + assertNotNull(result, "Inbox should never be null"); + assertTrue(result.getMessages().isEmpty()); + } + + @Test + @DisplayName("getInboxForUser – one message row returns Inbox with one Message") + void getInboxForUser_oneRow_inboxContainsOneMessage() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + + when(mockResultSet.next()).thenReturn(true, false); + when(mockResultSet.getString("message_title")).thenReturn("Hello"); + when(mockResultSet.getString("sender_charity_id")).thenReturn(CHARITY_UUID); + when(mockResultSet.getString("message_date")).thenReturn("2024-06-15"); + + Inbox result = userSelect.getInboxForUser(USER_UUID); + + assertEquals(1, result.getMessages().size()); + assertEquals("Hello", result.getMessages().get(0).getTitle()); + } + + @Test + @DisplayName("getInboxForUser – two message rows returns Inbox with two Messages") + void getInboxForUser_twoRows_inboxContainsTwoMessages() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + + when(mockResultSet.next()).thenReturn(true, true, false); + when(mockResultSet.getString("message_title")).thenReturn("Msg 1", "Msg 2"); + when(mockResultSet.getString("sender_charity_id")).thenReturn(CHARITY_UUID); + when(mockResultSet.getString("message_date")).thenReturn("2024-06-15"); + + Inbox result = userSelect.getInboxForUser(USER_UUID); + + assertEquals(2, result.getMessages().size()); + } + + @Test + @DisplayName("getInboxForUser – UUID is bound to PreparedStatement parameter 1") + void getInboxForUser_uuidBoundCorrectly() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockPreparedStatement); + when(mockPreparedStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + userSelect.getInboxForUser(USER_UUID); + + verify(mockPreparedStatement).setString(1, USER_UUID); + } + + @Test + @DisplayName("getInboxForUser – SQLException is wrapped in RuntimeException") + void getInboxForUser_sqlException_throwsRuntimeException() throws Exception { + when(mockDatabaseConnection.getMySqlConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenThrow(new SQLException("DB error")); + + assertThrows(RuntimeException.class, () -> userSelect.getInboxForUser(USER_UUID)); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** Stubs the core User columns on the mock ResultSet. */ + private void stubCoreUserColumns() throws SQLException { + when(mockResultSet.getString("UUID_User")).thenReturn(USER_UUID); + when(mockResultSet.getString("user_name")).thenReturn("Alice"); + when(mockResultSet.getString("user_email")).thenReturn("alice@example.com"); + when(mockResultSet.getString("user_password")).thenReturn("hashedpw"); + when(mockResultSet.getString("role")).thenReturn("USER"); + } + + /** Stubs the Settings columns on the mock ResultSet. */ + private void stubSettingsColumns(boolean isAnonymous, String language, boolean lightmode) + throws SQLException { + when(mockResultSet.getString("isAnonymous")).thenReturn(String.valueOf(isAnonymous)); + when(mockResultSet.getBoolean("isAnonymous")).thenReturn(isAnonymous); + when(mockResultSet.getString("language")).thenReturn(language); + when(mockResultSet.getBoolean("lightmode")).thenReturn(lightmode); + } + + /** Stubs the Message columns on the mock ResultSet for a single message row. */ + private void stubMessageColumns() throws SQLException { + when(mockResultSet.getString("UUID_message")).thenReturn(MESSAGE_UUID); + when(mockResultSet.getString("message_title")).thenReturn("Test Message"); + when(mockResultSet.getString("sender_charity_id")).thenReturn(CHARITY_UUID); + when(mockResultSet.getString("message_content")).thenReturn("Hello!"); + when(mockResultSet.getString("message_date")).thenReturn("2024-03-01"); + } +} \ No newline at end of file