From fab110f18ebd910a5e757b56c70ead3aa8209974 Mon Sep 17 00:00:00 2001 From: Samuel Fang Date: Mon, 6 Feb 2023 16:19:39 +0800 Subject: [PATCH] [#12048] v9: Skeleton implementation (#12056) --- .gitignore | 1 + build.gradle | 53 ++- docker-compose.yml | 11 + gradle.template.properties | 6 + .../it/storage/sqlapi/CoursesDbIT.java | 74 ++++ .../it/storage/sqlapi/package-info.java | 4 + .../BaseTestCaseWithSqlDatabaseAccess.java | 89 ++++ .../teammates/it/test/DbMigrationUtil.java | 43 ++ .../java/teammates/it/test/TestNgXmlTest.java | 95 ++++ .../java/teammates/it/test/package-info.java | 4 + src/it/resources/testng-it.xml | 11 + .../java/teammates/common/util/Config.java | 23 + .../teammates/common/util/HibernateUtil.java | 68 +++ .../java/teammates/sqllogic/api/Logic.java | 48 +++ .../teammates/sqllogic/api/package-info.java | 4 + .../teammates/sqllogic/core/CoursesLogic.java | 53 +++ .../sqllogic/core/FeedbackSessionsLogic.java | 32 ++ .../teammates/sqllogic/core/LogicStarter.java | 41 ++ .../teammates/sqllogic/core/package-info.java | 4 + .../teammates/storage/sqlapi/CoursesDb.java | 118 +++++ .../teammates/storage/sqlapi/EntitiesDb.java | 52 +++ .../storage/sqlapi/FeedbackSessionsDb.java | 132 ++++++ .../storage/sqlapi/package-info.java | 4 + .../storage/sqlentity/BaseEntity.java | 69 +++ .../teammates/storage/sqlentity/Course.java | 235 ++++++++++ .../storage/sqlentity/FeedbackSession.java | 406 ++++++++++++++++++ .../storage/sqlentity/package-info.java | 4 + .../java/teammates/ui/output/CourseData.java | 12 + .../ui/servlets/HibernateContextListener.java | 25 ++ .../teammates/ui/servlets/WebApiServlet.java | 32 +- src/main/java/teammates/ui/webapi/Action.java | 5 +- .../ui/webapi/CreateCourseAction.java | 30 +- .../webapi/CreateFeedbackSessionAction.java | 1 - .../db/changelog/db.changelog-root.xml | 8 + .../db/changelog/db.changelog-v9.xml | 82 ++++ src/main/webapp/WEB-INF/web.xml | 6 + .../architecture/ArchitectureTest.java | 18 - .../storage/sqlapi/CoursesDbTest.java | 62 +++ .../storage/sqlapi/package-info.java | 4 + .../ui/servlets/WebApiServletTest.java | 18 + src/test/resources/testng-component.xml | 2 + static-analysis/teammates-pmdMain.xml | 1 + 42 files changed, 1949 insertions(+), 41 deletions(-) create mode 100644 src/it/java/teammates/it/storage/sqlapi/CoursesDbIT.java create mode 100644 src/it/java/teammates/it/storage/sqlapi/package-info.java create mode 100644 src/it/java/teammates/it/test/BaseTestCaseWithSqlDatabaseAccess.java create mode 100644 src/it/java/teammates/it/test/DbMigrationUtil.java create mode 100644 src/it/java/teammates/it/test/TestNgXmlTest.java create mode 100644 src/it/java/teammates/it/test/package-info.java create mode 100644 src/it/resources/testng-it.xml create mode 100644 src/main/java/teammates/common/util/HibernateUtil.java create mode 100644 src/main/java/teammates/sqllogic/api/Logic.java create mode 100644 src/main/java/teammates/sqllogic/api/package-info.java create mode 100644 src/main/java/teammates/sqllogic/core/CoursesLogic.java create mode 100644 src/main/java/teammates/sqllogic/core/FeedbackSessionsLogic.java create mode 100644 src/main/java/teammates/sqllogic/core/LogicStarter.java create mode 100644 src/main/java/teammates/sqllogic/core/package-info.java create mode 100644 src/main/java/teammates/storage/sqlapi/CoursesDb.java create mode 100644 src/main/java/teammates/storage/sqlapi/EntitiesDb.java create mode 100644 src/main/java/teammates/storage/sqlapi/FeedbackSessionsDb.java create mode 100644 src/main/java/teammates/storage/sqlapi/package-info.java create mode 100644 src/main/java/teammates/storage/sqlentity/BaseEntity.java create mode 100644 src/main/java/teammates/storage/sqlentity/Course.java create mode 100644 src/main/java/teammates/storage/sqlentity/FeedbackSession.java create mode 100644 src/main/java/teammates/storage/sqlentity/package-info.java create mode 100644 src/main/java/teammates/ui/servlets/HibernateContextListener.java create mode 100644 src/main/resources/db/changelog/db.changelog-root.xml create mode 100644 src/main/resources/db/changelog/db.changelog-v9.xml create mode 100644 src/test/java/teammates/storage/sqlapi/CoursesDbTest.java create mode 100644 src/test/java/teammates/storage/sqlapi/package-info.java diff --git a/.gitignore b/.gitignore index 8df131cddfca..ae0c0d96cd66 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ src/web/dist/* src/web/webtools/* filestorage-dev/* datastore-dev/datastore/* +postgres-data/ !.gitkeep diff --git a/build.gradle b/build.gradle index 9fb04253f34d..fe122a673c3a 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,7 @@ apply plugin: "pmd" apply plugin: "com.github.spotbugs" apply plugin: "jacoco" apply plugin: "cz.habarta.typescript-generator" +apply plugin: "org.liquibase.gradle" def checkstyleVersion = "10.3.2" def pmdVersion = "6.48.0" @@ -26,11 +27,14 @@ buildscript { exclude group: "org.gradle" } classpath "com.google.guava:guava:31.1-jre" + classpath "org.liquibase:liquibase-gradle-plugin:2.1.1" } } configurations { staticAnalysis + + liquibaseRuntime.extendsFrom testImplementation } repositories { @@ -69,6 +73,8 @@ dependencies { implementation("org.eclipse.jetty:jetty-webapp") implementation("org.eclipse.jetty:jetty-annotations") implementation("org.jsoup:jsoup:1.15.2") + implementation("org.hibernate.orm:hibernate-core:6.1.6.Final") + implementation("org.postgresql:postgresql:42.5.2") testAnnotationProcessor(testng) @@ -76,6 +82,9 @@ dependencies { testImplementation("junit:junit:4.13.2") testImplementation("org.seleniumhq.selenium:selenium-java:4.3.0") testImplementation(testng) + testImplementation("org.testcontainers:postgresql:1.17.6") + testImplementation("org.liquibase:liquibase-core:4.19.0") + testImplementation("org.mockito:mockito-core:5.1.1") // For supporting authorization code flow locally testImplementation("com.google.oauth-client:google-oauth-client-jetty:1.34.1") // For using Gmail API @@ -88,6 +97,8 @@ dependencies { exclude group: "org.apache.jmeter", module: "bom" } + liquibaseRuntime("info.picocli:picocli:4.7.1") + liquibaseRuntime(sourceSets.main.output) } sourceSets { @@ -106,6 +117,7 @@ sourceSets { srcDir "src/test/java" srcDir "src/e2e/java" srcDir "src/lnp/java" + srcDir "src/it/java" srcDir "src/client/java" include "**/*.java" } @@ -113,12 +125,25 @@ sourceSets { srcDir "src/test/resources" srcDir "src/e2e/resources" srcDir "src/lnp/resources" + srcDir "src/it/resources" srcDir "src/client/resources" exclude "**/*.java" } } } +liquibase { + activities { + main { + searchPath "${projectDir}" + changeLogFile "src/main/resources/db/changelog/db.changelog-root.xml" + url project.properties['liquibaseDbUrl'] + username project.properties['liquibaseUsername'] + password project.properties['liquibasePassword'] + } + } +} + tasks.withType(cz.habarta.typescript.generator.gradle.GenerateTask) { jsonLibrary = "jackson2" optionalAnnotations = [ @@ -513,8 +538,8 @@ task lnpTests(type: Test) { } } -task componentTests(type: Test) { - description "Runs the full unit and integration test suite." +task unitTests(type: Test) { + description "Runs the full unit test suite." group "Test" useTestNG() options.suites "src/test/resources/testng-component.xml" @@ -531,6 +556,30 @@ task componentTests(type: Test) { } } +task integrationTests(type: Test) { + description "Runs the full integration test suite." + group "Test" + useTestNG() + options.suites "src/it/resources/testng-it.xml" + options.useDefaultListeners = true + ignoreFailures false + maxHeapSize = "1g" + reports.html.required = false + reports.junitXml.required = false + jvmArgs "-ea", "-Xss2m", "-Dfile.encoding=UTF-8" + afterTest afterTestClosure + afterSuite checkTestNgFailureClosure + testLogging { + events "passed" + } +} + +task componentTests(type: Test) { + description "Runs the full unit and integration test suite." + group "Test" + dependsOn unitTests, integrationTests +} + task e2eTests { description "Runs the full E2E test suite and retries failed test up to ${numOfTestRetries} times." group "Test" diff --git a/docker-compose.yml b/docker-compose.yml index eb894aa37a84..191521e398da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,3 +12,14 @@ services: context: solr ports: - 8983:8983 + postgres: + image: postgres:15.1-alpine + restart: always + volumes: + - ./postgres-data:/var/lib/postgresql/data + ports: + - 5432:5432 + environment: + POSTGRES_USER: teammates + POSTGRES_PASSWORD: teammates + POSTGRES_DB: teammates diff --git a/gradle.template.properties b/gradle.template.properties index 8e60e91e3ee2..ab53e8f05bfd 100644 --- a/gradle.template.properties +++ b/gradle.template.properties @@ -11,3 +11,9 @@ org.gradle.daemon=false # Use this property if you want to use a specific Cloud SDK installation. # If this property is not set, the latest Cloud SDK will always be used. # cloud.sdk.home=/Users/visenze/Documents/google-cloud-sdk + +# Liquibase +liquibaseTaskPrefix=liquibase +liquibaseDbUrl=jdbc:postgresql://localhost:5432/teammates +liquibaseUsername=teammates +liquibasePassword=teammates diff --git a/src/it/java/teammates/it/storage/sqlapi/CoursesDbIT.java b/src/it/java/teammates/it/storage/sqlapi/CoursesDbIT.java new file mode 100644 index 000000000000..49fd04bf49e3 --- /dev/null +++ b/src/it/java/teammates/it/storage/sqlapi/CoursesDbIT.java @@ -0,0 +1,74 @@ +package teammates.it.storage.sqlapi; + +import org.testng.annotations.Test; + +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.EntityDoesNotExistException; +import teammates.it.test.BaseTestCaseWithSqlDatabaseAccess; +import teammates.storage.sqlapi.CoursesDb; +import teammates.storage.sqlentity.Course; + +/** + * SUT: {@link CoursesDb}. + */ +public class CoursesDbIT extends BaseTestCaseWithSqlDatabaseAccess { + + private final CoursesDb coursesDb = CoursesDb.inst(); + + @Test + public void testCreateCourse() throws Exception { + ______TS("Create course, does not exists, succeeds"); + + Course course = new Course.CourseBuilder("course-id") + .withName("course-name") + .withInstitute("teammates") + .build(); + + coursesDb.createCourse(course); + + Course actualCourse = coursesDb.getCourse("course-id"); + verifyEquals(course, actualCourse); + + ______TS("Create course, already exists, execption thrown"); + + Course identicalCourse = new Course.CourseBuilder("course-id") + .withName("course-name") + .withInstitute("teammates") + .build(); + assertNotSame(course, identicalCourse); + + assertThrows(EntityAlreadyExistsException.class, () -> coursesDb.createCourse(identicalCourse)); + } + + @Test + public void testUpdateCourse() throws Exception { + ______TS("Update course, does not exists, exception thrown"); + + Course course = new Course.CourseBuilder("course-id") + .withName("course-name") + .withInstitute("teammates") + .build(); + + assertThrows(EntityDoesNotExistException.class, () -> coursesDb.updateCourse(course)); + + ______TS("Update course, already exists, update successful"); + + coursesDb.createCourse(course); + course.setName("new course name"); + + coursesDb.updateCourse(course); + Course actual = coursesDb.getCourse("course-id"); + verifyEquals(course, actual); + + ______TS("Update detached course, already exists, update successful"); + + // same id, different name + Course detachedCourse = new Course.CourseBuilder("course-id") + .withName("course") + .withInstitute("teammates") + .build(); + + coursesDb.updateCourse(detachedCourse); + verifyEquals(course, detachedCourse); + } +} diff --git a/src/it/java/teammates/it/storage/sqlapi/package-info.java b/src/it/java/teammates/it/storage/sqlapi/package-info.java new file mode 100644 index 000000000000..d38d8145d05d --- /dev/null +++ b/src/it/java/teammates/it/storage/sqlapi/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains test cases for {@link teammates.storage.search} package. + */ +package teammates.it.storage.sqlapi; diff --git a/src/it/java/teammates/it/test/BaseTestCaseWithSqlDatabaseAccess.java b/src/it/java/teammates/it/test/BaseTestCaseWithSqlDatabaseAccess.java new file mode 100644 index 000000000000..3d2850502fd6 --- /dev/null +++ b/src/it/java/teammates/it/test/BaseTestCaseWithSqlDatabaseAccess.java @@ -0,0 +1,89 @@ +package teammates.it.test; + +import org.testcontainers.containers.PostgreSQLContainer; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.AfterSuite; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.BeforeSuite; +import org.testng.annotations.Test; + +import teammates.common.util.HibernateUtil; +import teammates.common.util.JsonUtils; +import teammates.sqllogic.api.Logic; +import teammates.sqllogic.core.LogicStarter; +import teammates.storage.sqlentity.BaseEntity; +import teammates.storage.sqlentity.Course; +import teammates.test.BaseTestCase; + +/** + * Base test case for tests that access the database. + */ +@Test(singleThreaded = true) +public class BaseTestCaseWithSqlDatabaseAccess extends BaseTestCase { + /** + * Test container. + */ + protected static final PostgreSQLContainer PGSQL = new PostgreSQLContainer<>("postgres:15.1-alpine"); + + private final Logic logic = Logic.inst(); + + @BeforeSuite + public static void setUpClass() throws Exception { + PGSQL.start(); + DbMigrationUtil.resetDb(PGSQL.getJdbcUrl(), PGSQL.getUsername(), PGSQL.getPassword()); + HibernateUtil.buildSessionFactory(PGSQL.getJdbcUrl(), PGSQL.getUsername(), PGSQL.getPassword()); + + LogicStarter.initializeDependencies(); + } + + @AfterSuite + public static void tearDownClass() throws Exception { + PGSQL.close(); + } + + @BeforeMethod + public void setUp() throws Exception { + HibernateUtil.getSessionFactory().getCurrentSession().getTransaction().begin(); + DbMigrationUtil.resetDb(PGSQL.getJdbcUrl(), PGSQL.getUsername(), PGSQL.getPassword()); + } + + @AfterMethod + public void tearDown() { + HibernateUtil.getSessionFactory().getCurrentSession().getTransaction().commit(); + } + + /** + * Verifies that two entities are equal. + */ + protected void verifyEquals(BaseEntity expected, BaseEntity actual) { + if (expected instanceof Course) { + Course expectedCourse = (Course) expected; + Course actualCourse = (Course) actual; + equalizeIrrelevantData(expectedCourse, actualCourse); + assertEquals(JsonUtils.toJson(expectedCourse), JsonUtils.toJson(actualCourse)); + } + } + + /** + * Verifies that the given entity is present in the database. + */ + protected void verifyPresentInDatabase(BaseEntity expected) { + BaseEntity actual = getEntity(expected); + verifyEquals(expected, actual); + } + + private BaseEntity getEntity(BaseEntity entity) { + if (entity instanceof Course) { + return logic.getCourse(((Course) entity).getId()); + } else { + throw new RuntimeException("Unknown entity type!"); + } + } + + private void equalizeIrrelevantData(Course expected, Course actual) { + // Ignore time field as it is stamped at the time of creation in testing + expected.setCreatedAt(actual.getCreatedAt()); + expected.setUpdatedAt(actual.getUpdatedAt()); + } + +} diff --git a/src/it/java/teammates/it/test/DbMigrationUtil.java b/src/it/java/teammates/it/test/DbMigrationUtil.java new file mode 100644 index 000000000000..fea0f2e10dcc --- /dev/null +++ b/src/it/java/teammates/it/test/DbMigrationUtil.java @@ -0,0 +1,43 @@ +package teammates.it.test; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.HashMap; +import java.util.Map; + +import liquibase.Liquibase; +import liquibase.Scope; +import liquibase.database.Database; +import liquibase.database.DatabaseFactory; +import liquibase.database.jvm.JdbcConnection; +import liquibase.resource.DirectoryResourceAccessor; + +/** + * Utility class with methods to apply sql migrations in tests. + */ +public final class DbMigrationUtil { + + private DbMigrationUtil() { + // prevent instantiation + } + + /** + * Drop all tables and re-apply migrations. + */ + public static void resetDb(String dbUrl, String username, String password) throws Exception { + Map config = new HashMap<>(); + File file = new File(System.getProperty("user.dir")); + + Scope.child(config, () -> { + Connection conn = DriverManager.getConnection(dbUrl, username, password); + Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(conn)); + try (Liquibase liquibase = new Liquibase("src/main/resources/db/changelog/db.changelog-root.xml", + new DirectoryResourceAccessor(file), database)) { + liquibase.dropAll(); + liquibase.update(); + } + conn.close(); + }); + } +} diff --git a/src/it/java/teammates/it/test/TestNgXmlTest.java b/src/it/java/teammates/it/test/TestNgXmlTest.java new file mode 100644 index 000000000000..5e9a9451ae0b --- /dev/null +++ b/src/it/java/teammates/it/test/TestNgXmlTest.java @@ -0,0 +1,95 @@ +package teammates.it.test; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +import org.testng.annotations.Test; + +import teammates.test.BaseTestCase; +import teammates.test.FileHelper; + +/** + * Verifies that the testng-it.xml configuration file contains all the integration test cases in the project. + */ +public class TestNgXmlTest extends BaseTestCase { + + @Test + public void checkTestsInTestNg() throws Exception { + String testNgXml = FileHelper.readFile("./src/it/resources/testng-it.xml"); + + // + Map testFiles = getTestFiles(testNgXml, "./src/it/java/teammates"); + + testFiles.forEach((key, value) -> assertTrue(isTestFileIncluded(testNgXml, value, key))); + } + + /** + * Files to be checked in testng-it.xml are added to testFiles. + * + * @param testNgXml Contents of testng-it.xml + * @param rootPath Root path of test files + * @return Map containing {@code } + */ + private Map getTestFiles(String testNgXml, String rootPath) { + // BaseComponentTestCase, BaseTestCase (files in current directory) excluded because + // base classes are extended by the actual tests + + return addFilesToTestsRecursively(rootPath, true, "teammates", testNgXml); + } + + private boolean isTestFileIncluded(String testNgXml, String packageName, String testClassName) { + return testNgXml.contains(""); + } + + /** + * Recursively adds files from testng-it.xml which are to be checked. + * + * @param path Check files and directories in the current path + * + * @param areFilesInCurrentDirExcluded If true, files in the current path are not + * added to tests but sub-directories are still checked + * + * @param packageName Package name of the current file + * @param testNgXml Contents of testng-component.xml + * + * @return Map containing {@code } including + * current file or tests in the current directory + */ + private Map addFilesToTestsRecursively(String path, boolean areFilesInCurrentDirExcluded, + String packageName, String testNgXml) { + + Map testFiles = new HashMap<>(); + File folder = new File(path); + File[] listOfFiles = folder.listFiles(); + if (listOfFiles == null) { + return testFiles; + } + + for (File file : listOfFiles) { + String name = file.getName(); + + if (file.isFile() && name.endsWith(".java") && !name.startsWith("package-info") + && !areFilesInCurrentDirExcluded) { + testFiles.put(name.replace(".java", ""), packageName); + + } else if (file.isDirectory() && !name.endsWith("browsertests") && !name.endsWith("pageobjects") + && !name.endsWith("architecture")) { + // If the package name is in TestNG in the form of + // then files in the current directory are excluded because the whole package would be tested by TestNG. + + testFiles.putAll( + addFilesToTestsRecursively(path + "/" + name, + isPackageNameInTestNg(packageName + "." + name, testNgXml), + packageName + "." + name, testNgXml)); + } + } + + return testFiles; + } + + private boolean isPackageNameInTestNg(String packageName, String testNgXml) { + return testNgXml.contains(""); + } + +} diff --git a/src/it/java/teammates/it/test/package-info.java b/src/it/java/teammates/it/test/package-info.java new file mode 100644 index 000000000000..2bb940382f0f --- /dev/null +++ b/src/it/java/teammates/it/test/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains infrastructure and helpers needed for running the integration tests. + */ +package teammates.it.test; diff --git a/src/it/resources/testng-it.xml b/src/it/resources/testng-it.xml new file mode 100644 index 000000000000..2b888071b02c --- /dev/null +++ b/src/it/resources/testng-it.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/main/java/teammates/common/util/Config.java b/src/main/java/teammates/common/util/Config.java index b9862a5f0d88..3d2de4ee65f2 100644 --- a/src/main/java/teammates/common/util/Config.java +++ b/src/main/java/teammates/common/util/Config.java @@ -97,6 +97,18 @@ public final class Config { /** The value of the "app.localdatastore.port" in build-dev.properties file. */ public static final int APP_LOCALDATASTORE_PORT; + /** The value of the "app.localpostgres.port" in build-dev.properties file. */ + public static final int APP_LOCALPOSTGRES_PORT; + + /** The value of the "app.localpostgres.username" in build-dev.properties file. */ + public static final String APP_LOCALPOSTGRES_USERNAME; + + /** The value of the "app.localpostgres.password" in build-dev.properties file. */ + public static final String APP_LOCALPOSTGRES_PASSWORD; + + /** The value of the "app.localpostgres.db" in build-dev.properties file. */ + public static final String APP_LOCALPOSTGRES_DB; + /** The value of the "app.enable.devserver.login" in build-dev.properties file. */ public static final boolean ENABLE_DEVSERVER_LOGIN; @@ -170,6 +182,10 @@ public final class Config { // The following properties are not used in production server. // So they will only be read from build-dev.properties file. APP_LOCALDATASTORE_PORT = Integer.parseInt(devProperties.getProperty("app.localdatastore.port", "8484")); + APP_LOCALPOSTGRES_PORT = Integer.parseInt(devProperties.getProperty("app.localpostgres.port", "5432")); + APP_LOCALPOSTGRES_USERNAME = devProperties.getProperty("app.localpostgres.username", "teammates"); + APP_LOCALPOSTGRES_PASSWORD = devProperties.getProperty("app.localpostgres.password", "teammates"); + APP_LOCALPOSTGRES_DB = devProperties.getProperty("app.localpostgres.db", "teammates"); ENABLE_DEVSERVER_LOGIN = Boolean.parseBoolean(devProperties.getProperty("app.enable.devserver.login", "false")); TASKQUEUE_ACTIVE = Boolean.parseBoolean(devProperties.getProperty("app.taskqueue.active", "true")); } @@ -274,6 +290,13 @@ public static AppUrl getFrontEndAppUrl(String relativeUrl) { return new AppUrl(APP_FRONTEND_URL + relativeUrl); } + /** + * Returns db connection URL. + */ + public static String getDbConnectionUrl() { + return "jdbc:postgresql://localhost:" + APP_LOCALPOSTGRES_PORT + "/" + APP_LOCALPOSTGRES_DB; + } + public static boolean isUsingSendgrid() { return "sendgrid".equalsIgnoreCase(EMAIL_SERVICE) && SENDGRID_APIKEY != null && !SENDGRID_APIKEY.isEmpty(); } diff --git a/src/main/java/teammates/common/util/HibernateUtil.java b/src/main/java/teammates/common/util/HibernateUtil.java new file mode 100644 index 000000000000..d250a24177df --- /dev/null +++ b/src/main/java/teammates/common/util/HibernateUtil.java @@ -0,0 +1,68 @@ +package teammates.common.util; + +import java.util.List; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy; +import org.hibernate.cfg.Configuration; + +import teammates.storage.sqlentity.BaseEntity; +import teammates.storage.sqlentity.Course; +import teammates.storage.sqlentity.FeedbackSession; + +/** + * Class containing utils for setting up the Hibernate session factory. + */ +public final class HibernateUtil { + private static SessionFactory sessionFactory; + + private static final List> ANNOTATED_CLASSES = List.of(Course.class, + FeedbackSession.class); + + private HibernateUtil() { + // Utility class + // Intentional private constructor to prevent instantiation. + } + + /** + * Returns the SessionFactory. + */ + public static SessionFactory getSessionFactory() { + assert sessionFactory != null; + + return sessionFactory; + } + + /** + * Builds a session factory if it does not already exist. + */ + public static void buildSessionFactory(String dbUrl, String username, String password) { + synchronized (HibernateUtil.class) { + if (sessionFactory != null) { + return; + } + } + + Configuration config = new Configuration() + .setProperty("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect") + .setProperty("hibernate.connection.driver_class", "org.postgresql.Driver") + .setProperty("hibernate.connection.username", username) + .setProperty("hibernate.connection.password", password) + .setProperty("hibernate.connection.url", dbUrl) + .setProperty("hibernate.hbm2ddl.auto", "validate") + .setProperty("show_sql", "true") + .setProperty("hibernate.current_session_context_class", "thread") + .addPackage("teammates.storage.sqlentity"); + + for (Class cls : ANNOTATED_CLASSES) { + config = config.addAnnotatedClass(cls); + } + config.setPhysicalNamingStrategy(new CamelCaseToUnderscoresNamingStrategy()); + + setSessionFactory(config.buildSessionFactory()); + } + + public static void setSessionFactory(SessionFactory sessionFactory) { + HibernateUtil.sessionFactory = sessionFactory; + } +} diff --git a/src/main/java/teammates/sqllogic/api/Logic.java b/src/main/java/teammates/sqllogic/api/Logic.java new file mode 100644 index 000000000000..ec0bd7852435 --- /dev/null +++ b/src/main/java/teammates/sqllogic/api/Logic.java @@ -0,0 +1,48 @@ +package teammates.sqllogic.api; + +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.sqllogic.core.CoursesLogic; +import teammates.storage.sqlentity.Course; + +/** + * Provides the business logic for production usage of the system. + * + *

This is a Facade class which simply forwards the method to internal classes. + */ +public class Logic { + private static final Logic instance = new Logic(); + + final CoursesLogic coursesLogic = CoursesLogic.inst(); + // final FeedbackSessionsLogic feedbackSessionsLogic = FeedbackSessionsLogic.inst(); + + Logic() { + // prevent initialization + } + + public static Logic inst() { + return instance; + } + + // Courses + + /** + * Gets a course by course id. + * @param courseId courseId of the course. + * @return the specified course. + */ + public Course getCourse(String courseId) { + return coursesLogic.getCourse(courseId); + } + + /** + * Creates a course. + * @param course the course to create. + * @return the created course. + * @throws InvalidParametersException if the course is not valid. + * @throws EntityAlreadyExistsException if the course already exists. + */ + public Course createCourse(Course course) throws InvalidParametersException, EntityAlreadyExistsException { + return coursesLogic.createCourse(course); + } +} diff --git a/src/main/java/teammates/sqllogic/api/package-info.java b/src/main/java/teammates/sqllogic/api/package-info.java new file mode 100644 index 000000000000..fbd8975d48ce --- /dev/null +++ b/src/main/java/teammates/sqllogic/api/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains a single class as the entrypoint to the system. + */ +package teammates.sqllogic.api; diff --git a/src/main/java/teammates/sqllogic/core/CoursesLogic.java b/src/main/java/teammates/sqllogic/core/CoursesLogic.java new file mode 100644 index 000000000000..b66edb31f091 --- /dev/null +++ b/src/main/java/teammates/sqllogic/core/CoursesLogic.java @@ -0,0 +1,53 @@ +package teammates.sqllogic.core; + +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.storage.sqlapi.CoursesDb; +import teammates.storage.sqlentity.Course; + +/** + * Handles operations related to courses. + * + * @see Course + * @see CoursesDb + */ +public final class CoursesLogic { + + private static final CoursesLogic instance = new CoursesLogic(); + + private CoursesDb coursesDb; + + // private FeedbackSessionsLogic fsLogic; + + private CoursesLogic() { + // prevent initialization + } + + public static CoursesLogic inst() { + return instance; + } + + void initLogicDependencies(CoursesDb coursesDb, FeedbackSessionsLogic fsLogic) { + this.coursesDb = coursesDb; + // this.fsLogic = fsLogic; + } + + /** + * Creates a course. + * @return the created course + * @throws InvalidParametersException if the course is not valid + * @throws EntityAlreadyExistsException if the course already exists in the database. + */ + public Course createCourse(Course course) throws InvalidParametersException, EntityAlreadyExistsException { + return coursesDb.createCourse(course); + } + + /** + * Gets a course by course id. + * @param courseId of course. + * @return the specified course. + */ + public Course getCourse(String courseId) { + return coursesDb.getCourse(courseId); + } +} diff --git a/src/main/java/teammates/sqllogic/core/FeedbackSessionsLogic.java b/src/main/java/teammates/sqllogic/core/FeedbackSessionsLogic.java new file mode 100644 index 000000000000..6410c4c938fb --- /dev/null +++ b/src/main/java/teammates/sqllogic/core/FeedbackSessionsLogic.java @@ -0,0 +1,32 @@ +package teammates.sqllogic.core; + +import teammates.storage.sqlapi.FeedbackSessionsDb; + +/** + * Handles operations related to feedback sessions. + * + * @see FeedbackSession + * @see FeedbackSessionsDb + */ +public final class FeedbackSessionsLogic { + + private static final FeedbackSessionsLogic instance = new FeedbackSessionsLogic(); + + // private FeedbackSessionsDb fsDb; + + // private CoursesLogic coursesLogic; + + private FeedbackSessionsLogic() { + // prevent initialization + } + + public static FeedbackSessionsLogic inst() { + return instance; + } + + void initLogicDependencies(FeedbackSessionsDb fsDb, CoursesLogic coursesLogic) { + // this.fsDb = fsDb; + // this.coursesLogic = coursesLogic; + } + +} diff --git a/src/main/java/teammates/sqllogic/core/LogicStarter.java b/src/main/java/teammates/sqllogic/core/LogicStarter.java new file mode 100644 index 000000000000..a29e981cf313 --- /dev/null +++ b/src/main/java/teammates/sqllogic/core/LogicStarter.java @@ -0,0 +1,41 @@ +package teammates.sqllogic.core; + +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; + +import teammates.common.util.Logger; +import teammates.storage.sqlapi.CoursesDb; +import teammates.storage.sqlapi.FeedbackSessionsDb; + +/** + * Setup in web.xml to register logic classes at application startup. + */ +public class LogicStarter implements ServletContextListener { + + private static final Logger log = Logger.getLogger(); + + /** + * Registers dependencies between different logic classes. + */ + public static void initializeDependencies() { + CoursesLogic coursesLogic = CoursesLogic.inst(); + FeedbackSessionsLogic fsLogic = FeedbackSessionsLogic.inst(); + + coursesLogic.initLogicDependencies(CoursesDb.inst(), fsLogic); + fsLogic.initLogicDependencies(FeedbackSessionsDb.inst(), coursesLogic); + + log.info("Initialized dependencies between logic classes"); + } + + @Override + public void contextInitialized(ServletContextEvent event) { + // Invoked by Jetty at application startup. + initializeDependencies(); + } + + @Override + public void contextDestroyed(ServletContextEvent event) { + // Nothing to do + } + +} diff --git a/src/main/java/teammates/sqllogic/core/package-info.java b/src/main/java/teammates/sqllogic/core/package-info.java new file mode 100644 index 000000000000..8dda22a02eb0 --- /dev/null +++ b/src/main/java/teammates/sqllogic/core/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains a single class as the entrypoint to the system. + */ +package teammates.sqllogic.core; diff --git a/src/main/java/teammates/storage/sqlapi/CoursesDb.java b/src/main/java/teammates/storage/sqlapi/CoursesDb.java new file mode 100644 index 000000000000..6ab06a40c0ec --- /dev/null +++ b/src/main/java/teammates/storage/sqlapi/CoursesDb.java @@ -0,0 +1,118 @@ +package teammates.storage.sqlapi; + +import java.time.Instant; + +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.EntityDoesNotExistException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.HibernateUtil; +import teammates.storage.sqlentity.Course; + +/** + * Handles CRUD operations for courses. + * + * @see Course + */ +public final class CoursesDb extends EntitiesDb { + + private static final CoursesDb instance = new CoursesDb(); + + private CoursesDb() { + // prevent initialization + } + + public static CoursesDb inst() { + return instance; + } + + /** + * Returns a course with the {@code courseID} or null if it does not exist. + */ + public Course getCourse(String courseId) { + assert courseId != null; + + return HibernateUtil.getSessionFactory().getCurrentSession().get(Course.class, courseId); + } + + /** + * Creates a course. + */ + public Course createCourse(Course course) throws InvalidParametersException, EntityAlreadyExistsException { + assert course != null; + + course.sanitizeForSaving(); + if (!course.isValid()) { + throw new InvalidParametersException(course.getInvalidityInfo()); + } + + if (getCourse(course.getId()) != null) { + throw new EntityAlreadyExistsException(String.format(ERROR_CREATE_ENTITY_ALREADY_EXISTS, course.toString())); + } + + persist(course); + return course; + } + + /** + * Saves an updated {@code Course} to the db. + */ + public Course updateCourse(Course course) throws InvalidParametersException, EntityDoesNotExistException { + assert course != null; + + course.sanitizeForSaving(); + if (!course.isValid()) { + throw new InvalidParametersException(course.getInvalidityInfo()); + } + + if (getCourse(course.getId()) == null) { + throw new EntityDoesNotExistException(ERROR_UPDATE_NON_EXISTENT); + } + + return merge(course); + } + + /** + * Deletes a course. + */ + public void deleteCourse(String courseId) { + assert courseId != null; + + Course course = getCourse(courseId); + if (course != null) { + delete(course); + } + } + + /** + * Soft-deletes a course by its given corresponding ID. + * + * @return Soft-deletion time of the course. + */ + public Instant softDeleteCourse(String courseId) throws EntityDoesNotExistException { + assert courseId != null; + + Course course = getCourse(courseId); + if (course == null) { + throw new EntityDoesNotExistException(ERROR_UPDATE_NON_EXISTENT); + } + + course.setDeletedAt(Instant.now()); + + return course.getDeletedAt(); + } + + /** + * Restores a soft-deleted course by its given corresponding ID. + */ + public void restoreDeletedCourse(String courseId) throws EntityDoesNotExistException { + assert courseId != null; + + Course course = getCourse(courseId); + + if (course == null) { + throw new EntityDoesNotExistException(ERROR_UPDATE_NON_EXISTENT); + } + + course.setDeletedAt(null); + } +} diff --git a/src/main/java/teammates/storage/sqlapi/EntitiesDb.java b/src/main/java/teammates/storage/sqlapi/EntitiesDb.java new file mode 100644 index 000000000000..b43ab7c7e65e --- /dev/null +++ b/src/main/java/teammates/storage/sqlapi/EntitiesDb.java @@ -0,0 +1,52 @@ + +package teammates.storage.sqlapi; + +import teammates.common.util.HibernateUtil; +import teammates.common.util.JsonUtils; +import teammates.common.util.Logger; +import teammates.storage.sqlentity.BaseEntity; + +/** + * Base class for all classes performing CRUD operations against the database. + * + * @param subclass of BaseEntity + */ +class EntitiesDb { + + static final String ERROR_CREATE_ENTITY_ALREADY_EXISTS = "Trying to create an entity that exists: %s"; + static final String ERROR_UPDATE_NON_EXISTENT = "Trying to update non-existent Entity: "; + + static final Logger log = Logger.getLogger(); + + /** + * Copy the state of the given object onto the persistent object with the same identifier. + * If there is no persistent instance currently associated with the session, it will be loaded. + */ + E merge(E entity) { + assert entity != null; + + E newEntity = HibernateUtil.getSessionFactory().getCurrentSession().merge(entity); + log.info("Entity saves: " + JsonUtils.toJson(entity)); + return newEntity; + } + + /** + * Associate {@code entity} with the persistence context. + */ + void persist(E entity) { + assert entity != null; + + HibernateUtil.getSessionFactory().getCurrentSession().persist(entity); + log.info("Entity persisted: " + JsonUtils.toJson(entity)); + } + + /** + * Deletes {@code entity} from persistence context. + */ + void delete(E entity) { + assert entity != null; + + HibernateUtil.getSessionFactory().getCurrentSession().remove(entity); + log.info("Entity deleted: " + JsonUtils.toJson(entity)); + } +} diff --git a/src/main/java/teammates/storage/sqlapi/FeedbackSessionsDb.java b/src/main/java/teammates/storage/sqlapi/FeedbackSessionsDb.java new file mode 100644 index 000000000000..d0518f70004f --- /dev/null +++ b/src/main/java/teammates/storage/sqlapi/FeedbackSessionsDb.java @@ -0,0 +1,132 @@ +package teammates.storage.sqlapi; + +import java.time.Instant; + +import teammates.common.exception.EntityDoesNotExistException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.HibernateUtil; +import teammates.storage.sqlentity.FeedbackSession; + +/** + * Handles CRUD operations for feedback sessions. + * + * @see FeedbackSession + */ +public final class FeedbackSessionsDb extends EntitiesDb { + + private static final FeedbackSessionsDb instance = new FeedbackSessionsDb(); + + private FeedbackSessionsDb() { + // prevent initialization + } + + public static FeedbackSessionsDb inst() { + return instance; + } + + /** + * Gets a feedback session that is not soft-deleted. + * + * @return null if not found or soft-deleted. + */ + public FeedbackSession getFeedbackSession(Long fsId) { + assert fsId != null; + + FeedbackSession fs = HibernateUtil.getSessionFactory().getCurrentSession().get(FeedbackSession.class, fsId); + + if (fs != null && fs.getDeletedAt() != null) { + log.info("Trying to access soft-deleted session: " + fs.getName() + "/" + fs.getCourse().getId()); + return null; + } + + return fs; + } + + /** + * Gets a soft-deleted feedback session. + * + * @return null if not found or not soft-deleted. + */ + public FeedbackSession getSoftDeletedFeedbackSession(Long fsId) { + assert fsId != null; + + FeedbackSession fs = HibernateUtil.getSessionFactory().getCurrentSession().get(FeedbackSession.class, fsId); + + if (fs != null && fs.getDeletedAt() != null) { + log.info(fs.getName() + "/" + fs.getCourse().getId() + " is not soft-deleted"); + return null; + } + + return fs; + } + + /** + * Saves an updated {@code FeedbackSession} to the db. + * + * @return updated feedback session + * @throws InvalidParametersException if attributes to update are not valid + * @throws EntityDoesNotExistException if the feedback session cannot be found + */ + public FeedbackSession updateFeedbackSession(FeedbackSession feedbackSession) + throws InvalidParametersException, EntityDoesNotExistException { + assert feedbackSession != null; + + feedbackSession.sanitizeForSaving(); + if (!feedbackSession.isValid()) { + throw new InvalidParametersException(feedbackSession.getInvalidityInfo()); + } + + if (getFeedbackSession(feedbackSession.getId()) == null) { + throw new EntityDoesNotExistException(ERROR_UPDATE_NON_EXISTENT); + } + + return merge(feedbackSession); + } + + /** + * Soft-deletes a feedback session. + * + * @return Soft-deletion time of the feedback session. + */ + public Instant softDeleteFeedbackSession(Long fsId) + throws EntityDoesNotExistException { + assert fsId != null; + + FeedbackSession fs = getFeedbackSession(fsId); + if (fs == null) { + throw new EntityDoesNotExistException(ERROR_UPDATE_NON_EXISTENT); + } + + fs.setDeletedAt(Instant.now()); + + return fs.getDeletedAt(); + } + + /** + * Restores a specific soft deleted feedback session. + */ + public void restoreDeletedFeedbackSession(Long fsId) + throws EntityDoesNotExistException { + assert fsId != null; + + FeedbackSession fs = getFeedbackSession(fsId); + + if (fs == null) { + throw new EntityDoesNotExistException(ERROR_UPDATE_NON_EXISTENT); + } + + fs.setDeletedAt(null); + } + + /** + * Deletes a feedback session. + */ + public void deleteFeedbackSession(Long fsId) { + assert fsId != null; + + FeedbackSession fs = getFeedbackSession(fsId); + if (fs != null) { + delete(fs); + } + } +} diff --git a/src/main/java/teammates/storage/sqlapi/package-info.java b/src/main/java/teammates/storage/sqlapi/package-info.java new file mode 100644 index 000000000000..7a36eb336b99 --- /dev/null +++ b/src/main/java/teammates/storage/sqlapi/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains classes handling CRUD operations against the databse. + */ +package teammates.storage.sqlapi; diff --git a/src/main/java/teammates/storage/sqlentity/BaseEntity.java b/src/main/java/teammates/storage/sqlentity/BaseEntity.java new file mode 100644 index 000000000000..b020d9fe93fc --- /dev/null +++ b/src/main/java/teammates/storage/sqlentity/BaseEntity.java @@ -0,0 +1,69 @@ +package teammates.storage.sqlentity; + +import java.time.Duration; +import java.util.List; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * Base class for all entities. + */ +public abstract class BaseEntity { + + BaseEntity() { + // instantiate as child classes + } + + /** + * Perform any sanitization that needs to be done before saving. + */ + public abstract void sanitizeForSaving(); + + /** + * Returns a {@code List} of strings, one string for each attribute whose + * value is invalid, or an empty {@code List} if all attributes are valid. + * + *

The string explains why the value is invalid + * and what should values are acceptable. These explanations are + * good enough to show to the user. + */ + public abstract List getInvalidityInfo(); + + /** + * Returns true if the attributes represent a valid state for the entity. + */ + public boolean isValid() { + return getInvalidityInfo().isEmpty(); + } + + /** + * Adds {@code error} to {@code errors} if {@code error} is a non-empty string. + * + * @param error An error message, possibly empty. + * @param errors A List of errors, to add {@code error} to. + */ + void addNonEmptyError(String error, List errors) { + if (error.isEmpty()) { + return; + } + + errors.add(error); + } + + /** + * Attribute converter between Duration and Long types. + */ + @Converter + public static class DurationLongConverter implements AttributeConverter { + @Override + public Long convertToDatabaseColumn(Duration duration) { + return duration.toMinutes(); + } + + @Override + public Duration convertToEntityAttribute(Long minutes) { + return Duration.ofMinutes(minutes); + } + } +} diff --git a/src/main/java/teammates/storage/sqlentity/Course.java b/src/main/java/teammates/storage/sqlentity/Course.java new file mode 100644 index 000000000000..faa21629843a --- /dev/null +++ b/src/main/java/teammates/storage/sqlentity/Course.java @@ -0,0 +1,235 @@ +package teammates.storage.sqlentity; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang.StringUtils; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import teammates.common.util.Const; +import teammates.common.util.FieldValidator; +import teammates.common.util.SanitizationHelper; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +/** + * Represents a course entity. + */ +@Entity +@Table(name = "Courses") +public class Course extends BaseEntity { + @Id + private String id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String timeZone; + + @Column(nullable = false) + private String institute; + + @OneToMany(mappedBy = "course", cascade = CascadeType.ALL) + private List feedbackSessions = new ArrayList<>(); + + @CreationTimestamp + @Column(updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column + private Instant updatedAt; + + @Column + private Instant deletedAt; + + protected Course() { + // required by Hibernate + } + + private Course(CourseBuilder builder) { + this.setId(builder.id); + this.setName(builder.name); + + this.setTimeZone(StringUtils.defaultIfEmpty(builder.timeZone, Const.DEFAULT_TIME_ZONE)); + this.setInstitute(builder.institute); + + if (builder.deletedAt != null) { + this.setDeletedAt(builder.deletedAt); + } + } + + @Override + public List getInvalidityInfo() { + List errors = new ArrayList<>(); + + addNonEmptyError(FieldValidator.getInvalidityInfoForCourseId(getId()), errors); + addNonEmptyError(FieldValidator.getInvalidityInfoForCourseName(getName()), errors); + addNonEmptyError(FieldValidator.getInvalidityInfoForInstituteName(getInstitute()), errors); + + return errors; + } + + @Override + public void sanitizeForSaving() { + this.id = SanitizationHelper.sanitizeTitle(id); + this.name = SanitizationHelper.sanitizeName(name); + this.institute = SanitizationHelper.sanitizeTitle(institute); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getTimeZone() { + return timeZone; + } + + public void setTimeZone(String timeZone) { + this.timeZone = timeZone; + } + + public String getInstitute() { + return institute; + } + + public void setInstitute(String institute) { + this.institute = institute; + } + + public List getFeedbackSessions() { + return feedbackSessions; + } + + public void setFeedbackSessions(List feedbackSessions) { + this.feedbackSessions = feedbackSessions; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + public Instant getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(Instant deletedAt) { + this.deletedAt = deletedAt; + } + + @Override + public String toString() { + return "Course [id=" + id + ", name=" + name + ", timeZone=" + timeZone + ", institute=" + institute + + ", feedbackSessions=" + feedbackSessions + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + + ", deletedAt=" + deletedAt + "]"; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + ((name == null) ? 0 : name.hashCode()); + result = prime * result + ((timeZone == null) ? 0 : timeZone.hashCode()); + result = prime * result + ((institute == null) ? 0 : institute.hashCode()); + result = prime * result + ((feedbackSessions == null) ? 0 : feedbackSessions.hashCode()); + result = prime * result + ((createdAt == null) ? 0 : createdAt.hashCode()); + result = prime * result + ((updatedAt == null) ? 0 : updatedAt.hashCode()); + result = prime * result + ((deletedAt == null) ? 0 : deletedAt.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } else if (obj == null) { + return false; + } else if (this.getClass() != obj.getClass()) { + return false; + } + + Course o = (Course) obj; + return Objects.equals(this.id, o.id) + && Objects.equals(this.name, o.name) + && Objects.equals(this.timeZone, o.timeZone) + && Objects.equals(this.institute, o.institute) + && Objects.equals(this.feedbackSessions, o.feedbackSessions) + && Objects.equals(this.createdAt, o.createdAt) + && Objects.equals(this.updatedAt, o.updatedAt) + && Objects.equals(this.deletedAt, o.deletedAt); + } + + /** + * Builder for Course. + */ + public static class CourseBuilder { + private String id; + private String name; + private String institute; + + private String timeZone; + private Instant deletedAt; + + public CourseBuilder(String id) { + this.id = id; + } + + public CourseBuilder withName(String name) { + this.name = name; + return this; + } + + public CourseBuilder withInstitute(String institute) { + this.institute = institute; + return this; + } + + public CourseBuilder withTimeZone(String timeZone) { + this.timeZone = timeZone; + return this; + } + + public CourseBuilder withDeletedAt(Instant deletedAt) { + this.deletedAt = deletedAt; + return this; + } + + public Course build() { + return new Course(this); + } + } +} diff --git a/src/main/java/teammates/storage/sqlentity/FeedbackSession.java b/src/main/java/teammates/storage/sqlentity/FeedbackSession.java new file mode 100644 index 000000000000..4c5274361f1a --- /dev/null +++ b/src/main/java/teammates/storage/sqlentity/FeedbackSession.java @@ -0,0 +1,406 @@ +package teammates.storage.sqlentity; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.apache.commons.lang.StringUtils; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import teammates.common.util.Const; +import teammates.common.util.FieldValidator; +import teammates.common.util.SanitizationHelper; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +/** + * Represents a course entity. + */ +@Entity +@Table(name = "FeedbackSessions") +public class FeedbackSession extends BaseEntity { + @Id + @GeneratedValue + private Long id; + + @ManyToOne + @JoinColumn(name = "courseId") + private Course course; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String creatorEmail; + + @Column(nullable = false) + private String instructions; + + @Column(nullable = false) + private Instant startTime; + + @Column(nullable = false) + private Instant endTime; + + @Column(nullable = false) + private Instant sessionVisibleFromTime; + + @Column(nullable = false) + private Instant resultsVisibleFromTime; + + @Column(nullable = false) + @Convert(converter = DurationLongConverter.class) + private Duration gracePeriod; + + @Column(nullable = false) + private boolean isOpeningEmailEnabled; + + @Column(nullable = false) + private boolean isClosingEmailEnabled; + + @Column(nullable = false) + private boolean isPublishedEmailEnabled; + + @CreationTimestamp + @Column(updatable = false) + private Instant createdAt; + + @UpdateTimestamp + @Column + private Instant updatedAt; + + @Column + private Instant deletedAt; + + protected FeedbackSession() { + // required by Hibernate + } + + private FeedbackSession(FeedbackSessionBuilder builder) { + this.setName(builder.name); + this.setCourse(builder.course); + + this.setCreatorEmail(builder.creatorEmail); + this.setInstructions(StringUtils.defaultString(builder.instructions)); + this.setStartTime(builder.startTime); + this.setEndTime(builder.endTime); + this.setSessionVisibleFromTime(builder.sessionVisibleFromTime); + this.setResultsVisibleFromTime(builder.resultsVisibleFromTime); + this.setGracePeriod(Objects.requireNonNullElse(builder.gracePeriod, Duration.ZERO)); + this.setOpeningEmailEnabled(builder.isOpeningEmailEnabled); + this.setClosingEmailEnabled(builder.isClosingEmailEnabled); + this.setPublishedEmailEnabled(builder.isPublishedEmailEnabled); + + if (builder.deletedAt != null) { + this.setDeletedAt(builder.deletedAt); + } + } + + @Override + public void sanitizeForSaving() { + this.instructions = SanitizationHelper.sanitizeForRichText(instructions); + } + + @Override + public List getInvalidityInfo() { + List errors = new ArrayList<>(); + + // Check for null fields. + addNonEmptyError(FieldValidator.getValidityInfoForNonNullField( + FieldValidator.FEEDBACK_SESSION_NAME_FIELD_NAME, name), errors); + + addNonEmptyError(FieldValidator.getValidityInfoForNonNullField( + FieldValidator.COURSE_ID_FIELD_NAME, course), errors); + + addNonEmptyError(FieldValidator.getValidityInfoForNonNullField("instructions to students", instructions), + errors); + + addNonEmptyError(FieldValidator.getValidityInfoForNonNullField( + "time for the session to become visible", sessionVisibleFromTime), errors); + + addNonEmptyError(FieldValidator.getValidityInfoForNonNullField("creator's email", creatorEmail), errors); + + // Early return if any null fields + if (!errors.isEmpty()) { + return errors; + } + + addNonEmptyError(FieldValidator.getInvalidityInfoForFeedbackSessionName(name), errors); + + addNonEmptyError(FieldValidator.getInvalidityInfoForEmail(creatorEmail), errors); + + addNonEmptyError(FieldValidator.getInvalidityInfoForGracePeriod(gracePeriod), errors); + + addNonEmptyError(FieldValidator.getValidityInfoForNonNullField("submission opening time", startTime), errors); + + addNonEmptyError(FieldValidator.getValidityInfoForNonNullField("submission closing time", endTime), errors); + + addNonEmptyError(FieldValidator.getValidityInfoForNonNullField( + "time for the responses to become visible", resultsVisibleFromTime), errors); + + // Early return if any null fields + if (!errors.isEmpty()) { + return errors; + } + + addNonEmptyError(FieldValidator.getInvalidityInfoForTimeForSessionStartAndEnd(startTime, endTime), errors); + + addNonEmptyError(FieldValidator.getInvalidityInfoForTimeForVisibilityStartAndSessionStart( + sessionVisibleFromTime, startTime), errors); + + Instant actualSessionVisibleFromTime = sessionVisibleFromTime; + + if (actualSessionVisibleFromTime.equals(Const.TIME_REPRESENTS_FOLLOW_OPENING)) { + actualSessionVisibleFromTime = startTime; + } + + addNonEmptyError(FieldValidator.getInvalidityInfoForTimeForVisibilityStartAndResultsPublish( + actualSessionVisibleFromTime, resultsVisibleFromTime), errors); + + // TODO: add once extended dealines added to entity + // addNonEmptyError(FieldValidator.getInvalidityInfoForTimeForSessionEndAndExtendedDeadlines( + // endTime, studentDeadlines), errors); + + // addNonEmptyError(FieldValidator.getInvalidityInfoForTimeForSessionEndAndExtendedDeadlines( + // endTime, instructorDeadlines), errors); + + return errors; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Course getCourse() { + return course; + } + + public void setCourse(Course course) { + this.course = course; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCreatorEmail() { + return creatorEmail; + } + + public void setCreatorEmail(String creatorEmail) { + this.creatorEmail = creatorEmail; + } + + public String getInstructions() { + return instructions; + } + + public void setInstructions(String instructions) { + this.instructions = instructions; + } + + public Instant getStartTime() { + return startTime; + } + + public void setStartTime(Instant startTime) { + this.startTime = startTime; + } + + public Instant getEndTime() { + return endTime; + } + + public void setEndTime(Instant endTime) { + this.endTime = endTime; + } + + public Instant getSessionVisibleFromTime() { + return sessionVisibleFromTime; + } + + public void setSessionVisibleFromTime(Instant sessionVisibleFromTime) { + this.sessionVisibleFromTime = sessionVisibleFromTime; + } + + public Instant getResultsVisibleFromTime() { + return resultsVisibleFromTime; + } + + public void setResultsVisibleFromTime(Instant resultsVisibleFromTime) { + this.resultsVisibleFromTime = resultsVisibleFromTime; + } + + public Duration getGracePeriod() { + return gracePeriod; + } + + public void setGracePeriod(Duration gracePeriod) { + this.gracePeriod = gracePeriod; + } + + public boolean isOpeningEmailEnabled() { + return isOpeningEmailEnabled; + } + + public void setOpeningEmailEnabled(boolean isOpeningEmailEnabled) { + this.isOpeningEmailEnabled = isOpeningEmailEnabled; + } + + public boolean isClosingEmailEnabled() { + return isClosingEmailEnabled; + } + + public void setClosingEmailEnabled(boolean isClosingEmailEnabled) { + this.isClosingEmailEnabled = isClosingEmailEnabled; + } + + public boolean isPublishedEmailEnabled() { + return isPublishedEmailEnabled; + } + + public void setPublishedEmailEnabled(boolean isPublishedEmailEnabled) { + this.isPublishedEmailEnabled = isPublishedEmailEnabled; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + public Instant getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(Instant deletedAt) { + this.deletedAt = deletedAt; + } + + @Override + public String toString() { + return "FeedbackSession [id=" + id + ", course=" + course + ", name=" + name + ", creatorEmail=" + creatorEmail + + ", instructions=" + instructions + ", startTime=" + startTime + ", endTime=" + endTime + + ", sessionVisibleFromTime=" + sessionVisibleFromTime + ", resultsVisibleFromTime=" + + resultsVisibleFromTime + ", gracePeriod=" + gracePeriod + ", isOpeningEmailEnabled=" + + isOpeningEmailEnabled + ", isClosingEmailEnabled=" + isClosingEmailEnabled + + ", isPublishedEmailEnabled=" + isPublishedEmailEnabled + ", createdAt=" + createdAt + ", updatedAt=" + + updatedAt + ", deletedAt=" + deletedAt + "]"; + } + + /** + * Builder for FeedbackSession. + */ + public static class FeedbackSessionBuilder { + private String name; + private Course course; + + private String creatorEmail; + private String instructions; + private Instant startTime; + private Instant endTime; + private Instant sessionVisibleFromTime; + private Instant resultsVisibleFromTime; + private Duration gracePeriod; + private boolean isOpeningEmailEnabled; + private boolean isClosingEmailEnabled; + private boolean isPublishedEmailEnabled; + private Instant deletedAt; + + public FeedbackSessionBuilder(String name) { + this.name = name; + } + + public FeedbackSessionBuilder withCourse(Course course) { + this.course = course; + return this; + } + + public FeedbackSessionBuilder withCreatorEmail(String creatorEmail) { + this.creatorEmail = creatorEmail; + return this; + } + + public FeedbackSessionBuilder withInstructions(String instructions) { + this.instructions = instructions; + return this; + } + + public FeedbackSessionBuilder withStartTime(Instant startTime) { + this.startTime = startTime; + return this; + } + + public FeedbackSessionBuilder withEndTime(Instant endTime) { + this.endTime = endTime; + return this; + } + + public FeedbackSessionBuilder withSessionVisibleFromTime(Instant sessionVisibleFromTime) { + this.sessionVisibleFromTime = sessionVisibleFromTime; + return this; + } + + public FeedbackSessionBuilder withResultsVisibleFromTime(Instant resultsVisibleFromTime) { + this.resultsVisibleFromTime = resultsVisibleFromTime; + return this; + } + + public FeedbackSessionBuilder withGracePeriod(Duration gracePeriod) { + this.gracePeriod = gracePeriod; + return this; + } + + public FeedbackSessionBuilder withOpeningEmailEnabled(boolean isOpeningEmailEnabled) { + this.isOpeningEmailEnabled = isOpeningEmailEnabled; + return this; + } + + public FeedbackSessionBuilder withClosingEmailEnabled(boolean isClosingEmailEnabled) { + this.isClosingEmailEnabled = isClosingEmailEnabled; + return this; + } + + public FeedbackSessionBuilder withPublishedEmailEnabled(boolean isPublishedEmailEnabled) { + this.isPublishedEmailEnabled = isPublishedEmailEnabled; + return this; + } + + public FeedbackSessionBuilder withDeletedAt(Instant deletedAt) { + this.deletedAt = deletedAt; + return this; + } + + public FeedbackSession build() { + return new FeedbackSession(this); + } + } +} diff --git a/src/main/java/teammates/storage/sqlentity/package-info.java b/src/main/java/teammates/storage/sqlentity/package-info.java new file mode 100644 index 000000000000..492dcca6f343 --- /dev/null +++ b/src/main/java/teammates/storage/sqlentity/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains classes that represent JPA entities. + */ +package teammates.storage.sqlentity; diff --git a/src/main/java/teammates/ui/output/CourseData.java b/src/main/java/teammates/ui/output/CourseData.java index 3f9500f3df50..c0980146c553 100644 --- a/src/main/java/teammates/ui/output/CourseData.java +++ b/src/main/java/teammates/ui/output/CourseData.java @@ -4,6 +4,7 @@ import teammates.common.datatransfer.InstructorPermissionSet; import teammates.common.datatransfer.attributes.CourseAttributes; +import teammates.storage.sqlentity.Course; /** * The API output format of a course. @@ -30,6 +31,17 @@ public CourseData(CourseAttributes courseAttributes) { } } + public CourseData(Course course) { + this.courseId = course.getId(); + this.courseName = course.getName(); + this.timeZone = course.getTimeZone(); + this.institute = course.getInstitute(); + this.creationTimestamp = course.getCreatedAt().toEpochMilli(); + if (course.getDeletedAt() != null) { + this.deletionTimestamp = course.getDeletedAt().toEpochMilli(); + } + } + public String getCourseId() { return courseId; } diff --git a/src/main/java/teammates/ui/servlets/HibernateContextListener.java b/src/main/java/teammates/ui/servlets/HibernateContextListener.java new file mode 100644 index 000000000000..e377c9645e39 --- /dev/null +++ b/src/main/java/teammates/ui/servlets/HibernateContextListener.java @@ -0,0 +1,25 @@ +package teammates.ui.servlets; + +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; + +import teammates.common.util.Config; +import teammates.common.util.HibernateUtil; + +/** + * Setup in web.xml to set up Hibernate Session Factory at application startup. + */ +public class HibernateContextListener implements ServletContextListener { + + @Override + public void contextInitialized(ServletContextEvent event) { + // Invoked by Jetty at application startup. + HibernateUtil.buildSessionFactory(Config.getDbConnectionUrl(), Config.APP_LOCALPOSTGRES_USERNAME, + Config.APP_LOCALPOSTGRES_PASSWORD); + } + + @Override + public void contextDestroyed(ServletContextEvent event) { + // Nothing to do + } +} diff --git a/src/main/java/teammates/ui/servlets/WebApiServlet.java b/src/main/java/teammates/ui/servlets/WebApiServlet.java index c0ccd02fb889..08dfdaab0c05 100644 --- a/src/main/java/teammates/ui/servlets/WebApiServlet.java +++ b/src/main/java/teammates/ui/servlets/WebApiServlet.java @@ -7,11 +7,15 @@ import javax.servlet.http.HttpServletResponse; import org.apache.http.HttpStatus; +import org.hibernate.HibernateException; +import org.hibernate.Session; +import org.hibernate.resource.transaction.spi.TransactionStatus; import com.google.cloud.datastore.DatastoreException; import teammates.common.datatransfer.logs.RequestLogUser; import teammates.common.exception.DeadlineExceededException; +import teammates.common.util.HibernateUtil; import teammates.common.util.Logger; import teammates.ui.request.InvalidHttpRequestBodyException; import teammates.ui.webapi.Action; @@ -55,12 +59,10 @@ public void doDelete(HttpServletRequest req, HttpServletResponse resp) throws IO private void invokeServlet(HttpServletRequest req, HttpServletResponse resp) throws IOException { int statusCode = 0; Action action = null; + try { action = ActionFactory.getAction(req, req.getMethod()); - action.init(req); - action.checkAccessControl(); - - ActionResult result = action.execute(); + ActionResult result = executeWithTransaction(action, req); statusCode = result.getStatusCode(); result.send(resp); } catch (ActionMappingException e) { @@ -86,7 +88,7 @@ private void invokeServlet(HttpServletRequest req, HttpServletResponse resp) thr statusCode = HttpStatus.SC_GATEWAY_TIMEOUT; log.severe(dee.getClass().getSimpleName() + " caught by WebApiServlet", dee); throwError(resp, statusCode, "The request exceeded the server timeout limit. Please try again later."); - } catch (DatastoreException e) { + } catch (DatastoreException | HibernateException e) { statusCode = HttpStatus.SC_INTERNAL_SERVER_ERROR; log.severe(e.getClass().getSimpleName() + " caught by WebApiServlet: " + e.getMessage(), e); throwError(resp, statusCode, e.getMessage()); @@ -111,6 +113,26 @@ private void invokeServlet(HttpServletRequest req, HttpServletResponse resp) thr } } + private ActionResult executeWithTransaction(Action action, HttpServletRequest req) + throws InvalidOperationException, InvalidHttpRequestBodyException, UnauthorizedAccessException { + try { + HibernateUtil.getSessionFactory().getCurrentSession().beginTransaction(); + action.init(req); + action.checkAccessControl(); + + ActionResult result = action.execute(); + HibernateUtil.getSessionFactory().getCurrentSession().getTransaction().commit(); + return result; + } catch (Exception e) { + Session session = HibernateUtil.getSessionFactory().getCurrentSession(); + if (session.getTransaction().getStatus() == TransactionStatus.ACTIVE + || session.getTransaction().getStatus() == TransactionStatus.MARKED_ROLLBACK) { + session.getTransaction().rollback(); + } + throw e; + } + } + private void throwErrorBasedOnRequester(HttpServletRequest req, HttpServletResponse resp, Exception e, int statusCode) throws IOException { // The header X-AppEngine-QueueName cannot be spoofed as GAE will strip any user-sent X-AppEngine-QueueName headers. diff --git a/src/main/java/teammates/ui/webapi/Action.java b/src/main/java/teammates/ui/webapi/Action.java index 0d482f6d6956..b26186ff1a6f 100644 --- a/src/main/java/teammates/ui/webapi/Action.java +++ b/src/main/java/teammates/ui/webapi/Action.java @@ -19,11 +19,11 @@ import teammates.common.util.StringHelper; import teammates.logic.api.EmailGenerator; import teammates.logic.api.EmailSender; -import teammates.logic.api.Logic; import teammates.logic.api.LogsProcessor; import teammates.logic.api.RecaptchaVerifier; import teammates.logic.api.TaskQueuer; import teammates.logic.api.UserProvision; +import teammates.sqllogic.api.Logic; import teammates.ui.request.BasicRequest; import teammates.ui.request.InvalidHttpRequestBodyException; @@ -34,7 +34,8 @@ */ public abstract class Action { - Logic logic = Logic.inst(); + teammates.logic.api.Logic logic = teammates.logic.api.Logic.inst(); + Logic sqlLogic = Logic.inst(); UserProvision userProvision = UserProvision.inst(); GateKeeper gateKeeper = GateKeeper.inst(); EmailGenerator emailGenerator = EmailGenerator.inst(); diff --git a/src/main/java/teammates/ui/webapi/CreateCourseAction.java b/src/main/java/teammates/ui/webapi/CreateCourseAction.java index 989961306c87..f3657bdbc299 100644 --- a/src/main/java/teammates/ui/webapi/CreateCourseAction.java +++ b/src/main/java/teammates/ui/webapi/CreateCourseAction.java @@ -3,12 +3,13 @@ import java.util.List; import java.util.Objects; -import teammates.common.datatransfer.attributes.CourseAttributes; import teammates.common.datatransfer.attributes.InstructorAttributes; import teammates.common.exception.EntityAlreadyExistsException; import teammates.common.exception.InvalidParametersException; import teammates.common.util.Const; import teammates.common.util.FieldValidator; +import teammates.common.util.HibernateUtil; +import teammates.storage.sqlentity.Course; import teammates.ui.output.CourseData; import teammates.ui.request.CourseCreateRequest; import teammates.ui.request.InvalidHttpRequestBodyException; @@ -60,27 +61,30 @@ public JsonResult execute() throws InvalidHttpRequestBodyException, InvalidOpera String newCourseName = courseCreateRequest.getCourseName(); String institute = getNonNullRequestParamValue(Const.ParamsNames.INSTRUCTOR_INSTITUTION); - CourseAttributes courseAttributes = - CourseAttributes.builder(newCourseId) - .withName(newCourseName) - .withTimezone(newCourseTimeZone) - .withInstitute(institute) - .build(); + Course course = new Course.CourseBuilder(newCourseId) + .withName(newCourseName) + .withTimeZone(newCourseTimeZone) + .withInstitute(institute) + .build(); try { - logic.createCourseAndInstructor(userInfo.getId(), courseAttributes); + sqlLogic.createCourse(course); // TODO: Create instructor as well - InstructorAttributes instructorCreatedForCourse = logic.getInstructorForGoogleId(newCourseId, userInfo.getId()); - taskQueuer.scheduleInstructorForSearchIndexing(instructorCreatedForCourse.getCourseId(), - instructorCreatedForCourse.getEmail()); + // TODO: Migrate once instructor entity is ready. + // InstructorAttributes instructorCreatedForCourse = logic.getInstructorForGoogleId(newCourseId, + // userInfo.getId()); + // taskQueuer.scheduleInstructorForSearchIndexing(instructorCreatedForCourse.getCourseId(), + // instructorCreatedForCourse.getEmail()); } catch (EntityAlreadyExistsException e) { - throw new InvalidOperationException("The course ID " + courseAttributes.getId() + throw new InvalidOperationException("The course ID " + course.getId() + " has been used by another course, possibly by some other user." + " Please try again with a different course ID.", e); } catch (InvalidParametersException e) { throw new InvalidHttpRequestBodyException(e); } - return new JsonResult(new CourseData(logic.getCourse(newCourseId))); + HibernateUtil.getSessionFactory().getCurrentSession().flush(); + CourseData courseData = new CourseData(course); + return new JsonResult(courseData); } } diff --git a/src/main/java/teammates/ui/webapi/CreateFeedbackSessionAction.java b/src/main/java/teammates/ui/webapi/CreateFeedbackSessionAction.java index e0fe77186250..f21950cf5d04 100644 --- a/src/main/java/teammates/ui/webapi/CreateFeedbackSessionAction.java +++ b/src/main/java/teammates/ui/webapi/CreateFeedbackSessionAction.java @@ -88,7 +88,6 @@ public JsonResult execute() throws InvalidHttpRequestBodyException, InvalidOpera .withIsClosingEmailEnabled(createRequest.isClosingEmailEnabled()) .withIsPublishedEmailEnabled(createRequest.isPublishedEmailEnabled()) .build(); - try { logic.createFeedbackSession(fs); } catch (EntityAlreadyExistsException e) { diff --git a/src/main/resources/db/changelog/db.changelog-root.xml b/src/main/resources/db/changelog/db.changelog-root.xml new file mode 100644 index 000000000000..66d4b7d7c88e --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-root.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/main/resources/db/changelog/db.changelog-v9.xml b/src/main/resources/db/changelog/db.changelog-v9.xml new file mode 100644 index 000000000000..57b6e7f9587c --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-v9.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 5a45e31a6b5d..815513862a48 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -57,6 +57,12 @@ teammates.logic.core.LogicStarter + + teammates.sqllogic.core.LogicStarter + + + teammates.ui.servlets.HibernateContextListener + index.html diff --git a/src/test/java/teammates/architecture/ArchitectureTest.java b/src/test/java/teammates/architecture/ArchitectureTest.java index f26a03208a02..4b54eb6c26fa 100644 --- a/src/test/java/teammates/architecture/ArchitectureTest.java +++ b/src/test/java/teammates/architecture/ArchitectureTest.java @@ -65,13 +65,6 @@ private static JavaClasses forClasses(String... packageNames) { return new ClassFileImporter().importPackages(packageNames); } - @Test - public void testArchitecture_uiShouldNotTouchStorage() { - noClasses().that().resideInAPackage(includeSubpackages(UI_PACKAGE)) - .should().accessClassesThat().resideInAPackage(includeSubpackages(STORAGE_PACKAGE)) - .check(forClasses(UI_PACKAGE, STORAGE_PACKAGE)); - } - @Test public void testArchitecture_mainShouldNotTouchProductionCodeExceptCommon() { noClasses().that().resideInAPackage(MAIN_PACKAGE) @@ -303,17 +296,6 @@ public void testArchitecture_testClasses_driverShouldNotHaveAnyDependency() { noClasses().that().resideInAPackage(includeSubpackages(TEST_DRIVER_PACKAGE)) .should().accessClassesThat(new DescribedPredicate<>("") { - @Override - public boolean apply(JavaClass input) { - return input.getPackageName().startsWith(STORAGE_PACKAGE) - && !"OfyHelper".equals(input.getSimpleName()) - && !"AccountRequestSearchManager".equals(input.getSimpleName()) - && !"InstructorSearchManager".equals(input.getSimpleName()) - && !"StudentSearchManager".equals(input.getSimpleName()) - && !"SearchManagerFactory".equals(input.getSimpleName()); - } - }) - .orShould().accessClassesThat(new DescribedPredicate<>("") { @Override public boolean apply(JavaClass input) { return input.getPackageName().startsWith(LOGIC_CORE_PACKAGE) diff --git a/src/test/java/teammates/storage/sqlapi/CoursesDbTest.java b/src/test/java/teammates/storage/sqlapi/CoursesDbTest.java new file mode 100644 index 000000000000..c23cee4c41d8 --- /dev/null +++ b/src/test/java/teammates/storage/sqlapi/CoursesDbTest.java @@ -0,0 +1,62 @@ +package teammates.storage.sqlapi; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.exception.EntityAlreadyExistsException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.HibernateUtil; +import teammates.storage.sqlentity.Course; +import teammates.test.BaseTestCase; + +/** + * SUT: {@code CoursesDb}. + */ +public class CoursesDbTest extends BaseTestCase { + + private CoursesDb coursesDb = CoursesDb.inst(); + + private Session session; + + @BeforeMethod + public void setUp() { + session = mock(Session.class); + SessionFactory sessionFactory = mock(SessionFactory.class); + HibernateUtil.setSessionFactory(sessionFactory); + when(sessionFactory.getCurrentSession()).thenReturn(session); + } + + @Test + public void createCourseDoesNotExist() throws InvalidParametersException, EntityAlreadyExistsException { + Course c = new Course.CourseBuilder("course-id") + .withName("course-name") + .withInstitute("institute") + .build(); + when(session.get(Course.class, "course-id")).thenReturn(null); + + coursesDb.createCourse(c); + + verify(session, times(1)).persist(c); + } + + @Test + public void createCourseAlreadyExists() { + Course c = new Course.CourseBuilder("course-id") + .withName("course-name") + .withInstitute("institute") + .build(); + when(session.get(Course.class, "course-id")).thenReturn(c); + + EntityAlreadyExistsException ex = assertThrows(EntityAlreadyExistsException.class, () -> coursesDb.createCourse(c)); + assertEquals(ex.getMessage(), "Trying to create an entity that exists: " + c.toString()); + verify(session, never()).persist(c); + } +} diff --git a/src/test/java/teammates/storage/sqlapi/package-info.java b/src/test/java/teammates/storage/sqlapi/package-info.java new file mode 100644 index 000000000000..485c8b2982f7 --- /dev/null +++ b/src/test/java/teammates/storage/sqlapi/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains test cases for {@link teammates.storage.search} package. + */ +package teammates.storage.sqlapi; diff --git a/src/test/java/teammates/ui/servlets/WebApiServletTest.java b/src/test/java/teammates/ui/servlets/WebApiServletTest.java index 5d8dfee3ec4f..a5bff8e37873 100644 --- a/src/test/java/teammates/ui/servlets/WebApiServletTest.java +++ b/src/test/java/teammates/ui/servlets/WebApiServletTest.java @@ -1,5 +1,8 @@ package teammates.ui.servlets; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -8,11 +11,16 @@ import org.apache.http.HttpStatus; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; import com.google.cloud.datastore.DatastoreException; import teammates.common.util.Const; +import teammates.common.util.HibernateUtil; import teammates.test.BaseTestCase; import teammates.test.MockHttpServletRequest; import teammates.test.MockHttpServletResponse; @@ -30,6 +38,16 @@ public class WebApiServletTest extends BaseTestCase { private MockHttpServletRequest mockRequest; private MockHttpServletResponse mockResponse; + @BeforeClass + public static void classSetup() { + SessionFactory sessionFactory = mock(SessionFactory.class); + Session session = mock(Session.class); + Transaction transaction = mock(Transaction.class); + when(sessionFactory.getCurrentSession()).thenReturn(session); + when(session.getTransaction()).thenReturn(transaction); + HibernateUtil.setSessionFactory(sessionFactory); + } + private void setupMocks(String method, String requestUrl) { mockRequest = new MockHttpServletRequest(method, requestUrl); mockResponse = new MockHttpServletResponse(); diff --git a/src/test/resources/testng-component.xml b/src/test/resources/testng-component.xml index 19bc328b5025..87d83f4c1ee2 100644 --- a/src/test/resources/testng-component.xml +++ b/src/test/resources/testng-component.xml @@ -9,8 +9,10 @@ + + diff --git a/static-analysis/teammates-pmdMain.xml b/static-analysis/teammates-pmdMain.xml index 48a3096125bf..feaea895a187 100644 --- a/static-analysis/teammates-pmdMain.xml +++ b/static-analysis/teammates-pmdMain.xml @@ -9,6 +9,7 @@ .*/test/java/.* .*/e2e/java/.* .*/lnp/java/.* + .*/it/java/.* .*/client/java/.* .*.html .*.xml