Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ To inspect the stored data:
docker compose exec db psql -U findmypacks -d findmypacks -c "SELECT * FROM packages;"
```

## Package listing and deletion (issue #3)

After signing in, the home page loads packages from the database and displays them in a table. The table shows the tracking code, customer, destination, and posting date. It displays five packages per page and includes previous/next controls and the total number of records. Use the column headings for tracking code, customer, destination, or posting date to sort the results; clicking the active heading again reverses the sort direction.

Select individual rows with their checkboxes, or select all rows on the current page with the checkbox in the table heading. Click **Excluir selecionados** and confirm to delete the selected packages. The list reloads after deletion. Click **Editar** on a row to load that package in the registration form above.

The list endpoint supports Spring pagination and sorting parameters. The Web page requests five rows at a time:

| Operation | Method and path | Success |
|---|---|---|
| List packages | `GET /api/web/records?page=0&size=5&sort=postedAt,desc` | `200` with a paginated JSON response |
| Delete a package | `DELETE /api/web/records/{id}` | `204` with no response body |

Both endpoints require an authenticated session. The delete request also sends the CSRF token in the `X-XSRF-TOKEN` header. A list request returns HTTP 401 without a valid session; deleting an unknown ID returns HTTP 404. List and delete requests are recorded in the API logs, and their database reads/writes are logged with the same request ID.

The frontend behavior is covered by `PackageList.test.tsx`. Backend coverage is in `PackageListingApiIntegrationTest` and `PackageListingDataAccessTest`.

### Endpoints

Both endpoints require an authenticated session and a CSRF token (`GET /api/web/csrf`, sent in the `X-XSRF-TOKEN` header). Requests without a valid session receive HTTP 401. The JSON body uses the camelCase field names from the table above.
Expand Down Expand Up @@ -238,4 +255,4 @@ Use corresponding distinct directory and branch names for the other contributors

## Deployment

No deployment workflow is configured yet because a deployment target has not been selected. The pull-request CI runs through Docker Compose and verifies both tests and the complete application image.
No deployment workflow is configured yet because a deployment target has not been selected. The pull-request CI runs through Docker Compose and verifies both tests and the complete application image.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -20,6 +24,20 @@ public PackageController(PackageDataAccess packages) {
this.packages = packages;
}

@GetMapping
public Page<PackageResponse> list(Pageable pageable) {
return packages.findAll(pageable).map(PackageResponse::from);
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (packages.findById(id).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Package not found");
}
packages.deleteById(id);
return ResponseEntity.noContent().build();
}

@PostMapping
public ResponseEntity<PackageResponse> create(@Valid @RequestBody PackageRequest request) {
if (packages.existsByTrackingCode(request.trackingCode())) {
Expand All @@ -42,4 +60,4 @@ public PackageResponse update(@PathVariable Long id, @Valid @RequestBody Package
request.carrier(), request.status(), request.postedAt());
return PackageResponse.from(packages.update(record));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.function.Supplier;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
Expand Down Expand Up @@ -35,6 +37,18 @@ public Optional<PackageRecord> findById(Long id) {
() -> packages.findById(id));
}

public Page<PackageRecord> findAll(Pageable pageable) {
return run("list", "Package records listed", "Package records list failed",
() -> packages.findAll(pageable));
}

public void deleteById(Long id) {
run("delete", "Package record deleted", "Package record delete failed", () -> {
packages.deleteById(id);
return null;
});
}

public boolean existsByTrackingCode(String trackingCode) {
return run("check_tracking_code", "Tracking code checked", "Tracking code check failed",
() -> packages.existsByTrackingCode(trackingCode));
Expand Down Expand Up @@ -70,4 +84,4 @@ private <T> T run(String operation, String successMessage, String errorMessage,
throw exception;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ private String operationFor(HttpServletRequest request) {
if (uri.startsWith("/api/web/records/") && "PUT".equals(method)) {
return "update";
}
if ("/api/web/records".equals(uri) && "GET".equals(method)) {
return "list";
}
if (uri.startsWith("/api/web/records/") && "DELETE".equals(method)) {
return "delete";
}

return switch (request.getRequestURI()) {
case "/api/web/health" -> "health";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.findmypacks.records;

import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class PackageListingApiIntegrationTest {
private static final String URL = "/api/web/records";

@Autowired
private MockMvc mockMvc;

@Autowired
private PackageRecordRepository packages;

@BeforeEach
void cleanTable() {
packages.deleteAll();
}

private static RequestPostProcessor admin() {
return user("findmypacksadmin").roles("ADMIN");
}

private PackageRecord savePackage(String trackingCode, String customer) {
return packages.save(new PackageRecord(
trackingCode, customer, "Fortaleza", "CORREIOS", "POSTED", LocalDate.now().minusDays(1)));
}

@Test
void listsPackagesFromDatabaseWithFiveItemsPerPage() throws Exception {
for (int index = 1; index <= 7; index++) {
savePackage("CODE%05d".formatted(index), "Cliente %d".formatted(index));
}

mockMvc.perform(get(URL).param("page", "0").param("size", "5").with(admin()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(5))
.andExpect(jsonPath("$.number").value(0))
.andExpect(jsonPath("$.size").value(5))
.andExpect(jsonPath("$.totalElements").value(7))
.andExpect(jsonPath("$.totalPages").value(2))
.andExpect(jsonPath("$.content[0].trackingCode").exists())
.andExpect(jsonPath("$.content[0].customer").exists())
.andExpect(jsonPath("$.content[0].destination").exists())
.andExpect(jsonPath("$.content[0].postedAt").exists());

mockMvc.perform(get(URL).param("page", "1").param("size", "5").with(admin()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(2))
.andExpect(jsonPath("$.number").value(1));
}

@Test
void listsPackagesInRequestedSortOrder() throws Exception {
savePackage("CODE00001", "Zelda");
savePackage("CODE00002", "Ana");

mockMvc.perform(get(URL).param("sort", "customer,asc").with(admin()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].customer").value("Ana"))
.andExpect(jsonPath("$.content[1].customer").value("Zelda"));
}

@Test
void listingWithoutAuthenticationReturns401() throws Exception {
mockMvc.perform(get(URL)).andExpect(status().isUnauthorized());
}

@Test
void deletesSelectedPackageFromDatabase() throws Exception {
PackageRecord record = savePackage("CODE00001", "Ana");

mockMvc.perform(delete(URL + "/" + record.getId()).with(admin()).with(csrf()))
.andExpect(status().isNoContent());

assertThat(packages.findById(record.getId())).isEmpty();
}

@Test
void deletingUnknownPackageReturns404() throws Exception {
mockMvc.perform(delete(URL + "/999999").with(admin()).with(csrf()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("Package not found"));
}

@Test
void deletingWithoutAuthenticationReturns401AndKeepsPackage() throws Exception {
PackageRecord record = savePackage("CODE00001", "Ana");

mockMvc.perform(delete(URL + "/" + record.getId()).with(csrf()))
.andExpect(status().isUnauthorized());

assertThat(packages.findById(record.getId())).isPresent();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.findmypacks.records;

import com.findmypacks.logging.StructuredOperationLogger;
import com.findmypacks.logging.StructuredOperationLogger.Level;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.slf4j.MDC;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class PackageListingDataAccessTest {
private final PackageRecordRepository repository = mock(PackageRecordRepository.class);
private final StructuredOperationLogger operationLogger = mock(StructuredOperationLogger.class);
private final PackageDataAccess dataAccess = new PackageDataAccess(repository, operationLogger);
private final PackageRecord record = mock(PackageRecord.class);

@BeforeEach
void setLoggingContext() {
MDC.put("request_id", "package-listing-data-access-test");
MDC.put("client", "web");
}

@AfterEach
void clearLoggingContext() {
MDC.clear();
}

@Test
void listsPackagesAndLogsSuccessfulDatabaseRead() {
var pageable = PageRequest.of(0, 5);
var page = new PageImpl<>(List.of(record), pageable, 1);
when(repository.findAll(pageable)).thenReturn(page);

assertThat(dataAccess.findAll(pageable)).isSameAs(page);
verify(operationLogger).log(Level.INFO, "db", "list", "success", "Package records listed");
}

@Test
void logsListingFailureAndRethrowsIt() {
var pageable = PageRequest.of(0, 5);
IllegalStateException failure = new IllegalStateException("database unavailable");
when(repository.findAll(pageable)).thenThrow(failure);

assertThatThrownBy(() -> dataAccess.findAll(pageable)).isSameAs(failure);
verify(operationLogger).log(Level.ERROR, "db", "list", "error", "Package records list failed");
}

@Test
void deletesPackageAndLogsSuccessfulDatabaseWrite() {
dataAccess.deleteById(7L);

verify(repository).deleteById(7L);
verify(operationLogger).log(Level.INFO, "db", "delete", "success", "Package record deleted");
}

@Test
void logsDeleteFailureAndRethrowsIt() {
IllegalStateException failure = new IllegalStateException("database unavailable");
org.mockito.Mockito.doThrow(failure).when(repository).deleteById(7L);

assertThatThrownBy(() -> dataAccess.deleteById(7L)).isSameAs(failure);
verify(operationLogger).log(Level.ERROR, "db", "delete", "error", "Package record delete failed");
}
}
13 changes: 12 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import Dashboard from "./Dashboard";
import PackageForm from "./PackageForm";
import PackageList from "./PackageList";
import type { PackageRecord } from "./PackageForm";

type LoginResult = {
username: string;
Expand All @@ -19,6 +21,8 @@ export default function App() {
const [signedInAs, setSignedInAs] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [dashboardRefreshKey, setDashboardRefreshKey] = useState(0);
const [editingRecord, setEditingRecord] = useState<PackageRecord | null>(null);
const [refreshPackages, setRefreshPackages] = useState(0);
const [isCheckingSession, setIsCheckingSession] = useState(true);

useEffect(() => {
Expand Down Expand Up @@ -118,7 +122,14 @@ export default function App() {
Bem-vindo, {signedInAs}.
</p>
<Dashboard refreshKey={dashboardRefreshKey} />
<PackageForm onSaved={() => setDashboardRefreshKey((current) => current + 1)} />
<PackageForm
initialRecord={editingRecord}
onSaved={() => {
setDashboardRefreshKey((current) => current + 1);
setRefreshPackages((current) => current + 1);
}}
/>
<PackageList onEdit={setEditingRecord} refreshKey={refreshPackages} />
</main>
) : isCheckingSession ? (
<main className="session-loading" role="status">
Expand Down
Loading
Loading