Last Updated: 2026-02-02
| Environment | Base URL | Swagger UI |
|---|---|---|
| Development (Docker) | http://localhost:5080 |
http://localhost:5080/swagger |
| Production | https://api.project-nexus.net |
N/A (Swagger disabled) |
docker compose up -d| Password | Tenant Slug | Tenant ID | Role | |
|---|---|---|---|---|
| admin@acme.test | Test123! | acme | 1 | admin |
| member@acme.test | Test123! | acme | 1 | member |
| admin@globex.test | Test123! | globex | 2 | admin |
- Open Swagger UI at
http://localhost:5080/swagger - Find
POST /api/auth/login - Click "Try it out"
- Enter request body:
{ "email": "admin@acme.test", "password": "Test123!", "tenant_slug": "acme" } - Click "Execute"
- Copy
access_tokenfrom response - Click "Authorize" button (top right)
- Enter:
Bearer <paste-token-here> - Click "Authorize" then "Close"
All subsequent requests will include the JWT.
ACME Tenant (ID: 1):
- Users: Alice Admin (ID: 1), Charlie Contributor (ID: 3)
- Listings: "Home Repair Assistance" (ID: 1), "Garden Weeding Services" (ID: 3), "Bike Repair" (ID: 4)
- Groups: "Community Gardeners" (ID: 1), "Home Repair Network" (ID: 2)
- Events: "Gardening Workshop" (ID: 1), "Repair Meetup" (ID: 2)
Globex Tenant (ID: 2):
- Users: Bob Admin (ID: 2)
- Listings: "Language Tutoring" (ID: 2), "Cooking Classes" (ID: 5)
- Groups: "Language Exchange" (ID: 3)
- Events: "Cooking Class" (ID: 3)
{
"listings": [
{
"id": 3,
"title": "Garden Weeding Services",
"description": "Professional garden maintenance...",
"type": "offer",
"status": "active",
"created_at": "2026-01-20T10:00:00Z"
}
],
"users": [
{
"id": 1,
"first_name": "Alice",
"last_name": "Admin",
"avatar_url": null,
"bio": null
}
],
"groups": [],
"events": [],
"pagination": {
"page": 1,
"limit": 20,
"total": 2,
"pages": 1
}
}Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | garden |
| type | (leave empty) |
| page | (leave empty) |
| limit | (leave empty) |
Expected Result:
- Status:
200 OK listingsarray contains "Garden Weeding Services"users,groups,eventsarrays present (may be empty)pagination.page= 1pagination.limit= 20
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | repair |
| type | listings |
Expected Result:
- Status:
200 OK listingsarray contains "Home Repair Assistance" and/or "Bike Repair"usersarray is empty[]groupsarray is empty[]eventsarray is empty[]
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | alice |
| type | users |
Expected Result:
- Status:
200 OK usersarray contains Alice Adminlistings,groups,eventsarrays are empty
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | GARDEN |
| type | all |
Expected Result:
- Status:
200 OK - Same results as searching for
garden(lowercase) - Confirms ILIKE case-insensitive matching
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | (leave empty) |
Expected Result:
- Status:
400 Bad Request - Error message:
"Search query is required"or similar
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | a |
Expected Result:
- Status:
400 Bad Request - Error message:
"Search query must be at least 2 characters"
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (101 a's) |
Expected Result:
- Status:
400 Bad Request - Error message:
"Search query must not exceed 100 characters"
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | test |
| type | invalid |
Expected Result:
- Status:
400 Bad Request - Error message:
"Invalid type filter"or"type must be one of: all, listings, users, groups, events"
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | test |
| limit | 100 |
Expected Result:
- Status:
400 Bad Request - Error message:
"Limit must not exceed 50"
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | test |
| page | 0 |
Expected Result:
- Status:
400 Bad Request - Error message:
"Page must be at least 1"
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | tutoring |
| type | listings |
Expected Result:
- Status:
200 OK listingsarray is empty[](Globex's "Language Tutoring" not visible)pagination.total= 0
Precondition: Logged in as admin@globex.test
| Field | Value |
|---|---|
| q | garden |
| type | all |
Expected Result:
- Status:
200 OK - All arrays empty (ACME's "Garden Weeding Services" not visible)
pagination.total= 0
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | a |
| type | listings |
| page | 1 |
| limit | 1 |
Note: Use q=re or similar to match multiple listings
Expected Result:
- Status:
200 OK listingsarray contains exactly 1 itempagination.page= 1pagination.limit= 1pagination.total>= 1pagination.pages=ceil(total / limit)
Precondition: Logged in as admin@acme.test, multiple results exist
| Field | Value |
|---|---|
| q | re |
| type | listings |
| page | 2 |
| limit | 1 |
Expected Result:
- Status:
200 OK listingsarray contains second result (different from page 1)pagination.page= 2
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | garden |
| page | 999 |
| limit | 20 |
Expected Result:
- Status:
200 OK - All arrays empty (no results on page 999)
pagination.page= 999pagination.total= actual count
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | xyznonexistent123 |
| type | all |
Expected Result:
- Status:
200 OK - All arrays empty
[] pagination.total= 0pagination.pages= 0
Precondition: Click "Authorize" > "Logout" to remove token
| Field | Value |
|---|---|
| q | test |
Expected Result:
- Status:
401 Unauthorized
[
{ "text": "Garden Weeding Services", "type": "listings", "id": 3 },
{ "text": "Gardening Group", "type": "groups", "id": 2 }
]Note: Response is a flat array, not an object.
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | gar |
| limit | (leave empty) |
Expected Result:
- Status:
200 OK - Array of 0-5 suggestions
- Each item has
text,type,idfields typeis one of:listings,users,groups,events
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | re |
| limit | 3 |
Expected Result:
- Status:
200 OK - Array of max 3 suggestions
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | repair |
| limit | 1 |
Expected Result:
- Status:
200 OK - Array of exactly 1 suggestion (if matches exist)
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | (leave empty) |
Expected Result:
- Status:
400 Bad Request - Error message about missing query
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | a |
Expected Result:
- Status:
400 Bad Request - Error message:
"Search query must be at least 2 characters"
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | test |
| limit | 20 |
Expected Result:
- Status:
400 Bad Request - Error message:
"Limit must not exceed 10"
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | tutor |
Expected Result:
- Status:
200 OK - Empty array
[](Globex's "Language Tutoring" not visible)
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | xyznonexistent |
Expected Result:
- Status:
200 OK - Empty array
[]
Precondition: Remove JWT token
| Field | Value |
|---|---|
| q | test |
Expected Result:
- Status:
401 Unauthorized
{
"data": [
{
"id": 1,
"first_name": "Alice",
"last_name": "Admin",
"avatar_url": null,
"bio": null,
"created_at": "2026-01-15T10:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 2,
"pages": 1
}
}Note: avatar_url and bio are always null until User Profile phase adds these fields to the User entity.
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | (leave empty) |
| page | (leave empty) |
| limit | (leave empty) |
Expected Result:
- Status:
200 OK dataarray contains all ACME tenant users (Alice, Charlie)- Does NOT contain Globex users (Bob)
pagination.total= 2 (or actual ACME user count)
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | alice |
Expected Result:
- Status:
200 OK dataarray contains only Alice Adminpagination.total= 1
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | contributor |
Expected Result:
- Status:
200 OK dataarray contains only Charlie Contributorpagination.total= 1
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | ALICE |
Expected Result:
- Status:
200 OK dataarray contains Alice Admin- Confirms case-insensitive matching
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | ali |
Expected Result:
- Status:
200 OK dataarray contains Alice Admin- Confirms partial matching (ILIKE
%ali%)
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| limit | 100 |
Expected Result:
- Status:
400 Bad Request - Error message:
"Limit must not exceed 50"
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| page | -1 |
Expected Result:
- Status:
400 Bad Request - Error message:
"Page must be at least 1"
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | bob |
Expected Result:
- Status:
200 OK dataarray is empty[](Globex's Bob not visible)pagination.total= 0
Precondition: Logged in as admin@globex.test
| Field | Value |
|---|---|
| q | (leave empty) |
Expected Result:
- Status:
200 OK dataarray contains only Bob Admin- Does NOT contain Alice or Charlie
pagination.total= 1
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| page | 1 |
| limit | 1 |
Expected Result:
- Status:
200 OK dataarray contains exactly 1 memberpagination.page= 1pagination.limit= 1pagination.total= 2 (ACME has 2 users)pagination.pages= 2
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| page | 2 |
| limit | 1 |
Expected Result:
- Status:
200 OK dataarray contains 1 member (different from page 1)pagination.page= 2
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| page | 999 |
| limit | 20 |
Expected Result:
- Status:
200 OK dataarray is empty[]pagination.page= 999pagination.total= 2
Precondition: Logged in as admin@acme.test
| Field | Value |
|---|---|
| q | xyznonexistent |
Expected Result:
- Status:
200 OK dataarray is empty[]pagination.total= 0
Note: Requires an inactive user in seed data to verify. If no inactive users exist, skip this test.
Precondition: Logged in as admin@acme.test, inactive user exists
| Field | Value |
|---|---|
| q | (inactive user's name) |
Expected Result:
- Status:
200 OK - Inactive user NOT in results
Precondition: Remove JWT token
| Field | Value |
|---|---|
| (any) | (any) |
Expected Result:
- Status:
401 Unauthorized
Use this checklist to verify each endpoint returns the correct JSON structure.
- Response has
listingsarray - Response has
usersarray - Response has
groupsarray - Response has
eventsarray - Response has
paginationobject -
paginationhaspage,limit,total,pagesfields - Each listing has:
id,title,description,type,status,created_at - Each user has:
id,first_name,last_name,avatar_url,bio - Each group has:
id,name,description,member_count,is_public - Each event has:
id,title,description,location,starts_at,status
- Response is a JSON array (not object)
- Each suggestion has
text(string) - Each suggestion has
type(string: listings|users|groups|events) - Each suggestion has
id(integer)
- Response has
dataarray - Response has
paginationobject -
paginationhaspage,limit,total,pagesfields - Each member has:
id,first_name,last_name,avatar_url,bio,created_at -
avatar_urlandbioarenull(fields not yet on User entity)
-
GET /api/searchendpoint implemented -
GET /api/search/suggestionsendpoint implemented -
GET /api/membersendpoint implemented - DTOs created:
UnifiedSearchResultDto,SearchSuggestionDto,MemberDirectoryDto
-
qparameter min 2 chars enforced (400 if shorter) -
qparameter max 100 chars enforced (400 if longer) -
typefilter validated (400 for invalid values) -
pagemust be >= 1 (400 for 0 or negative) -
limitcapped at 50 for search, 10 for suggestions - Empty
qon/api/membersreturns all members
- Case-insensitive matching (ILIKE) works
- Partial matching works (e.g., "gar" matches "garden")
- Type filter correctly limits result arrays
- ACME user cannot see Globex data
- Globex user cannot see ACME data
- All EF Core queries use tenant-scoped filters
- Soft-deleted listings excluded (DeletedAt IS NULL)
- Inactive users excluded (IsActive = true)
- Cancelled events excluded (IsCancelled = false)
-
pageandlimitparameters work -
paginationobject in response is accurate - Beyond-last-page returns empty array (not error)
- All Test 2.x (search) pass
- All Test 3.x (suggestions) pass
- All Test 4.x (members) pass
- PHASE15_EXECUTION.md complete (this file)
- ROADMAP.md Phase 15 marked as COMPLETE
- FRONTEND_INTEGRATION.md updated with Phase 15 status
garden
repair
alice
bike
weeding
home
a (too short - 1 char)
ab (minimum valid - 2 chars)
<101 character string> (too long)
all (default)
listings
users
groups
events
invalid (should return 400)
page=1&limit=20 (defaults)
page=1&limit=1 (minimum per page)
page=1&limit=50 (maximum per page)
page=999&limit=20 (beyond last page)
page=0&limit=20 (invalid - should 400)
limit=100 (over max - returns 400)
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/search | Yes | Unified search (?q=term&type=all&page=1&limit=20) |
| GET | /api/search/suggestions | Yes | Autocomplete (?q=term&limit=5) |
| GET | /api/members | Yes | Member directory (?q=name&page=1&limit=20) |
Total new endpoints: 3