diff --git a/src/com/urise/webapp/Config.java b/src/com/urise/webapp/Config.java index 0759527..5e9465f 100644 --- a/src/com/urise/webapp/Config.java +++ b/src/com/urise/webapp/Config.java @@ -12,7 +12,6 @@ public class Config { private static final File PROPS = new File("C:/Java/BaseJava/config/resumes.properties"); private static final Config INSTANCE = new Config(); - private final Properties props = new Properties(); private final File storageDir; private final Storage storage; @@ -21,6 +20,7 @@ public class Config { private Config() { try(InputStream is = new FileInputStream(PROPS)) { + Properties props = new Properties(); props.load(is); storageDir = new File(props.getProperty("storage.dir")); storage = new SqlStorage(props.getProperty("db.url"), diff --git a/src/com/urise/webapp/ResumeTestData.java b/src/com/urise/webapp/ResumeTestData.java index 5a1cdf7..a3092a5 100644 --- a/src/com/urise/webapp/ResumeTestData.java +++ b/src/com/urise/webapp/ResumeTestData.java @@ -1,12 +1,18 @@ package com.urise.webapp; -import com.urise.webapp.model.Resume; +import com.urise.webapp.model.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; public class ResumeTestData { + private ResumeTestData() {} + public static Resume createResume(String uuid, String fullName) { Resume myResume = new Resume(uuid, fullName); -/* Map myContacts = myResume.getContacts(); + Map myContacts = myResume.getContacts(); myContacts.put(ContactType.PHONE, "+7(921) 855-0482"); myContacts.put(ContactType.SKYPE, "skype:grigory.kislin"); myContacts.put(ContactType.MAIL, "gkislin@yandex.ru"); @@ -77,7 +83,7 @@ public static Resume createResume(String uuid, String fullName) { "архитектурных шаблонов, UML, функционального программирования"); myQualifications.add("Родной русский, английский \"upper intermediate\""); mySections.put(SectionType.QUALIFICATION, new ListSection(myQualifications)); - +/* // EXPERIENCE section List myCompanies = new ArrayList<>(); myCompanies.add(new Company("Java Online Projects", "http://javaops.ru/", diff --git a/src/com/urise/webapp/model/ContactType.java b/src/com/urise/webapp/model/ContactType.java index 3a04374..a503c3b 100644 --- a/src/com/urise/webapp/model/ContactType.java +++ b/src/com/urise/webapp/model/ContactType.java @@ -8,17 +8,44 @@ public enum ContactType { PHONE("Тел."), MOBILE("Мобильный"), HOME_PHONE("Домашний тел."), - SKYPE("Skype"), - MAIL("Почта"), - LINKEDIN("Профиль LinkedIn"), - GITHUB("Профиль GitHub"), - STACKOVERFLOW("Профиль Stackoverflow"), - HOME_PAGE("Домашняя страница"); + SKYPE("Skype") { + @Override + public String toHtml0(String value) { + return getTitle() + ": " + toLink("skype:" + value, value); + } + }, + MAIL("Почта") { + @Override + public String toHtml0(String value) { + return getTitle() + ": " + toLink("mailto:" + value, value); + } + }, + LINKEDIN("Профиль LinkedIn") { + @Override + public String toHtml0(String value) { + return toLink(value); + } + }, + GITHUB("Профиль GitHub") { + @Override + public String toHtml0(String value) { + return toLink(value); + } + }, + STACKOVERFLOW("Профиль Stackoverflow") { + @Override + public String toHtml0(String value) { + return toLink(value); + } + }, + HOME_PAGE("Домашняя страница") { + @Override + public String toHtml0(String value) { + return toLink(value); + } + }; - private String title; - - ContactType() { - } + private final String title; ContactType(String title) { this.title = title; @@ -27,4 +54,20 @@ public enum ContactType { public String getTitle() { return title; } + + protected String toHtml0(String value) { + return title + ": " + value; + } + + public String toHtml(String value) { + return (value == null) ? "" : toHtml0(value); + } + + public String toLink(String href) { + return toLink(href, title); + } + + public static String toLink(String href, String title) { + return "" + title + ""; + } } diff --git a/src/com/urise/webapp/model/Resume.java b/src/com/urise/webapp/model/Resume.java index 6f6d9de..3e04a87 100644 --- a/src/com/urise/webapp/model/Resume.java +++ b/src/com/urise/webapp/model/Resume.java @@ -94,4 +94,12 @@ public void addContact(ContactType contactType, String value) { public void addSections(SectionType type, Section section) { sections.put(type, section); } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public String getContact(ContactType contactType) { + return contacts.get(contactType); + } } diff --git a/src/com/urise/webapp/sql/ExceptionUtil.java b/src/com/urise/webapp/sql/ExceptionUtil.java new file mode 100644 index 0000000..e0e9651 --- /dev/null +++ b/src/com/urise/webapp/sql/ExceptionUtil.java @@ -0,0 +1,23 @@ +package com.urise.webapp.sql; + +import com.urise.webapp.exception.ExistStorageException; +import com.urise.webapp.exception.StorageException; +import org.postgresql.util.PSQLException; + +import java.sql.SQLException; + +public class ExceptionUtil { + private ExceptionUtil() { + } + + public static StorageException convertException(SQLException e) { + if (e instanceof PSQLException) { + +// http://www.postgresql.org/docs/9.3/static/errcodes-appendix.html + if (e.getSQLState().equals("23505")) { + return new ExistStorageException(null); + } + } + return new StorageException(e.getMessage()); + } +} diff --git a/src/com/urise/webapp/sql/SqlExecutor.java b/src/com/urise/webapp/sql/SqlExecutor.java new file mode 100644 index 0000000..9543bfc --- /dev/null +++ b/src/com/urise/webapp/sql/SqlExecutor.java @@ -0,0 +1,8 @@ +package com.urise.webapp.sql; + +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public interface SqlExecutor { + T execute(PreparedStatement st) throws SQLException; +} diff --git a/src/com/urise/webapp/sql/SqlHelper.java b/src/com/urise/webapp/sql/SqlHelper.java new file mode 100644 index 0000000..fd630ce --- /dev/null +++ b/src/com/urise/webapp/sql/SqlHelper.java @@ -0,0 +1,45 @@ +package com.urise.webapp.sql; + +import com.urise.webapp.exception.StorageException; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public class SqlHelper { + private final ConnectionFactory connectionFactory; + + public SqlHelper(ConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + } + + public void execute(String sql) { + execute(sql, PreparedStatement::execute); + } + + public T execute(String sql, SqlExecutor executor) { + try (Connection conn = connectionFactory.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + return executor.execute(ps); + } catch (SQLException e) { + throw ExceptionUtil.convertException(e); + } + } + + public T transactionalExecute(SqlTransaction executor) { + try (Connection conn = connectionFactory.getConnection()) { + try { + conn.setAutoCommit(false); + T res = executor.execute(conn); + conn.commit(); + return res; + } catch (SQLException e) { + conn.rollback(); + throw ExceptionUtil.convertException(e); + } + } catch (SQLException e) { + throw new StorageException(e.getMessage()); + } + } + +} diff --git a/src/com/urise/webapp/sql/SqlTransaction.java b/src/com/urise/webapp/sql/SqlTransaction.java new file mode 100644 index 0000000..c4b2485 --- /dev/null +++ b/src/com/urise/webapp/sql/SqlTransaction.java @@ -0,0 +1,9 @@ +package com.urise.webapp.sql; + +import java.sql.Connection; + +import java.sql.SQLException; + +public interface SqlTransaction { + T execute(Connection conn) throws SQLException; +} diff --git a/src/com/urise/webapp/storage/SqlHelper.java b/src/com/urise/webapp/storage/SqlHelper.java deleted file mode 100644 index 38010ab..0000000 --- a/src/com/urise/webapp/storage/SqlHelper.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.urise.webapp.storage; - -import com.urise.webapp.exception.ExistStorageException; -import com.urise.webapp.exception.StorageException; -import com.urise.webapp.sql.ConnectionFactory; - -import java.io.IOException; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.SQLException; - -public class SqlHelper { - public final ConnectionFactory connectionFactory; - - public SqlHelper(ConnectionFactory connectionFactory) { - this.connectionFactory = connectionFactory; - } - - public T query(String sql, SqlHelperQuery query) { - try (Connection connection = connectionFactory.getConnection(); - PreparedStatement ps = connection.prepareStatement(sql)) { - return query.execute(ps); - } catch (SQLException e) { - if (e.getSQLState().equals("23505")) { - throw new ExistStorageException(e.getMessage()); - } - throw new StorageException(e.getMessage()); - } catch (IOException e) { - throw new StorageException(e.getMessage()); - } - } - - public interface SqlHelperQuery { - T execute(PreparedStatement ps) throws IOException, SQLException; - } -} diff --git a/src/com/urise/webapp/storage/SqlStorage.java b/src/com/urise/webapp/storage/SqlStorage.java index 1eea0b1..f014c92 100644 --- a/src/com/urise/webapp/storage/SqlStorage.java +++ b/src/com/urise/webapp/storage/SqlStorage.java @@ -1,100 +1,223 @@ package com.urise.webapp.storage; import com.urise.webapp.exception.NotExistStorageException; -import com.urise.webapp.model.Resume; -import com.urise.webapp.sql.ConnectionFactory; +import com.urise.webapp.model.*; +import com.urise.webapp.sql.SqlHelper; +import com.urise.webapp.util.JsonParser; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.ResultSet; +import java.sql.*; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public class SqlStorage implements Storage { - public final ConnectionFactory connectionFactory; - private final SqlHelper sqlHelper; + public final SqlHelper sqlHelper; - public SqlStorage(String dbUrl, String dbUser, String dbPass) { - this.connectionFactory = () -> DriverManager.getConnection(dbUrl, dbUser, dbPass); - sqlHelper = new SqlHelper(connectionFactory); + public SqlStorage(String dbUrl, String dbUser, String dbPassword) { + sqlHelper = new SqlHelper(() -> DriverManager.getConnection(dbUrl, dbUser, dbPassword)); } @Override public void clear() { - sqlHelper.query("DELETE FROM resume", PreparedStatement::execute); + sqlHelper.execute("DELETE FROM resume"); + } + + @Override + public Resume get(String uuid) { + return sqlHelper.transactionalExecute(conn -> { + Resume r; + try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM resume WHERE uuid =?")) { + ps.setString(1, uuid); + ResultSet rs = ps.executeQuery(); + if (!rs.next()) { + throw new NotExistStorageException(uuid); + } + r = new Resume(uuid, rs.getString("full_name")); + } + + try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM contact WHERE resume_uuid =?")) { + ps.setString(1, uuid); + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + addContact(rs, r); + } + } + + try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM section WHERE resume_uuid =?")) { + ps.setString(1, uuid); + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + addSection(rs, r); + } + } + + return r; + }); } @Override public void update(Resume r) { - sqlHelper.query("UPDATE resume r SET full_name=? WHERE r.uuid=?", - ps -> { - ps.setString(1, r.getFullName()); - ps.setString(2, r.getUuid()); - if (ps.executeUpdate() == 0) { - throw new NotExistStorageException(r.getUuid()); - } - return null; - }); + sqlHelper.transactionalExecute(conn -> { + try (PreparedStatement ps = conn.prepareStatement("UPDATE resume SET full_name = ? WHERE uuid = ?")) { + ps.setString(1, r.getFullName()); + ps.setString(2, r.getUuid()); + if (ps.executeUpdate() != 1) { + throw new NotExistStorageException(r.getUuid()); + } + } + deleteContacts(conn, r); + deleteSections(conn, r); + insertContacts(conn, r); + insertSections(conn, r); + return null; + }); } @Override public void save(Resume r) { - sqlHelper.query("INSERT INTO resume (uuid, full_name) VALUES (?, ?)", - ps -> { - ps.setString(1, r.getUuid()); - ps.setString(2, r.getFullName()); - ps.execute(); + sqlHelper.transactionalExecute(conn -> { + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO resume (uuid, full_name) VALUES (?,?)")) { + ps.setString(1, r.getUuid()); + ps.setString(2, r.getFullName()); + ps.execute(); + } + insertContacts(conn, r); + insertSections(conn, r); return null; - }); + } + ); } @Override - public Resume get(String uuid) { - String name = sqlHelper.query("SELECT * FROM resume r WHERE r.uuid = ?", + public void delete(String uuid) { + sqlHelper.execute("DELETE FROM resume WHERE uuid=?", ps -> { + ps.setString(1, uuid); + if (ps.executeUpdate() == 0) { + throw new NotExistStorageException(uuid); + } + return null; + }); + } + + @Override + public List getAllSorted() { + return sqlHelper.transactionalExecute(conn -> { + Map resumes = new LinkedHashMap<>(); + + try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM resume ORDER BY full_name, uuid")) { + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + String uuid = rs.getString("uuid"); + resumes.put(uuid, new Resume(uuid, rs.getString("full_name"))); + } + } + + try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM contact")) { + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + Resume r = resumes.get(rs.getString("resume_uuid")); + addContact(rs, r); + } + } + + try (PreparedStatement ps = conn.prepareStatement("SELECT * FROM section")) { + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + Resume r = resumes.get(rs.getString("resume_uuid")); + addSection(rs, r); + } + } + + return new ArrayList<>(resumes.values()); + }); + } + + @Override + public int size() { + return sqlHelper.execute("SELECT count(*) FROM resume", st -> { + ResultSet rs = st.executeQuery(); + return rs.next() ? rs.getInt(1) : 0; + }); + } + + private void insertContacts(Connection conn, Resume r) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO contact (resume_uuid, type, value) VALUES (?,?,?)")) { + for (Map.Entry e : r.getContacts().entrySet()) { + ps.setString(1, r.getUuid()); + ps.setString(2, e.getKey().name()); + ps.setString(3, e.getValue()); + ps.addBatch(); + } + ps.executeBatch(); + } + } + + private void insertSections(Connection conn, Resume r) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO section (resume_uuid, type, value) VALUES (?,?,?)")) { + for (Map.Entry e : r.getSections().entrySet()) { + ps.setString(1, r.getUuid()); + ps.setString(2, e.getKey().name()); + Section section = e.getValue(); + ps.setString(3, JsonParser.write(section, Section.class)); + ps.addBatch(); + } + ps.executeBatch(); + } + } + + private void deleteContacts(Connection conn, Resume r) throws SQLException { + deleteAttributes(conn, r, "DELETE FROM contact WHERE resume_uuid=?"); + } + + private void deleteSections(Connection conn, Resume r) throws SQLException { + deleteAttributes(conn, r, "DELETE FROM section WHERE resume_uuid=?"); + } + + private void deleteAttributes(Connection conn, Resume r, String sql) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, r.getUuid()); + ps.execute(); + } + } + + private void addAllContacts(Map map) { + sqlHelper.execute("SELECT * FROM contact", ps -> { - ps.setString(1, uuid); ResultSet rs = ps.executeQuery(); - if (!rs.next()) { - throw new NotExistStorageException(uuid); + while (rs.next()) { + addContact(rs, map.get(rs.getString("resume_uuid"))); } - return rs.getString("full_name"); + return null; }); - return new Resume(uuid, name); } - @Override - public void delete(String uuid) { - sqlHelper.query("DELETE FROM resume WHERE uuid = ?", + private void addContacts(Resume r) { + sqlHelper.execute( + "SELECT * FROM contact c WHERE c.resume_uuid =?", ps -> { - ps.setString(1, uuid); - if (ps.executeUpdate() == 0) { - throw new NotExistStorageException(uuid); + ps.setString(1, r.getUuid()); + ResultSet rs = ps.executeQuery(); + while (rs.next()) { + addContact(rs, r); } return null; }); } - @Override - public List getAllSorted() { - List resumes = new ArrayList<>(); - sqlHelper.query("SELECT * FROM resume ORDER BY full_name", ps -> { - ResultSet rs = ps.executeQuery(); - while (rs.next()) { - resumes.add(new Resume(rs.getString("uuid"), rs.getString("full_name"))); - } - return null; - }); - return resumes; + private void addContact(ResultSet rs, Resume r) throws SQLException { + String value = rs.getString("value"); + if (value != null) { + r.addContact(ContactType.valueOf(rs.getString("type")), value); + } } - @Override - public int size() { - return sqlHelper.query("SELECT count(*) AS count_rows FROM resume", ps -> { - ResultSet rs = ps.executeQuery(); - if (!rs.next()) { - throw new NotExistStorageException("db error"); - } - return rs.getInt("count_rows"); - }); + private void addSection(ResultSet rs, Resume r) throws SQLException { + String content = rs.getString("value"); + if (content != null) { + SectionType type = SectionType.valueOf(rs.getString("type")); + r.addSections(type, JsonParser.read(content, Section.class)); + } } -} + +} \ No newline at end of file diff --git a/src/com/urise/webapp/util/JsonParser.java b/src/com/urise/webapp/util/JsonParser.java index d6dd5a1..f908261 100644 --- a/src/com/urise/webapp/util/JsonParser.java +++ b/src/com/urise/webapp/util/JsonParser.java @@ -6,12 +6,10 @@ import java.io.Reader; import java.io.Writer; -import java.time.LocalDate; public class JsonParser { - private static final Gson GSON = new GsonBuilder() - .registerTypeAdapter(Section.class, new JsonSectionAdapter<>()) - .registerTypeAdapter(LocalDate.class, new LocalDateTypeAdapter()) + private static Gson GSON = new GsonBuilder() + .registerTypeAdapter(Section.class, new JsonSectionAdapter()) .create(); public static T read(Reader reader, Class clazz) { @@ -21,4 +19,16 @@ public static T read(Reader reader, Class clazz) { public static void write(T object, Writer writer) { GSON.toJson(object, writer); } -} + + public static T read(String content, Class clazz) { + return GSON.fromJson(content, clazz); + } + + public static String write(T object) { + return GSON.toJson(object); + } + + public static String write(T object, Class clazz) { + return GSON.toJson(object, clazz); + } +} \ No newline at end of file diff --git a/src/com/urise/webapp/util/JsonSectionAdapter.java b/src/com/urise/webapp/util/JsonSectionAdapter.java index 13ddbd7..2113163 100644 --- a/src/com/urise/webapp/util/JsonSectionAdapter.java +++ b/src/com/urise/webapp/util/JsonSectionAdapter.java @@ -22,6 +22,7 @@ public T deserialize(JsonElement json, Type type, JsonDeserializationContext con } } + @Override public JsonElement serialize(T section, Type type, JsonSerializationContext context) { JsonObject retValue = new JsonObject(); diff --git a/src/com/urise/webapp/web/ResumeServlet.java b/src/com/urise/webapp/web/ResumeServlet.java new file mode 100644 index 0000000..ad6e6c6 --- /dev/null +++ b/src/com/urise/webapp/web/ResumeServlet.java @@ -0,0 +1,69 @@ +package com.urise.webapp.web; + +import com.urise.webapp.Config; +import com.urise.webapp.model.ContactType; +import com.urise.webapp.model.Resume; +import com.urise.webapp.storage.Storage; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +public class ResumeServlet extends HttpServlet { + + private Storage storage; // = Config.get().getStorage(); + + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + storage = Config.get().getStorage(); + } + + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + request.setCharacterEncoding("UTF-8"); + String uuid = request.getParameter("uuid"); + String fullName = request.getParameter("fullName"); + Resume r = storage.get(uuid); + r.setFullName(fullName); + for (ContactType type : ContactType.values()) { + String value = request.getParameter(type.name()); + if (value != null && value.trim().length() != 0) { + r.addContact(type, value); + } else { + r.getContacts().remove(type); + } + } + storage.update(r); + response.sendRedirect("resume"); + } + + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + String uuid = request.getParameter("uuid"); + String action = request.getParameter("action"); + if (action == null) { + request.setAttribute("resumes", storage.getAllSorted()); + request.getRequestDispatcher("/WEB-INF/jsp/list.jsp").forward(request, response); + return; + } + Resume r; + switch (action) { + case "delete": + storage.delete(uuid); + response.sendRedirect("resume"); + return; + case "view": + case "edit": + r = storage.get(uuid); + break; + default: + throw new IllegalArgumentException("Action " + action + " is illegal"); + } + request.setAttribute("resume", r); + request.getRequestDispatcher( + ("view".equals(action) ? "/WEB-INF/jsp/view.jsp" : "/WEB-INF/jsp/edit.jsp") + ).forward(request, response); + } +} \ No newline at end of file diff --git a/test/com/urise/webapp/storage/AbstractStorageTest.java b/test/com/urise/webapp/storage/AbstractStorageTest.java index 5902202..0bda946 100644 --- a/test/com/urise/webapp/storage/AbstractStorageTest.java +++ b/test/com/urise/webapp/storage/AbstractStorageTest.java @@ -3,12 +3,14 @@ import com.urise.webapp.Config; import com.urise.webapp.exception.ExistStorageException; import com.urise.webapp.exception.NotExistStorageException; +import com.urise.webapp.model.ContactType; import com.urise.webapp.model.Resume; import org.junit.Before; import org.junit.Test; import java.io.File; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.UUID; @@ -20,10 +22,10 @@ public abstract class AbstractStorageTest { protected final Storage storage; - private static final String UUID_1 = String.valueOf(UUID.randomUUID()); - private static final String UUID_2 = String.valueOf(UUID.randomUUID()); - private static final String UUID_3 = String.valueOf(UUID.randomUUID()); - private static final String UUID_4 = String.valueOf(UUID.randomUUID()); + private static final String UUID_1 = UUID.randomUUID().toString(); + private static final String UUID_2 = UUID.randomUUID().toString(); + private static final String UUID_3 = UUID.randomUUID().toString(); + private static final String UUID_4 = UUID.randomUUID().toString(); private static final Resume RESUME_1; private static final Resume RESUME_2; @@ -63,6 +65,8 @@ public void clear() throws Exception { @Test public void update() throws Exception { Resume newResume = createResume(UUID_1, "newFirstResume"); + newResume.addContact(ContactType.HOME_PAGE, "http://gkislin.ru/"); + newResume.getContacts().put(ContactType.MAIL, "edarg@wsfws.ru"); storage.update(newResume); Resume oldRes = storage.get(UUID_1); assertEquals(newResume, oldRes); @@ -77,7 +81,9 @@ public void updateNotExist() throws Exception { public void getAllSorted() throws Exception { List list = storage.getAllSorted(); assertEquals(3, list.size()); - assertEquals(list, Arrays.asList(RESUME_1, RESUME_2, RESUME_3)); + List sortedList = Arrays.asList(RESUME_1, RESUME_2, RESUME_3); + Collections.sort(sortedList); + assertEquals(sortedList, list); } @Test