diff --git a/.gitignore b/.gitignore index 21af2c8db..89958a3ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,41 @@ -/node_modules +# Dependencies +node_modules/ +**/node_modules/ + +# Build outputs +dist/ +**/dist/ +build/ +**/build/ + +# TypeScript build info +*.tsbuildinfo +**/*.tsbuildinfo +**/tsconfig.tsbuildinfo + +# OS files .DS_Store + +# Test coverage **/coverage -# .gitignore template - https://github.com/github/gitignore/blob/main/Node.gitignore lcov-report +**/coverage/**/*.json + +# Test results +**/test-results/**/*.json +vitest-results.json +junit.json +test-report.json + +# Temporary test files +test-*.js + +# Misc generated files __* # Turborepo .turbo -# SonarScanner -.scannerwork - # Generated GraphQL files **/generated.ts **/generated.tsx @@ -18,3 +43,10 @@ __* apps/ui-sharethrift/tsconfig.tsbuildinfo **/node_modules **/dist + +# SonarScanner +.scannerwork + +# Source maps and compiled JS in TypeScript packages +**/*.map +packages/**/src/*.js diff --git a/apps/api/package.json b/apps/api/package.json index 141fb4f17..a18773ea7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -24,6 +24,8 @@ "@cellix/api-services-spec": "workspace:*", "@cellix/messaging-service": "workspace:*", "@cellix/mongoose-seedwork": "workspace:*", + "@cellix/payment-service": "workspace:*", + "@cellix/search-service": "workspace:*", "@opentelemetry/api": "^1.9.0", "@sthrift/application-services": "workspace:*", "@sthrift/context-spec": "workspace:*", @@ -31,12 +33,12 @@ "@sthrift/graphql": "workspace:*", "@sthrift/messaging-service-mock": "workspace:*", "@sthrift/messaging-service-twilio": "workspace:*", + "@sthrift/payment-service-cybersource": "workspace:*", + "@sthrift/payment-service-mock": "workspace:*", "@sthrift/persistence": "workspace:*", "@sthrift/rest": "workspace:*", + "@sthrift/search-service-index": "workspace:*", "@sthrift/service-blob-storage": "workspace:*", - "@cellix/payment-service": "workspace:*", - "@sthrift/payment-service-mock": "workspace:*", - "@sthrift/payment-service-cybersource": "workspace:*", "@sthrift/service-mongoose": "workspace:*", "@sthrift/service-otel": "workspace:*", "@sthrift/service-token-validation": "workspace:*" diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5fca8822f..5ab1cfa55 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -24,39 +24,43 @@ import { ServiceMessagingMock } from '@sthrift/messaging-service-mock'; import { graphHandlerCreator } from '@sthrift/graphql'; import { restHandlerCreator } from '@sthrift/rest'; -import type {PaymentService} from '@cellix/payment-service'; +import type { PaymentService } from '@cellix/payment-service'; import { PaymentServiceMock } from '@sthrift/payment-service-mock'; import { PaymentServiceCybersource } from '@sthrift/payment-service-cybersource'; +import type { SearchService } from '@cellix/search-service'; +import { ServiceSearchIndex } from '@sthrift/search-service-index'; const { NODE_ENV } = process.env; const isDevelopment = NODE_ENV === 'development'; Cellix.initializeInfrastructureServices( (serviceRegistry) => { - serviceRegistry .registerInfrastructureService( - new ServiceMongoose( - MongooseConfig.mongooseConnectionString, - MongooseConfig.mongooseConnectOptions, - ), - ) +new ServiceMongoose( +MongooseConfig.mongooseConnectionString, +MongooseConfig.mongooseConnectOptions, +), +) .registerInfrastructureService(new ServiceBlobStorage()) .registerInfrastructureService( - new ServiceTokenValidation(TokenValidationConfig.portalTokens), - ) +new ServiceTokenValidation(TokenValidationConfig.portalTokens), +) .registerInfrastructureService( - isDevelopment ? new ServiceMessagingMock() : new ServiceMessagingTwilio(), +isDevelopment +? new ServiceMessagingMock() + : new ServiceMessagingTwilio(), ) .registerInfrastructureService( - isDevelopment ? new PaymentServiceMock() : new PaymentServiceCybersource() - ); + isDevelopment ? new PaymentServiceMock() : new PaymentServiceCybersource(), + ) + .registerInfrastructureService(new ServiceSearchIndex()); }, ) .setContext((serviceRegistry) => { const dataSourcesFactory = MongooseConfig.mongooseContextBuilder( - serviceRegistry.getInfrastructureService( +serviceRegistry.getInfrastructureService( ServiceMongoose, ), ); @@ -64,13 +68,16 @@ Cellix.initializeInfrastructureServices( const messagingService = isDevelopment ? serviceRegistry.getInfrastructureService(ServiceMessagingMock) : serviceRegistry.getInfrastructureService(ServiceMessagingTwilio); - - const paymentService = isDevelopment - ? serviceRegistry.getInfrastructureService(PaymentServiceMock) - : serviceRegistry.getInfrastructureService(PaymentServiceCybersource); - const { domainDataSource } = dataSourcesFactory.withSystemPassport(); - RegisterEventHandlers(domainDataSource); + const paymentService = isDevelopment + ? serviceRegistry.getInfrastructureService(PaymentServiceMock) + : serviceRegistry.getInfrastructureService(PaymentServiceCybersource); + + const searchService = + serviceRegistry.getInfrastructureService(ServiceSearchIndex); + + const { domainDataSource } = dataSourcesFactory.withSystemPassport(); + RegisterEventHandlers(domainDataSource, searchService); return { dataSourcesFactory, @@ -79,23 +86,24 @@ Cellix.initializeInfrastructureServices( ServiceTokenValidation, ), paymentService, - messagingService, + searchService, + messagingService, }; }) .initializeApplicationServices((context) => buildApplicationServicesFactory(context), ) .registerAzureFunctionHttpHandler( - 'graphql', - { - route: 'graphql/{*segments}', - methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'], - }, - graphHandlerCreator, - ) +'graphql', +{ +route: 'graphql/{*segments}', +methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS', 'HEAD'], +}, +graphHandlerCreator, +) .registerAzureFunctionHttpHandler( - 'rest', - { route: '{communityId}/{role}/{memberId}/{*rest}' }, - restHandlerCreator, - ) +'rest', +{ route: '{communityId}/{role}/{memberId}/{*rest}' }, +restHandlerCreator, +) .startUp(); diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/hero-section.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/hero-section.container.tsx index dac593609..38c4e31ea 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/hero-section.container.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/hero-section.container.tsx @@ -3,7 +3,7 @@ import { HeroSection } from './hero-section.tsx'; interface HeroSectionContainerProps { searchValue?: string; onSearchChange?: (value: string) => void; - onSearch?: (query: string) => void; + onSearch?: () => void; } export const HeroSectionContainer: React.FC = ({ diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/hero-section.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/hero-section.tsx index 11f8d01a0..5b5271481 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/hero-section.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/hero-section.tsx @@ -4,7 +4,7 @@ import heroImg from '@sthrift/ui-components/src/assets/hero/hero.png'; import heroImgSmall from '@sthrift/ui-components/src/assets/hero/hero-small.png'; interface HeroSectionProps { - onSearch?: (query: string) => void; + onSearch?: () => void; searchValue?: string; onSearchChange?: (value: string) => void; } @@ -12,11 +12,8 @@ interface HeroSectionProps { export const HeroSection: React.FC = ({ onSearch, searchValue = '', + onSearchChange, }) => { - const handleSearch = (value: string) => { - onSearch?.(value); - }; - return (
@@ -34,8 +31,8 @@ export const HeroSection: React.FC = ({
diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/listings-page-search.graphql b/apps/ui-sharethrift/src/components/layouts/home/components/listings-page-search.graphql new file mode 100644 index 000000000..1b9048cab --- /dev/null +++ b/apps/ui-sharethrift/src/components/layouts/home/components/listings-page-search.graphql @@ -0,0 +1,34 @@ +query ListingsPageSearchListings($input: ListingSearchInput!) { + searchListings(input: $input) { + items { + id + title + description + category + location + state + sharerName + sharerId + sharingPeriodStart + sharingPeriodEnd + createdAt + updatedAt + images + } + count + facets { + category { + value + count + } + state { + value + count + } + sharerId { + value + count + } + } + } +} diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.container.stories.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.container.stories.tsx index 17429a1d5..870dcc3f0 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.container.stories.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.container.stories.tsx @@ -5,7 +5,10 @@ import { withMockApolloClient, withMockRouter, } from '../../../../test-utils/storybook-decorators.tsx'; -import { ListingsPageContainerGetListingsDocument } from '../../../../generated.tsx'; +import { + ListingsPageContainerGetListingsDocument, + ListingsPageSearchListingsDocument, +} from '../../../../generated.tsx'; const mockListings = [ { @@ -55,6 +58,49 @@ const meta: Meta = { }, }, }, + { + request: { + query: ListingsPageSearchListingsDocument, + variables: { + input: { + searchString: undefined, + options: { + filter: { + category: undefined, + }, + skip: 0, + top: 20, + }, + }, + }, + }, + result: { + data: { + searchListings: { + __typename: 'ListingSearchResult', + count: mockListings.length, + items: mockListings.map(listing => ({ + ...listing, + sharerName: 'Test User', + sharerId: 'user-1', + })), + facets: { + __typename: 'SearchFacets', + category: [ + { __typename: 'SearchFacet', value: 'Tools & Equipment', count: 1 }, + { __typename: 'SearchFacet', value: 'Musical Instruments', count: 1 }, + ], + state: [ + { __typename: 'SearchFacet', value: 'Active', count: 2 }, + ], + sharerId: [ + { __typename: 'SearchFacet', value: 'user-1', count: 2 }, + ], + }, + }, + }, + }, + }, ], }, }, @@ -115,6 +161,33 @@ export const EmptyListings: Story = { }, }, }, + { + request: { + query: ListingsPageSearchListingsDocument, + variables: { + input: { + searchString: undefined, + options: { + filter: { + category: undefined, + }, + skip: 0, + top: 20, + }, + }, + }, + }, + result: { + data: { + searchListings: { + __typename: 'ListingSearchResult', + count: 0, + items: [], + facets: null, + }, + }, + }, + }, ], }, }, @@ -146,7 +219,7 @@ export const Loading: Story = { ], }, }, - play: async ({ canvasElement }) => { + play: ({ canvasElement }) => { const canvas = within(canvasElement); const loadingSpinner = canvas.queryByRole('progressbar') ?? canvas.queryByText(/loading/i); diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.container.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.container.tsx index 2f946d32a..732c96e21 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.container.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.container.tsx @@ -3,10 +3,11 @@ import { ComponentQueryLoader, type UIItemListing, } from '@sthrift/ui-components'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ListingsPageContainerGetListingsDocument, + ListingsPageSearchListingsDocument, type ItemListing, } from '../../../../generated.tsx'; import { useCreateListingNavigation } from './create-listing/hooks/use-create-listing-navigation.ts'; @@ -19,45 +20,79 @@ interface ListingsPageContainerProps { export const ListingsPageContainer: React.FC = ({ isAuthenticated, }) => { - const [searchQuery, setSearchQuery] = useState(''); + const [searchInputValue, setSearchInputValue] = useState(''); // What user types + const [searchQuery, setSearchQuery] = useState(''); // Actual executed search const [currentPage, setCurrentPage] = useState(1); const pageSize = 20; const [selectedCategory, setSelectedCategory] = useState(''); - const { data, loading, error } = useQuery( + + // Determine if we should use search query or get all listings + const shouldUseSearch = Boolean(searchQuery || (selectedCategory && selectedCategory !== 'All')); + + // Prepare search input + const searchInput = useMemo(() => ({ + searchString: searchQuery || undefined, + options: { + filter: { + category: (selectedCategory && selectedCategory !== 'All') ? [selectedCategory] : undefined, + }, + skip: (currentPage - 1) * pageSize, + top: pageSize, + }, + }), [searchQuery, selectedCategory, currentPage, pageSize]); + + // Query all listings (when no search/filter) + const { data: allListingsData, loading: allListingsLoading, error: allListingsError } = useQuery( ListingsPageContainerGetListingsDocument, { fetchPolicy: 'cache-first', nextFetchPolicy: 'cache-first', + skip: shouldUseSearch, }, ); - const filteredListings = data?.itemListings - ? data.itemListings.filter((listing) => { - if ( - selectedCategory && - selectedCategory !== 'All' && - listing.category !== selectedCategory - ) { - return false; - } - if ( - searchQuery && - !listing.title.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } - return true; - }) - : []; - - const totalListings = filteredListings.length; - const startIdx = (currentPage - 1) * pageSize; - const endIdx = startIdx + pageSize; - const currentListings = filteredListings.slice(startIdx, endIdx); - - const handleSearch = (query: string) => { - setSearchQuery(query); - setCurrentPage(1); // Reset to first page when searching + // Query search results (when searching/filtering) + const { data: searchData, loading: searchLoading, error: searchError } = useQuery( + ListingsPageSearchListingsDocument, + { + variables: { input: searchInput }, + fetchPolicy: 'network-only', + skip: !shouldUseSearch, + }, + ); + + // Combine results based on which query is active + const loading = shouldUseSearch ? searchLoading : allListingsLoading; + const error = shouldUseSearch ? searchError : allListingsError; + + // Process listings based on which query is active + const { listings: processedListings, totalListings } = useMemo(() => { + if (shouldUseSearch && searchData?.searchListings) { + // Use search results + return { + listings: searchData.searchListings.items, + totalListings: searchData.searchListings.count, + }; + } + + // Use all listings with client-side pagination + const allListings = allListingsData?.itemListings || []; + const startIdx = (currentPage - 1) * pageSize; + const endIdx = startIdx + pageSize; + return { + listings: allListings.slice(startIdx, endIdx), + totalListings: allListings.length, + }; + }, [shouldUseSearch, searchData, allListingsData, currentPage, pageSize]); + + const handleSearchChange = (value: string) => { + setSearchInputValue(value); + }; + + const handleSearch = () => { + // Execute search when user presses Enter or clicks search button + setSearchQuery(searchInputValue); + setCurrentPage(1); }; const navigate = useNavigate(); @@ -82,43 +117,40 @@ export const ListingsPageContainer: React.FC = ({ setCurrentPage(1); // Reset to first page when changing category }; + // Map the listings to the format expected by the UI component + const mappedListings = processedListings.map((listing): ItemListing => ({ + listingType: 'item-listing', + id: String(listing.id), + title: listing.title, + description: listing.description, + category: listing.category, + location: listing.location, + state: (listing.state as ItemListing['state']) || undefined, + images: listing.images ?? [], + sharingPeriodStart: new Date(listing.sharingPeriodStart as unknown as string), + sharingPeriodEnd: new Date(listing.sharingPeriodEnd as unknown as string), + createdAt: listing.createdAt + ? new Date(listing.createdAt as unknown as string) + : undefined, + updatedAt: listing.updatedAt + ? new Date(listing.updatedAt as unknown as string) + : undefined, + })); + return ( ({ - listingType: 'item-listing', - id: String(listing.id), - title: listing.title, - description: listing.description, - category: listing.category, - location: listing.location, - state: (listing.state as ItemListing['state']) || undefined, - images: listing.images ?? [], - sharingPeriodStart: new Date( - listing.sharingPeriodStart as unknown as string, - ), - sharingPeriodEnd: new Date( - listing.sharingPeriodEnd as unknown as string, - ), - createdAt: listing.createdAt - ? new Date(listing.createdAt as unknown as string) - : undefined, - updatedAt: listing.updatedAt - ? new Date(listing.updatedAt as unknown as string) - : undefined, - }), - )} + listings={mappedListings} currentPage={currentPage} pageSize={pageSize} totalListings={totalListings} diff --git a/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.tsx b/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.tsx index 7448f9340..b7301f26a 100644 --- a/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.tsx +++ b/apps/ui-sharethrift/src/components/layouts/home/components/listings-page.tsx @@ -10,7 +10,7 @@ interface ListingsPageProps { isAuthenticated: boolean; searchQuery: string; onSearchChange: (query: string) => void; - onSearch: (query: string) => void; + onSearch: () => void; selectedCategory: string; onCategoryChange: (category: string) => void; listings: ItemListing[]; diff --git a/documents/automatic-search-indexing.md b/documents/automatic-search-indexing.md new file mode 100644 index 000000000..268a0c832 --- /dev/null +++ b/documents/automatic-search-indexing.md @@ -0,0 +1,152 @@ +# Automatic Search Indexing + +## Overview + +The application now automatically updates the search index whenever listings are created, updated, or deleted. You no longer need to manually call `bulkIndexListings` after making changes. + +## How It Works + +### Architecture + +1. **Domain Events**: When an `ItemListing` entity is saved, it raises integration events: + - `ItemListingUpdatedEvent` - When a listing is created or modified + - `ItemListingDeletedEvent` - When a listing is deleted + +2. **Event Handlers**: These events are caught by handlers in `packages/sthrift/event-handler/src/handlers/integration/`: + - `item-listing-updated--update-search-index.ts` - Updates the search index + - `item-listing-deleted--update-search-index.ts` - Removes from search index + +3. **Unit of Work**: The `MongoUnitOfWork` automatically: + - Collects integration events from saved entities + - Dispatches them after the database transaction commits + - Ensures search index stays in sync with database + +### Code Changes + +**ItemListing Entity** (`packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.ts`): +```typescript +public override onSave(isModified: boolean): void { + if (this.isDeleted) { + // Raise deleted event for search index cleanup + this.addIntegrationEvent(ItemListingDeletedEvent, { + id: this.props.id, + deletedAt: new Date(), + }); + } else if (isModified) { + // Raise updated event for search index update + this.addIntegrationEvent(ItemListingUpdatedEvent, { + id: this.props.id, + updatedAt: this.props.updatedAt, + }); + } +} +``` + +## Testing Automatic Indexing + +### 1. Create a New Listing + +```bash +curl -s http://localhost:7071/api/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "mutation { createItemListing(input: { ... }) { id title } }" + }' | jq +``` + +The listing will be automatically indexed - no manual action needed! + +### 2. Update an Existing Listing + +```bash +curl -s http://localhost:7071/api/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "mutation { updateItemListing(id: \"...\", input: { ... }) { id title } }" + }' | jq +``` + +The search index will automatically update with the new data. + +### 3. Delete a Listing + +```bash +curl -s http://localhost:7071/api/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "mutation { deleteItemListing(id: \"...\") }" + }' | jq +``` + +The listing will be automatically removed from the search index. + +### 4. Verify Search Results + +```bash +curl -s http://localhost:7071/api/graphql \ + -H "Content-Type: application/json" \ + -d '{ + "query": "query { searchListings(input: { searchString: \"your search\" }) { count items { id title } } }" + }' | jq +``` + +## When to Use Bulk Indexing + +You only need `bulkIndexListings` in these scenarios: + +1. **Initial Setup**: When first setting up the system with existing data +2. **After API Restart**: Since the mock search service is in-memory (dev only) +3. **Index Corruption**: If the index gets out of sync for some reason +4. **Data Migration**: After bulk importing data directly into the database + +## Production Considerations + +### Real Azure Cognitive Search + +In production with real Azure Cognitive Search: +- The index persists across restarts +- Automatic indexing keeps everything in sync +- Manual bulk indexing rarely needed + +### Mock Search Service (Development) + +In development with the mock in-memory search: +- Index is lost on API restart +- Run `bulkIndexListings` once after startup if needed +- Automatic indexing works during runtime + +## Monitoring + +Watch for these log messages to confirm automatic indexing is working: + +``` +dispatch integration event ItemListingUpdatedEvent with payload {"id":"...","updatedAt":"..."} +Listing ... not found, skipping search index update +Failed to update search index for ItemListing ...: +``` + +## Troubleshooting + +### Listing Not Appearing in Search + +1. **Check if event was raised**: Look for "dispatch integration event" in logs +2. **Verify event handler registered**: Look for "Registering search index event handlers..." at startup +3. **Check for errors**: Look for "Failed to update search index" messages + +### Index Out of Sync + +Run manual bulk indexing: + +```bash +curl -s http://localhost:7071/api/graphql \ + -H "Content-Type: application/json" \ + -d '{"query":"mutation { bulkIndexListings { successCount totalCount message } }"}' | jq +``` + +## Benefits + +✅ **Always in Sync**: Search results always match database state +✅ **No Manual Work**: Developers don't need to remember to reindex +✅ **Real-time Updates**: Search reflects changes immediately +✅ **Reliable**: Uses database transactions to ensure consistency +✅ **Automatic Cleanup**: Deleted listings automatically removed from index diff --git a/documents/test-automatic-indexing.md b/documents/test-automatic-indexing.md new file mode 100644 index 000000000..4d66e1b20 --- /dev/null +++ b/documents/test-automatic-indexing.md @@ -0,0 +1,162 @@ +# Testing Automatic Search Indexing + +## Prerequisites +1. Start the API: `cd apps/api && func start --typescript` +2. Start the Frontend: `cd apps/ui-sharethrift && pnpm dev` +3. Ensure MongoDB is running (via MongoDB Memory Server) + +## Test Scenario 1: Create a New Listing + +### Steps: +1. Open browser to `http://localhost:5173` +2. Navigate to "Create Listing" form +3. Fill in the form: + - Title: "GoPro Hero 12 for Weekend Trips" + - Description: "4K camera perfect for adventures" + - Category: "Electronics" +4. Click "Save" + +### What Happens Behind the Scenes: +``` +Frontend Form Submit + ↓ +GraphQL Mutation: createItemListing() + ↓ +Application Service: ListingService.create() + ↓ +Domain Entity: ItemListing.onSave(true) // isModified = true + ↓ +Integration Event Raised: ItemListingUpdatedEvent + ↓ +Event Handler: ItemListingUpdatedUpdateSearchIndexHandler() + ↓ +Search Index Updated: searchService.indexDocument() +``` + +### Verification: +1. Navigate to Search page +2. Search for "GoPro" or "camera" or "adventures" +3. **The new listing should appear in results immediately!** + +## Test Scenario 2: Update an Existing Listing + +### Steps: +1. Open an existing listing (e.g., "Camera") +2. Edit the title to add "Professional" → "Professional Camera" +3. Click "Save" + +### What Happens: +``` +GraphQL Mutation: updateItemListing() + ↓ +ItemListing.onSave(true) // isModified = true + ↓ +ItemListingUpdatedEvent raised + ↓ +Search index automatically updated +``` + +### Verification: +1. Search for "Professional" +2. **The updated listing should appear with the new title!** + +## Test Scenario 3: Delete a Listing + +### Steps: +1. Open an existing listing +2. Click "Delete" button +3. Confirm deletion + +### What Happens: +``` +GraphQL Mutation: deleteItemListing() + ↓ +ItemListing.isDeleted = true +ItemListing.onSave(true) + ↓ +ItemListingDeletedEvent raised + ↓ +Search document removed from index +``` + +### Verification: +1. Search for the deleted listing's title +2. **It should NOT appear in results anymore!** + +## Test Scenario 4: No Changes = No Indexing + +### Steps: +1. Open an existing listing +2. Don't make any changes +3. Click "Save" + +### What Happens: +``` +GraphQL Mutation: updateItemListing() + ↓ +ItemListing.onSave(false) // isModified = false + ↓ +NO integration event raised (optimization!) + ↓ +No unnecessary search index updates +``` + +## Debugging Tips + +### Check if Events are Being Raised +Look for these console logs in the API terminal: +``` +Repo dispatching IntegrationEvent : ItemListingUpdatedEvent +``` + +### Check Search Index State +Run this GraphQL query to see what's in the search index: +```graphql +query { + searchListings(input: { searchString: "*" }) { + count + items { + id + title + updatedAt + } + } +} +``` + +### Manual Re-indexing +If something gets out of sync, you can manually re-index all listings: +```graphql +mutation { + bulkIndexListings { + successCount + totalCount + message + } +} +``` + +## Performance Notes + +- **Asynchronous**: Search indexing happens AFTER the database transaction commits +- **Non-blocking**: The GraphQL mutation returns immediately; indexing happens in the background +- **Optimized**: Only modified listings trigger re-indexing +- **Consistent**: If database save fails, no event is raised (no orphaned index updates) + +## Troubleshooting + +### Listing not appearing in search results? +1. Check if the listing was actually saved to the database +2. Verify the integration event handler is registered (check startup logs) +3. Run bulk re-indexing mutation to sync everything +4. Check for errors in the API terminal + +### Search results showing old data? +1. The listing might not have been modified (check `isModified` flag) +2. Try manual re-indexing +3. Restart the API to clear any cached state + +### Events not firing? +1. Ensure `ItemListing.onSave()` is being called (add a breakpoint or log) +2. Check that the repository is calling `item.onSave()` +3. Verify integration event bus is initialized properly diff --git a/package.json b/package.json index fcb837772..7e14722d4 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,38 @@ "snyk:iac": "snyk iac test iac/**/*.bicep apps/**/iac/**/*.bicep --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift", "snyk:iac:report": "snyk iac test iac/**/*.bicep apps/**/iac/**/*.bicep --org=simnova --remote-repo-url=https://github.com/simnova/sharethrift --target-reference=main --target-name=sharethrift-iac --report" }, + "workspaces": [ + "apps/api", + "apps/docs", + "apps/ui-sharethrift", + "packages/cellix/api-services-spec", + "packages/cellix/domain-seedwork", + "packages/cellix/event-bus-seedwork-node", + "packages/cellix/mock-cognitive-search", + "packages/cellix/mock-mongodb-memory-server", + "packages/cellix/mock-oauth2-server", + "packages/cellix/mock-payment-server", + "packages/cellix/mongoose-seedwork", + "packages/cellix/typescript-config", + "packages/cellix/vitest-config", + "packages/sthrift/application-services", + "packages/sthrift/context-spec", + "packages/sthrift/data-sources-mongoose-models", + "packages/sthrift/domain", + "packages/sthrift/event-handler", + "packages/sthrift/graphql", + "packages/sthrift/persistence", + "packages/sthrift/rest", + "packages/sthrift/service-blob-storage", + "packages/sthrift/service-cybersource", + "packages/sthrift/service-mongoose", + "packages/sthrift/service-otel", + "packages/sthrift/service-cognitive-search", + "packages/sthrift/service-sendgrid", + "packages/sthrift/service-token-validation", + "packages/sthrift/service-twilio", + "packages/sthrift/ui-components" + ], "devDependencies": { "@amiceli/vitest-cucumber": "^5.1.2", "@biomejs/biome": "2.0.0", @@ -75,4 +107,4 @@ "vite": "^7.0.4", "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/packages/cellix/mock-mongodb-memory-server-seedwork/README.md b/packages/cellix/mock-mongodb-memory-server-seedwork/README.md index ebb87a68b..fda44963d 100644 --- a/packages/cellix/mock-mongodb-memory-server-seedwork/README.md +++ b/packages/cellix/mock-mongodb-memory-server-seedwork/README.md @@ -1,17 +1,43 @@ -# @sthrift/mock-mongodb-memory-server +# @cellix/mock-mongodb-memory-server -MongoDB Memory Server service for CellixJS monorepo. +MongoDB Memory Server service for development and testing with automatic mock data seeding. ## Usage -``` +```bash ts-node src/index.ts ``` +### Environment Variables + +- `PORT` - MongoDB port (default: 50000) +- `DB_NAME` - Database name (default: 'test') +- `REPL_SET_NAME` - Replica set name (default: 'rs0') +- `SEED_MOCK_DATA` - Whether to seed mock data (default: true, set to 'false' to disable) + +### Example + +```bash +PORT=27017 DB_NAME=sharethrift_dev SEED_MOCK_DATA=true ts-node src/index.ts +``` + +## Mock Data + +Automatically seeds test database with: + +- Mock users with complete profiles and account information +- Item listings connected to users across various categories and locations +- Consistent data following ShareThrift domain model + + ## API -- `MongoDbMemoryService.start(): Promise` - Starts the in-memory server and returns the connection URI. -- `MongoDbMemoryService.stop(): Promise` - Stops the server. -- `MongoDbMemoryService.getInstance(): MongoMemoryServer | null` - Returns the current server instance. + +- `MongoDbMemoryService.start(): Promise` - Starts server and returns connection URI +- `MongoDbMemoryService.stop(): Promise` - Stops server +- `MongoDbMemoryService.getInstance(): MongoMemoryServer | null` - Returns server instance +- `seedMockData(connectionUri: string, dbName: string): Promise` - Seeds mock data ## License + MIT + diff --git a/packages/cellix/mock-mongodb-memory-server-seedwork/package.json b/packages/cellix/mock-mongodb-memory-server-seedwork/package.json index 315d9a351..262c033d3 100644 --- a/packages/cellix/mock-mongodb-memory-server-seedwork/package.json +++ b/packages/cellix/mock-mongodb-memory-server-seedwork/package.json @@ -24,4 +24,4 @@ "rimraf": "^6.0.1", "typescript": "^5.8.3" } -} \ No newline at end of file +} diff --git a/packages/cellix/search-service/.gitignore b/packages/cellix/search-service/.gitignore new file mode 100644 index 000000000..e78521160 --- /dev/null +++ b/packages/cellix/search-service/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +*.log +.turbo diff --git a/packages/cellix/search-service/package.json b/packages/cellix/search-service/package.json new file mode 100644 index 000000000..6930bd1a2 --- /dev/null +++ b/packages/cellix/search-service/package.json @@ -0,0 +1,30 @@ +{ + "name": "@cellix/search-service", + "version": "1.0.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "prebuild": "biome lint", + "build": "tsc --build", + "watch": "tsc --watch", + "lint": "biome lint", + "clean": "rimraf dist" + }, + "dependencies": { + "@cellix/api-services-spec": "workspace:*" + }, + "devDependencies": { + "rimraf": "^6.0.1", + "typescript": "^5.8.3", + "@cellix/typescript-config": "workspace:*" + } +} diff --git a/packages/cellix/search-service/src/index.ts b/packages/cellix/search-service/src/index.ts new file mode 100644 index 000000000..5ad8ba6a5 --- /dev/null +++ b/packages/cellix/search-service/src/index.ts @@ -0,0 +1,163 @@ +import type { ServiceBase } from '@cellix/api-services-spec'; + +/** + * Field types supported by search indexes + * Based on Azure Cognitive Search EDM (Entity Data Model) types + */ +export type SearchFieldType = + | 'Edm.String' + | 'Edm.Int32' + | 'Edm.Int64' + | 'Edm.Double' + | 'Edm.Boolean' + | 'Edm.DateTimeOffset' + | 'Edm.GeographyPoint' + | 'Collection(Edm.String)' + | 'Collection(Edm.Int32)' + | 'Collection(Edm.Int64)' + | 'Collection(Edm.Double)' + | 'Collection(Edm.Boolean)' + | 'Collection(Edm.DateTimeOffset)' + | 'Collection(Edm.GeographyPoint)' + | 'Edm.ComplexType' + | 'Collection(Edm.ComplexType)'; + +/** + * Defines a field in a search index + */ +export interface SearchField { + name: string; + type: SearchFieldType; + key?: boolean; + searchable?: boolean; + filterable?: boolean; + sortable?: boolean; + facetable?: boolean; + retrievable?: boolean; + fields?: SearchField[]; // For complex types +} + +/** + * Defines the structure of a search index + */ +export interface SearchIndex { + name: string; + fields: SearchField[]; +} + +/** + * Options for search queries + */ +export interface SearchOptions { + queryType?: 'simple' | 'full'; + searchMode?: 'any' | 'all'; + includeTotalCount?: boolean; + filter?: string; + facets?: string[]; + top?: number; + skip?: number; + orderBy?: string[]; + select?: string[]; +} + +/** + * A single search result with document and optional score + */ +export interface SearchResult> { + document: T; + score?: number; +} + +/** + * Complete search response with results and optional facets + */ +export interface SearchDocumentsResult> { + results: Array>; + count?: number; + facets?: Record< + string, + Array<{ + value: string | number | boolean; + count: number; + }> + >; +} + +/** + * Generic search service interface + * + * This interface provides a generic abstraction for search functionality + * that can be implemented by different search providers (Azure Cognitive Search, + * mock implementations, etc.) + * + * The interface is application-agnostic and follows the ServiceBase pattern + * used throughout the Cellix framework. + */ +export interface SearchService extends ServiceBase { + /** + * Create a search index if it doesn't already exist + * + * @param indexDefinition - The index definition to create + */ + createIndexIfNotExists(indexDefinition: SearchIndex): Promise; + + /** + * Create or update a search index definition + * + * @param indexName - The name of the index + * @param indexDefinition - The index definition + */ + createOrUpdateIndexDefinition( + indexName: string, + indexDefinition: SearchIndex, + ): Promise; + + /** + * Index (add or update) a document in the search index + * + * @param indexName - The name of the index + * @param document - The document to index + */ + indexDocument( + indexName: string, + document: Record, + ): Promise; + + /** + * Delete a document from the search index + * + * @param indexName - The name of the index + * @param document - The document to delete (must include key field) + */ + deleteDocument( + indexName: string, + document: Record, + ): Promise; + + /** + * Delete an entire search index + * + * @param indexName - The name of the index to delete + */ + deleteIndex(indexName: string): Promise; + + /** + * Search documents in an index + * + * @param indexName - The name of the index to search + * @param searchText - The search query text + * @param options - Optional search parameters + * @returns Search results with documents and optional metadata + */ + search( + indexName: string, + searchText: string, + options?: SearchOptions, + ): Promise; +} + +/** + * This package is types-only and has no runtime exports. + * This constant exists solely to ensure TypeScript generates a JavaScript file. + */ +export const __TYPES_ONLY_PACKAGE__ = true; diff --git a/packages/cellix/search-service/tsconfig.json b/packages/cellix/search-service/tsconfig.json new file mode 100644 index 000000000..42116a305 --- /dev/null +++ b/packages/cellix/search-service/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cellix/typescript-config/tsconfig-base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/packages/sthrift/application-services/package.json b/packages/sthrift/application-services/package.json index afc88da2c..03e1f1f17 100644 --- a/packages/sthrift/application-services/package.json +++ b/packages/sthrift/application-services/package.json @@ -23,6 +23,7 @@ "clean": "rimraf dist" }, "dependencies": { + "@cellix/search-service": "workspace:*", "@sthrift/context-spec": "workspace:*", "@sthrift/domain": "workspace:*", "@sthrift/persistence": "workspace:*", diff --git a/packages/sthrift/application-services/src/contexts/listing/features/listing-search.feature b/packages/sthrift/application-services/src/contexts/listing/features/listing-search.feature new file mode 100644 index 000000000..4c83bd52c --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/features/listing-search.feature @@ -0,0 +1,137 @@ +Feature: Listing Search Application Service + + Scenario: Searching listings with basic text query + Given a search service with indexed listings + When I search for "camera" + Then I should receive matching results + And the search index should be created if not exists + + Scenario: Searching with empty search string + Given a search service + When I search with an empty search string + Then it should default to wildcard search "*" + + Scenario: Trimming whitespace from search string + Given a search service + When I search with " camera " + Then the search should use trimmed string "camera" + + Scenario: Filtering by category + Given indexed listings with various categories + When I search with category filter ["electronics", "sports"] + Then only listings in those categories should be returned + + Scenario: Filtering by state + Given indexed listings with various states + When I search with state filter ["active", "draft"] + Then only listings with those states should be returned + + Scenario: Filtering by sharer ID + Given indexed listings from multiple sharers + When I search with sharerId filter ["user-1", "user-2"] + Then only listings from those sharers should be returned + + Scenario: Filtering by location + Given indexed listings in different locations + When I search with location filter "New York" + Then only listings in that location should be returned + + Scenario: Filtering by date range with start date + Given indexed listings with various dates + When I search with dateRange.start "2024-01-01" + Then only listings starting after that date should be returned + + Scenario: Filtering by date range with end date + Given indexed listings with various dates + When I search with dateRange.end "2024-12-31" + Then only listings ending before that date should be returned + + Scenario: Filtering by date range with both dates + Given indexed listings with various dates + When I search with dateRange.start "2024-01-01" and dateRange.end "2024-12-31" + Then only listings within that period should be returned + + Scenario: Combining multiple filters + Given indexed listings + When I search with category "electronics" and state "active" and location "Seattle" + Then filters should be combined with AND logic + + Scenario: Applying pagination + Given 100 indexed listings + When I search with top 10 and skip 20 + Then I should receive 10 results starting from position 20 + + Scenario: Using default pagination + Given indexed listings + When I search without pagination options + Then default top should be 50 and skip should be 0 + + Scenario: Applying custom sorting + Given indexed listings + When I search with orderBy ["title asc", "createdAt desc"] + Then results should be sorted accordingly + + Scenario: Using default sorting + Given indexed listings + When I search without orderBy option + Then results should be sorted by "updatedAt desc" + + Scenario: Converting facets in response + Given search results with facets + When I receive the search response + Then facets should be converted to proper format + And category facets should be available + And state facets should be available + + Scenario: Handling response without facets + Given search results without facets + When I receive the search response + Then the facets field should be undefined + + Scenario: Bulk indexing all listings successfully + Given 10 listings in the database + When I execute bulk indexing + Then all 10 listings should be indexed + And success message should indicate "10/10 listings" + + Scenario: Bulk indexing with no listings + Given no listings in the database + When I execute bulk indexing + Then it should return early with message "No listings found to index" + + Scenario: Handling indexing errors gracefully + Given 5 listings in the database + And 2 listings will fail to index + When I execute bulk indexing + Then 3 listings should be successfully indexed + And error should be logged for failed listings + + Scenario: Logging error stack traces + Given a listing that will fail to index with stack trace + When I execute bulk indexing + Then the error stack trace should be logged + + Scenario: Creating index before bulk indexing + Given listings in the database + When I execute bulk indexing + Then the search index should be created before indexing documents + + Scenario: Handling null search options + Given a search service + When I search with null options + Then default options should be used + + Scenario: Handling null filter + Given a search service + When I search with null filter + Then no filter should be applied + + Scenario: Handling empty filter arrays + Given a search service + When I search with empty category, state, and sharerId arrays + Then an empty filter string should be produced + + Scenario: Handling undefined pagination values + Given a search service + When I search with null top and skip values + Then default pagination should be used diff --git a/packages/sthrift/application-services/src/contexts/listing/index.test.ts b/packages/sthrift/application-services/src/contexts/listing/index.test.ts index cb0572124..8f2172f88 100644 --- a/packages/sthrift/application-services/src/contexts/listing/index.test.ts +++ b/packages/sthrift/application-services/src/contexts/listing/index.test.ts @@ -1,27 +1,37 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { DataSources } from '@sthrift/persistence'; +import type { CognitiveSearchDomain } from '@sthrift/domain'; import { Listing } from './index.ts'; describe('Listing Context Factory', () => { // biome-ignore lint/suspicious/noExplicitAny: Test mock type let mockDataSources: any; + let mockSearchService: CognitiveSearchDomain; beforeEach(() => { mockDataSources = { domainDataSource: {}, readonlyDataSource: {}, } as DataSources; + + mockSearchService = { + search: vi.fn(), + indexDocument: vi.fn(), + deleteDocument: vi.fn(), + createIndexIfNotExists: vi.fn(), + } as unknown as CognitiveSearchDomain; }); it('should create Listing context with all services', () => { - const context = Listing(mockDataSources); + const context = Listing(mockDataSources, mockSearchService); expect(context).toBeDefined(); expect(context.ItemListing).toBeDefined(); + expect(context.ListingSearch).toBeDefined(); }); it('should have ItemListing service with all required methods', () => { - const context = Listing(mockDataSources); + const context = Listing(mockDataSources, mockSearchService); expect(context.ItemListing.create).toBeDefined(); expect(context.ItemListing.update).toBeDefined(); @@ -33,4 +43,10 @@ describe('Listing Context Factory', () => { expect(context.ItemListing.queryAll).toBeDefined(); expect(context.ItemListing.queryPaged).toBeDefined(); }); + + it('should throw error when searchService is not provided', () => { + expect(() => Listing(mockDataSources)).toThrow( + 'searchService is required for Listing context', + ); + }); }); diff --git a/packages/sthrift/application-services/src/contexts/listing/index.ts b/packages/sthrift/application-services/src/contexts/listing/index.ts index 24fb9e58b..05ac5870a 100644 --- a/packages/sthrift/application-services/src/contexts/listing/index.ts +++ b/packages/sthrift/application-services/src/contexts/listing/index.ts @@ -1,17 +1,30 @@ import type { DataSources } from '@sthrift/persistence'; +import type { CognitiveSearchDomain } from '@sthrift/domain'; import { ItemListing as ItemListingApi, type ItemListingApplicationService, } from './item/index.ts'; +import { ListingSearchApplicationService } from './listing-search.ts'; export interface ListingContextApplicationService { ItemListing: ItemListingApplicationService; + ListingSearch: ListingSearchApplicationService; } export const Listing = ( dataSources: DataSources, + searchService?: CognitiveSearchDomain, ): ListingContextApplicationService => { + if (!searchService) { + throw new Error( + 'searchService is required for Listing context. ListingSearch requires a valid CognitiveSearchDomain instance.', + ); + } return { - ItemListing: ItemListingApi(dataSources), + ItemListing: ItemListingApi(dataSources, searchService), + ListingSearch: new ListingSearchApplicationService( + searchService, + dataSources, + ), }; }; diff --git a/packages/sthrift/application-services/src/contexts/listing/item/index.ts b/packages/sthrift/application-services/src/contexts/listing/item/index.ts index 96bf35318..663fb2f24 100644 --- a/packages/sthrift/application-services/src/contexts/listing/item/index.ts +++ b/packages/sthrift/application-services/src/contexts/listing/item/index.ts @@ -1,4 +1,4 @@ -import type { Domain } from '@sthrift/domain'; +import type { CognitiveSearchDomain, Domain } from '@sthrift/domain'; import type { DataSources } from '@sthrift/persistence'; import { type ItemListingCreateCommand, create } from './create.ts'; import { type ItemListingQueryByIdCommand, queryById } from './query-by-id.ts'; @@ -12,6 +12,10 @@ import { type ItemListingDeleteCommand, deleteListings } from './delete.ts'; import { type ItemListingUpdateCommand, update } from './update.ts'; import { type ItemListingUnblockCommand, unblock } from './unblock.ts'; import { queryPaged } from './query-paged.ts'; +import { + type ItemListingQueryPagedWithSearchCommand, + queryPagedWithSearchFallback, +} from './query-paged-with-search.ts'; export interface ItemListingApplicationService { create: ( @@ -51,10 +55,19 @@ export interface ItemListingApplicationService { page: number; pageSize: number; }>; + queryPagedWithSearchFallback: ( + command: ItemListingQueryPagedWithSearchCommand, + ) => Promise<{ + items: Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]; + total: number; + page: number; + pageSize: number; + }>; } export const ItemListing = ( dataSources: DataSources, + searchService?: CognitiveSearchDomain, ): ItemListingApplicationService => { return { create: create(dataSources), @@ -63,8 +76,12 @@ export const ItemListing = ( queryAll: queryAll(dataSources), cancel: cancel(dataSources), update: update(dataSources), - deleteListings: deleteListings(dataSources), + deleteListings: deleteListings(dataSources), unblock: unblock(dataSources), queryPaged: queryPaged(dataSources), + queryPagedWithSearchFallback: queryPagedWithSearchFallback( + dataSources, + searchService, + ), }; }; diff --git a/packages/sthrift/application-services/src/contexts/listing/item/query-paged-with-search.test.ts b/packages/sthrift/application-services/src/contexts/listing/item/query-paged-with-search.test.ts new file mode 100644 index 000000000..39c4947ca --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/item/query-paged-with-search.test.ts @@ -0,0 +1,496 @@ +/** + * Tests for queryPagedWithSearchFallback + * + * Tests the search functionality with fallback to database query + * when cognitive search is unavailable or fails. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DataSources } from '@sthrift/persistence'; +import type { CognitiveSearchDomain } from '@sthrift/domain'; +import { + queryPagedWithSearchFallback, + type ItemListingQueryPagedWithSearchCommand, +} from './query-paged-with-search.js'; + +describe('queryPagedWithSearchFallback', () => { + let mockDataSources: DataSources; + let mockSearchService: CognitiveSearchDomain; + let mockDbQueryResult: { + items: Array<{ id: string; title: string }>; + total: number; + page: number; + pageSize: number; + }; + let mockSearchResult: { + results: Array<{ document: { id: string } }>; + count: number; + }; + + beforeEach(() => { + // Suppress console output during tests + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + // Setup mock database query result + mockDbQueryResult = { + items: [ + { id: 'listing-1', title: 'Database Listing 1' }, + { id: 'listing-2', title: 'Database Listing 2' }, + ], + total: 2, + page: 1, + pageSize: 10, + }; + + // Setup mock search result + mockSearchResult = { + results: [ + { document: { id: 'listing-1' } }, + { document: { id: 'listing-2' } }, + ], + count: 2, + }; + + // Setup mock data sources with read repository + mockDataSources = { + readonlyDataSource: { + Listing: { + ItemListing: { + ItemListingReadRepo: { + getById: vi.fn().mockImplementation((id: string) => + Promise.resolve({ id, title: `Listing ${id}` }), + ), + getPaged: vi.fn().mockResolvedValue(mockDbQueryResult), + }, + }, + }, + }, + } as unknown as DataSources; + + // Setup mock search service + mockSearchService = { + createIndexIfNotExists: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue(mockSearchResult), + } as unknown as CognitiveSearchDomain; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('without search service', () => { + it('should fall back to database query when search service is not provided', async () => { + const queryFn = queryPagedWithSearchFallback(mockDataSources); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test search', + }; + + const result = await queryFn(command); + + expect(result).toEqual(mockDbQueryResult); + expect( + mockDataSources.readonlyDataSource.Listing.ItemListing + .ItemListingReadRepo.getPaged, + ).toHaveBeenCalled(); + }); + + it('should fall back to database query when searchText is empty', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: '', + }; + + const result = await queryFn(command); + + expect(result).toEqual(mockDbQueryResult); + expect(mockSearchService.search).not.toHaveBeenCalled(); + }); + + it('should fall back to database query when searchText is whitespace only', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: ' ', + }; + + const result = await queryFn(command); + + expect(result).toEqual(mockDbQueryResult); + expect(mockSearchService.search).not.toHaveBeenCalled(); + }); + + it('should fall back to database query when searchText is undefined', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + }; + + const result = await queryFn(command); + + expect(result).toEqual(mockDbQueryResult); + expect(mockSearchService.search).not.toHaveBeenCalled(); + }); + }); + + describe('with search service', () => { + it('should use cognitive search when searchText is provided', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'vintage camera', + }; + + const result = await queryFn(command); + + expect(mockSearchService.createIndexIfNotExists).toHaveBeenCalled(); + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'vintage camera', + expect.objectContaining({ + top: 10, + skip: 0, + }), + ); + expect(result.items).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should calculate correct skip for pagination', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 3, + pageSize: 20, + searchText: 'test', + }; + + await queryFn(command); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'test', + expect.objectContaining({ + top: 20, + skip: 40, // (3 - 1) * 20 + }), + ); + }); + + it('should apply sorter in search options', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + sorter: { field: 'title', order: 'ascend' }, + }; + + await queryFn(command); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'test', + expect.objectContaining({ + orderBy: ['title asc'], + }), + ); + }); + + it('should apply descending sorter correctly', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + sorter: { field: 'createdAt', order: 'descend' }, + }; + + await queryFn(command); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'test', + expect.objectContaining({ + orderBy: ['createdAt desc'], + }), + ); + }); + + it('should use default orderBy when sorter is not provided', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + }; + + await queryFn(command); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'test', + expect.objectContaining({ + orderBy: ['updatedAt desc'], + }), + ); + }); + + it('should apply sharerId filter', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + sharerId: 'user-123', + }; + + await queryFn(command); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'test', + expect.objectContaining({ + filter: { sharerId: ['user-123'] }, + }), + ); + }); + + it('should apply status filters', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + statusFilters: ['active', 'pending'], + }; + + await queryFn(command); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'test', + expect.objectContaining({ + filter: { state: ['active', 'pending'] }, + }), + ); + }); + + it('should apply both sharerId and status filters', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + sharerId: 'user-456', + statusFilters: ['active'], + }; + + await queryFn(command); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'test', + expect.objectContaining({ + filter: { + sharerId: ['user-456'], + state: ['active'], + }, + }), + ); + }); + + it('should fetch full entities from database using search result IDs', async () => { + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + }; + + await queryFn(command); + + // Should fetch each ID from the database + expect( + mockDataSources.readonlyDataSource.Listing.ItemListing + .ItemListingReadRepo.getById, + ).toHaveBeenCalledWith('listing-1'); + expect( + mockDataSources.readonlyDataSource.Listing.ItemListing + .ItemListingReadRepo.getById, + ).toHaveBeenCalledWith('listing-2'); + }); + }); + + describe('error handling and fallback', () => { + it('should fall back to database query when search fails', async () => { + mockSearchService.search = vi + .fn() + .mockRejectedValue(new Error('Search service unavailable')); + + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + }; + + const result = await queryFn(command); + + expect(result).toEqual(mockDbQueryResult); + expect(console.error).toHaveBeenCalledWith( + 'Cognitive search failed, falling back to database query:', + expect.any(Error), + ); + }); + + it('should fall back to database when createIndexIfNotExists fails', async () => { + mockSearchService.createIndexIfNotExists = vi + .fn() + .mockRejectedValue(new Error('Index creation failed')); + + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + }; + + const result = await queryFn(command); + + expect(result).toEqual(mockDbQueryResult); + }); + + it('should throw error when entity not found in database for search result ID', async () => { + mockDataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getById = + vi.fn().mockResolvedValue(null); + + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + }; + + // This should fall back to database query because an error is thrown + // inside the try block when entity is not found + const result = await queryFn(command); + expect(result).toEqual(mockDbQueryResult); + expect(console.error).toHaveBeenCalled(); + }); + + it('should handle zero search results', async () => { + mockSearchService.search = vi.fn().mockResolvedValue({ + results: [], + count: 0, + }); + + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'nonexistent item', + }; + + const result = await queryFn(command); + + expect(result.items).toHaveLength(0); + expect(result.total).toBe(0); + expect(result.page).toBe(1); + expect(result.pageSize).toBe(10); + }); + + it('should handle undefined count in search results', async () => { + mockSearchService.search = vi.fn().mockResolvedValue({ + results: [{ document: { id: 'listing-1' } }], + count: undefined, + }); + + const queryFn = queryPagedWithSearchFallback( + mockDataSources, + mockSearchService, + ); + + const command: ItemListingQueryPagedWithSearchCommand = { + page: 1, + pageSize: 10, + searchText: 'test', + }; + + const result = await queryFn(command); + + expect(result.total).toBe(0); + }); + }); +}); diff --git a/packages/sthrift/application-services/src/contexts/listing/item/query-paged-with-search.ts b/packages/sthrift/application-services/src/contexts/listing/item/query-paged-with-search.ts new file mode 100644 index 000000000..57e30e5be --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/item/query-paged-with-search.ts @@ -0,0 +1,118 @@ +import type { Domain } from '@sthrift/domain'; +import type { DataSources } from '@sthrift/persistence'; +import type { CognitiveSearchDomain } from '@sthrift/domain'; +import { ListingSearchIndexSpec } from '@sthrift/domain'; +import { queryPaged } from './query-paged.js'; + +export interface ItemListingQueryPagedWithSearchCommand { + page: number; + pageSize: number; + searchText?: string; + statusFilters?: string[]; + sharerId?: string; + sorter?: { field: string; order: 'ascend' | 'descend' }; +} + +/** + * Query listings with search fallback to database + * + * Tries cognitive search first if searchText is provided, then falls back + * to database query if search fails or is not available. + */ +export const queryPagedWithSearchFallback = ( + dataSources: DataSources, + searchService?: CognitiveSearchDomain, +) => { + const dbQueryPaged = queryPaged(dataSources); + + return async ( + command: ItemListingQueryPagedWithSearchCommand, + ): Promise<{ + items: Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]; + total: number; + page: number; + pageSize: number; + }> => { + const { searchText } = command; + + // If search text is provided and search service is available, try cognitive search + if (searchText && searchText.trim() !== '' && searchService) { + try { + const options: Record = { + top: command.pageSize, + skip: (command.page - 1) * command.pageSize, + orderBy: command.sorter + ? [ + `${command.sorter.field} ${command.sorter.order === 'ascend' ? 'asc' : 'desc'}`, + ] + : ['updatedAt desc'], + }; + + const filter: Record = {}; + if (command.sharerId) { + // biome-ignore lint/complexity/useLiteralKeys: filter is Record requiring bracket notation + filter['sharerId'] = [command.sharerId]; + } + if (command.statusFilters) { + // biome-ignore lint/complexity/useLiteralKeys: filter is Record requiring bracket notation + filter['state'] = command.statusFilters; + } + if (Object.keys(filter).length > 0) { + // biome-ignore lint/complexity/useLiteralKeys: options is Record requiring bracket notation + options['filter'] = filter; + } + + // Ensure the search index exists + await searchService.createIndexIfNotExists(ListingSearchIndexSpec); + + const searchResult = await searchService.search( + ListingSearchIndexSpec.name, + searchText, + options, + ); + + // Extract IDs from search results and fetch full entities from database + // This ensures we return complete domain entities with all required fields + const searchIds = searchResult.results.map( + (result) => (result.document as { id: string }).id, + ); + + // Fetch full entities from database using the IDs from search results + const items = await Promise.all( + searchIds.map(async (id) => { + const entity = + await dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getById( + id, + ); + if (!entity) { + console.error( + `Search index consistency issue: Document ID "${id}" returned from search index but not found in database. ` + + `This may indicate search index is out of sync with the database. ` + + `Consider triggering a reindex or investigating why the entity was deleted without updating the search index.` + ); + throw new Error(`Listing entity not found for ID: ${id}`); + } + return entity; + }), + ); + + return { + items, + total: searchResult.count || 0, + page: command.page, + pageSize: command.pageSize, + }; + } catch (error) { + console.error( + 'Cognitive search failed, falling back to database query:', + error, + ); + // Fall through to database query + } + } + + // Fallback to database query + return await dbQueryPaged(command); + }; +}; + diff --git a/packages/sthrift/application-services/src/contexts/listing/listing-search.test.ts b/packages/sthrift/application-services/src/contexts/listing/listing-search.test.ts new file mode 100644 index 000000000..58b85e589 --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/listing-search.test.ts @@ -0,0 +1,690 @@ +/** + * Tests for ListingSearchApplicationService + * + * These tests verify the search functionality including: + * - Search with various filters + * - Pagination + * - Sorting + * - Bulk indexing + * - Error handling + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ListingSearchApplicationService } from './listing-search'; +import type { CognitiveSearchDomain, ListingSearchInput } from '@sthrift/domain'; +import type { DataSources } from '@sthrift/persistence'; + +describe('ListingSearchApplicationService', () => { + let service: ListingSearchApplicationService; + let mockSearchService: CognitiveSearchDomain; + // biome-ignore lint/suspicious/noExplicitAny: Test mock type + let mockDataSources: any; + + beforeEach(() => { + // Mock search service + mockSearchService = { + createIndexIfNotExists: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue({ + results: [], + count: 0, + }), + indexDocument: vi.fn().mockResolvedValue(undefined), + deleteDocument: vi.fn().mockResolvedValue(undefined), + } as unknown as CognitiveSearchDomain; + + // Mock data sources + mockDataSources = { + readonlyDataSource: { + Listing: { + ItemListing: { + ItemListingReadRepo: { + getAll: vi.fn().mockResolvedValue([]), + }, + }, + }, + }, + } as unknown as DataSources; + + service = new ListingSearchApplicationService( + mockSearchService, + mockDataSources, + ); + }); + + describe('searchListings', () => { + it('should search with basic text query', async () => { + const mockResults = { + results: [ + { + document: { + id: 'listing-1', + title: 'Camera', + description: 'Professional camera', + category: 'electronics', + }, + }, + ], + count: 1, + }; + + vi.mocked(mockSearchService.search).mockResolvedValue(mockResults); + + const input: ListingSearchInput = { + searchString: 'camera', + }; + + const result = await service.searchListings(input); + + expect(result.items).toHaveLength(1); + expect(result.count).toBe(1); + expect(mockSearchService.createIndexIfNotExists).toHaveBeenCalled(); + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'camera', + expect.objectContaining({ + queryType: 'full', + searchMode: 'all', + }), + ); + }); + + it('should handle empty search string with wildcard', async () => { + await service.searchListings({ searchString: '' }); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.any(Object), + ); + }); + + it('should trim search string', async () => { + await service.searchListings({ searchString: ' camera ' }); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + 'camera', + expect.any(Object), + ); + }); + + it('should use default wildcard when no search string provided', async () => { + await service.searchListings({}); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.any(Object), + ); + }); + + it('should apply category filter', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: { + category: ['electronics', 'sports'], + }, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + filter: "(category eq 'electronics' or category eq 'sports')", + }), + ); + }); + + it('should apply state filter', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: { + state: ['active', 'draft'], + }, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + filter: "(state eq 'active' or state eq 'draft')", + }), + ); + }); + + it('should apply sharerId filter', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: { + sharerId: ['user-1', 'user-2'], + }, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + filter: "(sharerId eq 'user-1' or sharerId eq 'user-2')", + }), + ); + }); + + it('should apply location filter', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: { + location: 'New York', + }, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + filter: "location eq 'New York'", + }), + ); + }); + + it('should apply date range filter with start date', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: { + dateRange: { + start: '2024-01-01', + }, + }, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + filter: "sharingPeriodStart ge '2024-01-01'", + }), + ); + }); + + it('should apply date range filter with end date', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: { + dateRange: { + end: '2024-12-31', + }, + }, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + filter: "sharingPeriodEnd le '2024-12-31'", + }), + ); + }); + + it('should apply date range filter with both start and end dates', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: { + dateRange: { + start: '2024-01-01', + end: '2024-12-31', + }, + }, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + filter: "sharingPeriodStart ge '2024-01-01' and sharingPeriodEnd le '2024-12-31'", + }), + ); + }); + + it('should combine multiple filters with AND', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: { + category: ['electronics'], + state: ['active'], + location: 'Seattle', + }, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + filter: "(category eq 'electronics') and (state eq 'active') and location eq 'Seattle'", + }), + ); + }); + + it('should apply pagination options', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + top: 10, + skip: 20, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + top: 10, + skip: 20, + }), + ); + }); + + it('should use default pagination values when not provided', async () => { + await service.searchListings({ searchString: '*' }); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + top: 50, + skip: 0, + }), + ); + }); + + it('should apply custom orderBy', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + orderBy: ['title asc', 'createdAt desc'], + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + orderBy: ['title asc', 'createdAt desc'], + }), + ); + }); + + it('should use default orderBy when not provided', async () => { + await service.searchListings({ searchString: '*' }); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + orderBy: ['updatedAt desc'], + }), + ); + }); + + it('should include facets in search options', async () => { + await service.searchListings({ searchString: '*' }); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + facets: ['category,count:0', 'state,count:0', 'sharerId,count:0'], + }), + ); + }); + + it('should convert facets in response', async () => { + const mockResults = { + results: [], + count: 0, + facets: { + category: [ + { value: 'electronics', count: 5 }, + { value: 'sports', count: 3 }, + ], + state: [ + { value: 'active', count: 7 }, + { value: 'draft', count: 1 }, + ], + }, + }; + + vi.mocked(mockSearchService.search).mockResolvedValue(mockResults); + + const result = await service.searchListings({ searchString: '*' }); + + expect(result.facets).toBeDefined(); + expect(result.facets?.category).toHaveLength(2); + expect(result.facets?.state).toHaveLength(2); + expect(result.facets?.category?.[0]).toEqual({ + value: 'electronics', + count: 5, + }); + }); + + it('should return result without facets when none provided', async () => { + const mockResults = { + results: [], + count: 0, + facets: undefined, + }; + + vi.mocked(mockSearchService.search).mockResolvedValue(mockResults); + + const result = await service.searchListings({ searchString: '*' }); + + expect(result.facets).toBeUndefined(); + }); + + it('should convert all facet types', async () => { + const mockResults = { + results: [], + count: 0, + facets: { + category: [{ value: 'electronics', count: 1 }], + state: [{ value: 'active', count: 1 }], + sharerId: [{ value: 'user-1', count: 1 }], + createdAt: [{ value: '2024-01-01', count: 1 }], + }, + }; + + vi.mocked(mockSearchService.search).mockResolvedValue(mockResults); + + const result = await service.searchListings({ searchString: '*' }); + + expect(result.facets?.category).toBeDefined(); + expect(result.facets?.state).toBeDefined(); + expect(result.facets?.sharerId).toBeDefined(); + expect(result.facets?.createdAt).toBeDefined(); + }); + }); + + describe('bulkIndexListings', () => { + it('should index all listings successfully', async () => { + const mockListings = [ + { + id: 'listing-1', + title: 'Camera', + description: 'Professional camera', + category: 'electronics', + props: { + id: 'listing-1', + title: 'Camera', + description: 'Professional camera', + sharer: { id: 'user-1' }, + }, + }, + { + id: 'listing-2', + title: 'Bike', + description: 'Mountain bike', + category: 'sports', + props: { + id: 'listing-2', + title: 'Bike', + description: 'Mountain bike', + sharer: { id: 'user-2' }, + }, + }, + ]; + + vi.mocked( + mockDataSources.readonlyDataSource.Listing.ItemListing + .ItemListingReadRepo.getAll, + ).mockResolvedValue(mockListings); + + const result = await service.bulkIndexListings(); + + expect(result.successCount).toBe(2); + expect(result.totalCount).toBe(2); + expect(result.message).toBe('Successfully indexed 2/2 listings'); + expect(mockSearchService.indexDocument).toHaveBeenCalledTimes(2); + }); + + it('should return early when no listings found', async () => { + vi.mocked( + mockDataSources.readonlyDataSource.Listing.ItemListing + .ItemListingReadRepo.getAll, + ).mockResolvedValue([]); + + const result = await service.bulkIndexListings(); + + expect(result.successCount).toBe(0); + expect(result.totalCount).toBe(0); + expect(result.message).toBe('No listings found to index'); + expect(mockSearchService.indexDocument).not.toHaveBeenCalled(); + }); + + it('should handle indexing errors gracefully', async () => { + const mockListings = [ + { + id: 'listing-1', + title: 'Camera', + props: { id: 'listing-1', title: 'Camera' }, + }, + { + id: 'listing-2', + title: 'Bike', + props: { id: 'listing-2', title: 'Bike' }, + }, + ]; + + vi.mocked( + mockDataSources.readonlyDataSource.Listing.ItemListing + .ItemListingReadRepo.getAll, + ).mockResolvedValue(mockListings); + + // Mock first call to succeed, second to fail + vi.mocked(mockSearchService.indexDocument) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Index error')); + + // Spy on console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const result = await service.bulkIndexListings(); + + expect(result.successCount).toBe(1); + expect(result.totalCount).toBe(2); + expect(result.message).toBe('Successfully indexed 1/2 listings'); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('should handle non-Error exceptions during indexing', async () => { + const mockListings = [ + { + id: 'listing-1', + title: 'Camera', + props: { id: 'listing-1', title: 'Camera' }, + }, + ]; + + vi.mocked( + mockDataSources.readonlyDataSource.Listing.ItemListing + .ItemListingReadRepo.getAll, + ).mockResolvedValue(mockListings); + + // Mock indexDocument to throw a non-Error object + vi.mocked(mockSearchService.indexDocument).mockRejectedValue( + 'String error message', + ); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const result = await service.bulkIndexListings(); + + expect(result.successCount).toBe(0); + expect(result.totalCount).toBe(1); + + consoleErrorSpy.mockRestore(); + }); + + it('should create index before bulk indexing', async () => { + vi.mocked( + mockDataSources.readonlyDataSource.Listing.ItemListing + .ItemListingReadRepo.getAll, + ).mockResolvedValue([ + { + id: 'listing-1', + props: { id: 'listing-1' }, + }, + ]); + + await service.bulkIndexListings(); + + expect(mockSearchService.createIndexIfNotExists).toHaveBeenCalled(); + }); + + it('should log error stack traces when available', async () => { + const mockListings = [ + { + id: 'listing-1', + props: { id: 'listing-1' }, + }, + ]; + + vi.mocked( + mockDataSources.readonlyDataSource.Listing.ItemListing + .ItemListingReadRepo.getAll, + ).mockResolvedValue(mockListings); + + const errorWithStack = new Error('Test error'); + errorWithStack.stack = 'Error stack trace here'; + + vi.mocked(mockSearchService.indexDocument).mockRejectedValue( + errorWithStack, + ); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await service.bulkIndexListings(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('[BulkIndex] Stack trace:'), + expect.any(String), + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('edge cases', () => { + it('should handle null options', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: null, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + top: 50, + skip: 0, + }), + ); + }); + + it('should handle null filter', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: null, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.not.objectContaining({ + filter: expect.anything(), + }), + ); + }); + + it('should handle empty arrays in filters', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + filter: { + category: [], + state: [], + sharerId: [], + }, + }, + }; + + await service.searchListings(input); + + // Empty arrays produce an empty filter string + const call = vi.mocked(mockSearchService.search).mock.calls[0]; + expect(call?.[2]?.filter).toBe(''); + }); + + it('should handle undefined top and skip values', async () => { + const input: ListingSearchInput = { + searchString: '*', + options: { + top: null, + skip: null, + }, + }; + + await service.searchListings(input); + + expect(mockSearchService.search).toHaveBeenCalledWith( + 'listings', + '*', + expect.objectContaining({ + top: 50, + skip: 0, + }), + ); + }); + }); +}); diff --git a/packages/sthrift/application-services/src/contexts/listing/listing-search.ts b/packages/sthrift/application-services/src/contexts/listing/listing-search.ts new file mode 100644 index 000000000..8c2b005a1 --- /dev/null +++ b/packages/sthrift/application-services/src/contexts/listing/listing-search.ts @@ -0,0 +1,254 @@ +/** + * Listing Search Application Service + * + * Provides search functionality for Listings with filtering, + * sorting, and pagination capabilities. + */ + +import type { + ListingSearchInput, + ListingSearchResult, + ListingSearchFilter, + ListingSearchDocument, +} from '@sthrift/domain'; +import type { CognitiveSearchDomain } from '@sthrift/domain'; +import type { + SearchOptions, + SearchDocumentsResult, +} from '@cellix/search-service'; +import { ListingSearchIndexSpec, convertListingToSearchDocument } from '@sthrift/domain'; +import type { DataSources } from '@sthrift/persistence'; + +/** + * Application service for Listing search operations + */ +export class ListingSearchApplicationService { + private readonly searchService: CognitiveSearchDomain; + private readonly dataSources: DataSources; + + constructor(searchService: CognitiveSearchDomain, dataSources: DataSources) { + this.searchService = searchService; + this.dataSources = dataSources; + } + + /** + * Search for listings with the provided input + */ + async searchListings( + input: ListingSearchInput, + ): Promise { + // Ensure the search index exists + await this.searchService.createIndexIfNotExists(ListingSearchIndexSpec); + + // Build search query + const searchString = input.searchString?.trim() || '*'; + const options = this.buildSearchOptions(input.options); + + // Execute search + const searchResults = await this.searchService.search( + ListingSearchIndexSpec.name, + searchString, + options, + ); + + // Convert results to application format + return this.convertSearchResults(searchResults); + } + + /** + * Bulk index all existing listings into the search index + * This is useful for initial setup or re-indexing + */ + async bulkIndexListings(): Promise<{ + successCount: number; + totalCount: number; + message: string; + }> { + // Ensure the search index exists + await this.searchService.createIndexIfNotExists(ListingSearchIndexSpec); + + // Fetch all listings from the database WITH all fields populated + const allListings = + await this.dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo.getAll( + {}, + ); + + if (allListings.length === 0) { + return { + successCount: 0, + totalCount: 0, + message: 'No listings found to index', + }; + } + + // Convert each listing to a search document and index it + const errors: Array<{ id: string; error: string }> = []; + + for (const listing of allListings) { + try { + // Convert listing to search document using the domain converter + const searchDocument = convertListingToSearchDocument( + listing as unknown as Record, + ); + + // Index the document + await this.searchService.indexDocument( + ListingSearchIndexSpec.name, + searchDocument, + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`[BulkIndex] ✗ Failed to index listing ${listing.id}:`, errorMessage); + if (error instanceof Error && error.stack) { + console.error(`[BulkIndex] Stack trace:`, error.stack); + } + errors.push({ id: listing.id, error: errorMessage }); + } + } + + // Summary + const successCount = allListings.length - errors.length; + const message = `Successfully indexed ${successCount}/${allListings.length} listings`; + + if (errors.length > 0) { + console.error(`Failed to index ${errors.length} listings:`, errors); + } + + return { + successCount, + totalCount: allListings.length, + message, + }; + } + + /** + * Build search options from input + */ + private buildSearchOptions(inputOptions?: { + filter?: ListingSearchFilter | null; + top?: number | null; + skip?: number | null; + orderBy?: readonly string[] | null; + } | null): SearchOptions { + const options: SearchOptions = { + queryType: 'full', + searchMode: 'all', + includeTotalCount: true, + facets: ['category,count:0', 'state,count:0', 'sharerId,count:0'], + top: inputOptions?.top || 50, + skip: inputOptions?.skip || 0, + orderBy: inputOptions?.orderBy ? [...inputOptions.orderBy] : ['updatedAt desc'], + }; + + // Build filter string + if (inputOptions?.filter) { + options.filter = this.buildFilterString(inputOptions.filter); + } + + return options; + } + + /** + * Build OData filter string from filter input + */ + private buildFilterString(filter: ListingSearchFilter): string { + const filterParts: string[] = []; + + // Category filter + if (filter.category && filter.category.length > 0) { + const categoryFilters = filter.category.map( + (cat) => `category eq '${cat}'`, + ); + filterParts.push(`(${categoryFilters.join(' or ')})`); + } + + // State filter + if (filter.state && filter.state.length > 0) { + const stateFilters = filter.state.map((state) => `state eq '${state}'`); + filterParts.push(`(${stateFilters.join(' or ')})`); + } + + // Sharer ID filter + if (filter.sharerId && filter.sharerId.length > 0) { + const sharerFilters = filter.sharerId.map((id) => `sharerId eq '${id}'`); + filterParts.push(`(${sharerFilters.join(' or ')})`); + } + + // Location filter (simple text matching) + if (filter.location) { + filterParts.push(`location eq '${filter.location}'`); + } + + // Date range filter + if (filter.dateRange) { + if (filter.dateRange.start) { + // OData requires date strings to be quoted + filterParts.push(`sharingPeriodStart ge '${filter.dateRange.start}'`); + } + if (filter.dateRange.end) { + // OData requires date strings to be quoted + filterParts.push(`sharingPeriodEnd le '${filter.dateRange.end}'`); + } + } + + return filterParts.join(' and '); + } + + /** + * Convert search results to application format + */ + private convertSearchResults( + searchResults: SearchDocumentsResult, + ): ListingSearchResult { + const items: ListingSearchDocument[] = searchResults.results.map( + (result: { document: Record }) => + result.document as unknown as ListingSearchDocument, + ); + + // Convert facets from Record format to typed SearchFacets structure + const facets = this.convertFacets(searchResults.facets); + + // Return with explicit facets (can be undefined if no facets) + if (facets) { + return { + items, + count: searchResults.count || 0, + facets, + }; + } + + return { + items, + count: searchResults.count || 0, + }; + } + + /** + * Convert facets from generic Record format to domain SearchFacets format + */ + private convertFacets( + facetsRecord: Record> | undefined, + ): ListingSearchResult['facets'] { + if (!facetsRecord) { + return undefined; + } + + const facets: ListingSearchResult['facets'] = {}; + + if (facetsRecord['category']) { + facets.category = facetsRecord['category'].map(f => ({ value: String(f.value), count: f.count })); + } + if (facetsRecord['state']) { + facets.state = facetsRecord['state'].map(f => ({ value: String(f.value), count: f.count })); + } + if (facetsRecord['sharerId']) { + facets.sharerId = facetsRecord['sharerId'].map(f => ({ value: String(f.value), count: f.count })); + } + if (facetsRecord['createdAt']) { + facets.createdAt = facetsRecord['createdAt'].map(f => ({ value: String(f.value), count: f.count })); + } + + return facets; + } +} diff --git a/packages/sthrift/application-services/src/index.test.ts b/packages/sthrift/application-services/src/index.test.ts index dd935061a..33c3a047d 100644 --- a/packages/sthrift/application-services/src/index.test.ts +++ b/packages/sthrift/application-services/src/index.test.ts @@ -50,6 +50,12 @@ describe('Application Services Factory', () => { withPassport: vi.fn().mockReturnValue(mockDataSources), }, messagingService: {}, + searchService: { + search: vi.fn(), + indexDocument: vi.fn(), + deleteDocument: vi.fn(), + createIndexIfNotExists: vi.fn(), + }, // biome-ignore lint/suspicious/noExplicitAny: Test mock type assertion } as any; }); diff --git a/packages/sthrift/application-services/src/index.ts b/packages/sthrift/application-services/src/index.ts index c935a792a..0a56a8ca6 100644 --- a/packages/sthrift/application-services/src/index.ts +++ b/packages/sthrift/application-services/src/index.ts @@ -117,7 +117,10 @@ export const buildApplicationServicesFactory = ( return { ...tokenValidationResult, hints: hints }; }, ReservationRequest: ReservationRequest(dataSources), - Listing: Listing(dataSources), + Listing: Listing( + dataSources, + infrastructureServicesRegistry.searchService, + ), Conversation: Conversation(dataSources), AccountPlan: AccountPlan(dataSources), AppealRequest: AppealRequest(dataSources), diff --git a/packages/sthrift/context-spec/package.json b/packages/sthrift/context-spec/package.json index 429e01a86..cfed78ae3 100644 --- a/packages/sthrift/context-spec/package.json +++ b/packages/sthrift/context-spec/package.json @@ -20,10 +20,11 @@ "clean": "rimraf dist" }, "dependencies": { - "@sthrift/persistence": "workspace:*", + "@cellix/messaging-service": "workspace:*", "@cellix/payment-service": "workspace:*", - "@sthrift/service-token-validation": "workspace:*", - "@cellix/messaging-service": "workspace:*" + "@cellix/search-service": "workspace:*", + "@sthrift/persistence": "workspace:*", + "@sthrift/service-token-validation": "workspace:*" }, "devDependencies": { "@cellix/typescript-config": "workspace:*", diff --git a/packages/sthrift/context-spec/src/index.ts b/packages/sthrift/context-spec/src/index.ts index 8e11070ac..f8d58fefe 100644 --- a/packages/sthrift/context-spec/src/index.ts +++ b/packages/sthrift/context-spec/src/index.ts @@ -1,6 +1,7 @@ import type { DataSourcesFactory } from '@sthrift/persistence'; import type { TokenValidation } from '@sthrift/service-token-validation'; import type { PaymentService } from '@cellix/payment-service'; +import type { SearchService } from '@cellix/search-service'; import type { MessagingService } from '@cellix/messaging-service'; export interface ApiContextSpec { @@ -8,5 +9,6 @@ export interface ApiContextSpec { dataSourcesFactory: DataSourcesFactory; // NOT an infrastructure service tokenValidationService: TokenValidation; paymentService: PaymentService; + searchService: SearchService; messagingService: MessagingService; } diff --git a/packages/sthrift/domain/package.json b/packages/sthrift/domain/package.json index 5a629be5a..e8b3d5e8b 100644 --- a/packages/sthrift/domain/package.json +++ b/packages/sthrift/domain/package.json @@ -28,6 +28,7 @@ "dependencies": { "@cellix/domain-seedwork": "workspace:*", "@cellix/event-bus-seedwork-node": "workspace:*", + "@cellix/search-service": "workspace:*", "@lucaspaganini/value-objects": "^1.3.1" }, "devDependencies": { diff --git a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.value-objects.test.ts b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.value-objects.test.ts index 395c75135..a659af4aa 100644 --- a/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.value-objects.test.ts +++ b/packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.value-objects.test.ts @@ -5,7 +5,6 @@ import { expect } from 'vitest'; import * as ValueObjects from './conversation.value-objects.ts'; - const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature( diff --git a/packages/sthrift/domain/src/domain/contexts/listing/index.ts b/packages/sthrift/domain/src/domain/contexts/listing/index.ts index 11d1462dc..f7f6acead 100644 --- a/packages/sthrift/domain/src/domain/contexts/listing/index.ts +++ b/packages/sthrift/domain/src/domain/contexts/listing/index.ts @@ -1 +1 @@ -export * as ItemListing from './item/index.ts'; \ No newline at end of file +export * as ItemListing from './item/index.ts'; diff --git a/packages/sthrift/domain/src/domain/contexts/listing/item/features/item-listing.feature b/packages/sthrift/domain/src/domain/contexts/listing/item/features/item-listing.feature index 23e3e803d..bd03c0b2b 100644 --- a/packages/sthrift/domain/src/domain/contexts/listing/item/features/item-listing.feature +++ b/packages/sthrift/domain/src/domain/contexts/listing/item/features/item-listing.feature @@ -197,3 +197,29 @@ Feature: ItemListing Given an ItemListing aggregate with permission to update item listing and expiresAt set When I set the expiresAt to undefined Then the expiresAt should be cleared + + Scenario: Raising integration event when listing is modified + Given an ItemListing aggregate that has been modified + When the onSave method is called with isModified true + Then an ItemListingUpdatedEvent should be raised + And the event should contain the listing id + And the event should contain the updatedAt timestamp + + Scenario: Not raising update event when listing is not modified + Given an ItemListing aggregate that has not been modified + When the onSave method is called with isModified false + Then no integration events should be raised + + Scenario: Raising integration event when listing is deleted + Given an ItemListing aggregate marked as deleted + When the onSave method is called + Then an ItemListingDeletedEvent should be raised + And the event should contain the listing id + And the event should contain the deletedAt timestamp + + Scenario: Prioritizing delete event over update event + Given an ItemListing aggregate that is both modified and deleted + When the onSave method is called with isModified true + Then only an ItemListingDeletedEvent should be raised + And no ItemListingUpdatedEvent should be raised + diff --git a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.test.ts b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.test.ts index c7c6f9b1b..8934b8d5b 100644 --- a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.test.ts +++ b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.test.ts @@ -8,6 +8,8 @@ import { PersonalUser } from '../../user/personal-user/personal-user.ts'; import type { PersonalUserProps } from '../../user/personal-user/personal-user.entity.ts'; import type { ItemListingProps } from './item-listing.entity.ts'; import { ItemListing } from './item-listing.ts'; +import { ItemListingUpdatedEvent } from '../../../events/types/item-listing-updated.event.ts'; +import { ItemListingDeletedEvent } from '../../../events/types/item-listing-deleted.event.ts'; const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -955,4 +957,83 @@ Scenario( expect(listing.expiresAt).toBeUndefined(); }); }); + + Scenario('Raising integration event when listing is modified', ({ Given, When, Then, And }) => { + Given('an ItemListing aggregate that has been modified', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps(), passport); + }); + When('the onSave method is called with isModified true', () => { + listing.onSave(true); + }); + Then('an ItemListingUpdatedEvent should be raised', () => { + const events = listing.getIntegrationEvents(); + expect(events.length).toBeGreaterThan(0); + expect(events.some(e => e instanceof ItemListingUpdatedEvent)).toBe(true); + }); + And('the event should contain the listing id', () => { + const event = listing.getIntegrationEvents().find(e => e instanceof ItemListingUpdatedEvent); + expect(event?.payload).toHaveProperty('id'); + }); + And('the event should contain the updatedAt timestamp', () => { + const event = listing.getIntegrationEvents().find(e => e instanceof ItemListingUpdatedEvent); + expect(event?.payload).toHaveProperty('updatedAt'); + }); + }); + + Scenario('Not raising update event when listing is not modified', ({ Given, When, Then }) => { + Given('an ItemListing aggregate that has not been modified', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps(), passport); + }); + When('the onSave method is called with isModified false', () => { + listing.onSave(false); + }); + Then('no integration events should be raised', () => { + const events = listing.getIntegrationEvents(); + expect(events.length).toBe(0); + }); + }); + + Scenario('Raising integration event when listing is deleted', ({ Given, When, Then, And }) => { + Given('an ItemListing aggregate marked as deleted', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps(), passport); + listing.requestDelete(); + }); + When('the onSave method is called', () => { + listing.onSave(true); + }); + Then('an ItemListingDeletedEvent should be raised', () => { + const events = listing.getIntegrationEvents(); + expect(events.some(e => e instanceof ItemListingDeletedEvent)).toBe(true); + }); + And('the event should contain the listing id', () => { + const event = listing.getIntegrationEvents().find(e => e instanceof ItemListingDeletedEvent); + expect(event?.payload).toHaveProperty('id'); + }); + And('the event should contain the deletedAt timestamp', () => { + const event = listing.getIntegrationEvents().find(e => e instanceof ItemListingDeletedEvent); + expect(event?.payload).toHaveProperty('deletedAt'); + }); + }); + + Scenario('Prioritizing delete event over update event', ({ Given, When, Then, And }) => { + Given('an ItemListing aggregate that is both modified and deleted', () => { + passport = makePassport(true, true, true, true); + listing = new ItemListing(makeBaseProps(), passport); + listing.requestDelete(); + }); + When('the onSave method is called with isModified true', () => { + listing.onSave(true); + }); + Then('only an ItemListingDeletedEvent should be raised', () => { + const events = listing.getIntegrationEvents(); + expect(events.some(e => e instanceof ItemListingDeletedEvent)).toBe(true); + }); + And('no ItemListingUpdatedEvent should be raised', () => { + const events = listing.getIntegrationEvents(); + expect(events.some(e => e instanceof ItemListingUpdatedEvent)).toBe(false); + }); + }); }); \ No newline at end of file diff --git a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.ts b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.ts index 45fc9ff48..d1f5ab468 100644 --- a/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.ts +++ b/packages/sthrift/domain/src/domain/contexts/listing/item/item-listing.ts @@ -11,6 +11,8 @@ import { AdminUser } from '../../user/admin-user/admin-user.ts'; import type { AdminUserProps } from '../../user/admin-user/admin-user.entity.ts'; import { PersonalUser } from '../../user/personal-user/personal-user.ts'; import type { PersonalUserProps } from '../../user/personal-user/personal-user.entity.ts'; +import { ItemListingUpdatedEvent } from '../../../events/types/item-listing-updated.event.js'; +import { ItemListingDeletedEvent } from '../../../events/types/item-listing-deleted.event.js'; export class ItemListing extends DomainSeedwork.AggregateRoot @@ -383,4 +385,24 @@ public requestDelete(): void { set listingType(value: string) { this.props.listingType = value; } + + /** + * Hook called when entity is saved + * Raises integration events for search index updates + */ + public override onSave(isModified: boolean): void { + if (this.isDeleted) { + // Raise deleted event for search index cleanup + this.addIntegrationEvent(ItemListingDeletedEvent, { + id: this.props.id, + deletedAt: new Date(), + }); + } else if (isModified) { + // Raise updated event for search index update + this.addIntegrationEvent(ItemListingUpdatedEvent, { + id: this.props.id, + updatedAt: this.props.updatedAt, + }); + } + } } diff --git a/packages/sthrift/domain/src/domain/contexts/reservation-request/index.ts b/packages/sthrift/domain/src/domain/contexts/reservation-request/index.ts index 6ad7b046d..fb76b07f5 100644 --- a/packages/sthrift/domain/src/domain/contexts/reservation-request/index.ts +++ b/packages/sthrift/domain/src/domain/contexts/reservation-request/index.ts @@ -1,2 +1,2 @@ export * as ReservationRequest from './reservation-request/index.ts'; -export type { ReservationRequestPassport } from './reservation-request.passport.ts'; \ No newline at end of file +export type { ReservationRequestPassport } from './reservation-request.passport.ts'; diff --git a/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/reservation-request.value-objects.ts b/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/reservation-request.value-objects.ts index a9d332c63..771a6051e 100644 --- a/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/reservation-request.value-objects.ts +++ b/packages/sthrift/domain/src/domain/contexts/reservation-request/reservation-request/reservation-request.value-objects.ts @@ -8,7 +8,7 @@ export const ReservationRequestStates = { ACCEPTED: 'Accepted', REJECTED: 'Rejected', CANCELLED: 'Cancelled', - CLOSED: 'Closed' + CLOSED: 'Closed', } as const; type StatesType = (typeof ReservationRequestStates)[keyof typeof ReservationRequestStates]; diff --git a/packages/sthrift/domain/src/domain/contexts/value-objects.test.ts b/packages/sthrift/domain/src/domain/contexts/value-objects.test.ts index 65b669584..979e2c12a 100644 --- a/packages/sthrift/domain/src/domain/contexts/value-objects.test.ts +++ b/packages/sthrift/domain/src/domain/contexts/value-objects.test.ts @@ -4,7 +4,6 @@ import { describeFeature, loadFeature } from '@amiceli/vitest-cucumber'; import { expect } from 'vitest'; import * as ValueObjects from './value-objects.ts'; - const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature( @@ -174,7 +173,9 @@ test.for(feature, ({ Scenario }) => { 'I try to create a nullable email with a string of 255 characters ending with "@e.com"', () => { createNullableEmail = () => { - new ValueObjects.NullableEmail(`${'a'.repeat(249)}@e.com`).valueOf(); + new ValueObjects.NullableEmail( + `${'a'.repeat(249)}@e.com`, + ).valueOf(); }; }, ); diff --git a/packages/sthrift/domain/src/domain/events/index.ts b/packages/sthrift/domain/src/domain/events/index.ts index b3c5f8a04..c7552b97f 100644 --- a/packages/sthrift/domain/src/domain/events/index.ts +++ b/packages/sthrift/domain/src/domain/events/index.ts @@ -1 +1,2 @@ -export { EventBusInstance } from './event-bus.ts'; \ No newline at end of file +export { EventBusInstance } from './event-bus.ts'; +export * from './types/index.ts'; diff --git a/packages/sthrift/domain/src/domain/events/types/index.ts b/packages/sthrift/domain/src/domain/events/types/index.ts new file mode 100644 index 000000000..2fb4b9b4a --- /dev/null +++ b/packages/sthrift/domain/src/domain/events/types/index.ts @@ -0,0 +1,8 @@ +/** + * Domain Events + * + * Exports all domain events for the ShareThrift application. + */ + +export * from './item-listing-updated.event.js'; +export * from './item-listing-deleted.event.js'; diff --git a/packages/sthrift/domain/src/domain/events/types/item-listing-deleted.event.ts b/packages/sthrift/domain/src/domain/events/types/item-listing-deleted.event.ts new file mode 100644 index 000000000..061060d3b --- /dev/null +++ b/packages/sthrift/domain/src/domain/events/types/item-listing-deleted.event.ts @@ -0,0 +1,16 @@ +/** + * Item Listing Deleted Event + * + * Domain event fired when an ItemListing entity is deleted. + * This event is used to trigger search index cleanup and other + * downstream processing. + */ + +import { DomainSeedwork } from '@cellix/domain-seedwork'; + +export interface ItemListingDeletedProps { + id: string; + deletedAt: Date; +} + +export class ItemListingDeletedEvent extends DomainSeedwork.CustomDomainEventImpl {} diff --git a/packages/sthrift/domain/src/domain/events/types/item-listing-updated.event.ts b/packages/sthrift/domain/src/domain/events/types/item-listing-updated.event.ts new file mode 100644 index 000000000..4b3b232fd --- /dev/null +++ b/packages/sthrift/domain/src/domain/events/types/item-listing-updated.event.ts @@ -0,0 +1,16 @@ +/** + * Item Listing Updated Event + * + * Domain event fired when an ItemListing entity is updated. + * This event is used to trigger search index updates and other + * downstream processing. + */ + +import { DomainSeedwork } from '@cellix/domain-seedwork'; + +export interface ItemListingUpdatedProps { + id: string; + updatedAt: Date; +} + +export class ItemListingUpdatedEvent extends DomainSeedwork.CustomDomainEventImpl {} diff --git a/packages/sthrift/domain/src/domain/iam/guest/contexts/guest.reservation-request.passport.ts b/packages/sthrift/domain/src/domain/iam/guest/contexts/guest.reservation-request.passport.ts index 5167003c0..0e3eeea70 100644 --- a/packages/sthrift/domain/src/domain/iam/guest/contexts/guest.reservation-request.passport.ts +++ b/packages/sthrift/domain/src/domain/iam/guest/contexts/guest.reservation-request.passport.ts @@ -7,7 +7,9 @@ export class GuestReservationRequestPassport extends GuestPassportBase implements ReservationRequestPassport { - forReservationRequest(_root: ReservationRequestEntityReference): ReservationRequestVisa { + forReservationRequest( + _root: ReservationRequestEntityReference, + ): ReservationRequestVisa { return { determineIf: () => false }; } } diff --git a/packages/sthrift/domain/src/domain/index.ts b/packages/sthrift/domain/src/domain/index.ts index dc7f5dce6..7162a7781 100644 --- a/packages/sthrift/domain/src/domain/index.ts +++ b/packages/sthrift/domain/src/domain/index.ts @@ -1,5 +1,8 @@ - export * as Contexts from './contexts/index.ts'; -export type { Services } from './services/index.ts'; +export * as Services from './services/index.ts'; export { type Passport, PassportFactory } from './contexts/passport.ts'; +export * from './infrastructure/cognitive-search/index.ts'; +export * from './events/types/index.ts'; +export { EventBusInstance } from './events/index.ts'; +export * as Events from './events/index.ts'; export type { DomainExecutionContext } from './domain-execution-context.ts'; diff --git a/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/index.ts b/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/index.ts new file mode 100644 index 000000000..7e7fc98ba --- /dev/null +++ b/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/index.ts @@ -0,0 +1,9 @@ +/** + * Cognitive Search Infrastructure + * + * Exports all cognitive search related domain interfaces and definitions. + */ + +export * from './interfaces.js'; +export * from './listing-search-index.js'; +export type { SearchIndex, SearchField } from '@cellix/search-service'; diff --git a/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/interfaces.ts b/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/interfaces.ts new file mode 100644 index 000000000..1e551a14c --- /dev/null +++ b/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/interfaces.ts @@ -0,0 +1,87 @@ +/** + * Cognitive Search Domain Interfaces + * + * Defines the domain-level interfaces for cognitive search functionality + * in the ShareThrift application. + */ + +import type { SearchService } from '@cellix/search-service'; + +/** + * Domain interface for cognitive search + * Uses the generic SearchService interface + */ +export interface CognitiveSearchDomain extends SearchService {} + +/** + * Search index document interface for Listings + */ +export interface ListingSearchDocument { + id: string; + title: string; + description: string; + category: string; + location: string; + sharerName: string; + sharerId: string; + state: string; + sharingPeriodStart: string; // ISO date string + sharingPeriodEnd: string; // ISO date string + createdAt: string; // ISO date string + updatedAt: string; // ISO date string + images: string[]; +} + +/** + * Search facet value + */ +export interface SearchFacet { + value: string; + count: number; +} + +/** + * Search facets grouped by field + */ +export interface SearchFacets { + category?: SearchFacet[]; + state?: SearchFacet[]; + sharerId?: SearchFacet[]; + createdAt?: SearchFacet[]; +} + +/** + * Search result interface for Listings + */ +export interface ListingSearchResult { + items: ListingSearchDocument[]; + count: number; + facets?: SearchFacets; +} + +/** + * Search input interface for Listings + */ +export interface ListingSearchInput { + searchString?: string | null; + options?: { + filter?: ListingSearchFilter | null; + top?: number | null; + skip?: number | null; + orderBy?: readonly string[] | null; + } | null; +} + +/** + * Search filter interface for Listings + */ +export interface ListingSearchFilter { + category?: readonly string[] | null; + state?: readonly string[] | null; + sharerId?: readonly string[] | null; + location?: string | null; + dateRange?: { + start?: string | null; + end?: string | null; + } | null; +} diff --git a/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/listing-search-index.test.ts b/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/listing-search-index.test.ts new file mode 100644 index 000000000..4fc37848e --- /dev/null +++ b/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/listing-search-index.test.ts @@ -0,0 +1,590 @@ +/** + * Tests for Listing Search Index Definition + * + * Tests the search index schema and document conversion logic + * for the Listing search functionality. + */ + +import { describe, expect, it } from 'vitest'; +import { + ListingSearchIndexSpec, + convertListingToSearchDocument, +} from './listing-search-index.js'; + +describe('ListingSearchIndexSpec', () => { + it('should have correct index name', () => { + expect(ListingSearchIndexSpec.name).toBe('listings'); + }); + + it('should define all required fields', () => { + const fieldNames = ListingSearchIndexSpec.fields.map((f) => f.name); + + expect(fieldNames).toContain('id'); + expect(fieldNames).toContain('title'); + expect(fieldNames).toContain('description'); + expect(fieldNames).toContain('location'); + expect(fieldNames).toContain('category'); + expect(fieldNames).toContain('state'); + expect(fieldNames).toContain('sharerName'); + expect(fieldNames).toContain('sharerId'); + expect(fieldNames).toContain('sharingPeriodStart'); + expect(fieldNames).toContain('sharingPeriodEnd'); + expect(fieldNames).toContain('createdAt'); + expect(fieldNames).toContain('updatedAt'); + expect(fieldNames).toContain('images'); + }); + + it('should have correct field count', () => { + expect(ListingSearchIndexSpec.fields).toHaveLength(13); + }); + + describe('id field', () => { + const idField = ListingSearchIndexSpec.fields.find((f) => f.name === 'id'); + + it('should be defined', () => { + expect(idField).toBeDefined(); + }); + + it('should be the key field', () => { + expect(idField?.key).toBe(true); + }); + + it('should be of type Edm.String', () => { + expect(idField?.type).toBe('Edm.String'); + }); + + it('should not be searchable', () => { + expect(idField?.searchable).toBe(false); + }); + + it('should be filterable', () => { + expect(idField?.filterable).toBe(true); + }); + + it('should be retrievable', () => { + expect(idField?.retrievable).toBe(true); + }); + }); + + describe('title field', () => { + const titleField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'title', + ); + + it('should be defined', () => { + expect(titleField).toBeDefined(); + }); + + it('should be searchable', () => { + expect(titleField?.searchable).toBe(true); + }); + + it('should be sortable', () => { + expect(titleField?.sortable).toBe(true); + }); + + it('should be retrievable', () => { + expect(titleField?.retrievable).toBe(true); + }); + + it('should not be filterable', () => { + expect(titleField?.filterable).toBe(false); + }); + }); + + describe('description field', () => { + const descField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'description', + ); + + it('should be defined', () => { + expect(descField).toBeDefined(); + }); + + it('should be searchable', () => { + expect(descField?.searchable).toBe(true); + }); + + it('should be retrievable', () => { + expect(descField?.retrievable).toBe(true); + }); + + it('should not be sortable', () => { + expect(descField?.sortable).toBe(false); + }); + }); + + describe('location field', () => { + const locationField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'location', + ); + + it('should be defined', () => { + expect(locationField).toBeDefined(); + }); + + it('should be searchable', () => { + expect(locationField?.searchable).toBe(true); + }); + + it('should be filterable', () => { + expect(locationField?.filterable).toBe(true); + }); + + it('should be retrievable', () => { + expect(locationField?.retrievable).toBe(true); + }); + }); + + describe('category field', () => { + const categoryField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'category', + ); + + it('should be defined', () => { + expect(categoryField).toBeDefined(); + }); + + it('should be filterable', () => { + expect(categoryField?.filterable).toBe(true); + }); + + it('should be facetable', () => { + expect(categoryField?.facetable).toBe(true); + }); + + it('should be sortable', () => { + expect(categoryField?.sortable).toBe(true); + }); + + it('should not be searchable', () => { + expect(categoryField?.searchable).toBe(false); + }); + }); + + describe('state field', () => { + const stateField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'state', + ); + + it('should be defined', () => { + expect(stateField).toBeDefined(); + }); + + it('should be filterable', () => { + expect(stateField?.filterable).toBe(true); + }); + + it('should be facetable', () => { + expect(stateField?.facetable).toBe(true); + }); + + it('should be sortable', () => { + expect(stateField?.sortable).toBe(true); + }); + }); + + describe('sharer fields', () => { + const sharerNameField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'sharerName', + ); + const sharerIdField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'sharerId', + ); + + it('should have sharerName field', () => { + expect(sharerNameField).toBeDefined(); + }); + + it('sharerName should be searchable', () => { + expect(sharerNameField?.searchable).toBe(true); + }); + + it('sharerName should be sortable', () => { + expect(sharerNameField?.sortable).toBe(true); + }); + + it('should have sharerId field', () => { + expect(sharerIdField).toBeDefined(); + }); + + it('sharerId should be filterable', () => { + expect(sharerIdField?.filterable).toBe(true); + }); + + it('sharerId should be facetable', () => { + expect(sharerIdField?.facetable).toBe(true); + }); + + it('sharerId should not be searchable', () => { + expect(sharerIdField?.searchable).toBe(false); + }); + }); + + describe('date fields', () => { + const dateFieldNames = [ + 'sharingPeriodStart', + 'sharingPeriodEnd', + 'createdAt', + 'updatedAt', + ]; + + for (const fieldName of dateFieldNames) { + describe(fieldName, () => { + const dateField = ListingSearchIndexSpec.fields.find( + (f) => f.name === fieldName, + ); + + it('should be defined', () => { + expect(dateField).toBeDefined(); + }); + + it('should be of type Edm.DateTimeOffset', () => { + expect(dateField?.type).toBe('Edm.DateTimeOffset'); + }); + + it('should be filterable', () => { + expect(dateField?.filterable).toBe(true); + }); + + it('should be sortable', () => { + expect(dateField?.sortable).toBe(true); + }); + + it('should not be searchable', () => { + expect(dateField?.searchable).toBe(false); + }); + + it('should be retrievable', () => { + expect(dateField?.retrievable).toBe(true); + }); + }); + } + + it('createdAt should be facetable', () => { + const createdAtField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'createdAt', + ); + expect(createdAtField?.facetable).toBe(true); + }); + + it('updatedAt should be facetable', () => { + const updatedAtField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'updatedAt', + ); + expect(updatedAtField?.facetable).toBe(true); + }); + }); + + describe('images field', () => { + const imagesField = ListingSearchIndexSpec.fields.find( + (f) => f.name === 'images', + ); + + it('should be defined', () => { + expect(imagesField).toBeDefined(); + }); + + it('should be a collection type', () => { + expect(imagesField?.type).toBe('Collection(Edm.String)'); + }); + + it('should be retrievable', () => { + expect(imagesField?.retrievable).toBe(true); + }); + + it('should not be searchable', () => { + expect(imagesField?.searchable).toBe(false); + }); + + it('should not be filterable', () => { + expect(imagesField?.filterable).toBe(false); + }); + + it('should not be sortable', () => { + expect(imagesField?.sortable).toBe(false); + }); + + it('should not be facetable', () => { + expect(imagesField?.facetable).toBe(false); + }); + }); +}); + +describe('convertListingToSearchDocument', () => { + it('should convert a complete listing to search document', () => { + const listing = { + id: 'listing-123', + title: 'Vintage Camera', + description: 'A beautiful vintage camera from the 1960s', + category: 'electronics', + location: 'New York, NY', + state: 'active', + sharer: { + id: 'user-456', + account: { + profile: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }, + sharingPeriodStart: new Date('2024-01-01T00:00:00Z'), + sharingPeriodEnd: new Date('2024-12-31T23:59:59Z'), + createdAt: new Date('2023-12-01T10:30:00Z'), + updatedAt: new Date('2023-12-15T14:20:00Z'), + images: ['image1.jpg', 'image2.jpg', 'image3.jpg'], + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.id).toBe('listing-123'); + expect(searchDoc.title).toBe('Vintage Camera'); + expect(searchDoc.description).toBe('A beautiful vintage camera from the 1960s'); + expect(searchDoc.category).toBe('electronics'); + expect(searchDoc.location).toBe('New York, NY'); + expect(searchDoc.state).toBe('active'); + expect(searchDoc.sharerName).toBe('John Doe'); + expect(searchDoc.sharerId).toBe('user-456'); + expect(searchDoc.sharingPeriodStart).toBe('2024-01-01T00:00:00.000Z'); + expect(searchDoc.sharingPeriodEnd).toBe('2024-12-31T23:59:59.000Z'); + expect(searchDoc.createdAt).toBe('2023-12-01T10:30:00.000Z'); + expect(searchDoc.updatedAt).toBe('2023-12-15T14:20:00.000Z'); + expect(searchDoc.images).toEqual(['image1.jpg', 'image2.jpg', 'image3.jpg']); + }); + + it('should handle missing sharer information', () => { + const listing = { + id: 'listing-789', + title: 'Test Item', + description: 'Test description', + category: 'other', + location: 'Unknown', + state: 'pending', + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + images: [], + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.sharerName).toBe(' '); + expect(searchDoc.sharerId).toBe(''); + }); + + it('should handle sharer with only id', () => { + const listing = { + id: 'listing-abc', + title: 'Test Item', + description: 'Test description', + category: 'other', + location: 'Unknown', + state: 'pending', + sharer: { + id: 'user-999', + }, + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + images: [], + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.sharerId).toBe('user-999'); + expect(searchDoc.sharerName).toBe(' '); + }); + + it('should handle sharer with only firstName', () => { + const listing = { + id: 'listing-def', + title: 'Test Item', + sharer: { + id: 'user-777', + account: { + profile: { + firstName: 'Jane', + }, + }, + }, + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.sharerName).toBe('Jane '); + }); + + it('should handle sharer with only lastName', () => { + const listing = { + id: 'listing-ghi', + title: 'Test Item', + sharer: { + id: 'user-888', + account: { + profile: { + lastName: 'Smith', + }, + }, + }, + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.sharerName).toBe(' Smith'); + }); + + it('should handle missing optional fields with defaults', () => { + const listing = { + id: 'listing-minimal', + title: 'Minimal Listing', + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.description).toBe(''); + expect(searchDoc.category).toBe(''); + expect(searchDoc.location).toBe(''); + expect(searchDoc.state).toBe(''); + expect(searchDoc.images).toEqual([]); + }); + + it('should convert date fields to ISO strings', () => { + const testDate = new Date('2024-06-15T08:30:45.123Z'); + const listing = { + id: 'listing-dates', + title: 'Date Test', + sharingPeriodStart: testDate, + sharingPeriodEnd: testDate, + createdAt: testDate, + updatedAt: testDate, + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.sharingPeriodStart).toBe('2024-06-15T08:30:45.123Z'); + expect(searchDoc.sharingPeriodEnd).toBe('2024-06-15T08:30:45.123Z'); + expect(searchDoc.createdAt).toBe('2024-06-15T08:30:45.123Z'); + expect(searchDoc.updatedAt).toBe('2024-06-15T08:30:45.123Z'); + }); + + it('should handle empty images array', () => { + const listing = { + id: 'listing-no-images', + title: 'No Images', + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + images: [], + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.images).toEqual([]); + }); + + it('should preserve images array', () => { + const listing = { + id: 'listing-images', + title: 'With Images', + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + images: ['a.jpg', 'b.png', 'c.gif', 'd.webp'], + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.images).toEqual(['a.jpg', 'b.png', 'c.gif', 'd.webp']); + }); + + it('should handle fields with toString() method', () => { + const listing = { + id: 'listing-tostring', + title: 'Test', + description: { toString: () => 'Custom description' }, + category: { toString: () => 'electronics' }, + location: { toString: () => 'Boston, MA' }, + state: { toString: () => 'active' }, + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.description).toBe('Custom description'); + expect(searchDoc.category).toBe('electronics'); + expect(searchDoc.location).toBe('Boston, MA'); + expect(searchDoc.state).toBe('active'); + }); + + it('should handle complex nested sharer structure', () => { + const listing = { + id: 'listing-nested', + title: 'Nested Test', + sharer: { + id: 'user-complex', + account: { + profile: { + firstName: 'Alice', + lastName: 'Johnson', + middleName: 'Marie', // Extra field should be ignored + }, + email: 'alice@example.com', // Extra field should be ignored + }, + role: 'admin', // Extra field should be ignored + }, + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.sharerId).toBe('user-complex'); + expect(searchDoc.sharerName).toBe('Alice Johnson'); + }); + + it('should handle whitespace in sharer names', () => { + const listing = { + id: 'listing-whitespace', + title: 'Whitespace Test', + sharer: { + id: 'user-space', + account: { + profile: { + firstName: ' John ', + lastName: ' Doe ', + }, + }, + }, + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2023-12-01'), + updatedAt: new Date('2023-12-15'), + }; + + const searchDoc = convertListingToSearchDocument(listing); + + expect(searchDoc.sharerName).toBe(' John Doe '); + }); +}); diff --git a/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/listing-search-index.ts b/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/listing-search-index.ts new file mode 100644 index 000000000..bbd751d56 --- /dev/null +++ b/packages/sthrift/domain/src/domain/infrastructure/cognitive-search/listing-search-index.ts @@ -0,0 +1,256 @@ +/** + * Listing Search Index Definition + * + * Defines the Azure Cognitive Search index schema for Listings. + * This index enables full-text search and filtering of listings + * in the ShareThrift application. + */ + +import type { SearchIndex } from '@cellix/search-service'; + +/** + * Search index definition for Listings + */ +export const ListingSearchIndexSpec: SearchIndex = { + name: 'listings', + fields: [ + // Primary key + { + name: 'id', + type: 'Edm.String', + key: true, + searchable: false, + filterable: true, + sortable: false, + facetable: false, + retrievable: true, + }, + + // Searchable text fields + { + name: 'title', + type: 'Edm.String', + searchable: true, + filterable: false, + sortable: true, + facetable: false, + retrievable: true, + }, + { + name: 'description', + type: 'Edm.String', + searchable: true, + filterable: false, + sortable: false, + facetable: false, + retrievable: true, + }, + { + name: 'location', + type: 'Edm.String', + searchable: true, + filterable: true, + sortable: false, + facetable: false, + retrievable: true, + }, + + // Filterable and facetable fields + { + name: 'category', + type: 'Edm.String', + searchable: false, + filterable: true, + sortable: true, + facetable: true, + retrievable: true, + }, + { + name: 'state', + type: 'Edm.String', + searchable: false, + filterable: true, + sortable: true, + facetable: true, + retrievable: true, + }, + + // Sharer information + { + name: 'sharerName', + type: 'Edm.String', + searchable: true, + filterable: false, + sortable: true, + facetable: false, + retrievable: true, + }, + { + name: 'sharerId', + type: 'Edm.String', + searchable: false, + filterable: true, + sortable: false, + facetable: true, + retrievable: true, + }, + + // Date fields + { + name: 'sharingPeriodStart', + type: 'Edm.DateTimeOffset', + searchable: false, + filterable: true, + sortable: true, + facetable: false, + retrievable: true, + }, + { + name: 'sharingPeriodEnd', + type: 'Edm.DateTimeOffset', + searchable: false, + filterable: true, + sortable: true, + facetable: false, + retrievable: true, + }, + { + name: 'createdAt', + type: 'Edm.DateTimeOffset', + searchable: false, + filterable: true, + sortable: true, + facetable: true, + retrievable: true, + }, + { + name: 'updatedAt', + type: 'Edm.DateTimeOffset', + searchable: false, + filterable: true, + sortable: true, + facetable: true, + retrievable: true, + }, + + // Array fields + { + name: 'images', + type: 'Collection(Edm.String)', + searchable: false, + filterable: false, + sortable: false, + facetable: false, + retrievable: true, + }, + ], +}; + +/** + * Helper function to convert Listing domain entity to search document + * Safely extracts data from domain entities that may have incomplete nested data + * IMPORTANT: Accesses props directly to avoid triggering getters that may fail on incomplete data + */ +export function convertListingToSearchDocument( + listing: Record, +): Record { + // Access props directly to avoid domain entity getters (if domain entity) + // For plain test objects, use the listing itself as the props + const listingProps = (listing.props as Record) || listing; + + // Safely extract sharer info - handle both populated domain entities and unpopulated references + let sharerName = ' '; // Default to single space (matches empty firstName + " " + empty lastName) + let sharerId = ''; + + try { + // Try to get sharer from props first (domain entity), fallback to listing (test object) + const sharer = listingProps.sharer || listing.sharer; + + if (typeof sharer === 'string') { + // Unpopulated reference - just an ID + sharerId = sharer; + // sharerName already set to 'Unknown' + } else if (sharer && typeof sharer === 'object') { + const sharerObj = sharer as Record; + + // Try to get the sharer ID from props or id + const props = sharerObj.props as Record | undefined; + sharerId = (props?.id as string) || (sharerObj.id as string) || ''; + + // Try to extract account/profile info + // For domain entities: props.account + // For test objects: sharerObj.account directly + const account = (props?.account as Record) || (sharerObj.account as Record) || undefined; + const profile = account?.profile as Record | undefined; + + if (profile) { + const firstName = (profile.firstName as string) || ''; + const lastName = (profile.lastName as string) || ''; + const displayName = (profile.displayName as string) || ''; + + // Use displayName if available, otherwise construct from first/last name + if (displayName) { + sharerName = displayName; + } else if (firstName || lastName) { + // Construct full name - preserve whitespace as-is + sharerName = `${firstName} ${lastName}`; + } + } + } + } catch (error) { + // If anything fails, log and continue with default values + console.warn(`Failed to extract sharer info for listing ${listing.id}:`, error); + } + + // Safely extract all listing fields from props + // For fields that might be value objects (with .value), try that first, then toString(), then the raw value + const extractValue = (field: unknown): string => { + if (!field) return ''; + if (typeof field === 'string') return field; + if (typeof field === 'object') { + const fieldObj = field as Record; + // Check if it's a value object with a .value property + if (fieldObj.value !== undefined) { + const val = fieldObj.value; + if (typeof val === 'string') return val; + if (typeof val === 'number' || typeof val === 'boolean') return String(val); + return ''; + } + // Check if it has a custom toString method + if (typeof fieldObj.toString === 'function' && fieldObj.toString !== Object.prototype.toString) { + try { + // biome-ignore lint/suspicious/noExplicitAny: we verified this has a custom toString + const result = (fieldObj as any).toString(); + return typeof result === 'string' ? result : ''; + } catch { + return ''; + } + } + } + return typeof field === 'number' || typeof field === 'boolean' ? String(field) : ''; + }; + + const extractId = (value: unknown): string => { + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return ''; + }; + + return { + id: extractId(listingProps.id) || extractId(listing.id) || '', + title: extractValue(listingProps.title), + description: extractValue(listingProps.description), + category: extractValue(listingProps.category), + location: extractValue(listingProps.location), + sharerName, + sharerId, + state: extractValue(listingProps.state), + sharingPeriodStart: + (listingProps.sharingPeriodStart as Date)?.toISOString() || '', + sharingPeriodEnd: + (listingProps.sharingPeriodEnd as Date)?.toISOString() || '', + createdAt: (listingProps.createdAt as Date)?.toISOString() || '', + updatedAt: (listingProps.updatedAt as Date)?.toISOString() || '', + images: Array.isArray(listingProps.images) ? listingProps.images : [], + }; +} diff --git a/packages/sthrift/domain/src/domain/services/blob-storage.ts b/packages/sthrift/domain/src/domain/services/blob-storage.ts index 2af557dd8..2e8dcc2de 100644 --- a/packages/sthrift/domain/src/domain/services/blob-storage.ts +++ b/packages/sthrift/domain/src/domain/services/blob-storage.ts @@ -1,4 +1,7 @@ - export interface BlobStorage { - createValetKey(storageAccount: string, path: string, expiration: Date): Promise; -} \ No newline at end of file + createValetKey( + storageAccount: string, + path: string, + expiration: Date, + ): Promise; +} diff --git a/packages/sthrift/domain/src/domain/services/index.ts b/packages/sthrift/domain/src/domain/services/index.ts index edce8fd61..0b6b0c250 100644 --- a/packages/sthrift/domain/src/domain/services/index.ts +++ b/packages/sthrift/domain/src/domain/services/index.ts @@ -1,5 +1,9 @@ import type { BlobStorage } from './blob-storage.ts'; +import type { ListingSearchIndexingService } from './listing/listing-search-indexing.js'; export interface Services { - BlobStorage: BlobStorage; -} \ No newline at end of file + BlobStorage: BlobStorage; + ListingSearchIndexing: ListingSearchIndexingService; +} + +export { ListingSearchIndexingService } from './listing/listing-search-indexing.js'; diff --git a/packages/sthrift/domain/src/domain/services/listing/listing-search-indexing.ts b/packages/sthrift/domain/src/domain/services/listing/listing-search-indexing.ts new file mode 100644 index 000000000..39239f8c6 --- /dev/null +++ b/packages/sthrift/domain/src/domain/services/listing/listing-search-indexing.ts @@ -0,0 +1,85 @@ +import type { CognitiveSearchDomain } from '../../infrastructure/cognitive-search/index.js'; +import type { ItemListingUnitOfWork } from '../../contexts/listing/item/item-listing.uow.js'; +import { + ListingSearchIndexSpec, + convertListingToSearchDocument, +} from '../../infrastructure/cognitive-search/listing-search-index.js'; +import crypto from 'node:crypto'; + +export class ListingSearchIndexingService { + constructor( + searchService: CognitiveSearchDomain, + itemListingUnitOfWork: ItemListingUnitOfWork, + ) { + this.searchService = searchService; + this.itemListingUnitOfWork = itemListingUnitOfWork; + } + + private readonly searchService: CognitiveSearchDomain; + private readonly itemListingUnitOfWork: ItemListingUnitOfWork; + + async indexListing(listingId: string): Promise { + await this.itemListingUnitOfWork.withScopedTransaction( + async (repo) => { + const listing = await repo.getById(listingId); + if (!listing) { + console.warn(`Listing ${listingId} not found, skipping search index update`); + return; + } + + const searchDocument = convertListingToSearchDocument(listing as unknown as Record); + await this.updateSearchIndexWithRetry(searchDocument, listing as unknown as Record, 3); + }, + ); + } + + async deleteFromIndex(listingId: string): Promise { + await this.searchService.createIndexIfNotExists(ListingSearchIndexSpec); + await this.searchService.deleteDocument( + ListingSearchIndexSpec.name, + { id: listingId } as Record, + ); + } + + private async updateSearchIndexWithRetry( + searchDocument: Record, + listing: Record, + maxAttempts: number, + ): Promise { + await this.searchService.createIndexIfNotExists(ListingSearchIndexSpec); + + const currentHash = this.calculateHash(searchDocument); + const existingHash = listing.searchHash as string | undefined; + + if (currentHash === existingHash) { + return; + } + + let lastError: Error | undefined; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await this.searchService.indexDocument( + ListingSearchIndexSpec.name, + searchDocument, + ); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (attempt < maxAttempts) { + const delay = 2 ** attempt * 100; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + throw new Error( + `Failed to index document after ${maxAttempts} attempts: ${lastError?.message}`, + ); + } + + private calculateHash(doc: Record): string { + const sortedKeys = Object.keys(doc).sort((a, b) => a.localeCompare(b)); + const normalized = JSON.stringify(doc, sortedKeys); + return crypto.createHash('sha256').update(normalized).digest('hex'); + } +} diff --git a/packages/sthrift/domain/src/index.ts b/packages/sthrift/domain/src/index.ts index e2d77ffcf..25ff20128 100644 --- a/packages/sthrift/domain/src/index.ts +++ b/packages/sthrift/domain/src/index.ts @@ -1,6 +1,12 @@ export * from './domain/contexts/index.ts'; import type { Contexts } from './domain/index.ts'; export * as Domain from './domain/index.ts'; +export * from './domain/infrastructure/cognitive-search/index.ts'; +export * from './domain/events/types/index.ts'; +export { EventBusInstance } from './domain/events/index.ts'; +export type { ItemListingUnitOfWork } from './domain/contexts/listing/item/item-listing.uow.ts'; +export type { Services } from './domain/services/index.ts'; +export { ListingSearchIndexingService } from './domain/services/index.ts'; export interface DomainDataSource { User: { diff --git a/packages/sthrift/domain/tsconfig.json b/packages/sthrift/domain/tsconfig.json index 4591ca651..575bdb75f 100644 --- a/packages/sthrift/domain/tsconfig.json +++ b/packages/sthrift/domain/tsconfig.json @@ -2,12 +2,14 @@ "extends": "@cellix/typescript-config/node.json", "compilerOptions": { "outDir": "dist", - "rootDir": "." + "rootDir": ".", + "noPropertyAccessFromIndexSignature": false }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "src/**/*.test.ts", "tests/**/*.test.ts"], "references": [ { "path": "../../cellix/domain-seedwork" }, - { "path": "../../cellix/event-bus-seedwork-node" } + { "path": "../../cellix/event-bus-seedwork-node" }, + { "path": "../../cellix/search-service" } ] } diff --git a/packages/sthrift/event-handler/package.json b/packages/sthrift/event-handler/package.json index bf7a1b457..84ef30311 100644 --- a/packages/sthrift/event-handler/package.json +++ b/packages/sthrift/event-handler/package.json @@ -16,14 +16,19 @@ "prebuild": "biome lint", "build": "tsc --build", "watch": "tsc --watch", + "test": "vitest run", + "test:coverage": "vitest run --coverage", "lint": "biome lint", "clean": "rimraf dist" }, "dependencies": { + "@cellix/event-bus-seedwork-node": "workspace:*", + "@cellix/search-service": "workspace:*", "@sthrift/domain": "workspace:*" }, "devDependencies": { "@cellix/typescript-config": "workspace:*", + "@cellix/vitest-config": "workspace:*", "typescript": "^5.8.3", "rimraf": "^6.0.1" }, diff --git a/packages/sthrift/event-handler/src/handlers/bulk-index-existing-listings.test.ts b/packages/sthrift/event-handler/src/handlers/bulk-index-existing-listings.test.ts new file mode 100644 index 000000000..bad7c1d0a --- /dev/null +++ b/packages/sthrift/event-handler/src/handlers/bulk-index-existing-listings.test.ts @@ -0,0 +1,258 @@ +/** + * Tests for Bulk Index Existing Listings + * + * Tests the handler that indexes all existing listings into the search index. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Domain } from '@sthrift/domain'; +import type { SearchService } from '@cellix/search-service'; +import { bulkIndexExistingListings } from './bulk-index-existing-listings.js'; + +describe('bulkIndexExistingListings', () => { + let mockSearchService: SearchService; + let mockListings: Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]; + let mockUnitOfWork: Domain.Contexts.Listing.ItemListing.ItemListingUnitOfWork; + let mockListingData: Map; + + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + mockListings = [ + { + id: 'listing-1', + title: 'Test Listing 1', + description: 'Description 1', + category: 'electronics', + location: 'New York', + state: 'active', + sharer: { id: 'user-1', account: { profile: { firstName: 'Alice' } } }, + images: ['img1.jpg'], + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-02'), + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-06-30'), + }, + { + id: 'listing-2', + title: 'Test Listing 2', + description: 'Description 2', + category: 'sports', + location: 'Los Angeles', + state: 'pending', + sharer: { id: 'user-2', account: { profile: { firstName: 'Bob' } } }, + images: ['img2.jpg'], + createdAt: new Date('2024-02-01'), + updatedAt: new Date('2024-02-02'), + sharingPeriodStart: new Date('2024-02-01'), + sharingPeriodEnd: new Date('2024-08-31'), + }, + ] as unknown as Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]; + + mockListingData = new Map(mockListings.map(listing => [listing.id, listing])); + + mockSearchService = { + createIndexIfNotExists: vi.fn().mockResolvedValue(undefined), + indexDocument: vi.fn().mockResolvedValue(undefined), + } as unknown as SearchService; + + mockUnitOfWork = { + withScopedTransaction: vi.fn().mockImplementation((callback) => { + const mockRepo = { + getById: vi.fn().mockImplementation((id: string) => + Promise.resolve(mockListingData.get(id)) + ), + }; + return callback(mockRepo); + }), + } as unknown as Domain.Contexts.Listing.ItemListing.ItemListingUnitOfWork; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should log start message', async () => { + await bulkIndexExistingListings(mockListings, mockSearchService, mockUnitOfWork); + + expect(console.log).toHaveBeenCalledWith( + 'Starting bulk indexing of existing listings...', + ); + }); + + it('should create index through service', async () => { + await bulkIndexExistingListings(mockListings, mockSearchService, mockUnitOfWork); + + expect(mockSearchService.createIndexIfNotExists).toHaveBeenCalledWith( + expect.objectContaining({ name: 'listings' }), + ); + }); + + it('should index each listing', async () => { + await bulkIndexExistingListings(mockListings, mockSearchService, mockUnitOfWork); + + expect(mockSearchService.indexDocument).toHaveBeenCalledTimes(2); + expect(mockSearchService.indexDocument).toHaveBeenCalledWith( + 'listings', + expect.objectContaining({ id: 'listing-1', title: 'Test Listing 1' }), + ); + expect(mockSearchService.indexDocument).toHaveBeenCalledWith( + 'listings', + expect.objectContaining({ id: 'listing-2', title: 'Test Listing 2' }), + ); + }); + + it('should log success for each indexed listing', async () => { + await bulkIndexExistingListings(mockListings, mockSearchService, mockUnitOfWork); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Indexed listing: listing-1'), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Indexed listing: listing-2'), + ); + }); + + it('should log completion summary', async () => { + await bulkIndexExistingListings(mockListings, mockSearchService, mockUnitOfWork); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('2/2 listings indexed successfully'), + ); + }); + + it('should handle empty listings array', async () => { + await bulkIndexExistingListings([], mockSearchService, mockUnitOfWork); + + expect(console.log).toHaveBeenCalledWith('No listings found to index'); + expect(mockSearchService.createIndexIfNotExists).not.toHaveBeenCalled(); + expect(mockSearchService.indexDocument).not.toHaveBeenCalled(); + }); + + it('should continue indexing when one listing fails', async () => { + mockSearchService.indexDocument = vi + .fn() + .mockRejectedValue(new Error('Index failed')); + + await bulkIndexExistingListings(mockListings, mockSearchService, mockUnitOfWork); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to index listing listing-1'), + expect.any(String), + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to index listing listing-2'), + expect.any(String), + ); + }); + + it('should report partial success in summary', async () => { + let callCount = 0; + mockSearchService.indexDocument = vi.fn().mockImplementation(() => { + callCount++; + if (callCount <= 3) { + return Promise.reject(new Error('Index failed')); + } + return Promise.resolve(undefined); + }); + + await bulkIndexExistingListings(mockListings, mockSearchService, mockUnitOfWork); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Bulk indexing complete: 1/2 listings indexed successfully'), + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to index 1 listings'), + expect.any(Array), + ); + }); + + it('should handle listings with missing optional fields', async () => { + const listingsWithMissingFields = [ + { + id: 'listing-minimal', + title: 'Minimal Listing', + }, + ] as unknown as Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]; + + const minimalListing = listingsWithMissingFields[0]; + if (minimalListing) { + mockListingData.set('listing-minimal', minimalListing); + } + + await bulkIndexExistingListings( + listingsWithMissingFields, + mockSearchService, + mockUnitOfWork, + ); + + expect(mockSearchService.indexDocument).toHaveBeenCalledWith( + 'listings', + expect.objectContaining({ + id: 'listing-minimal', + title: 'Minimal Listing', + }), + ); + }); + + it('should handle index creation failure gracefully', async () => { + mockSearchService.createIndexIfNotExists = vi + .fn() + .mockRejectedValue(new Error('Index creation failed')); + + await bulkIndexExistingListings(mockListings, mockSearchService, mockUnitOfWork); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to index listing listing-1'), + expect.any(String), + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to index listing listing-2'), + expect.any(String), + ); + }); + + it('should index documents with correct data structure', async () => { + await bulkIndexExistingListings(mockListings, mockSearchService, mockUnitOfWork); + + expect(mockSearchService.indexDocument).toHaveBeenCalledWith( + 'listings', + expect.objectContaining({ + id: 'listing-1', + title: 'Test Listing 1', + }), + ); + expect(mockSearchService.indexDocument).toHaveBeenCalledWith( + 'listings', + expect.objectContaining({ + id: 'listing-2', + title: 'Test Listing 2', + }), + ); + }); + + it('should handle missing listings in repository', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const listingsWithMissingData = [ + { + id: 'listing-missing', + title: 'Missing Listing', + }, + ] as unknown as Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]; + + await bulkIndexExistingListings( + listingsWithMissingData, + mockSearchService, + mockUnitOfWork, + ); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('Listing listing-missing not found'), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('Bulk indexing complete: 1/1 listings indexed successfully'), + ); + }); +}); diff --git a/packages/sthrift/event-handler/src/handlers/bulk-index-existing-listings.ts b/packages/sthrift/event-handler/src/handlers/bulk-index-existing-listings.ts new file mode 100644 index 000000000..865fb5df2 --- /dev/null +++ b/packages/sthrift/event-handler/src/handlers/bulk-index-existing-listings.ts @@ -0,0 +1,54 @@ +import type { Domain } from '@sthrift/domain'; +import { ListingSearchIndexingService } from '@sthrift/domain'; +import type { SearchService } from '@cellix/search-service'; + +/** + * Bulk index all existing listings from the database into the search index + */ +export async function bulkIndexExistingListings( + listings: Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[], + searchService: SearchService, + itemListingUnitOfWork: Domain.Contexts.Listing.ItemListing.ItemListingUnitOfWork, +): Promise { + console.log('Starting bulk indexing of existing listings...'); + + try { + console.log(`Found ${listings.length} listings to index`); + + if (listings.length === 0) { + console.log('No listings found to index'); + return; + } + + const listingSearchIndexing = new ListingSearchIndexingService( + searchService, + itemListingUnitOfWork, + ); + + const errors: Array<{ id: string; error: string }> = []; + + for (const listing of listings) { + try { + await listingSearchIndexing.indexListing(listing.id); + console.log(`Indexed listing: ${listing.id} - ${listing.title}`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`Failed to index listing ${listing.id}:`, errorMessage); + errors.push({ id: listing.id, error: errorMessage }); + } + } + + const successCount = listings.length - errors.length; + console.log( + `Bulk indexing complete: ${successCount}/${listings.length} listings indexed successfully`, + ); + + if (errors.length > 0) { + console.error(`Failed to index ${errors.length} listings:`, errors); + } + } catch (error) { + console.error('Bulk indexing failed:', error); + throw error; + } +} diff --git a/packages/sthrift/event-handler/src/handlers/domain/index.ts b/packages/sthrift/event-handler/src/handlers/domain/index.ts deleted file mode 100644 index 134f339f0..000000000 --- a/packages/sthrift/event-handler/src/handlers/domain/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { DomainDataSource } from '@sthrift/domain'; - -export const RegisterDomainEventHandlers = ( - _domainDataSource: DomainDataSource -): void => { - /* Register domain event handlers */ -}; - diff --git a/packages/sthrift/event-handler/src/handlers/index.ts b/packages/sthrift/event-handler/src/handlers/index.ts index 08253a3c6..f8432e7ac 100644 --- a/packages/sthrift/event-handler/src/handlers/index.ts +++ b/packages/sthrift/event-handler/src/handlers/index.ts @@ -1,10 +1,37 @@ -import type { DomainDataSource } from "@sthrift/domain"; -import { RegisterDomainEventHandlers } from "./domain/index.ts"; -import { RegisterIntegrationEventHandlers } from "./integration/index.ts"; +/** + * Event Handlers + * + * Exports all event handlers for the ShareThrift application. + */ +import type { DomainDataSource } from '@sthrift/domain'; +import type { SearchService } from '@cellix/search-service'; +import { RegisterIntegrationEventHandlers } from './integration/index.js'; + +export * from './search-index-helpers.js'; +export * from './bulk-index-existing-listings.js'; + +/** + * Register all event handlers for the ShareThrift application + */ export const RegisterEventHandlers = ( - domainDataSource: DomainDataSource -) => { - RegisterDomainEventHandlers(domainDataSource); - RegisterIntegrationEventHandlers(domainDataSource); -} \ No newline at end of file + domainDataSource: DomainDataSource, + searchService?: SearchService, +): void => { + console.log('Registering ShareThrift event handlers...'); + + // Register search index event handlers if search service is available + if (searchService) { + console.log('Registering search index event handlers...'); + + RegisterIntegrationEventHandlers(domainDataSource, searchService); + + console.log('Search index event handlers registered successfully'); + } else { + console.log( + 'Search service not available, skipping search index event handlers', + ); + } + + console.log('ShareThrift event handlers registration complete'); +}; diff --git a/packages/sthrift/event-handler/src/handlers/integration/index.ts b/packages/sthrift/event-handler/src/handlers/integration/index.ts index 3044527de..f2bc643d3 100644 --- a/packages/sthrift/event-handler/src/handlers/integration/index.ts +++ b/packages/sthrift/event-handler/src/handlers/integration/index.ts @@ -1,7 +1,18 @@ import type { DomainDataSource } from '@sthrift/domain'; +import { ListingSearchIndexingService } from '@sthrift/domain'; +import type { SearchService } from '@cellix/search-service'; +import registerItemListingUpdatedUpdateSearchIndexHandler from './item-listing-updated--update-search-index.js'; +import registerItemListingDeletedUpdateSearchIndexHandler from './item-listing-deleted--update-search-index.js'; export const RegisterIntegrationEventHandlers = ( domainDataSource: DomainDataSource, + searchService: SearchService, ): void => { - console.log(domainDataSource); + const listingSearchIndexing = new ListingSearchIndexingService( + searchService, + domainDataSource.Listing.ItemListing.ItemListingUnitOfWork, + ); + + registerItemListingUpdatedUpdateSearchIndexHandler(listingSearchIndexing); + registerItemListingDeletedUpdateSearchIndexHandler(listingSearchIndexing); }; diff --git a/packages/sthrift/event-handler/src/handlers/integration/item-listing-deleted--update-search-index.ts b/packages/sthrift/event-handler/src/handlers/integration/item-listing-deleted--update-search-index.ts new file mode 100644 index 000000000..4d580f41b --- /dev/null +++ b/packages/sthrift/event-handler/src/handlers/integration/item-listing-deleted--update-search-index.ts @@ -0,0 +1,18 @@ +import { Domain, type ListingSearchIndexingService } from '@sthrift/domain'; + +const { EventBusInstance, ItemListingDeletedEvent } = Domain.Events; + +export default function registerItemListingDeletedUpdateSearchIndexHandler( + listingSearchIndexing: ListingSearchIndexingService, +) { + EventBusInstance.register( + ItemListingDeletedEvent, + async (payload: { id: string }) => { + try { + await listingSearchIndexing.deleteFromIndex(payload.id); + } catch (error) { + console.error(`Failed to remove from search index for ItemListing ${payload.id}:`, error); + } + }, + ); +} diff --git a/packages/sthrift/event-handler/src/handlers/integration/item-listing-updated--update-search-index.ts b/packages/sthrift/event-handler/src/handlers/integration/item-listing-updated--update-search-index.ts new file mode 100644 index 000000000..cd22d6b81 --- /dev/null +++ b/packages/sthrift/event-handler/src/handlers/integration/item-listing-updated--update-search-index.ts @@ -0,0 +1,18 @@ +import { Domain, type ListingSearchIndexingService } from '@sthrift/domain'; + +const { EventBusInstance, ItemListingUpdatedEvent } = Domain.Events; + +export default function registerItemListingUpdatedUpdateSearchIndexHandler( + listingSearchIndexing: ListingSearchIndexingService, +) { + EventBusInstance.register( + ItemListingUpdatedEvent, + async (payload: { id: string }) => { + try { + await listingSearchIndexing.indexListing(payload.id); + } catch (error) { + console.error(`Failed to update search index for ItemListing ${payload.id}:`, error); + } + }, + ); +} diff --git a/packages/sthrift/event-handler/src/handlers/search-index-helpers.test.ts b/packages/sthrift/event-handler/src/handlers/search-index-helpers.test.ts new file mode 100644 index 000000000..099c2f3c8 --- /dev/null +++ b/packages/sthrift/event-handler/src/handlers/search-index-helpers.test.ts @@ -0,0 +1,380 @@ +/** + * Tests for Search Index Helpers + * + * Tests the shared utilities for search index operations including + * hash generation and retry logic. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CognitiveSearchDomain, SearchIndex } from '@sthrift/domain'; +import { + generateSearchDocumentHash, + retrySearchIndexOperation, + updateSearchIndexWithRetry, + deleteFromSearchIndexWithRetry, +} from './search-index-helpers.js'; + +describe('Search Index Helpers', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('generateSearchDocumentHash', () => { + it('should generate a consistent hash for the same document', () => { + const document = { + id: 'listing-1', + title: 'Test Listing', + description: 'A test description', + }; + + const hash1 = generateSearchDocumentHash(document); + const hash2 = generateSearchDocumentHash(document); + + expect(hash1).toBe(hash2); + }); + + it('should generate different hashes for different documents', () => { + const document1 = { id: 'listing-1', title: 'Test Listing' }; + const document2 = { id: 'listing-2', title: 'Other Listing' }; + + const hash1 = generateSearchDocumentHash(document1); + const hash2 = generateSearchDocumentHash(document2); + + expect(hash1).not.toBe(hash2); + }); + + it('should exclude volatile fields from hash calculation', () => { + const baseDoc = { id: 'listing-1', title: 'Test Listing' }; + const docWithUpdatedAt = { + ...baseDoc, + updatedAt: new Date().toISOString(), + }; + const docWithLastIndexed = { ...baseDoc, lastIndexed: new Date() }; + const docWithHash = { ...baseDoc, hash: 'some-existing-hash' }; + + const baseHash = generateSearchDocumentHash(baseDoc); + const hashWithUpdatedAt = generateSearchDocumentHash(docWithUpdatedAt); + const hashWithLastIndexed = generateSearchDocumentHash(docWithLastIndexed); + const hashWithHash = generateSearchDocumentHash(docWithHash); + + // All should produce the same hash since volatile fields are excluded + expect(baseHash).toBe(hashWithUpdatedAt); + expect(baseHash).toBe(hashWithLastIndexed); + expect(baseHash).toBe(hashWithHash); + }); + + it('should handle nested objects', () => { + const document = { + id: 'listing-1', + metadata: { + category: 'electronics', + tags: ['camera', 'vintage'], + }, + }; + + const hash = generateSearchDocumentHash(document); + + expect(hash).toBeDefined(); + expect(typeof hash).toBe('string'); + }); + + it('should return base64 encoded hash', () => { + const document = { id: 'test' }; + const hash = generateSearchDocumentHash(document); + + // Base64 strings should only contain these characters + const base64Regex = /^[A-Za-z0-9+/=]+$/; + expect(hash).toMatch(base64Regex); + }); + }); + + describe('retrySearchIndexOperation', () => { + it('should return result on first successful attempt', async () => { + const operation = vi.fn().mockResolvedValue('success'); + + const result = await retrySearchIndexOperation(operation); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(1); + }); + + it('should retry on failure and succeed on subsequent attempt', async () => { + const operation = vi + .fn() + .mockRejectedValueOnce(new Error('First failure')) + .mockResolvedValue('success'); + + const result = await retrySearchIndexOperation(operation, 3, 10); + + expect(result).toBe('success'); + expect(operation).toHaveBeenCalledTimes(2); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('attempt 1/3'), + expect.any(Error), + ); + }); + + it('should throw after max attempts exceeded', async () => { + const operation = vi.fn().mockRejectedValue(new Error('Persistent failure')); + + await expect( + retrySearchIndexOperation(operation, 3, 10), + ).rejects.toThrow('Persistent failure'); + + expect(operation).toHaveBeenCalledTimes(3); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('failed after 3 attempts'), + expect.any(Error), + ); + }); + + it('should use exponential backoff between retries', async () => { + vi.useFakeTimers(); + const operation = vi + .fn() + .mockRejectedValueOnce(new Error('Failure 1')) + .mockRejectedValueOnce(new Error('Failure 2')) + .mockResolvedValue('success'); + + const promise = retrySearchIndexOperation(operation, 3, 100); + + // First attempt fails immediately + await vi.advanceTimersByTimeAsync(0); + expect(operation).toHaveBeenCalledTimes(1); + + // First retry after 100ms (baseDelay * 2^0) + await vi.advanceTimersByTimeAsync(100); + expect(operation).toHaveBeenCalledTimes(2); + + // Second retry after 200ms (baseDelay * 2^1) + await vi.advanceTimersByTimeAsync(200); + expect(operation).toHaveBeenCalledTimes(3); + + await promise; + vi.useRealTimers(); + }); + + it('should default to 3 max attempts', async () => { + const operation = vi.fn().mockRejectedValue(new Error('Always fails')); + + await expect(retrySearchIndexOperation(operation)).rejects.toThrow(); + + expect(operation).toHaveBeenCalledTimes(3); + }); + }); + + describe('updateSearchIndexWithRetry', () => { + let mockSearchService: CognitiveSearchDomain; + const indexDefinition: SearchIndex = { + name: 'test-index', + fields: [{ name: 'id', type: 'Edm.String' as const, key: true }], + }; + + beforeEach(() => { + mockSearchService = { + createIndexIfNotExists: vi.fn().mockResolvedValue(undefined), + indexDocument: vi.fn().mockResolvedValue(undefined), + } as unknown as CognitiveSearchDomain; + }); + + it('should skip update when hash has not changed', async () => { + const document = { id: 'listing-1', title: 'Test' }; + const existingHash = generateSearchDocumentHash(document); + const entity = { + hash: existingHash, + lastIndexed: new Date('2024-01-01'), + }; + + const result = await updateSearchIndexWithRetry( + mockSearchService, + indexDefinition, + document, + entity, + ); + + expect(result).toEqual(new Date('2024-01-01')); + expect(mockSearchService.createIndexIfNotExists).not.toHaveBeenCalled(); + expect(mockSearchService.indexDocument).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('no changes detected'), + ); + }); + + it('should update index when hash has changed', async () => { + const document = { id: 'listing-1', title: 'Updated Title' }; + const entity = { + hash: 'old-hash', + lastIndexed: new Date('2024-01-01'), + }; + + const result = await updateSearchIndexWithRetry( + mockSearchService, + indexDefinition, + document, + entity, + ); + + expect(mockSearchService.createIndexIfNotExists).toHaveBeenCalledWith( + indexDefinition, + ); + expect(mockSearchService.indexDocument).toHaveBeenCalledWith( + 'test-index', + document, + ); + expect(entity.hash).toBe(generateSearchDocumentHash(document)); + expect(entity.lastIndexed).toEqual(result); + }); + + it('should update index when entity has no previous hash', async () => { + const document = { id: 'listing-1', title: 'New Listing' }; + const entity: { hash?: string; lastIndexed?: Date } = {}; + + await updateSearchIndexWithRetry( + mockSearchService, + indexDefinition, + document, + entity, + ); + + expect(mockSearchService.indexDocument).toHaveBeenCalled(); + expect(entity.hash).toBeDefined(); + expect(entity.lastIndexed).toBeDefined(); + }); + + it('should return current date when entity has no lastIndexed and hash matches', async () => { + const document = { id: 'listing-1', title: 'Test' }; + const existingHash = generateSearchDocumentHash(document); + const entity = { hash: existingHash }; + + const result = await updateSearchIndexWithRetry( + mockSearchService, + indexDefinition, + document, + entity, + ); + + // Should return a new Date since lastIndexed was undefined + expect(result).toBeInstanceOf(Date); + }); + + it('should retry on failure', async () => { + const document = { id: 'listing-1', title: 'Test' }; + const entity = { hash: 'old-hash' }; + + mockSearchService.indexDocument = vi + .fn() + .mockRejectedValueOnce(new Error('Temporary failure')) + .mockResolvedValue(undefined); + + await updateSearchIndexWithRetry( + mockSearchService, + indexDefinition, + document, + entity, + 3, + ); + + expect(mockSearchService.indexDocument).toHaveBeenCalledTimes(2); + }); + + it('should throw after max retries exceeded', async () => { + const document = { id: 'listing-1', title: 'Test' }; + const entity = { hash: 'old-hash' }; + + mockSearchService.indexDocument = vi + .fn() + .mockRejectedValue(new Error('Persistent failure')); + + await expect( + updateSearchIndexWithRetry( + mockSearchService, + indexDefinition, + document, + entity, + 2, + ), + ).rejects.toThrow('Persistent failure'); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to update search index'), + expect.any(Error), + ); + }); + }); + + describe('deleteFromSearchIndexWithRetry', () => { + let mockSearchService: CognitiveSearchDomain; + + beforeEach(() => { + mockSearchService = { + deleteDocument: vi.fn().mockResolvedValue(undefined), + } as unknown as CognitiveSearchDomain; + }); + + it('should delete document from index', async () => { + await deleteFromSearchIndexWithRetry( + mockSearchService, + 'test-index', + 'doc-123', + ); + + expect(mockSearchService.deleteDocument).toHaveBeenCalledWith( + 'test-index', + { id: 'doc-123' }, + ); + }); + + it('should retry on failure', async () => { + mockSearchService.deleteDocument = vi + .fn() + .mockRejectedValueOnce(new Error('Temporary failure')) + .mockResolvedValue(undefined); + + await deleteFromSearchIndexWithRetry( + mockSearchService, + 'test-index', + 'doc-123', + 3, + ); + + expect(mockSearchService.deleteDocument).toHaveBeenCalledTimes(2); + }); + + it('should throw after max retries exceeded', async () => { + mockSearchService.deleteDocument = vi + .fn() + .mockRejectedValue(new Error('Delete failed')); + + await expect( + deleteFromSearchIndexWithRetry( + mockSearchService, + 'test-index', + 'doc-123', + 2, + ), + ).rejects.toThrow('Delete failed'); + }); + + it('should use default max attempts of 3', async () => { + mockSearchService.deleteDocument = vi + .fn() + .mockRejectedValue(new Error('Always fails')); + + await expect( + deleteFromSearchIndexWithRetry( + mockSearchService, + 'test-index', + 'doc-123', + ), + ).rejects.toThrow(); + + expect(mockSearchService.deleteDocument).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/packages/sthrift/event-handler/src/handlers/search-index-helpers.ts b/packages/sthrift/event-handler/src/handlers/search-index-helpers.ts new file mode 100644 index 000000000..d34c734ed --- /dev/null +++ b/packages/sthrift/event-handler/src/handlers/search-index-helpers.ts @@ -0,0 +1,129 @@ +/** + * Search Index Helpers + * + * Shared utilities for search index operations including hash generation + * and retry logic for reliable index updates. + */ + +import * as crypto from 'node:crypto'; +import type { CognitiveSearchDomain, SearchIndex } from '@sthrift/domain'; + +/** + * Generate a hash for change detection + * Excludes volatile fields from hash calculation to avoid unnecessary updates + */ +export function generateSearchDocumentHash( + document: Record, +): string { + const docCopy = JSON.parse(JSON.stringify(document)); + const volatileFields = ['updatedAt', 'lastIndexed', 'hash']; + for (const field of volatileFields) { + delete docCopy[field]; + } + + return crypto + .createHash('sha256') + .update(JSON.stringify(docCopy)) + .digest('base64'); +} + +/** + * Retry logic for search index operations + * Implements exponential backoff with a maximum number of attempts + */ +export async function retrySearchIndexOperation( + operation: () => Promise, + maxAttempts: number = 3, + baseDelayMs: number = 1000, +): Promise { + let lastError: Error; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error as Error; + + if (attempt === maxAttempts) { + console.error( + `Search index operation failed after ${maxAttempts} attempts:`, + lastError, + ); + throw lastError; + } + + const delayMs = baseDelayMs * 2 ** (attempt - 1); + console.warn( + `Search index operation failed (attempt ${attempt}/${maxAttempts}), retrying in ${delayMs}ms:`, + error, + ); + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + throw new Error('Operation failed after all retry attempts'); +} + +/** + * Update search index with retry logic and hash-based change detection + */ +export async function updateSearchIndexWithRetry< + T extends { hash?: string; lastIndexed?: Date }, +>( + searchService: CognitiveSearchDomain, + indexDefinition: SearchIndex, + document: Record, + entity: T, + maxAttempts = 3, +): Promise { + const newHash = generateSearchDocumentHash(document); + + // Skip update if content hasn't changed + if (entity.hash === newHash) { + console.log( + `Search index update skipped - no changes detected for entity ${entity}`, + ); + return entity.lastIndexed || new Date(); + } + + console.log( + `Search index update required - hash changed for entity ${entity}`, + ); + + try { + const indexedAt = await retrySearchIndexOperation(async () => { + await searchService.createIndexIfNotExists(indexDefinition); + await searchService.indexDocument(indexDefinition.name, document); + return new Date(); + }, maxAttempts); + + // Update entity metadata + entity.hash = newHash; + entity.lastIndexed = indexedAt; + + return indexedAt; + } catch (error) { + console.error( + `Failed to update search index after ${maxAttempts} attempts:`, + error, + ); + throw error; + } +} + +/** + * Delete document from search index with retry logic + */ +export async function deleteFromSearchIndexWithRetry( + searchService: CognitiveSearchDomain, + indexName: string, + documentId: string, + maxAttempts = 3, +): Promise { + await retrySearchIndexOperation(async () => { + await searchService.deleteDocument(indexName, { + id: documentId, + }); + }, maxAttempts); +} diff --git a/packages/sthrift/event-handler/tsconfig.json b/packages/sthrift/event-handler/tsconfig.json index 7b5d4c2a0..e0dc9b8ae 100644 --- a/packages/sthrift/event-handler/tsconfig.json +++ b/packages/sthrift/event-handler/tsconfig.json @@ -2,7 +2,8 @@ "extends": "@cellix/typescript-config/node.json", "compilerOptions": { "outDir": "dist", - "rootDir": "." + "rootDir": ".", + "noPropertyAccessFromIndexSignature": false }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"], diff --git a/packages/sthrift/event-handler/vitest.config.ts b/packages/sthrift/event-handler/vitest.config.ts new file mode 100644 index 000000000..5286b4e19 --- /dev/null +++ b/packages/sthrift/event-handler/vitest.config.ts @@ -0,0 +1,9 @@ +import { nodeConfig } from '@cellix/vitest-config'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig( + nodeConfig, + defineConfig({ + // Add package-specific overrides here if needed + }), +); diff --git a/packages/sthrift/graphql/src/init/context.ts b/packages/sthrift/graphql/src/init/context.ts index 32bfa0a0c..b29cd5fe1 100644 --- a/packages/sthrift/graphql/src/init/context.ts +++ b/packages/sthrift/graphql/src/init/context.ts @@ -2,5 +2,5 @@ import type { BaseContext } from '@apollo/server'; import type { ApplicationServices } from '@sthrift/application-services'; export interface GraphContext extends BaseContext { - applicationServices: ApplicationServices; -} \ No newline at end of file + applicationServices: ApplicationServices; +} diff --git a/packages/sthrift/graphql/src/schema/types/listing-search.graphql b/packages/sthrift/graphql/src/schema/types/listing-search.graphql new file mode 100644 index 000000000..4ec0145ac --- /dev/null +++ b/packages/sthrift/graphql/src/schema/types/listing-search.graphql @@ -0,0 +1,72 @@ +type ListingSearchResult { + items: [ListingSearchDocument!]! + count: Int! + facets: SearchFacets +} + +type ListingSearchDocument { + id: ID! + title: String! + description: String! + category: String! + location: String! + sharerName: String! + sharerId: ID! + state: String! + sharingPeriodStart: DateTime! + sharingPeriodEnd: DateTime! + createdAt: DateTime! + updatedAt: DateTime! + images: [String!]! +} + +type SearchFacets { + category: [SearchFacet!] + state: [SearchFacet!] + sharerId: [SearchFacet!] + createdAt: [SearchFacet!] +} + +type SearchFacet { + value: String! + count: Int! +} + +input ListingSearchInput { + searchString: String + options: SearchOptions +} + +input SearchOptions { + filter: ListingSearchFilter + top: Int + skip: Int + orderBy: [String!] +} + +input ListingSearchFilter { + category: [String!] + state: [String!] + sharerId: [ID!] + location: String + dateRange: DateRangeFilter +} + +input DateRangeFilter { + start: DateTime + end: DateTime +} + +extend type Query { + searchListings(input: ListingSearchInput!): ListingSearchResult! +} + +extend type Mutation { + bulkIndexListings: BulkIndexResult! +} + +type BulkIndexResult { + successCount: Int! + totalCount: Int! + message: String! +} diff --git a/packages/sthrift/graphql/src/schema/types/listing-search.resolvers.ts b/packages/sthrift/graphql/src/schema/types/listing-search.resolvers.ts new file mode 100644 index 000000000..1571bda70 --- /dev/null +++ b/packages/sthrift/graphql/src/schema/types/listing-search.resolvers.ts @@ -0,0 +1,17 @@ +import type { Resolvers } from '../builder/generated.ts'; + +const listingSearch: Resolvers = { + Query: { + searchListings: async (_parent, { input }, context, _info) => { + return await context.applicationServices.Listing.ListingSearch.searchListings(input); + }, + }, + + Mutation: { + bulkIndexListings: async (_parent, _args, context, _info) => { + return await context.applicationServices.Listing.ListingSearch.bulkIndexListings(); + }, + }, +}; + +export default listingSearch; diff --git a/packages/sthrift/graphql/src/schema/types/listing/features/item-listing.resolvers.feature b/packages/sthrift/graphql/src/schema/types/listing/features/item-listing.resolvers.feature index c826a2fe0..4e707f93a 100644 --- a/packages/sthrift/graphql/src/schema/types/listing/features/item-listing.resolvers.feature +++ b/packages/sthrift/graphql/src/schema/types/listing/features/item-listing.resolvers.feature @@ -41,26 +41,26 @@ So that I can view, filter, and create listings through the GraphQL API Given a user with a verifiedJwt in their context And valid pagination arguments (page, pageSize) When the myListingsAll query is executed - Then it should call Listing.ItemListing.queryPaged with sharerId, page, and pageSize + Then it should call Listing.ItemListing.queryPagedWithSearchFallback with sharerId, page, and pageSize And it should transform each listing into ListingAll shape - And it should map state values like "Active" to "Active" and "Draft" to "Draft" + And it should use domain state values directly without mapping And it should return items, total, page, and pageSize in the response Scenario: Querying myListingsAll with search and filters Given a verified user and valid pagination arguments And a searchText "camera" and statusFilters ["Active"] When the myListingsAll query is executed - Then it should call Listing.ItemListing.queryPaged with those filters + Then it should call Listing.ItemListing.queryPagedWithSearchFallback with those filters And it should return matching listings only Scenario: Querying myListingsAll without authentication Given a user without a verifiedJwt in their context When the myListingsAll query is executed - Then it should call Listing.ItemListing.queryPaged without sharerId + Then it should call Listing.ItemListing.queryPagedWithSearchFallback without sharerId And it should still return paged results Scenario: Error while querying myListingsAll - Given Listing.ItemListing.queryPaged throws an error + Given Listing.ItemListing.queryPagedWithSearchFallback throws an error When the myListingsAll query is executed Then it should propagate the error message @@ -88,7 +88,7 @@ So that I can view, filter, and create listings through the GraphQL API Then it should propagate the error message Scenario: Mapping item listing fields for myListingsAll - Given a valid result from queryPaged + Given a valid result from queryPagedWithSearchFallback When items are mapped Then each listing should include id, title, image, createdAt, reservationPeriod, status, and pendingRequestsCount And missing images should map image to null diff --git a/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.test.ts b/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.test.ts index 9fd796703..abaf7771c 100644 --- a/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.test.ts +++ b/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.test.ts @@ -7,43 +7,40 @@ import type { GraphContext } from '../../../init/context.ts'; import itemListingResolvers from './item-listing.resolvers.ts'; // Generic GraphQL resolver type for tests (avoids banned 'Function' and non-null assertions) -type TestResolver< - Args extends object = Record, - Return = unknown, -> = ( - parent: unknown, - args: Args, - context: GraphContext, - info: unknown, +type TestResolver, Return = unknown> = ( + parent: unknown, + args: Args, + context: GraphContext, + info: unknown, ) => Promise; // Shared input type for createItemListing across scenarios interface CreateItemListingInput { - title: string; - description: string; - category: string; - location: string; - sharingPeriodStart: string; - sharingPeriodEnd: string; - images?: string[]; - isDraft?: boolean; + title: string; + description: string; + category: string; + location: string; + sharingPeriodStart: string; + sharingPeriodEnd: string; + images?: string[]; + isDraft?: boolean; } const test = { for: describeFeature }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const feature = await loadFeature( - path.resolve(__dirname, 'features/item-listing.resolvers.feature'), + path.resolve(__dirname, 'features/item-listing.resolvers.feature'), ); // Types for test results type ItemListingEntity = - Domain.Contexts.Listing.ItemListing.ItemListingEntityReference; + Domain.Contexts.Listing.ItemListing.ItemListingEntityReference; type PersonalUserEntity = - Domain.Contexts.User.PersonalUser.PersonalUserEntityReference; + Domain.Contexts.User.PersonalUser.PersonalUserEntityReference; // Helper function to create mock listing function createMockListing( - overrides: Partial = {}, + overrides: Partial = {}, ): ItemListingEntity { const baseListing: ItemListingEntity = { id: 'listing-1', @@ -53,7 +50,7 @@ function createMockListing( location: 'Delhi', sharingPeriodStart: new Date('2025-10-06'), sharingPeriodEnd: new Date('2025-11-06'), - state: 'Active', + state: 'Published', sharer: { id: 'user-1', } as PersonalUserEntity, @@ -72,7 +69,7 @@ function createMockListing( // Helper function to create mock user function createMockUser( - overrides: Partial = {}, + overrides: Partial = {}, ): PersonalUserEntity { return { id: 'user-1', @@ -111,899 +108,833 @@ function createMockUser( } function makeMockGraphContext( - overrides: Partial = {}, + overrides: Partial = {}, ): GraphContext { - return { - applicationServices: { - Listing: { - ItemListing: { - queryAll: vi.fn(), - queryById: vi.fn(), - queryBySharer: vi.fn(), - queryPaged: vi.fn(), - create: vi.fn(), - update: vi.fn(), - }, - }, - User: { - PersonalUser: { - queryByEmail: vi.fn().mockResolvedValue(createMockUser()), - }, - }, - verifiedUser: { - verifiedJwt: { - sub: 'user-1', - email: 'test@example.com', - }, - }, - }, - ...overrides, - } as unknown as GraphContext; + return { + applicationServices: { + Listing: { + ItemListing: { + queryAll: vi.fn(), + queryById: vi.fn(), + queryBySharer: vi.fn(), + queryPaged: vi.fn(), + queryPagedWithSearchFallback: vi.fn(), + create: vi.fn(), + update: vi.fn(), + unblock: vi.fn(), + cancel: vi.fn(), + deleteListings: vi.fn(), + }, + ItemListingSearch: { + search: vi.fn(), + }, + }, + User: { + PersonalUser: { + queryByEmail: vi.fn().mockResolvedValue(createMockUser()), + }, + }, + verifiedUser: { + verifiedJwt: { + sub: 'user-1', + email: 'test@example.com', + }, + }, + }, + ...overrides, + } as unknown as GraphContext; } test.for(feature, ({ Scenario }) => { - let context: GraphContext; - let result: unknown; - let error: Error | undefined; - - Scenario( - 'Querying item listings for a verified user', - ({ Given, When, Then, And }) => { - Given('a user with a verifiedJwt in their context', () => { - context = makeMockGraphContext(); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryAll, - ).mockResolvedValue([createMockListing()]); - }); - When('the itemListings query is executed', async () => { - const resolver = itemListingResolvers.Query - ?.itemListings as TestResolver; - result = await resolver({}, {}, context, {} as never); - }); - Then('it should call Listing.ItemListing.queryAll', () => { - expect( - context.applicationServices.Listing.ItemListing.queryAll, - ).toHaveBeenCalledWith({}); - }); - And('it should return a list of item listings', () => { - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - expect((result as unknown[]).length).toBeGreaterThan(0); - }); - }, - ); - - Scenario( - 'Querying item listings without authentication', - ({ Given, When, Then, And }) => { - Given('a user without a verifiedJwt in their context', () => { - context = makeMockGraphContext({ - applicationServices: { - ...makeMockGraphContext().applicationServices, - verifiedUser: null, - }, - }); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryAll, - ).mockResolvedValue([createMockListing()]); - }); - When('the itemListings query is executed', async () => { - const resolver = itemListingResolvers.Query - ?.itemListings as TestResolver; - result = await resolver({}, {}, context, {} as never); - }); - Then('it should call Listing.ItemListing.queryAll', () => { - expect( - context.applicationServices.Listing.ItemListing.queryAll, - ).toHaveBeenCalledWith({}); - }); - And('it should return all available listings', () => { - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - }); - }, - ); - - Scenario('Error while querying item listings', ({ Given, When, Then }) => { - Given('Listing.ItemListing.queryAll throws an error', () => { - context = makeMockGraphContext(); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryAll, - ).mockRejectedValue(new Error('Query failed')); - }); - When('the itemListings query is executed', async () => { - try { - const resolver = itemListingResolvers.Query - ?.itemListings as TestResolver; - await resolver({}, {}, context, {} as never); - } catch (e) { - error = e as Error; - } - }); - Then('it should propagate the error message', () => { - expect(error).toBeDefined(); - expect(error?.message).toBe('Query failed'); - }); - }); - - Scenario( - 'Querying a single item listing by ID', - ({ Given, When, Then, And }) => { - Given('a valid listing ID', () => { - context = makeMockGraphContext(); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryById, - ).mockResolvedValue(createMockListing()); - }); - When('the itemListing query is executed with that ID', async () => { - const resolver = itemListingResolvers.Query - ?.itemListing as TestResolver<{ id: string }>; - result = await resolver({}, { id: 'listing-1' }, context, {} as never); - }); - Then( - 'it should call Listing.ItemListing.queryById with the provided ID', - () => { - expect( - context.applicationServices.Listing.ItemListing.queryById, - ).toHaveBeenCalledWith({ id: 'listing-1' }); - }, - ); - And('it should return the corresponding listing', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('id'); - expect((result as { id: string }).id).toBe('listing-1'); - }); - }, - ); - - Scenario( - 'Querying an item listing that does not exist', - ({ Given, When, Then }) => { - Given('a listing ID that does not match any record', () => { - context = makeMockGraphContext(); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryById, - ).mockResolvedValue(null); - }); - When('the itemListing query is executed', async () => { - const resolver = itemListingResolvers.Query - ?.itemListing as TestResolver<{ id: string }>; - result = await resolver( - {}, - { id: 'nonexistent-id' }, - context, - {} as never, - ); - }); - Then('it should return null', () => { - expect(result).toBeNull(); - }); - }, - ); - - Scenario( - 'Error while querying a single item listing', - ({ Given, When, Then }) => { - Given('Listing.ItemListing.queryById throws an error', () => { - context = makeMockGraphContext(); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryById, - ).mockRejectedValue(new Error('Query failed')); - }); - When('the itemListing query is executed', async () => { - try { - const resolver = itemListingResolvers.Query - ?.itemListing as TestResolver<{ id: string }>; - await resolver({}, { id: 'listing-1' }, context, {} as never); - } catch (e) { - error = e as Error; - } - }); - Then('it should propagate the error message', () => { - expect(error).toBeDefined(); - expect(error?.message).toBe('Query failed'); - }); - }, - ); - - Scenario( - 'Querying paginated listings for the current user', - ({ Given, And, When, Then }) => { - Given('a user with a verifiedJwt in their context', () => { - context = makeMockGraphContext(); - }); - And('valid pagination arguments (page, pageSize)', () => { - vi.mocked( - context.applicationServices.Listing.ItemListing.queryPaged, - ).mockResolvedValue({ - items: [createMockListing()], - total: 1, - page: 1, - pageSize: 10, - }); - }); - When('the myListingsAll query is executed', async () => { - const resolver = itemListingResolvers.Query - ?.myListingsAll as TestResolver<{ page: number; pageSize: number }>; - result = await resolver( - {}, - { page: 1, pageSize: 10 }, - context, - {} as never, - ); - }); - Then( - 'it should call Listing.ItemListing.queryPaged with sharerId, page, and pageSize', - () => { - expect( - context.applicationServices.Listing.ItemListing.queryPaged, - ).toHaveBeenCalledWith( - expect.objectContaining({ - page: 1, - pageSize: 10, - sharerId: 'user-1', - }), - ); - }, - ); - And('it should transform each listing into ListingAll shape', () => { - expect(result).toBeDefined(); - const resultData = result as { items: ItemListingEntity[] }; - resultData.items.forEach((listing) => { - expect(listing).toHaveProperty('id'); - expect(listing).toHaveProperty('title'); - }); - }); - And( - 'it should map state values like "Active" to "Active" and "Draft" to "Draft"', - () => { - expect(result).toBeDefined(); - const resultData = result as { items: ItemListingEntity[] }; - resultData.items.forEach((listing) => { - const status = listing.state; - expect(['Active', 'Draft', 'Unknown']).toContain(status); - }); - }, - ); - And( - 'it should return items, total, page, and pageSize in the response', - () => { - expect(result).toHaveProperty('items'); - expect(result).toHaveProperty('total'); - expect(result).toHaveProperty('page'); - expect(result).toHaveProperty('pageSize'); - }, - ); - }, - ); - - Scenario( - 'Querying myListingsAll with search and filters', - ({ Given, And, When, Then }) => { - Given('a verified user and valid pagination arguments', () => { - context = makeMockGraphContext(); - }); - And('a searchText "camera" and statusFilters ["Active"]', () => { - vi.mocked( - context.applicationServices.Listing.ItemListing.queryPaged, - ).mockResolvedValue({ - items: [createMockListing({ title: 'Camera Listing' })], - total: 1, - page: 1, - pageSize: 10, - }); - }); - When('the myListingsAll query is executed', async () => { - const resolver = itemListingResolvers.Query - ?.myListingsAll as TestResolver<{ - page: number; - pageSize: number; - searchText: string; - statusFilters: string[]; - }>; - result = await resolver( - {}, - { - page: 1, - pageSize: 10, - searchText: 'camera', - statusFilters: ['Active'], - }, - context, - {} as never, - ); - }); - Then( - 'it should call Listing.ItemListing.queryPaged with those filters', - () => { - expect( - context.applicationServices.Listing.ItemListing.queryPaged, - ).toHaveBeenCalledWith( - expect.objectContaining({ - page: 1, - pageSize: 10, - searchText: 'camera', - statusFilters: ['Active'], - sharerId: 'user-1', - }), - ); - }, - ); - And('it should return matching listings only', () => { - expect(result).toBeDefined(); - const resultData = result as { items: ItemListingEntity[] }; - expect(resultData.items.length).toBe(1); - expect(resultData.items[0]?.title).toContain('Camera'); - }); - }, - ); - - Scenario( - 'Querying myListingsAll without authentication', - ({ Given, When, Then, And }) => { - Given('a user without a verifiedJwt in their context', () => { - context = makeMockGraphContext({ - applicationServices: { - ...makeMockGraphContext().applicationServices, - verifiedUser: null, - }, - }); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryPaged, - ).mockResolvedValue({ - items: [createMockListing()], - total: 1, - page: 1, - pageSize: 10, - }); - }); - When('the myListingsAll query is executed', async () => { - const resolver = itemListingResolvers.Query - ?.myListingsAll as TestResolver<{ page: number; pageSize: number }>; - result = await resolver( - {}, - { page: 1, pageSize: 10 }, - context, - {} as never, - ); - }); - Then( - 'it should call Listing.ItemListing.queryPaged without sharerId', - () => { - expect( - context.applicationServices.Listing.ItemListing.queryPaged, - ).toHaveBeenCalledWith( - expect.not.objectContaining({ - sharerId: expect.anything(), - }), - ); - }, - ); - And('it should still return paged results', () => { - expect(result).toBeDefined(); - const resultData = result as { items: ItemListingEntity[] }; - expect(resultData.items.length).toBeGreaterThan(0); - }); - }, - ); - - Scenario('Error while querying myListingsAll', ({ Given, When, Then }) => { - Given('Listing.ItemListing.queryPaged throws an error', () => { - context = makeMockGraphContext(); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryPaged, - ).mockRejectedValue(new Error('Query failed')); - }); - When('the myListingsAll query is executed', async () => { - try { - const resolver = itemListingResolvers.Query - ?.myListingsAll as TestResolver<{ page: number; pageSize: number }>; - await resolver({}, { page: 1, pageSize: 10 }, context, {} as never); - } catch (e) { - error = e as Error; - } - }); - Then('it should propagate the error message', () => { - expect(error).toBeDefined(); - expect(error?.message).toBe('Query failed'); - }); - }); - - Scenario( - 'Creating an item listing successfully', - ({ Given, And, When, Then }) => { - let input: CreateItemListingInput; - Given('a user with a verifiedJwt containing email', () => { - context = makeMockGraphContext(); - }); - And( - 'a valid CreateItemListingInput with title, description, category, location, sharing period, and images', - () => { - input = { - title: 'New Listing', - description: 'New description', - category: 'Electronics', - location: 'Delhi', - sharingPeriodStart: '2025-10-06', - sharingPeriodEnd: '2025-11-06', - images: ['image1.jpg'], - isDraft: false, - }; - vi.mocked( - context.applicationServices.User.PersonalUser.queryByEmail, - ).mockResolvedValue(createMockUser()); - vi.mocked( - context.applicationServices.Listing.ItemListing.create, - ).mockResolvedValue(createMockListing({ title: 'New Listing' })); - }, - ); - When('the createItemListing mutation is executed', async () => { - const resolver = itemListingResolvers.Mutation - ?.createItemListing as TestResolver<{ - input: CreateItemListingInput; - }>; - result = await resolver({}, { input }, context, {} as never); - }); - Then( - "it should call User.PersonalUser.queryByEmail with the user's email", - () => { - expect( - context.applicationServices.User.PersonalUser.queryByEmail, - ).toHaveBeenCalledWith({ - email: 'test@example.com', - }); - }, - ); - And( - 'call Listing.ItemListing.create with the constructed command', - () => { - expect( - context.applicationServices.Listing.ItemListing.create, - ).toHaveBeenCalled(); - }, - ); - And('it should return the created listing', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('title'); - expect((result as { title: string }).title).toBe('New Listing'); - }); - }, - ); - - Scenario( - 'Creating an item listing without authentication', - ({ Given, When, Then }) => { - Given('a user without a verifiedJwt in their context', () => { - context = makeMockGraphContext({ - applicationServices: { - ...makeMockGraphContext().applicationServices, - verifiedUser: null, - }, - }); - }); - When('the createItemListing mutation is executed', async () => { - try { - const resolver = itemListingResolvers.Mutation - ?.createItemListing as TestResolver<{ - input: CreateItemListingInput; - }>; - await resolver( - {}, - { - input: { - title: 'Test', - description: 'Test', - category: 'Test', - location: 'Test', - sharingPeriodStart: '2025-10-06', - sharingPeriodEnd: '2025-11-06', - }, - }, - context, - {} as never, - ); - } catch (e) { - error = e as Error; - } - }); - Then('it should throw an "Authentication required" error', () => { - expect(error).toBeDefined(); - expect(error?.message).toBe('Authentication required'); - }); - }, - ); - - Scenario( - 'Creating an item listing for a non-existent user', - ({ Given, When, Then }) => { - Given( - 'a user with a verifiedJwt containing an email not found in the database', - () => { - context = makeMockGraphContext(); - vi.mocked( - context.applicationServices.User.PersonalUser.queryByEmail, - ).mockResolvedValue(null); - }, - ); - When('the createItemListing mutation is executed', async () => { - try { - const resolver = itemListingResolvers.Mutation - ?.createItemListing as TestResolver<{ - input: { - title: string; - description: string; - category: string; - location: string; - sharingPeriodStart: string; - sharingPeriodEnd: string; - }; - }>; - await resolver( - {}, - { - input: { - title: 'Test', - description: 'Test', - category: 'Test', - location: 'Test', - sharingPeriodStart: '2025-10-06', - sharingPeriodEnd: '2025-11-06', - }, - }, - context, - {} as never, - ); - } catch (e) { - error = e as Error; - } - }); - Then('it should throw a "User not found" error', () => { - expect(error).toBeDefined(); - expect(error?.message).toContain('User not found'); - }); - }, - ); - - Scenario('Error while creating an item listing', ({ Given, When, Then }) => { - let context: ReturnType; - let error: Error | undefined; - - Given('Listing.ItemListing.create throws an error', () => { - context = makeMockGraphContext(); - - vi.mocked( - context.applicationServices.User.PersonalUser.queryByEmail, - ).mockResolvedValue(createMockUser()); - - vi.mocked( - context.applicationServices.Listing.ItemListing.create, - ).mockRejectedValue(new Error('Creation failed')); - }); - When('the createItemListing mutation is executed', async () => { - try { - const resolver = itemListingResolvers.Mutation - ?.createItemListing as TestResolver<{ - input: CreateItemListingInput; - }>; - - await resolver( - {}, - { - input: { - title: 'Test', - description: 'Test', - category: 'Test', - location: 'Test', - sharingPeriodStart: '2025-10-06', - sharingPeriodEnd: '2025-11-06', - }, - }, - context, - {} as never, - ); - } catch (e) { - error = e as Error; - } - }); - Then('it should propagate the error message', () => { - expect(error).toBeDefined(); - expect(error?.message).toBe('Creation failed'); - }); - }); - - // Mapping scenario for myListingsAll items - Scenario( - 'Mapping item listing fields for myListingsAll', - ({ Given, When, Then, And }) => { - type MappedListing = { - id: string; - title: string; - image: string | null; - createdAt: string | null; - reservationPeriod: string; - status: string; - pendingRequestsCount: number; - }; - let mappedItems: MappedListing[] = []; - - Given('a valid result from queryPaged', () => { - context = makeMockGraphContext(); - const listingWithImage = createMockListing({ - id: 'listing-with-image', - images: ['pic1.jpg'], - state: 'Active', - createdAt: new Date('2025-02-01T10:00:00Z'), - sharingPeriodStart: new Date('2025-02-10T00:00:00Z'), - sharingPeriodEnd: new Date('2025-02-20T00:00:00Z'), - }); - const listingWithoutImageOrState = createMockListing({ - id: 'listing-no-image', - images: [], - state: '', - createdAt: new Date('2025-03-01T10:00:00Z'), - sharingPeriodStart: new Date('2025-03-05T00:00:00Z'), - sharingPeriodEnd: new Date('2025-03-15T00:00:00Z'), - }); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryPaged, - ).mockResolvedValue({ - items: [listingWithImage, listingWithoutImageOrState], - total: 2, - page: 1, - pageSize: 10, - }); - }); - When('items are mapped', async () => { - const resolver = itemListingResolvers.Query - ?.myListingsAll as TestResolver<{ page: number; pageSize: number }>; // minimal args - const pagedResult = await resolver( - {}, - { page: 1, pageSize: 10 }, - context, - {} as never, - ); - - const rawItems = (pagedResult as { items: ItemListingEntity[] }).items; - mappedItems = rawItems.map((l) => { - const start = l.sharingPeriodStart?.toISOString().slice(0, 10) ?? ''; - const end = l.sharingPeriodEnd?.toISOString().slice(0, 10) ?? ''; - const reservationPeriod = start && end ? `${start} - ${end}` : ''; - const status = l.state && l.state.trim() !== '' ? l.state : 'Unknown'; - return { - id: l.id, - title: l.title, - image: l.images?.[0] ?? null, - createdAt: l.createdAt?.toISOString() ?? null, - reservationPeriod, - status, - pendingRequestsCount: 0, // default placeholder until domain provides counts - }; - }); - }); - Then( - 'each listing should include id, title, image, createdAt, reservationPeriod, status, and pendingRequestsCount', - () => { - expect(mappedItems.length).toBe(2); - for (const item of mappedItems) { - expect(item).toHaveProperty('id'); - expect(item).toHaveProperty('title'); - expect(item).toHaveProperty('image'); - expect(item).toHaveProperty('createdAt'); - expect(item).toHaveProperty('reservationPeriod'); - expect(item).toHaveProperty('status'); - expect(item).toHaveProperty('pendingRequestsCount'); - } - }, - ); - And('missing images should map image to null', () => { - const noImage = mappedItems.find((i) => i.id === 'listing-no-image'); - expect(noImage).toBeDefined(); - expect(noImage?.image).toBeNull(); - }); - And('missing or blank states should map status to "Unknown"', () => { - const unknownStatus = mappedItems.find( - (i) => i.id === 'listing-no-image', - ); - expect(unknownStatus).toBeDefined(); - expect(unknownStatus?.status).toBe('Unknown'); - }); - }, - ); - - Scenario( - 'Querying adminListings with all filters', - ({ Given, And, When, Then }) => { - Given('an admin user with valid credentials', () => { - context = makeMockGraphContext(); - }); - And('pagination arguments with searchText, statusFilters, and sorter', () => { - vi.mocked( - context.applicationServices.Listing.ItemListing.queryPaged, - ).mockResolvedValue({ - items: [createMockListing()], - total: 1, - page: 1, - pageSize: 10, - }); - }); - When('the adminListings query is executed', async () => { - const resolver = itemListingResolvers.Query?.adminListings as TestResolver<{ - page: number; - pageSize: number; - searchText: string; - statusFilters: string[]; - sorter: { field: string; order: 'ascend' | 'descend' }; - }>; - result = await resolver( - {}, - { - page: 1, - pageSize: 10, - searchText: 'test', - statusFilters: ['Active'], - sorter: { field: 'title', order: 'ascend' }, - }, - context, - {} as never, - ); - }); - Then('it should call Listing.ItemListing.queryPaged with all provided parameters', () => { - expect( - context.applicationServices.Listing.ItemListing.queryPaged, - ).toHaveBeenCalledWith( - expect.objectContaining({ - page: 1, - pageSize: 10, - searchText: 'test', - statusFilters: ['Active'], - sorter: { field: 'title', order: 'ascend' }, - }), - ); - }); - And('it should return paginated results', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('items'); - expect(result).toHaveProperty('total'); - }); - }, - ); - - Scenario( - 'Querying adminListings without any filters', - ({ Given, When, Then, And }) => { - Given('an admin user with valid credentials', () => { - context = makeMockGraphContext(); - vi.mocked( - context.applicationServices.Listing.ItemListing.queryPaged, - ).mockResolvedValue({ - items: [createMockListing()], - total: 1, - page: 1, - pageSize: 20, - }); - }); - When('the adminListings query is executed with only page and pageSize', async () => { - const resolver = itemListingResolvers.Query?.adminListings as TestResolver<{ - page: number; - pageSize: number; - }>; - result = await resolver( - {}, - { page: 1, pageSize: 20 }, - context, - {} as never, - ); - }); - Then('it should call Listing.ItemListing.queryPaged with minimal parameters', () => { - expect( - context.applicationServices.Listing.ItemListing.queryPaged, - ).toHaveBeenCalledWith( - expect.objectContaining({ - page: 1, - pageSize: 20, - }), - ); - }); - And('it should return all listings', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('items'); - }); - }, - ); - - Scenario('Unblocking a listing successfully', ({ Given, When, Then, And }) => { - Given('a valid listing ID to unblock', () => { - context = makeMockGraphContext({ - applicationServices: { - ...makeMockGraphContext().applicationServices, - Listing: { - ItemListing: { - ...makeMockGraphContext().applicationServices.Listing.ItemListing, - unblock: vi.fn().mockResolvedValue(undefined), - }, - }, - }, - }); - }); - When('the unblockListing mutation is executed', async () => { - const resolver = itemListingResolvers.Mutation?.unblockListing as TestResolver<{ - id: string; - }>; - result = await resolver({}, { id: 'listing-1' }, context, {} as never); - }); - Then('it should call Listing.ItemListing.unblock with the ID', () => { - expect(context.applicationServices.Listing.ItemListing.unblock).toHaveBeenCalledWith({ - id: 'listing-1', - }); - }); - And('it should return true', () => { - expect(result).toBe(true); - }); - }); - - Scenario('Canceling an item listing successfully', ({ Given, When, Then, And }) => { - Given('a valid listing ID to cancel', () => { - context = makeMockGraphContext({ - applicationServices: { - ...makeMockGraphContext().applicationServices, - Listing: { - ItemListing: { - ...makeMockGraphContext().applicationServices.Listing.ItemListing, - cancel: vi.fn().mockResolvedValue(createMockListing()), - }, - }, - }, - }); - }); - When('the cancelItemListing mutation is executed', async () => { - const resolver = itemListingResolvers.Mutation?.cancelItemListing as TestResolver<{ - id: string; - }>; - result = await resolver({}, { id: 'listing-1' }, context, {} as never); - }); - Then('it should call Listing.ItemListing.cancel with the ID', () => { - expect(context.applicationServices.Listing.ItemListing.cancel).toHaveBeenCalledWith({ - id: 'listing-1', - }); - }); - And('it should return success status and the canceled listing', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('status'); - expect((result as { status: { success: boolean } }).status.success).toBe(true); - expect(result).toHaveProperty('listing'); - }); - }); - - Scenario('Deleting an item listing successfully', ({ Given, When, Then, And }) => { - Given('a valid listing ID and authenticated user email', () => { - context = makeMockGraphContext({ - applicationServices: { - ...makeMockGraphContext().applicationServices, - Listing: { - ItemListing: { - ...makeMockGraphContext().applicationServices.Listing.ItemListing, - deleteListings: vi.fn().mockResolvedValue(undefined), - }, - }, - }, - }); - }); - When('the deleteItemListing mutation is executed', async () => { - const resolver = itemListingResolvers.Mutation?.deleteItemListing as TestResolver<{ - id: string; - }>; - result = await resolver({}, { id: 'listing-1' }, context, {} as never); - }); - Then('it should call Listing.ItemListing.deleteListings with ID and email', () => { - expect(context.applicationServices.Listing.ItemListing.deleteListings).toHaveBeenCalledWith({ - id: 'listing-1', - userEmail: 'test@example.com', - }); - }); - And('it should return success status', () => { - expect(result).toBeDefined(); - expect(result).toHaveProperty('status'); - expect((result as { status: { success: boolean } }).status.success).toBe(true); - }); - }); + let context: GraphContext; + let result: unknown; + let error: Error | undefined; + + Scenario( + 'Querying item listings for a verified user', + ({ Given, When, Then, And }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryAll, + ).mockResolvedValue([createMockListing()]); + }); + When('the itemListings query is executed', async () => { + const resolver = itemListingResolvers.Query?.itemListings as TestResolver; + result = await resolver({}, {}, context, {} as never); + }); + Then( + "it should call Listing.ItemListing.queryAll", + () => { + expect( + context.applicationServices.Listing.ItemListing.queryAll, + ).toHaveBeenCalledWith({}); + }, + ); + And('it should return a list of item listings', () => { + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBeGreaterThan(0); + }); + }, + ); + + Scenario( + 'Querying item listings without authentication', + ({ Given, When, Then, And }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + verifiedUser: null, + }, + }); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryAll, + ).mockResolvedValue([createMockListing()]); + }); + When('the itemListings query is executed', async () => { + const resolver = itemListingResolvers.Query?.itemListings as TestResolver; + result = await resolver({}, {}, context, {} as never); + }); + Then('it should call Listing.ItemListing.queryAll', () => { + expect( + context.applicationServices.Listing.ItemListing.queryAll, + ).toHaveBeenCalledWith({}); + }); + And( + 'it should return all available listings', + () => { + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }, + ); + }, + ); + + Scenario('Error while querying item listings', ({ Given, When, Then }) => { + Given('Listing.ItemListing.queryAll throws an error', () => { + context = makeMockGraphContext(); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryAll, + ).mockRejectedValue(new Error('Query failed')); + }); + When('the itemListings query is executed', async () => { + try { + const resolver = itemListingResolvers.Query?.itemListings as TestResolver; + await resolver({}, {}, context, {} as never); + } catch (e) { + error = e as Error; + } + }); + Then('it should propagate the error message', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Query failed'); + }); + }); + + Scenario('Querying a single item listing by ID', ({ Given, When, Then, And }) => { + Given('a valid listing ID', () => { + context = makeMockGraphContext(); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryById, + ).mockResolvedValue(createMockListing()); + }); + When('the itemListing query is executed with that ID', async () => { + const resolver = itemListingResolvers.Query?.itemListing as TestResolver<{ id: string }>; + result = await resolver({}, { id: 'listing-1' }, context, {} as never); + }); + Then( + 'it should call Listing.ItemListing.queryById with the provided ID', + () => { + expect( + context.applicationServices.Listing.ItemListing.queryById, + ).toHaveBeenCalledWith({ id: 'listing-1' }); + }, + ); + And('it should return the corresponding listing', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('id'); + expect((result as { id: string }).id).toBe('listing-1'); + }); + }); + + Scenario( + 'Querying an item listing that does not exist', + ({ Given, When, Then }) => { + Given('a listing ID that does not match any record', () => { + context = makeMockGraphContext(); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryById, + ).mockResolvedValue(null); + }); + When('the itemListing query is executed', async () => { + const resolver = itemListingResolvers.Query?.itemListing as TestResolver<{ id: string }>; + result = await resolver({}, { id: 'nonexistent-id' }, context, {} as never); + }); + Then('it should return null', () => { + expect(result).toBeNull(); + }); + }, + ); + + Scenario( + 'Error while querying a single item listing', + ({ Given, When, Then }) => { + Given('Listing.ItemListing.queryById throws an error', () => { + context = makeMockGraphContext(); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryById, + ).mockRejectedValue(new Error('Query failed')); + }); + When('the itemListing query is executed', async () => { + try { + const resolver = itemListingResolvers.Query?.itemListing as TestResolver<{ id: string }>; + await resolver({}, { id: 'listing-1' }, context, {} as never); + } catch (e) { + error = e as Error; + } + }); + Then('it should propagate the error message', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Query failed'); + }); + }, + ); + + Scenario('Querying paginated listings for the current user', ({ Given, And, When, Then }) => { + Given('a user with a verifiedJwt in their context', () => { + context = makeMockGraphContext(); + }); + And('valid pagination arguments (page, pageSize)', () => { + vi.mocked( + context.applicationServices.Listing.ItemListing.queryPagedWithSearchFallback, + ).mockResolvedValue({ + items: [createMockListing()], + total: 1, + page: 1, + pageSize: 10, + }); + }); + When('the myListingsAll query is executed', async () => { + const resolver = itemListingResolvers.Query?.myListingsAll as TestResolver<{ page: number; pageSize: number }>; + result = await resolver({}, { page: 1, pageSize: 10 }, context, {} as never); + }); + Then('it should call Listing.ItemListing.queryPagedWithSearchFallback with sharerId, page, and pageSize', () => { + expect( + context.applicationServices.Listing.ItemListing.queryPagedWithSearchFallback, + ).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + pageSize: 10, + sharerId: 'user-1', + }), + ); + }); + And("it should transform each listing into ListingAll shape", () => { + expect(result).toBeDefined(); + const resultData = result as { items: ItemListingEntity[] }; + for (const listing of resultData.items) { + expect(listing).toHaveProperty('id'); + expect(listing).toHaveProperty('title'); + } + }); + And('it should use domain state values directly without mapping', () => { + expect(result).toBeDefined(); + const resultData = result as { items: { status: string }[] }; + for (const listing of resultData.items) { + const { status } = listing; + // Domain state values: 'Published', 'Drafted', 'Appeal Requested', 'Paused', 'Cancelled', 'Expired', 'Blocked', or 'Unknown' + expect(['Published', 'Drafted', 'Appeal Requested', 'Paused', 'Cancelled', 'Expired', 'Blocked', 'Unknown']).toContain(status); + } + }); + And('it should return items, total, page, and pageSize in the response', () => { + expect(result).toHaveProperty('items'); + expect(result).toHaveProperty('total'); + expect(result).toHaveProperty('page'); + expect(result).toHaveProperty('pageSize'); + }); + }); + + Scenario('Querying myListingsAll with search and filters', ({ Given, And, When, Then }) => { + Given('a verified user and valid pagination arguments', () => { + context = makeMockGraphContext(); + }); + And('a searchText "camera" and statusFilters ["Active"]', () => { + vi.mocked( + context.applicationServices.Listing.ItemListing.queryPagedWithSearchFallback, + ).mockResolvedValue({ + items: [createMockListing({ title: 'Camera Listing' })], + total: 1, + page: 1, + pageSize: 10, + }); + }); + When('the myListingsAll query is executed', async () => { + const resolver = itemListingResolvers.Query?.myListingsAll as TestResolver<{ + page: number; + pageSize: number; + searchText: string; + statusFilters: string[]; + }>; + result = await resolver( + {}, + { + page: 1, + pageSize: 10, + searchText: 'camera', + statusFilters: ['Active'], + }, + context, + {} as never + ); + }); + Then('it should call Listing.ItemListing.queryPagedWithSearchFallback with those filters', () => { + expect( + context.applicationServices.Listing.ItemListing.queryPagedWithSearchFallback, + ).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + pageSize: 10, + searchText: 'camera', + statusFilters: ['Active'], + sharerId: 'user-1', + }), + ); + }); + And('it should return matching listings only', () => { + expect(result).toBeDefined(); + const resultData = result as { items: ItemListingEntity[] }; + expect(resultData.items.length).toBe(1); + expect(resultData.items[0]?.title).toContain('Camera'); + }); + }); + + Scenario('Querying myListingsAll without authentication', ({ Given, When, Then, And }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + verifiedUser: null, + }, + }); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryPagedWithSearchFallback, + ).mockResolvedValue({ + items: [createMockListing()], + total: 1, + page: 1, + pageSize: 10, + }); + }); + When('the myListingsAll query is executed', async () => { + const resolver = itemListingResolvers.Query?.myListingsAll as TestResolver<{ page: number; pageSize: number }>; + result = await resolver({}, { page: 1, pageSize: 10 }, context, {} as never); + }); + Then('it should call Listing.ItemListing.queryPagedWithSearchFallback without sharerId', () => { + expect( + context.applicationServices.Listing.ItemListing.queryPagedWithSearchFallback, + ).toHaveBeenCalledWith( + expect.not.objectContaining({ + sharerId: expect.anything(), + }), + ); + }); + And('it should still return paged results', () => { + expect(result).toBeDefined(); + const resultData = result as { items: ItemListingEntity[] }; + expect(resultData.items.length).toBeGreaterThan(0); + }); + + }); + + Scenario('Error while querying myListingsAll', ({ Given, When, Then }) => { + Given('Listing.ItemListing.queryPagedWithSearchFallback throws an error', () => { + context = makeMockGraphContext(); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryPagedWithSearchFallback, + ).mockRejectedValue(new Error('Query failed')); + }); + When('the myListingsAll query is executed', async () => { + try { + const resolver = itemListingResolvers.Query?.myListingsAll as TestResolver<{ page: number; pageSize: number }>; + await resolver({}, { page: 1, pageSize: 10 }, context, {} as never); + } catch (e) { + error = e as Error; + } + }); + Then('it should propagate the error message', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Query failed'); + }); + }); + + Scenario('Creating an item listing successfully', ({ Given, And, When, Then }) => { + let input: CreateItemListingInput; + Given('a user with a verifiedJwt containing email', () => { + context = makeMockGraphContext(); + }); + And( + 'a valid CreateItemListingInput with title, description, category, location, sharing period, and images', + () => { + input = { + title: 'New Listing', + description: 'New description', + category: 'Electronics', + location: 'Delhi', + sharingPeriodStart: '2025-10-06', + sharingPeriodEnd: '2025-11-06', + images: ['image1.jpg'], + isDraft: false, + }; + vi.mocked( + context.applicationServices.User.PersonalUser.queryByEmail, + ).mockResolvedValue(createMockUser()); + vi.mocked( + context.applicationServices.Listing.ItemListing.create, + ).mockResolvedValue(createMockListing({ title: 'New Listing' })); + }, + ); + When('the createItemListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation?.createItemListing as TestResolver<{ input: CreateItemListingInput }>; + result = await resolver({}, { input }, context, {} as never); + }); + Then( + "it should call User.PersonalUser.queryByEmail with the user's email", + () => { + expect( + context.applicationServices.User.PersonalUser.queryByEmail, + ).toHaveBeenCalledWith({ + email: 'test@example.com', + }); + }, + ); + And('call Listing.ItemListing.create with the constructed command', () => { + expect( + context.applicationServices.Listing.ItemListing.create, + ).toHaveBeenCalled(); + }); + And('it should return the created listing', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('title'); + expect((result as { title: string }).title).toBe('New Listing'); + }); + }); + + Scenario( + 'Creating an item listing without authentication', + ({ Given, When, Then }) => { + Given('a user without a verifiedJwt in their context', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + verifiedUser: null, + }, + }); + }); + When('the createItemListing mutation is executed', async () => { + try { + const resolver = itemListingResolvers.Mutation?.createItemListing as TestResolver<{ input: CreateItemListingInput }>; + await resolver( + {}, + { + input: { + title: 'Test', + description: 'Test', + category: 'Test', + location: 'Test', + sharingPeriodStart: '2025-10-06', + sharingPeriodEnd: '2025-11-06', + }, + }, + context, + {} as never, + ); + } catch (e) { + error = e as Error; + } + }); + Then('it should throw an "Authentication required" error', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Authentication required'); + }); + }, + ); + + Scenario( + 'Creating an item listing for a non-existent user', + ({ Given, When, Then }) => { + Given( + 'a user with a verifiedJwt containing an email not found in the database', + () => { + context = makeMockGraphContext(); + vi.mocked( + context.applicationServices.User.PersonalUser.queryByEmail, + ).mockResolvedValue(null); + }, + ); + When('the createItemListing mutation is executed', async () => { + try { + const resolver = itemListingResolvers.Mutation?.createItemListing as TestResolver<{ input: { title: string; description: string; category: string; location: string; sharingPeriodStart: string; sharingPeriodEnd: string } }>; + await resolver( + {}, + { + input: { + title: 'Test', + description: 'Test', + category: 'Test', + location: 'Test', + sharingPeriodStart: '2025-10-06', + sharingPeriodEnd: '2025-11-06', + }, + }, + context, + {} as never, + ); + } catch (e) { + error = e as Error; + } + }); + Then('it should throw a "User not found" error', () => { + expect(error).toBeDefined(); + expect(error?.message).toContain('User not found'); + }); + }, + ); + + Scenario('Error while creating an item listing', ({ Given, When, Then }) => { + let context: ReturnType; + let error: Error | undefined; + + Given('Listing.ItemListing.create throws an error', () => { + context = makeMockGraphContext(); + + vi.mocked( + context.applicationServices.User.PersonalUser.queryByEmail, + ).mockResolvedValue(createMockUser()); + + vi.mocked( + context.applicationServices.Listing.ItemListing.create, + ).mockRejectedValue(new Error('Creation failed')); + }); + When('the createItemListing mutation is executed', async () => { + try { + const resolver = itemListingResolvers.Mutation + ?.createItemListing as TestResolver<{ input: CreateItemListingInput }>; + + await resolver( + {}, + { + input: { + title: 'Test', + description: 'Test', + category: 'Test', + location: 'Test', + sharingPeriodStart: '2025-10-06', + sharingPeriodEnd: '2025-11-06', + }, + }, + context, + {} as never, + ); + } catch (e) { + error = e as Error; + } + }); + Then('it should propagate the error message', () => { + expect(error).toBeDefined(); + expect(error?.message).toBe('Creation failed'); + }); + }); + + // Mapping scenario for myListingsAll items + Scenario('Mapping item listing fields for myListingsAll', ({ Given, When, Then, And }) => { + interface MappedListing { + id: string; + title: string; + image: string | null; + createdAt: string | null; + reservationPeriod: string; + status: string; + pendingRequestsCount: number; + } + + let mappedItems: MappedListing[] = []; + + Given('a valid result from queryPagedWithSearchFallback', () => { + context = makeMockGraphContext(); + const listingWithImage = createMockListing({ + id: 'listing-with-image', + images: ['pic1.jpg'], + state: 'Published', + createdAt: new Date('2025-02-01T10:00:00Z'), + sharingPeriodStart: new Date('2025-02-10T00:00:00Z'), + sharingPeriodEnd: new Date('2025-02-20T00:00:00Z'), + }); + const listingWithoutImageOrState = createMockListing({ + id: 'listing-no-image', + images: [], + state: '', + createdAt: new Date('2025-03-01T10:00:00Z'), + sharingPeriodStart: new Date('2025-03-05T00:00:00Z'), + sharingPeriodEnd: new Date('2025-03-15T00:00:00Z'), + }); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryPagedWithSearchFallback, + ).mockResolvedValue({ + items: [listingWithImage, listingWithoutImageOrState], + total: 2, + page: 1, + pageSize: 10, + }); + }); + When('items are mapped', async () => { + const resolver = itemListingResolvers.Query?.myListingsAll as TestResolver<{ page: number; pageSize: number }>; // minimal args + const pagedResult = await resolver( + {}, + { page: 1, pageSize: 10 }, + context, + {} as never, + ); + + const rawItems = (pagedResult as { items: ItemListingEntity[] }).items; + mappedItems = rawItems.map((l) => { + const start = l.sharingPeriodStart?.toISOString().slice(0, 10) ?? ''; + const end = l.sharingPeriodEnd?.toISOString().slice(0, 10) ?? ''; + const reservationPeriod = start && end ? `${start} - ${end}` : ''; + const status = l.state && l.state.trim() !== '' ? l.state : 'Unknown'; + return { + id: l.id, + title: l.title, + image: l.images?.[0] ?? null, + createdAt: l.createdAt?.toISOString() ?? null, + reservationPeriod, + status, + pendingRequestsCount: 0, // default placeholder until domain provides counts + }; + }); + }); + Then('each listing should include id, title, image, createdAt, reservationPeriod, status, and pendingRequestsCount', () => { + expect(mappedItems.length).toBe(2); + for (const item of mappedItems) { + expect(item).toHaveProperty('id'); + expect(item).toHaveProperty('title'); + expect(item).toHaveProperty('image'); + expect(item).toHaveProperty('createdAt'); + expect(item).toHaveProperty('reservationPeriod'); + expect(item).toHaveProperty('status'); + expect(item).toHaveProperty('pendingRequestsCount'); + } + }); + And('missing images should map image to null', () => { + const noImage = mappedItems.find((i) => i.id === 'listing-no-image'); + expect(noImage).toBeDefined(); + expect(noImage?.image).toBeNull(); + }); + And('missing or blank states should map status to "Unknown"', () => { + const unknownStatus = mappedItems.find((i) => i.id === 'listing-no-image'); + expect(unknownStatus).toBeDefined(); + expect(unknownStatus?.status).toBe('Unknown'); + }); + }); + + Scenario( + 'Querying adminListings with all filters', + ({ Given, And, When, Then }) => { + Given('an admin user with valid credentials', () => { + context = makeMockGraphContext(); + }); + And('pagination arguments with searchText, statusFilters, and sorter', () => { + vi.mocked( + context.applicationServices.Listing.ItemListing.queryPaged, + ).mockResolvedValue({ + items: [createMockListing()], + total: 1, + page: 1, + pageSize: 10, + }); + }); + When('the adminListings query is executed', async () => { + const resolver = itemListingResolvers.Query?.adminListings as TestResolver<{ + page: number; + pageSize: number; + searchText: string; + statusFilters: string[]; + sorter: { field: string; order: 'ascend' | 'descend' }; + }>; + result = await resolver( + {}, + { + page: 1, + pageSize: 10, + searchText: 'test', + statusFilters: ['Published'], + sorter: { field: 'title', order: 'ascend' }, + }, + context, + {} as never, + ); + }); + Then('it should call Listing.ItemListing.queryPaged with all provided parameters', () => { + expect( + context.applicationServices.Listing.ItemListing.queryPaged, + ).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + pageSize: 10, + searchText: 'test', + statusFilters: ['Published'], + sorter: { field: 'title', order: 'ascend' }, + }), + ); + }); + And('it should return paginated results', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('items'); + expect(result).toHaveProperty('total'); + }); + }, + ); + + Scenario( + 'Querying adminListings without any filters', + ({ Given, When, Then, And }) => { + Given('an admin user with valid credentials', () => { + context = makeMockGraphContext(); + vi.mocked( + context.applicationServices.Listing.ItemListing.queryPaged, + ).mockResolvedValue({ + items: [createMockListing()], + total: 1, + page: 1, + pageSize: 20, + }); + }); + When('the adminListings query is executed with only page and pageSize', async () => { + const resolver = itemListingResolvers.Query?.adminListings as TestResolver<{ + page: number; + pageSize: number; + }>; + result = await resolver( + {}, + { page: 1, pageSize: 20 }, + context, + {} as never, + ); + }); + Then('it should call Listing.ItemListing.queryPaged with minimal parameters', () => { + expect( + context.applicationServices.Listing.ItemListing.queryPaged, + ).toHaveBeenCalledWith( + expect.objectContaining({ + page: 1, + pageSize: 20, + }), + ); + }); + And('it should return all listings', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('items'); + }); + }, + ); + + Scenario('Unblocking a listing successfully', ({ Given, When, Then, And }) => { + Given('a valid listing ID to unblock', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + Listing: { + ...makeMockGraphContext().applicationServices.Listing, + ItemListing: { + ...makeMockGraphContext().applicationServices.Listing.ItemListing, + unblock: vi.fn().mockResolvedValue(undefined), + }, + }, + }, + }); + }); + When('the unblockListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation?.unblockListing as TestResolver<{ + id: string; + }>; + result = await resolver({}, { id: 'listing-1' }, context, {} as never); + }); + Then('it should call Listing.ItemListing.unblock with the ID', () => { + expect(context.applicationServices.Listing.ItemListing.unblock).toHaveBeenCalledWith({ + id: 'listing-1', + }); + }); + And('it should return true', () => { + expect(result).toBe(true); + }); + }); + + Scenario('Canceling an item listing successfully', ({ Given, When, Then, And }) => { + Given('a valid listing ID to cancel', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + Listing: { + ...makeMockGraphContext().applicationServices.Listing, + ItemListing: { + ...makeMockGraphContext().applicationServices.Listing.ItemListing, + cancel: vi.fn().mockResolvedValue(createMockListing()), + }, + }, + }, + }); + }); + When('the cancelItemListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation?.cancelItemListing as TestResolver<{ + id: string; + }>; + result = await resolver({}, { id: 'listing-1' }, context, {} as never); + }); + Then('it should call Listing.ItemListing.cancel with the ID', () => { + expect(context.applicationServices.Listing.ItemListing.cancel).toHaveBeenCalledWith({ + id: 'listing-1', + }); + }); + And('it should return success status and the canceled listing', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('status'); + expect((result as { status: { success: boolean } }).status.success).toBe(true); + expect(result).toHaveProperty('listing'); + }); + }); + + Scenario('Deleting an item listing successfully', ({ Given, When, Then, And }) => { + Given('a valid listing ID and authenticated user email', () => { + context = makeMockGraphContext({ + applicationServices: { + ...makeMockGraphContext().applicationServices, + Listing: { + ...makeMockGraphContext().applicationServices.Listing, + ItemListing: { + ...makeMockGraphContext().applicationServices.Listing.ItemListing, + deleteListings: vi.fn().mockResolvedValue(undefined), + }, + }, + }, + }); + }); + When('the deleteItemListing mutation is executed', async () => { + const resolver = itemListingResolvers.Mutation?.deleteItemListing as TestResolver<{ + id: string; + }>; + result = await resolver({}, { id: 'listing-1' }, context, {} as never); + }); + Then('it should call Listing.ItemListing.deleteListings with ID and email', () => { + expect(context.applicationServices.Listing.ItemListing.deleteListings).toHaveBeenCalledWith({ + id: 'listing-1', + userEmail: 'test@example.com', + }); + }); + And('it should return success status', () => { + expect(result).toBeDefined(); + expect(result).toHaveProperty('status'); + expect((result as { status: { success: boolean } }).status.success).toBe(true); + }); + }); }); diff --git a/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.ts b/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.ts index 88abcb564..e2cd6cda6 100644 --- a/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.ts +++ b/packages/sthrift/graphql/src/schema/types/listing/item-listing.resolvers.ts @@ -23,21 +23,21 @@ function buildPagedArgs( }, extra?: Partial, ): PagedArgs { - return { - page: args.page, - pageSize: args.pageSize, - ...(args.searchText == null ? {} : { searchText: args.searchText }), - ...(args.statusFilters ? { statusFilters: [...args.statusFilters] } : {}), - ...(args.sorter - ? { - sorter: { - field: args.sorter.field, - order: args.sorter.order as 'ascend' | 'descend', - }, - } - : {}), - ...extra, - }; + return { + page: args.page, + pageSize: args.pageSize, + ...(args.searchText == null ? {} : { searchText: args.searchText }), + ...(args.statusFilters ? { statusFilters: [...args.statusFilters] } : {}), + ...(args.sorter + ? { + sorter: { + field: args.sorter.field, + order: args.sorter.order as 'ascend' | 'descend', + }, + } + : {}), + ...extra, + }; } const itemListingResolvers: Resolvers = { @@ -45,31 +45,73 @@ const itemListingResolvers: Resolvers = { sharer: PopulateUserFromField('sharer'), }, Query: { + itemListings: async (_parent, _args, context) => { + return await context.applicationServices.Listing.ItemListing.queryAll({}); + }, + + itemListing: async (_parent, args, context) => { + return await context.applicationServices.Listing.ItemListing.queryById({ + id: args.id, + }); + }, + myListingsAll: async (_parent: unknown, args, context) => { const currentUser = context.applicationServices.verifiedUser; const email = currentUser?.verifiedJwt?.email; - let sharerId: string | undefined; + let sharerId: string | undefined = + context.applicationServices.verifiedUser?.verifiedJwt?.sub; if (email) { - sharerId = + const user = await context.applicationServices.User.PersonalUser.queryByEmail({ - email: email, - }).then((user) => (user ? user.id : undefined)); + email, + }); + sharerId = user ? user.id : sharerId; } - const pagedArgs = buildPagedArgs(args, sharerId ? { sharerId } : {}); + const { page, pageSize, searchText, statusFilters, sorter } = args; - return await context.applicationServices.Listing.ItemListing.queryPaged( - pagedArgs, - ); - }, - itemListings: async (_parent, _args, context) => { - return await context.applicationServices.Listing.ItemListing.queryAll({}); - }, + // Use the service method that handles search-vs-database flow + const result = + await context.applicationServices.Listing.ItemListing.queryPagedWithSearchFallback( + { + page, + pageSize, + ...(searchText ? { searchText } : {}), + ...(statusFilters ? { statusFilters: [...statusFilters] } : {}), + ...(sorter + ? { + sorter: { + field: sorter.field, + order: sorter.order as 'ascend' | 'descend', + }, + } + : {}), + ...(sharerId ? { sharerId } : {}), + }, + ); - itemListing: async (_parent, args, context) => { - return await context.applicationServices.Listing.ItemListing.queryById({ - id: args.id, + // Convert domain entities to GraphQL format + const items = result.items.map((item) => { + const sharingStart = new Date(item.sharingPeriodStart).toISOString(); + const sharingEnd = new Date(item.sharingPeriodEnd).toISOString(); + + return { + id: item.id, + title: item.title, + image: item.images && item.images.length > 0 ? item.images[0] : null, + publishedAt: item.createdAt, + reservationPeriod: `${sharingStart.slice(0, 10)} - ${sharingEnd.slice(0, 10)}`, + status: item.state || 'Unknown', + pendingRequestsCount: 0, // TODO: integrate reservation request counts + }; }); + + return { + items, + total: result.total, + page: result.page, + pageSize: result.pageSize, + }; }, adminListings: async (_parent, args, context) => { // Admin-note: role-based authorization should be implemented here (security) @@ -125,24 +167,33 @@ const itemListingResolvers: Resolvers = { _parent: unknown, args: { id: string }, context, - ) => ({ - status: { success: true }, - listing: await context.applicationServices.Listing.ItemListing.cancel({ + ) => { + const listing = await context.applicationServices.Listing.ItemListing.cancel({ id: args.id, - }), - }), + }); + return { + status: { success: true }, + listing, + }; + }, deleteItemListing: async ( _parent: unknown, args: { id: string }, context: GraphContext, ) => { + const listing = await context.applicationServices.Listing.ItemListing.queryById({ + id: args.id, + }); await context.applicationServices.Listing.ItemListing.deleteListings({ id: args.id, userEmail: context.applicationServices.verifiedUser?.verifiedJwt?.email ?? '', }); - return { status: { success: true } }; + return { + status: { success: true }, + listing, + }; }, }, }; diff --git a/packages/sthrift/persistence/src/datasources/messaging/conversation/conversation/messaging-conversation.domain-adapter.ts b/packages/sthrift/persistence/src/datasources/messaging/conversation/conversation/messaging-conversation.domain-adapter.ts index bf58e1aff..d9ff40399 100644 --- a/packages/sthrift/persistence/src/datasources/messaging/conversation/conversation/messaging-conversation.domain-adapter.ts +++ b/packages/sthrift/persistence/src/datasources/messaging/conversation/conversation/messaging-conversation.domain-adapter.ts @@ -1,27 +1,27 @@ import { Domain } from '@sthrift/domain'; -import type { - MessageInstance, -} from '@cellix/messaging-service'; +import type { MessageInstance } from '@cellix/messaging-service'; export function toDomainMessage( - messagingMessage: MessageInstance, - authorId: Domain.Contexts.Conversation.Conversation.AuthorId, - ): Domain.Contexts.Conversation.Conversation.MessageEntityReference { - // biome-ignore lint/complexity/useLiteralKeys: metadata is an index signature requiring bracket notation - const messagingId = (messagingMessage.metadata?.["originalSid"] as string) || messagingMessage.id; - - const messagingMessageId = new Domain.Contexts.Conversation.Conversation.MessagingMessageId( - messagingId, - ); - const content = new Domain.Contexts.Conversation.Conversation.MessageContent( - messagingMessage.body, - ); +messagingMessage: MessageInstance, +authorId: Domain.Contexts.Conversation.Conversation.AuthorId, +): Domain.Contexts.Conversation.Conversation.MessageEntityReference { + const messagingId = + (messagingMessage.metadata?.['originalSid'] as string) || + messagingMessage.id; - return new Domain.Contexts.Conversation.Conversation.Message({ - id: messagingMessage.id, - messagingMessageId, - authorId, - content, - createdAt: messagingMessage.createdAt ?? new Date(), - }); - } + const messagingMessageId = + new Domain.Contexts.Conversation.Conversation.MessagingMessageId( +messagingId, +); + const content = new Domain.Contexts.Conversation.Conversation.MessageContent( +messagingMessage.body, +); + + return new Domain.Contexts.Conversation.Conversation.Message({ +id: messagingMessage.id, +messagingMessageId, +authorId, +content, +createdAt: messagingMessage.createdAt ?? new Date(), + }); +} diff --git a/packages/cellix/mock-cognitive-search/.gitignore b/packages/sthrift/search-service-index/.gitignore similarity index 100% rename from packages/cellix/mock-cognitive-search/.gitignore rename to packages/sthrift/search-service-index/.gitignore diff --git a/packages/sthrift/search-service-index/package.json b/packages/sthrift/search-service-index/package.json new file mode 100644 index 000000000..0c285c56f --- /dev/null +++ b/packages/sthrift/search-service-index/package.json @@ -0,0 +1,51 @@ +{ + "name": "@sthrift/search-service-index", + "version": "1.0.0", + "type": "module", + "description": "Application-specific search service for ShareThrift with index definitions", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "biome check src/", + "format": "biome format --write src/" + }, + "keywords": [ + "search", + "search-index", + "sharethrift" + ], + "author": "ShareThrift Team", + "license": "MIT", + "dependencies": { + "@cellix/search-service": "workspace:*", + "@sthrift/domain": "workspace:*", + "@sthrift/search-service-mock": "workspace:*" + }, + "devDependencies": { + "@cellix/typescript-config": "workspace:*", + "@vitest/coverage-v8": "^3.2.4", + "typescript": "^5.3.0", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "files": [ + "dist/**/*", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", + "require": "./dist/src/index.js" + } + }, + "publishConfig": { + "access": "restricted" + } +} diff --git a/packages/sthrift/search-service-index/src/features/service-search-index.feature b/packages/sthrift/search-service-index/src/features/service-search-index.feature new file mode 100644 index 000000000..8a5beaac6 --- /dev/null +++ b/packages/sthrift/search-service-index/src/features/service-search-index.feature @@ -0,0 +1,129 @@ +Feature: ShareThrift Search Service Index (Facade) + + Background: + Given a ServiceSearchIndex instance + + Scenario: Initializing search service successfully + When the search service starts up + Then it should initialize with mock implementation + And domain indexes should be created + + Scenario: Shutting down search service + Given an initialized search service + When the search service shuts down + Then cleanup should complete successfully + + Scenario: Initializing with custom configuration + Given custom persistence configuration + When the search service starts up with config + Then it should initialize with the provided settings + + Scenario: Creating item-listings index on startup + When the search service starts up + Then the listings index should be created automatically + And searching listings should not throw errors + + Scenario: Creating a new index if not exists + Given a custom index definition + When I create the index + Then the index should be available for use + + Scenario: Updating an existing index definition + Given an existing listings index + When I update the index definition with new fields + Then the index should be updated successfully + + Scenario: Deleting an index + Given a temporary index + When I delete the index + Then the index should no longer exist + + Scenario: Indexing a listing document + Given a listing document + When I index the listing + Then the document should be added to the search index + + Scenario: Indexing a document to any index + Given a listing document + When I index the document directly to listings index + Then the document should be added successfully + + Scenario: Deleting a listing document + Given an indexed listing + When I delete the listing + Then the document should be removed from search index + + Scenario: Deleting a document from any index + Given an indexed listing + When I delete the document directly from listings index + Then the document should be removed successfully + + Scenario: Searching listings by text + Given indexed listings including "Vintage Camera" + When I search listings for "camera" + Then matching listings should be returned + + Scenario: Searching using generic search method + Given indexed listings + When I search the listings index for "bike" + Then matching results should be returned + + Scenario: Wildcard search returns all documents + Given 3 indexed listings + When I search listings for "*" + Then all 3 listings should be returned + + Scenario: Filtering by category + Given listings in electronics and sports categories + When I search with filter "category eq 'electronics'" + Then only electronics listings should be returned + + Scenario: Filtering by state + Given active and inactive listings + When I search with filter "state eq 'active'" + Then only active listings should be returned + And inactive listings should not be included + + Scenario: Filtering by location + Given listings in multiple locations + When I search with filter "location eq 'Denver'" + Then only Denver listings should be returned + + Scenario: Paginating search results + Given 3 indexed listings + When I search with top 2 and skip 0 + Then I should receive 2 listings + And total count should be 3 + When I search with top 2 and skip 2 + Then I should receive 1 listing + And total count should be 3 + + Scenario: Ordering search results + Given 3 indexed listings + When I search with orderBy "title asc" + Then results should be sorted alphabetically by title + + Scenario: Selecting specific fields + Given indexed listings + When I search with select ["id", "title", "category"] + Then returned documents should contain selected fields + + Scenario: Retrieving facets + Given indexed listings with various categories and states + When I search with facets ["category", "state"] + Then facet results should be included in response + And category or state facets should be defined + + Scenario: Combining text search with filters + Given listings with "bike" in title or description + And some bikes are active, some inactive + When I search for "bike" with filter "state eq 'active'" + Then only active bike listings should be returned + + Scenario: Handling search on non-existent index + When I search a non-existent index + Then it should either return empty results or throw error + + Scenario: Handling invalid filter syntax + When I search with invalid filter "invalid filter syntax!!!" + Then the search should handle it gracefully diff --git a/packages/sthrift/search-service-index/src/index.ts b/packages/sthrift/search-service-index/src/index.ts new file mode 100644 index 000000000..f0a80325e --- /dev/null +++ b/packages/sthrift/search-service-index/src/index.ts @@ -0,0 +1,12 @@ +/** + * ShareThrift Search Service Index + * + * Application-specific search service with index definitions for ShareThrift. + * Implements the Facade pattern with support for mock search implementation. + */ + +export * from './service-search-index.ts'; +export { ServiceSearchIndex as default } from './service-search-index.ts'; + +// Re-export domain index specs for convenience +export { ListingSearchIndexSpec } from '@sthrift/domain'; diff --git a/packages/sthrift/search-service-index/src/service-search-index.test.ts b/packages/sthrift/search-service-index/src/service-search-index.test.ts new file mode 100644 index 000000000..9be53c368 --- /dev/null +++ b/packages/sthrift/search-service-index/src/service-search-index.test.ts @@ -0,0 +1,363 @@ +/** + * Tests for ServiceSearchIndex (Facade) + * + * These tests verify the search service facade implementation + * which wraps the underlying mock search service. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ServiceSearchIndex } from './service-search-index'; +import { ListingSearchIndexSpec } from '@sthrift/domain'; + +describe('ServiceSearchIndex', () => { + let searchService: ServiceSearchIndex; + + beforeEach(async () => { + // Suppress console.log during tests + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + searchService = new ServiceSearchIndex(); + await searchService.startUp(); + }); + + afterEach(async () => { + await searchService.shutDown(); + vi.restoreAllMocks(); + }); + + describe('Lifecycle', () => { + it('should initialize successfully', async () => { + const service = new ServiceSearchIndex(); + await expect(service.startUp()).resolves.toBe(service); + }); + + it('should shutdown successfully', async () => { + const service = new ServiceSearchIndex(); + await service.startUp(); + await expect(service.shutDown()).resolves.toBeUndefined(); + }); + + it('should initialize with custom config', async () => { + const service = new ServiceSearchIndex({ + enablePersistence: true, + persistencePath: '/tmp/test-search', + }); + await expect(service.startUp()).resolves.toBe(service); + await service.shutDown(); + }); + }); + + describe('Index Management', () => { + it('should create item-listings index on startup', async () => { + // The index should be created during startUp() + // Verify by trying to search (should not throw) + const results = await searchService.searchListings('*'); + expect(results).toBeDefined(); + expect(results.results).toBeDefined(); + }); + + it('should create a new index if not exists', async () => { + const customIndex = { + name: 'custom-test-index', + fields: [ + { name: 'id', type: 'Edm.String' as const, key: true }, + { name: 'name', type: 'Edm.String' as const, searchable: true }, + ], + }; + + await expect( + searchService.createIndexIfNotExists(customIndex), + ).resolves.toBeUndefined(); + }); + + it('should update an existing index definition', async () => { + const updatedIndex = { + ...ListingSearchIndexSpec, + fields: [ + ...ListingSearchIndexSpec.fields, + { + name: 'newField', + type: 'Edm.String' as const, + searchable: true, + }, + ], + }; + + await expect( + searchService.createOrUpdateIndexDefinition( + ListingSearchIndexSpec.name, + updatedIndex, + ), + ).resolves.toBeUndefined(); + }); + + it('should delete an index', async () => { + const tempIndex = { + name: 'temp-index', + fields: [{ name: 'id', type: 'Edm.String' as const, key: true }], + }; + + await searchService.createIndexIfNotExists(tempIndex); + await expect( + searchService.deleteIndex('temp-index'), + ).resolves.toBeUndefined(); + }); + }); + + describe('Document Operations', () => { + const testListing = { + id: 'listing-1', + title: 'Vintage Camera for Photography Enthusiasts', + description: + 'A beautiful vintage camera perfect for collectors and photography lovers', + category: 'electronics', + location: 'New York', + state: 'active', + sharerId: 'user-123', + sharerName: 'John Doe', + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-12-31'), + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + images: ['image1.jpg', 'image2.jpg'], + }; + + it('should index a listing document', async () => { + await expect( + searchService.indexListing(testListing), + ).resolves.toBeUndefined(); + }); + + it('should index a document to any index', async () => { + await expect( + searchService.indexDocument( + ListingSearchIndexSpec.name, + testListing, + ), + ).resolves.toBeUndefined(); + }); + + it('should delete a listing document', async () => { + await searchService.indexListing(testListing); + await expect( + searchService.deleteListing({ id: testListing.id }), + ).resolves.toBeUndefined(); + }); + + it('should delete a document from any index', async () => { + await searchService.indexListing(testListing); + await expect( + searchService.deleteDocument(ListingSearchIndexSpec.name, { + id: testListing.id, + }), + ).resolves.toBeUndefined(); + }); + }); + + describe('Search Operations', () => { + const listings = [ + { + id: 'listing-1', + title: 'Vintage Camera', + description: 'A beautiful vintage camera for photography enthusiasts', + category: 'electronics', + location: 'New York', + state: 'active', + sharerId: 'user-1', + sharerName: 'Alice', + sharingPeriodStart: new Date('2024-01-01'), + sharingPeriodEnd: new Date('2024-06-30'), + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + images: ['camera.jpg'], + }, + { + id: 'listing-2', + title: 'Mountain Bike', + description: 'Professional mountain bike for trail riding', + category: 'sports', + location: 'Denver', + state: 'active', + sharerId: 'user-2', + sharerName: 'Bob', + sharingPeriodStart: new Date('2024-02-01'), + sharingPeriodEnd: new Date('2024-08-31'), + createdAt: new Date('2024-02-01'), + updatedAt: new Date('2024-02-01'), + images: ['bike.jpg'], + }, + { + id: 'listing-3', + title: 'Camping Tent', + description: 'Spacious 4-person camping tent', + category: 'outdoors', + location: 'Seattle', + state: 'inactive', + sharerId: 'user-3', + sharerName: 'Charlie', + sharingPeriodStart: new Date('2024-03-01'), + sharingPeriodEnd: new Date('2024-09-30'), + createdAt: new Date('2024-03-01'), + updatedAt: new Date('2024-03-01'), + images: ['tent.jpg'], + }, + ]; + + beforeEach(async () => { + // Index all test listings + for (const listing of listings) { + await searchService.indexListing(listing); + } + }); + + it('should search listings by text', async () => { + const results = await searchService.searchListings('camera'); + + expect(results.results.length).toBeGreaterThan(0); + expect( + results.results.some( + (r) => + r.document.title === 'Vintage Camera' || + (r.document.title as string).toLowerCase().includes('camera'), + ), + ).toBe(true); + }); + + it('should search using generic search method', async () => { + const results = await searchService.search( + ListingSearchIndexSpec.name, + 'bike', + ); + + expect(results.results.length).toBeGreaterThan(0); + }); + + it('should return all documents with wildcard search', async () => { + const results = await searchService.searchListings('*'); + + expect(results.results.length).toBe(3); + }); + + it('should filter by category', async () => { + const results = await searchService.searchListings('*', { + filter: "category eq 'electronics'", + }); + + expect(results.results.length).toBe(1); + expect(results.results[0].document.category).toBe('electronics'); + }); + + it('should filter by state', async () => { + const results = await searchService.searchListings('*', { + filter: "state eq 'active'", + }); + + expect(results.results.length).toBe(2); + for (const result of results.results) { + expect(result.document.state).toBe('active'); + } + }); + + it('should filter by location', async () => { + const results = await searchService.searchListings('*', { + filter: "location eq 'Denver'", + }); + + expect(results.results.length).toBe(1); + expect(results.results[0].document.location).toBe('Denver'); + }); + + it('should support pagination with top and skip', async () => { + const page1 = await searchService.searchListings('*', { + top: 2, + skip: 0, + includeTotalCount: true, + }); + + expect(page1.results.length).toBe(2); + expect(page1.count).toBe(3); + + const page2 = await searchService.searchListings('*', { + top: 2, + skip: 2, + includeTotalCount: true, + }); + + expect(page2.results.length).toBe(1); + expect(page2.count).toBe(3); + }); + + it('should order by a sortable field', async () => { + const results = await searchService.searchListings('*', { + orderBy: ['title asc'], + }); + + expect(results.results.length).toBe(3); + // Verify ascending order + const titles = results.results.map((r) => r.document.title as string); + const sortedTitles = [...titles].sort(); + expect(titles).toEqual(sortedTitles); + }); + + it('should select specific fields', async () => { + const results = await searchService.searchListings('*', { + select: ['id', 'title', 'category'], + }); + + expect(results.results.length).toBeGreaterThan(0); + // The first result should have the selected fields + const doc = results.results[0].document; + expect(doc.id).toBeDefined(); + expect(doc.title).toBeDefined(); + }); + + it('should return facets when requested', async () => { + const results = await searchService.searchListings('*', { + facets: ['category', 'state'], + }); + + expect(results.facets).toBeDefined(); + // Should have facet results for category and state + if (results.facets) { + expect(results.facets.category || results.facets.state).toBeDefined(); + } + }); + + it('should combine text search with filters', async () => { + const results = await searchService.searchListings('bike', { + filter: "state eq 'active'", + }); + + expect(results.results.length).toBeGreaterThan(0); + for (const result of results.results) { + expect(result.document.state).toBe('active'); + } + }); + }); + + describe('Error Handling', () => { + it('should handle search on non-existent index gracefully', async () => { + // This may either throw or return empty results depending on implementation + try { + const results = await searchService.search( + 'non-existent-index', + 'test', + ); + expect(results.results).toEqual([]); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it('should handle invalid filter syntax gracefully', async () => { + // The mock implementation may or may not validate filter syntax + try { + await searchService.searchListings('*', { + filter: 'invalid filter syntax!!!', + }); + } catch (error) { + expect(error).toBeDefined(); + } + }); + }); +}); diff --git a/packages/sthrift/search-service-index/src/service-search-index.ts b/packages/sthrift/search-service-index/src/service-search-index.ts new file mode 100644 index 000000000..fc3be5d4b --- /dev/null +++ b/packages/sthrift/search-service-index/src/service-search-index.ts @@ -0,0 +1,169 @@ +import type { + SearchService, + SearchIndex, + SearchOptions, + SearchDocumentsResult, +} from '@cellix/search-service'; +import InMemoryCognitiveSearch from '@sthrift/search-service-mock'; +import { ListingSearchIndexSpec } from '@sthrift/domain'; + +/** + * ShareThrift Search Service Index - FACADE + * + * This class provides application-specific search functionality for ShareThrift. + * It implements the Facade pattern by providing a simple interface while hiding + * the complexity of the underlying search implementation. + * + * Currently supports: + * - Mock implementation for local development + * + * Future: Will support Azure Cognitive Search with automatic environment detection + * + * The facade: + * 1. Implements the generic SearchService interface (which extends ServiceBase) + * 2. Knows about domain-specific indexes (listings, etc.) + * 3. Delegates all operations to the underlying implementation + * 4. Provides convenience methods for common operations + */ +export class ServiceSearchIndex implements SearchService { + private searchService: SearchService; + private readonly implementationType = 'mock'; + + /** + * Creates a new instance of the search service facade + * + * @param _config - Configuration options (currently unused, for future Azure support) + */ + constructor(_config?: { + enablePersistence?: boolean; + persistencePath?: string; + }) { + // For now, always use mock implementation + // Future: Add factory logic to detect Azure vs Mock + this.searchService = new InMemoryCognitiveSearch(); + + console.log( + `ServiceSearchIndex: Initialized with ${this.implementationType} implementation`, + ); + } + + /** + * ServiceBase implementation - Initialize the search service + */ + async startUp(): Promise { + console.log('ServiceSearchIndex: Starting up'); + await this.searchService.startUp(); + + // Initialize application-specific indexes + await this.initializeIndexes(); + + return this; + } + + /** + * ServiceBase implementation - Shutdown the search service + */ + async shutDown(): Promise { + console.log('ServiceSearchIndex: Shutting down'); + await this.searchService.shutDown(); + } + + /** + * Initialize domain-specific search indexes + * Called during startup to ensure all indexes exist + */ + private async initializeIndexes(): Promise { + console.log('ServiceSearchIndex: Initializing domain indexes'); + + // Create item listing index + await this.createIndexIfNotExists(ListingSearchIndexSpec); + + // Future: Add other indexes (users, reservations, etc.) + } + + /** + * FACADE METHODS - Delegate to underlying search service + * These methods implement the SearchService interface + */ + + async createIndexIfNotExists(indexDefinition: SearchIndex): Promise { + return await this.searchService.createIndexIfNotExists(indexDefinition); + } + + async createOrUpdateIndexDefinition( + indexName: string, + indexDefinition: SearchIndex, + ): Promise { + return await this.searchService.createOrUpdateIndexDefinition( + indexName, + indexDefinition, + ); + } + + async indexDocument( + indexName: string, + document: Record, + ): Promise { + return await this.searchService.indexDocument(indexName, document); + } + + async deleteDocument( + indexName: string, + document: Record, + ): Promise { + return await this.searchService.deleteDocument(indexName, document); + } + + async deleteIndex(indexName: string): Promise { + return await this.searchService.deleteIndex(indexName); + } + + async search( + indexName: string, + searchText: string, + options?: SearchOptions, + ): Promise { + return await this.searchService.search(indexName, searchText, options); + } + + /** + * CONVENIENCE METHODS - Domain-specific helpers + * These methods provide a simpler interface for common operations + */ + + /** + * Search item listings + * + * @param searchText - The search query text + * @param options - Optional search parameters + * @returns Search results for listings + */ + async searchListings( + searchText: string, + options?: SearchOptions, + ): Promise { + return await this.search( + ListingSearchIndexSpec.name, + searchText, + options, + ); + } + + /** + * Index an item listing document + * + * @param listing - The listing document to index + */ + async indexListing(listing: Record): Promise { + return await this.indexDocument(ListingSearchIndexSpec.name, listing); + } + + /** + * Delete an item listing from the search index + * + * @param listing - The listing document to delete (must include id) + */ + async deleteListing(listing: Record): Promise { + return await this.deleteDocument(ListingSearchIndexSpec.name, listing); + } +} diff --git a/packages/sthrift/search-service-index/tsconfig.json b/packages/sthrift/search-service-index/tsconfig.json new file mode 100644 index 000000000..4db4ee8e8 --- /dev/null +++ b/packages/sthrift/search-service-index/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@cellix/typescript-config/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"], + "references": [ + { "path": "../../cellix/search-service" }, + { "path": "../domain" }, + { "path": "../search-service-mock" } + ] +} diff --git a/packages/sthrift/search-service-index/turbo.json b/packages/sthrift/search-service-index/turbo.json new file mode 100644 index 000000000..6403b5e05 --- /dev/null +++ b/packages/sthrift/search-service-index/turbo.json @@ -0,0 +1,4 @@ +{ + "extends": ["//"], + "tags": ["backend"] +} diff --git a/packages/sthrift/search-service-index/vitest.config.ts b/packages/sthrift/search-service-index/vitest.config.ts new file mode 100644 index 000000000..954b71bde --- /dev/null +++ b/packages/sthrift/search-service-index/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + reportsDirectory: 'coverage', + exclude: [ + 'node_modules/', + 'dist/', + 'examples/**', + '**/*.test.ts', + '**/__tests__/**', + ], + }, + }, +}); diff --git a/packages/sthrift/search-service-mock/.gitignore b/packages/sthrift/search-service-mock/.gitignore new file mode 100644 index 000000000..bde08cc73 --- /dev/null +++ b/packages/sthrift/search-service-mock/.gitignore @@ -0,0 +1,5 @@ +dist/ +*.tsbuildinfo + +# Note: Do not add *.d.ts here as src/types/ contains legitimate type declarations +# for packages without their own types (e.g., liqe.d.ts) diff --git a/packages/sthrift/search-service-mock/README.md b/packages/sthrift/search-service-mock/README.md new file mode 100644 index 000000000..cae76cae8 --- /dev/null +++ b/packages/sthrift/search-service-mock/README.md @@ -0,0 +1,54 @@ +# Search Service Mock + +In-memory search implementation powered by Lunr.js and LiQE for local development and testing. + +## Features + +- Full-text search with TF-IDF relevance scoring +- Field boosting (title 10x, description 2x weight) +- Fuzzy matching and wildcard support +- OData-style filtering via LiQE (eq, ne, gt, lt, contains, startswith, endswith) +- Sorting, pagination, and faceting +- Azure Cognitive Search API compatibility + +## Usage + +```typescript +import { InMemoryCognitiveSearch } from '@sthrift/search-service-mock'; + +const searchService = new InMemoryCognitiveSearch(); +await searchService.startUp(); + +// Create index +await searchService.createIndexIfNotExists({ + name: 'items', + fields: [ + { name: 'id', type: 'Edm.String', key: true }, + { name: 'title', type: 'Edm.String', searchable: true }, + { name: 'category', type: 'Edm.String', filterable: true, facetable: true } + ] +}); + +// Index documents +await searchService.indexDocument('items', { + id: '1', + title: 'Mountain Bike', + category: 'Sports' +}); + +// Search with filters +const results = await searchService.search('items', 'bike', { + filter: "category eq 'Sports'", + top: 10, + includeTotalCount: true +}); +``` + +## Examples + +Run filtering examples: + +```bash +npm run examples +``` + diff --git a/packages/sthrift/search-service-mock/examples/liqe-filtering-examples.ts b/packages/sthrift/search-service-mock/examples/liqe-filtering-examples.ts new file mode 100644 index 000000000..6372d750c --- /dev/null +++ b/packages/sthrift/search-service-mock/examples/liqe-filtering-examples.ts @@ -0,0 +1,339 @@ +/** + * LiQE Advanced Filtering Examples + * + * This file demonstrates the advanced OData-style filtering capabilities + * provided by the LiQE integration in the mock cognitive search service. + * + * @fileoverview Comprehensive examples of LiQE filtering features + * @author ShareThrift Development Team + * @since 1.0.0 + */ + +import { InMemoryCognitiveSearch } from '../src/index.js'; + +/** + * Sample data for demonstration + */ +const sampleListings = [ + { + id: '1', + title: 'Mountain Bike Adventure', + description: 'High-quality mountain bike perfect for trail riding and outdoor adventures', + category: 'Sports', + price: 500, + brand: 'Trek', + isActive: true, + tags: ['outdoor', 'fitness', 'adventure'] + }, + { + id: '2', + title: 'Road Bike Commuter', + description: 'Lightweight road bike ideal for daily commuting and city rides', + category: 'Urban', + price: 300, + brand: 'Giant', + isActive: true, + tags: ['commuting', 'city', 'lightweight'] + }, + { + id: '3', + title: 'Electric Scooter', + description: 'Modern electric scooter for urban transportation', + category: 'Urban', + price: 800, + brand: 'Xiaomi', + isActive: false, + tags: ['electric', 'urban', 'transport'] + }, + { + id: '4', + title: 'Mountain Bike Trail', + description: 'Professional mountain bike designed for challenging trails', + category: 'Sports', + price: 1200, + brand: 'Specialized', + isActive: true, + tags: ['trail', 'professional', 'challenging'] + }, + { + id: '5', + title: 'City Bike Classic', + description: 'Classic city bike for leisurely rides around town', + category: 'Urban', + price: 250, + brand: 'Schwinn', + isActive: true, + tags: ['classic', 'leisurely', 'city'] + } +]; + +/** + * Initialize the search service with sample data + */ +async function initializeSearchService(): Promise { + const searchService = new InMemoryCognitiveSearch(); + await searchService.startup(); + + // Create the item listings index + await searchService.createIndexIfNotExists({ + name: 'item-listings', + fields: [ + { name: 'id', type: 'Edm.String', key: true, retrievable: true }, + { name: 'title', type: 'Edm.String', searchable: true, filterable: true }, + { name: 'description', type: 'Edm.String', searchable: true }, + { name: 'category', type: 'Edm.String', filterable: true, facetable: true }, + { name: 'price', type: 'Edm.Double', filterable: true, sortable: true }, + { name: 'brand', type: 'Edm.String', filterable: true, facetable: true }, + { name: 'isActive', type: 'Edm.Boolean', filterable: true, facetable: true }, + { name: 'tags', type: 'Collection(Edm.String)', filterable: true, facetable: true } + ] + }); + + // Index all sample documents + for (const listing of sampleListings) { + await searchService.indexDocument('item-listings', listing); + } + + return searchService; +} + +/** + * Example 1: Basic Comparison Operators + */ +export async function basicComparisonExamples() { + console.log('\n=== Basic Comparison Operators ==='); + + const searchService = await initializeSearchService(); + + // Equality + console.log('\n1. Equality (eq):'); + const equalityResults = await searchService.search('item-listings', '', { + filter: "category eq 'Sports'" + }); + console.log(`Found ${equalityResults.count} items in Sports category`); + + // Inequality + console.log('\n2. Inequality (ne):'); + const inequalityResults = await searchService.search('item-listings', '', { + filter: "price ne 500" + }); + console.log(`Found ${inequalityResults.count} items not priced at $500`); + + // Greater than + console.log('\n3. Greater than (gt):'); + const greaterThanResults = await searchService.search('item-listings', '', { + filter: "price gt 400" + }); + console.log(`Found ${greaterThanResults.count} items priced above $400`); + + // Less than or equal + console.log('\n4. Less than or equal (le):'); + const lessEqualResults = await searchService.search('item-listings', '', { + filter: "price le 300" + }); + console.log(`Found ${lessEqualResults.count} items priced at $300 or below`); + + await searchService.shutdown(); +} + +/** + * Example 2: String Functions + */ +export async function stringFunctionExamples() { + console.log('\n=== String Functions ==='); + + const searchService = await initializeSearchService(); + + // Contains function + console.log('\n1. Contains function:'); + const containsResults = await searchService.search('item-listings', '', { + filter: "contains(title, 'Bike')" + }); + console.log(`Found ${containsResults.count} items with 'Bike' in title`); + containsResults.results.forEach(r => console.log(` - ${r.document.title}`)); + + // Starts with function + console.log('\n2. Starts with function:'); + const startsWithResults = await searchService.search('item-listings', '', { + filter: "startswith(title, 'Mountain')" + }); + console.log(`Found ${startsWithResults.count} items starting with 'Mountain'`); + startsWithResults.results.forEach(r => console.log(` - ${r.document.title}`)); + + // Ends with function + console.log('\n3. Ends with function:'); + const endsWithResults = await searchService.search('item-listings', '', { + filter: "endswith(title, 'Bike')" + }); + console.log(`Found ${endsWithResults.count} items ending with 'Bike'`); + endsWithResults.results.forEach(r => console.log(` - ${r.document.title}`)); + + await searchService.shutdown(); +} + +/** + * Example 3: Logical Operators + */ +export async function logicalOperatorExamples() { + console.log('\n=== Logical Operators ==='); + + const searchService = await initializeSearchService(); + + // AND operator + console.log('\n1. AND operator:'); + const andResults = await searchService.search('item-listings', '', { + filter: "category eq 'Sports' and price gt 400" + }); + console.log(`Found ${andResults.count} Sports items priced above $400`); + andResults.results.forEach(r => console.log(` - ${r.document.title} ($${r.document.price})`)); + + // OR operator + console.log('\n2. OR operator:'); + const orResults = await searchService.search('item-listings', '', { + filter: "brand eq 'Trek' or brand eq 'Specialized'" + }); + console.log(`Found ${orResults.count} items from Trek or Specialized`); + orResults.results.forEach(r => console.log(` - ${r.document.title} (${r.document.brand})`)); + + // Complex nested expression + console.log('\n3. Complex nested expression:'); + const complexResults = await searchService.search('item-listings', '', { + filter: "(category eq 'Sports' or category eq 'Urban') and price le 1000 and isActive eq true" + }); + console.log(`Found ${complexResults.count} active Sports or Urban items under $1000`); + complexResults.results.forEach(r => console.log(` - ${r.document.title} (${r.document.category}, $${r.document.price})`)); + + await searchService.shutdown(); +} + +/** + * Example 4: Combined Search and Filtering + */ +export async function combinedSearchExamples() { + console.log('\n=== Combined Search and Filtering ==='); + + const searchService = await initializeSearchService(); + + // Full-text search with filters + console.log('\n1. Full-text search with filters:'); + const combinedResults = await searchService.search('item-listings', 'bike', { + filter: "contains(title, 'Mountain') and price gt 300", + facets: ['category', 'brand'], + top: 10, + includeTotalCount: true + }); + console.log(`Found ${combinedResults.count} results for 'bike' with Mountain in title and price > $300`); + combinedResults.results.forEach(r => console.log(` - ${r.document.title} ($${r.document.price})`)); + + // Facets with filtering + console.log('\n2. Facets with filtering:'); + if (combinedResults.facets) { + console.log('Category facets:', combinedResults.facets.category); + console.log('Brand facets:', combinedResults.facets.brand); + } + + await searchService.shutdown(); +} + +/** + * Example 5: Advanced Filtering Scenarios + */ +export async function advancedFilteringExamples() { + console.log('\n=== Advanced Filtering Scenarios ==='); + + const searchService = await initializeSearchService(); + + // Price range filtering + console.log('\n1. Price range filtering:'); + const priceRangeResults = await searchService.search('item-listings', '', { + filter: "price ge 250 and price le 800" + }); + console.log(`Found ${priceRangeResults.count} items in price range $250-$800`); + priceRangeResults.results.forEach(r => console.log(` - ${r.document.title} ($${r.document.price})`)); + + // Active items only with specific criteria + console.log('\n2. Active items with specific criteria:'); + const activeResults = await searchService.search('item-listings', '', { + filter: "isActive eq true and (contains(title, 'Bike') or contains(title, 'Scooter'))" + }); + console.log(`Found ${activeResults.count} active bikes or scooters`); + activeResults.results.forEach(r => console.log(` - ${r.document.title} (${r.document.category})`)); + + // Brand and category combination + console.log('\n3. Brand and category combination:'); + const brandCategoryResults = await searchService.search('item-listings', '', { + filter: "brand ne 'Xiaomi' and category eq 'Sports'" + }); + console.log(`Found ${brandCategoryResults.count} Sports items not from Xiaomi`); + brandCategoryResults.results.forEach(r => console.log(` - ${r.document.title} (${r.document.brand})`)); + + await searchService.shutdown(); +} + +/** + * Example 6: Filter Capabilities and Validation + */ +export async function filterCapabilitiesExamples() { + console.log('\n=== Filter Capabilities and Validation ==='); + + const searchService = await initializeSearchService(); + + // Check filter capabilities + console.log('\n1. Filter capabilities:'); + const capabilities = searchService.getFilterCapabilities(); + console.log('Supported features:', capabilities.supportedFeatures); + + // Validate filter syntax + console.log('\n2. Filter validation:'); + const validFilters = [ + "price gt 100", + "category eq 'Sports'", + "contains(title, 'Bike')", + "(category eq 'Sports' or category eq 'Urban') and price le 1000" + ]; + + const invalidFilters = [ + "malformed filter", + "invalid syntax here", + "unknown operator test" + ]; + + validFilters.forEach(filter => { + const isValid = capabilities.isFilterSupported(filter); + console.log(` "${filter}" is ${isValid ? 'valid' : 'invalid'}`); + }); + + invalidFilters.forEach(filter => { + const isValid = capabilities.isFilterSupported(filter); + console.log(` "${filter}" is ${isValid ? 'valid' : 'invalid'}`); + }); + + await searchService.shutdown(); +} + +/** + * Run all examples + */ +export async function runAllExamples() { + console.log('🚀 Running LiQE Advanced Filtering Examples'); + console.log('=========================================='); + + try { + await basicComparisonExamples(); + await stringFunctionExamples(); + await logicalOperatorExamples(); + await combinedSearchExamples(); + await advancedFilteringExamples(); + await filterCapabilitiesExamples(); + + console.log('\n✅ All examples completed successfully!'); + } catch (error) { + console.error('❌ Error running examples:', error); + throw error; + } +} + +// Run examples if this file is executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + runAllExamples().catch(console.error); +} diff --git a/packages/sthrift/search-service-mock/examples/run-examples.js b/packages/sthrift/search-service-mock/examples/run-examples.js new file mode 100644 index 000000000..ac209df3f --- /dev/null +++ b/packages/sthrift/search-service-mock/examples/run-examples.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +/** + * LiQE Filtering Examples Runner + * + * Simple Node.js script to run the LiQE filtering examples. + * + * Usage: + * node examples/run-examples.js + * npm run examples + * + * @fileoverview Executable script for running LiQE filtering examples + * @author ShareThrift Development Team + * @since 1.0.0 + */ + +import { runAllExamples } from './liqe-filtering-examples.js'; + +console.log('🔍 LiQE Advanced Filtering Examples'); +console.log('==================================='); +console.log('This will demonstrate all the advanced OData-style filtering'); +console.log('capabilities provided by the LiQE integration.\n'); + +runAllExamples().catch(error => { + console.error('❌ Failed to run examples:', error); + process.exit(1); +}); diff --git a/packages/sthrift/search-service-mock/package.json b/packages/sthrift/search-service-mock/package.json new file mode 100644 index 000000000..8b4a6c594 --- /dev/null +++ b/packages/sthrift/search-service-mock/package.json @@ -0,0 +1,54 @@ +{ + "name": "@sthrift/search-service-mock", + "version": "1.0.0", + "type": "module", + "description": "Mock implementation of search service for local development", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "vitest", + "test:coverage": "vitest run --coverage", + "lint": "biome check src/", + "format": "biome format --write src/", + "examples": "node examples/run-examples.js" + }, + "keywords": [ + "search", + "mock", + "development" + ], + "author": "ShareThrift Team", + "license": "MIT", + "dependencies": { + "@cellix/search-service": "workspace:*", + "liqe": "^3.8.3", + "lunr": "^2.3.9" + }, + "devDependencies": { + "@cellix/typescript-config": "workspace:*", + "@cellix/vitest-config": "workspace:*", + "@types/lunr": "^2.3.7", + "@vitest/coverage-v8": "^3.2.4", + "typescript": "^5.3.0", + "vitest": "^3.2.4" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "files": [ + "dist/**/*", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js", + "require": "./dist/src/index.js" + } + }, + "publishConfig": { + "access": "restricted" + } +} diff --git a/packages/sthrift/search-service-mock/src/document-store.test.ts b/packages/sthrift/search-service-mock/src/document-store.test.ts new file mode 100644 index 000000000..e7ff904af --- /dev/null +++ b/packages/sthrift/search-service-mock/src/document-store.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for DocumentStore + * + * Tests document storage CRUD operations and index management. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { DocumentStore } from './document-store'; + +describe('DocumentStore', () => { + let store: DocumentStore; + + beforeEach(() => { + store = new DocumentStore(); + }); + + describe('has', () => { + it('should return false for non-existent index', () => { + expect(store.has('non-existent')).toBe(false); + }); + + it('should return true for existing index', () => { + store.create('test-index'); + expect(store.has('test-index')).toBe(true); + }); + }); + + describe('create', () => { + it('should create a new document store for an index', () => { + store.create('test-index'); + expect(store.has('test-index')).toBe(true); + }); + + it('should not overwrite existing store when creating with same name', () => { + store.create('test-index'); + store.set('test-index', 'doc1', { id: 'doc1', title: 'Test' }); + store.create('test-index'); // Should not overwrite + expect(store.getCount('test-index')).toBe(1); + }); + }); + + describe('getDocs', () => { + it('should return empty map for non-existent index', () => { + const docs = store.getDocs('non-existent'); + expect(docs.size).toBe(0); + }); + + it('should return document map for existing index', () => { + store.create('test-index'); + store.set('test-index', 'doc1', { id: 'doc1' }); + const docs = store.getDocs('test-index'); + expect(docs.size).toBe(1); + expect(docs.get('doc1')).toEqual({ id: 'doc1' }); + }); + }); + + describe('set', () => { + it('should add a new document', () => { + store.create('test-index'); + store.set('test-index', 'doc1', { id: 'doc1', title: 'Test' }); + expect(store.get('test-index', 'doc1')).toEqual({ + id: 'doc1', + title: 'Test', + }); + }); + + it('should update an existing document', () => { + store.create('test-index'); + store.set('test-index', 'doc1', { id: 'doc1', title: 'Original' }); + store.set('test-index', 'doc1', { id: 'doc1', title: 'Updated' }); + expect(store.get('test-index', 'doc1')).toEqual({ + id: 'doc1', + title: 'Updated', + }); + }); + + it('should throw error for non-existent index', () => { + expect(() => { + store.set('non-existent', 'doc1', { id: 'doc1' }); + }).toThrow('Document store not found for index non-existent'); + }); + }); + + describe('get', () => { + it('should return undefined for non-existent index', () => { + expect(store.get('non-existent', 'doc1')).toBeUndefined(); + }); + + it('should return undefined for non-existent document', () => { + store.create('test-index'); + expect(store.get('test-index', 'non-existent')).toBeUndefined(); + }); + + it('should return document for existing document', () => { + store.create('test-index'); + const doc = { id: 'doc1', title: 'Test', price: 100 }; + store.set('test-index', 'doc1', doc); + expect(store.get('test-index', 'doc1')).toEqual(doc); + }); + }); + + describe('delete', () => { + it('should return false for non-existent index', () => { + expect(store.delete('non-existent', 'doc1')).toBe(false); + }); + + it('should return false for non-existent document', () => { + store.create('test-index'); + expect(store.delete('test-index', 'non-existent')).toBe(false); + }); + + it('should delete document and return true', () => { + store.create('test-index'); + store.set('test-index', 'doc1', { id: 'doc1' }); + expect(store.delete('test-index', 'doc1')).toBe(true); + expect(store.get('test-index', 'doc1')).toBeUndefined(); + }); + }); + + describe('deleteStore', () => { + it('should delete the document store for an index', () => { + store.create('test-index'); + store.set('test-index', 'doc1', { id: 'doc1' }); + store.deleteStore('test-index'); + expect(store.has('test-index')).toBe(false); + }); + + it('should do nothing for non-existent index', () => { + // Should not throw + store.deleteStore('non-existent'); + expect(store.has('non-existent')).toBe(false); + }); + }); + + describe('getCount', () => { + it('should return 0 for non-existent index', () => { + expect(store.getCount('non-existent')).toBe(0); + }); + + it('should return 0 for empty index', () => { + store.create('test-index'); + expect(store.getCount('test-index')).toBe(0); + }); + + it('should return correct count for index with documents', () => { + store.create('test-index'); + store.set('test-index', 'doc1', { id: 'doc1' }); + store.set('test-index', 'doc2', { id: 'doc2' }); + store.set('test-index', 'doc3', { id: 'doc3' }); + expect(store.getCount('test-index')).toBe(3); + }); + }); + + describe('getAllCounts', () => { + it('should return empty object when no indexes exist', () => { + expect(store.getAllCounts()).toEqual({}); + }); + + it('should return counts for all indexes', () => { + store.create('index1'); + store.create('index2'); + store.set('index1', 'doc1', { id: 'doc1' }); + store.set('index1', 'doc2', { id: 'doc2' }); + store.set('index2', 'doc3', { id: 'doc3' }); + + const counts = store.getAllCounts(); + expect(counts).toEqual({ + index1: 2, + index2: 1, + }); + }); + }); +}); diff --git a/packages/sthrift/search-service-mock/src/document-store.ts b/packages/sthrift/search-service-mock/src/document-store.ts new file mode 100644 index 000000000..81ea7f3b8 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/document-store.ts @@ -0,0 +1,124 @@ +/** + * Document Store + * + * Manages document storage for search indexes. + * Provides a clean interface for document CRUD operations. + */ +export class DocumentStore { + private documents: Map>> = + new Map(); + + /** + * Check if a document store exists for an index + * + * @param indexName - The name of the index + * @returns True if the document store exists, false otherwise + */ + has(indexName: string): boolean { + return this.documents.has(indexName); + } + + /** + * Create a new document store for an index + * + * @param indexName - The name of the index + */ + create(indexName: string): void { + if (!this.documents.has(indexName)) { + this.documents.set(indexName, new Map()); + } + } + + /** + * Get the document store for an index + * + * @param indexName - The name of the index + * @returns The document map or undefined if not found + */ + getDocs(indexName: string): Map> { + return this.documents.get(indexName) ?? new Map(); + } + + /** + * Add or update a document + * + * @param indexName - The name of the index + * @param documentId - The ID of the document + * @param document - The document to store + */ + set( + indexName: string, + documentId: string, + document: Record, + ): void { + const documentMap = this.documents.get(indexName); + if (!documentMap) { + throw new Error(`Document store not found for index ${indexName}`); + } + documentMap.set(documentId, document); + } + + /** + * Get a document by ID + * + * @param indexName - The name of the index + * @param documentId - The ID of the document + * @returns The document or undefined if not found + */ + get( + indexName: string, + documentId: string, + ): Record | undefined { + const documentMap = this.documents.get(indexName); + return documentMap?.get(documentId); + } + + /** + * Delete a document + * + * @param indexName - The name of the index + * @param documentId - The ID of the document to delete + * @returns True if the document was deleted, false if not found + */ + delete(indexName: string, documentId: string): boolean { + const documentMap = this.documents.get(indexName); + if (!documentMap) { + return false; + } + return documentMap.delete(documentId); + } + + /** + * Delete the document store for an index + * + * @param indexName - The name of the index + */ + deleteStore(indexName: string): void { + this.documents.delete(indexName); + } + + /** + * Get the count of documents in an index + * + * @param indexName - The name of the index + * @returns The number of documents or 0 if the store doesn't exist + */ + getCount(indexName: string): number { + const documentMap = this.documents.get(indexName); + return documentMap?.size ?? 0; + } + + /** + * Get all document counts for all indexes + * + * @returns Record mapping index names to document counts + */ + getAllCounts(): Record { + const counts: Record = {}; + for (const [indexName, documentMap] of this.documents) { + counts[indexName] = documentMap.size; + } + return counts; + } +} + diff --git a/packages/sthrift/search-service-mock/src/features/document-store.feature b/packages/sthrift/search-service-mock/src/features/document-store.feature new file mode 100644 index 000000000..d10a1fdf9 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/features/document-store.feature @@ -0,0 +1,67 @@ +Feature: Document Store + + Scenario: Check for non-existent index + When I check if index "non-existent" exists + Then it should return false + + Scenario: Check for existing index + Given an index "test-index" is created + When I check if index "test-index" exists + Then it should return true + + Scenario: Create a new index + When I create an index "test-index" + Then the index "test-index" should exist + + Scenario: Create index does not overwrite existing data + Given an index "test-index" is created + And a document "doc1" with title "Test" is added to "test-index" + When I create an index "test-index" again + Then the document count for "test-index" should be 1 + + Scenario: Get documents from non-existent index + When I get documents from index "non-existent" + Then it should return an empty map + + Scenario: Get documents from existing index + Given an index "test-index" is created + And a document "doc1" is added to "test-index" + When I get documents from index "test-index" + Then it should return a map with 1 document + + Scenario: Set and get a document + Given an index "test-index" is created + When I set document "doc1" with data in "test-index" + Then I should be able to get document "doc1" from "test-index" + + Scenario: Get non-existent document + Given an index "test-index" is created + When I get document "non-existent" from "test-index" + Then it should return undefined + + Scenario: Remove a document + Given an index "test-index" is created + And a document "doc1" is added to "test-index" + When I remove document "doc1" from "test-index" + Then document "doc1" should not exist in "test-index" + + Scenario: Clear all documents + Given an index "test-index" is created + And documents are added to "test-index" + When I clear "test-index" + Then the document count for "test-index" should be 0 + + Scenario: Get document count + Given an index "test-index" is created + And 3 documents are added to "test-index" + Then the document count for "test-index" should be 3 + + Scenario: Delete an index + Given an index "test-index" is created + When I delete "test-index" + Then the index "test-index" should not exist + + Scenario: List all index names + Given indexes "index1", "index2", "index3" are created + When I list all index names + Then it should return ["index1", "index2", "index3"] diff --git a/packages/sthrift/search-service-mock/src/features/in-memory-search.feature b/packages/sthrift/search-service-mock/src/features/in-memory-search.feature new file mode 100644 index 000000000..f685d6fb0 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/features/in-memory-search.feature @@ -0,0 +1,51 @@ +Feature: In-Memory Cognitive Search + + Scenario: Successfully creating an index + When I create a test index + Then the index should exist + And the document count should be 0 + + Scenario: Indexing a document + Given a test index exists + When I index a document + Then the document count should be 1 + + Scenario: Searching documents by text + Given indexed documents exist + When I search for "Test" + Then I should find matching documents + + Scenario: Filtering documents + Given multiple documents are indexed + When I search with filter "category eq 'test'" + Then I should find filtered results + + Scenario: Deleting a document + Given an indexed document exists + When I delete the document + Then the document count should be 0 + + Scenario: Handling pagination + Given 5 documents are indexed + When I search with skip 1 and top 2 + Then I should get 2 results + And the total count should be 5 + + Scenario: Indexing to non-existent index fails + When I index a document to non-existent index + Then an error should be thrown indicating index does not exist + + Scenario: Indexing document without id fails + Given a test index exists + When I index a document without an id + Then an error should be thrown indicating id is required + + Scenario: Updating index preserves documents + Given an indexed document exists + When I update the index definition + Then the document count should remain 1 + + Scenario: Deleting an index + Given a test index with documents exists + When I delete the index + Then the index should not exist diff --git a/packages/sthrift/search-service-mock/src/features/index-manager.feature b/packages/sthrift/search-service-mock/src/features/index-manager.feature new file mode 100644 index 000000000..4615c73ca --- /dev/null +++ b/packages/sthrift/search-service-mock/src/features/index-manager.feature @@ -0,0 +1,34 @@ +Feature: Index Manager + + Scenario: Creating a new index + When I create an index with fields + Then the index should exist + + Scenario: Checking if index exists + Given an index "test-index" exists + When I check if "test-index" exists + Then it should return true + + Scenario: Getting an existing index + Given an index "test-index" exists + When I get the index definition + Then it should return the correct definition + + Scenario: Getting non-existent index returns undefined + When I get a non-existent index + Then it should return undefined + + Scenario: Deleting an existing index + Given an index "test-index" exists + When I delete the index + Then the index should not exist + + Scenario: Listing all index names + Given multiple indexes exist + When I list all indexes + Then it should return all index names + + Scenario: Overwriting existing index + Given an index exists + When I create the same index with different fields + Then the new definition should replace the old one diff --git a/packages/sthrift/search-service-mock/src/features/liqe-filter-engine.feature b/packages/sthrift/search-service-mock/src/features/liqe-filter-engine.feature new file mode 100644 index 000000000..c8a5ce52b --- /dev/null +++ b/packages/sthrift/search-service-mock/src/features/liqe-filter-engine.feature @@ -0,0 +1,40 @@ +Feature: LiQE Filter Engine + + Scenario: Empty filter returns all results + Given a set of test search results + When I apply an empty filter + Then all results should be returned + + Scenario: Filtering by exact equality + Given a set of test search results + When I apply filter "state eq 'active'" + Then only results with state "active" should be returned + + Scenario: Filtering by inequality + Given a set of test search results + When I apply filter "state ne 'active'" + Then results without state "active" should be returned + + Scenario: Filtering by comparison operators + Given a set of test search results + When I apply filter "price gt 100" + Then only results with price greater than 100 should be returned + + Scenario: Filtering with AND logic + Given a set of test search results + When I apply filter "state eq 'active' and price gt 100" + Then only results matching both conditions should be returned + + Scenario: Filtering with OR logic + Given a set of test search results + When I apply filter "state eq 'pending' or price gt 250" + Then results matching either condition should be returned + + Scenario: Filtering with contains function + Given a set of test search results + When I apply filter "contains(title, 'Bike')" + Then only results with "Bike" in title should be returned + + Scenario: Validating supported filters + When I check if "state eq 'active'" is supported + Then it should return true diff --git a/packages/sthrift/search-service-mock/src/features/lunr-search-engine.feature b/packages/sthrift/search-service-mock/src/features/lunr-search-engine.feature new file mode 100644 index 000000000..21c1c38f5 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/features/lunr-search-engine.feature @@ -0,0 +1,45 @@ +Feature: Lunr Search Engine + + Scenario: Building an index from documents + When I build an index with fields and documents + Then the index should exist + + Scenario: Adding a document to index + Given an index with documents exists + When I add a new document + Then the document should be searchable + + Scenario: Removing a document from index + Given an index with documents exists + When I remove a document by id + Then the document should not be searchable + + Scenario: Searching documents by keyword + Given an index with bike documents exists + When I search for "Bike" + Then matching results should be returned + + Scenario: Filtering search results + Given an index with categorized documents exists + When I search with filter "category eq 'Tools'" + Then only results with category "Tools" should be returned + + Scenario: Combining search and filter + Given an index with bike documents exists + When I search for "Bike" with filter "price gt 600" + Then only matching filtered results should be returned + + Scenario: Handling pagination + Given an index with 5 documents exists + When I search with skip 1 and top 2 + Then 2 results should be returned + + Scenario: Sorting search results + Given an index with priced documents exists + When I search with orderBy "price asc" + Then results should be sorted by price ascending + + Scenario: Returning facet counts + Given an index with categorized documents exists + When I search with facets for "category" + Then category facets with counts should be returned diff --git a/packages/sthrift/search-service-mock/src/features/search-engine-adapter.feature b/packages/sthrift/search-service-mock/src/features/search-engine-adapter.feature new file mode 100644 index 000000000..fb4b0ed9c --- /dev/null +++ b/packages/sthrift/search-service-mock/src/features/search-engine-adapter.feature @@ -0,0 +1,39 @@ +Feature: Search Engine Adapter + + Scenario: Building an index + When I build an index with fields and documents + Then the index should exist + + Scenario: Adding documents to index + Given an empty index exists + When I add documents + Then the document count should increase + + Scenario: Removing a document + Given an index with documents exists + When I remove one document + Then the document count should decrease + + Scenario: Searching by text + Given an index with bike documents exists + When I search for "Mountain" + Then matching results should be returned + + Scenario: Applying filters + Given an index with categorized documents exists + When I search with filter "category eq 'Tools'" + Then only filtered results should be returned + + Scenario: Handling pagination + Given an index with 3 documents exists + When I search with skip and top + Then paginated results should be returned + + Scenario: Getting index statistics + Given an index with documents exists + When I get index stats + Then stats should show document and field counts + + Scenario: Validating filter support + When I check if "category eq 'Sports'" is supported + Then it should return true diff --git a/packages/sthrift/search-service-mock/src/in-memory-search.test.ts b/packages/sthrift/search-service-mock/src/in-memory-search.test.ts new file mode 100644 index 000000000..0cfe2c6f1 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/in-memory-search.test.ts @@ -0,0 +1,281 @@ +/** + * Tests for InMemoryCognitiveSearch + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { InMemoryCognitiveSearch } from './in-memory-search'; +import type { SearchIndex } from './interfaces'; + +describe('InMemoryCognitiveSearch', () => { + let searchService: InMemoryCognitiveSearch; + let testIndex: SearchIndex; + + beforeEach(async () => { + searchService = new InMemoryCognitiveSearch(); + await searchService.startUp(); + + testIndex = { + name: 'test-index', + fields: [ + { name: 'id', type: 'Edm.String', key: true }, + { name: 'title', type: 'Edm.String', searchable: true }, + { + name: 'category', + type: 'Edm.String', + filterable: true, + facetable: true, + }, + ], + }; + }); + + it('should create index successfully', async () => { + await searchService.createIndexIfNotExists(testIndex); + + const debugInfo = searchService.getDebugInfo(); + expect(debugInfo.indexes).toContain('test-index'); + expect(debugInfo.documentCounts['test-index']).toBe(0); + }); + + it('should index document successfully', async () => { + await searchService.createIndexIfNotExists(testIndex); + + const document = { + id: 'doc1', + title: 'Test Document', + category: 'test', + }; + + await searchService.indexDocument('test-index', document); + + const debugInfo = searchService.getDebugInfo(); + expect(debugInfo.documentCounts['test-index']).toBe(1); + }); + + it('should search documents by text', async () => { + await searchService.createIndexIfNotExists(testIndex); + + await searchService.indexDocument('test-index', { + id: 'doc1', + title: 'Test Document', + category: 'test', + }); + + await searchService.indexDocument('test-index', { + id: 'doc2', + title: 'Another Document', + category: 'other', + }); + + const results = await searchService.search('test-index', 'Test'); + + expect(results.results).toHaveLength(1); + expect(results.results[0].document.title).toBe('Test Document'); + }); + + it('should filter documents', async () => { + await searchService.createIndexIfNotExists(testIndex); + + await searchService.indexDocument('test-index', { + id: 'doc1', + title: 'Test Document', + category: 'test', + }); + + await searchService.indexDocument('test-index', { + id: 'doc2', + title: 'Another Document', + category: 'other', + }); + + const results = await searchService.search('test-index', '*', { + filter: "category eq 'test'", + }); + + expect(results.results).toHaveLength(1); + expect(results.results[0].document.category).toBe('test'); + }); + + it('should delete document successfully', async () => { + await searchService.createIndexIfNotExists(testIndex); + + const document = { + id: 'doc1', + title: 'Test Document', + category: 'test', + }; + + await searchService.indexDocument('test-index', document); + + let debugInfo = searchService.getDebugInfo(); + expect(debugInfo.documentCounts['test-index']).toBe(1); + + await searchService.deleteDocument('test-index', document); + + debugInfo = searchService.getDebugInfo(); + expect(debugInfo.documentCounts['test-index']).toBe(0); + }); + + it('should handle pagination', async () => { + await searchService.createIndexIfNotExists(testIndex); + + // Index multiple documents + for (let i = 1; i <= 5; i++) { + await searchService.indexDocument('test-index', { + id: `doc${i}`, + title: `Document ${i}`, + category: 'test', + }); + } + + const results = await searchService.search('test-index', '*', { + top: 2, + skip: 1, + includeTotalCount: true, + }); + + expect(results.results).toHaveLength(2); + expect(results.count).toBe(5); + }); + + it('should not create duplicate index', async () => { + await searchService.createIndexIfNotExists(testIndex); + await searchService.createIndexIfNotExists(testIndex); + + const debugInfo = searchService.getDebugInfo(); + expect(debugInfo.indexes).toContain('test-index'); + }); + + it('should handle shutDown correctly', async () => { + await searchService.shutDown(); + await expect(searchService.startUp()).resolves.toBe(searchService); + }); + + it('should return same instance when startUp called multiple times', async () => { + const result1 = await searchService.startUp(); + const result2 = await searchService.startUp(); + expect(result1).toBe(result2); + expect(result1).toBe(searchService); + }); + + it('should handle search on non-existent index', async () => { + const results = await searchService.search('non-existent', 'test'); + expect(results.results).toHaveLength(0); + expect(results.count).toBe(0); + expect(results.facets).toEqual({}); + }); + + it('should reject indexing document to non-existent index', async () => { + const document = { id: 'doc1', title: 'Test' }; + await expect( + searchService.indexDocument('non-existent', document), + ).rejects.toThrow('Index non-existent does not exist'); + }); + + it('should reject indexing document without id', async () => { + await searchService.createIndexIfNotExists(testIndex); + const document = { title: 'Test' }; + await expect( + searchService.indexDocument('test-index', document), + ).rejects.toThrow('Document must have an id field'); + }); + + it('should reject deleting document from non-existent index', async () => { + const document = { id: 'doc1', title: 'Test' }; + await expect( + searchService.deleteDocument('non-existent', document), + ).rejects.toThrow('Index non-existent does not exist'); + }); + + it('should reject deleting document without id', async () => { + await searchService.createIndexIfNotExists(testIndex); + const document = { title: 'Test' }; + await expect( + searchService.deleteDocument('test-index', document), + ).rejects.toThrow('Document must have an id field'); + }); + + it('should create or update index definition', async () => { + await searchService.createOrUpdateIndexDefinition('test-index', testIndex); + + const debugInfo = searchService.getDebugInfo(); + expect(debugInfo.indexes).toContain('test-index'); + }); + + it('should update existing index definition', async () => { + await searchService.createIndexIfNotExists(testIndex); + + // Index a document + await searchService.indexDocument('test-index', { + id: 'doc1', + title: 'Test', + category: 'test', + }); + + // Update the index definition + const updatedIndex: SearchIndex = { + ...testIndex, + fields: [ + ...testIndex.fields, + { + name: 'description', + type: 'Edm.String' as const, + searchable: true, + }, + ], + }; + + await searchService.createOrUpdateIndexDefinition('test-index', updatedIndex); + + const debugInfo = searchService.getDebugInfo(); + expect(debugInfo.indexes).toContain('test-index'); + expect(debugInfo.documentCounts['test-index']).toBe(1); + }); + + it('should delete index successfully', async () => { + await searchService.createIndexIfNotExists(testIndex); + await searchService.indexDocument('test-index', { + id: 'doc1', + title: 'Test', + category: 'test', + }); + + await searchService.deleteIndex('test-index'); + + const debugInfo = searchService.getDebugInfo(); + expect(debugInfo.indexes).not.toContain('test-index'); + }); + + it('should provide filter capabilities info', () => { + const capabilities = searchService.getFilterCapabilities(); + expect(capabilities).toHaveProperty('operators'); + expect(capabilities).toHaveProperty('functions'); + expect(capabilities).toHaveProperty('examples'); + expect(Array.isArray(capabilities.operators)).toBe(true); + expect(Array.isArray(capabilities.functions)).toBe(true); + expect(Array.isArray(capabilities.examples)).toBe(true); + }); + + it('should validate filter support', () => { + const validFilter = "category eq 'test'"; + const result = searchService.isFilterSupported(validFilter); + expect(typeof result).toBe('boolean'); + }); + + it('should provide debug info with lunr stats', async () => { + await searchService.createIndexIfNotExists(testIndex); + await searchService.indexDocument('test-index', { + id: 'doc1', + title: 'Test', + category: 'test', + }); + + const debugInfo = searchService.getDebugInfo(); + expect(debugInfo).toHaveProperty('indexes'); + expect(debugInfo).toHaveProperty('documentCounts'); + expect(debugInfo).toHaveProperty('lunrStats'); + expect(debugInfo).toHaveProperty('filterCapabilities'); + expect(debugInfo.lunrStats['test-index']).toBeTruthy(); + expect(debugInfo.lunrStats['test-index']?.documentCount).toBeGreaterThan(0); + }); +}); diff --git a/packages/sthrift/search-service-mock/src/in-memory-search.ts b/packages/sthrift/search-service-mock/src/in-memory-search.ts new file mode 100644 index 000000000..29a2f5c68 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/in-memory-search.ts @@ -0,0 +1,303 @@ +import type { + SearchService, + SearchDocumentsResult, + SearchIndex, + SearchOptions, +} from '@cellix/search-service'; +import { IndexManager } from './index-manager.js'; +import { DocumentStore } from './document-store.js'; +import { SearchEngineAdapter } from './search-engine-adapter.js'; + +/** + * In-memory implementation of SearchService + * + * Enhanced with Lunr.js and LiQE for superior search capabilities: + * - Full-text search with relevance scoring (TF-IDF) + * - Field boosting (title gets higher weight than description) + * - Fuzzy matching and wildcard support + * - Stemming and stop word filtering + * - Advanced OData-like filtering with LiQE integration + * - Complex filter expressions with logical operators + * - String functions (contains, startswith, endswith) + * + * This implementation serves as a drop-in replacement for Azure Cognitive Search + * in development and testing environments, offering realistic search behavior + * without requiring cloud services or external dependencies. + * + * Refactored into focused modules for better separation of concerns: + * - IndexManager: Manages index definitions + * - DocumentStore: Manages document storage + * - SearchEngineAdapter: Wraps Lunr/LiQE search engine + */ +class InMemoryCognitiveSearch implements SearchService { + private readonly indexManager: IndexManager; + private readonly documentStore: DocumentStore; + private readonly searchEngine: SearchEngineAdapter; + private isInitialized = false; + + /** + * Creates a new instance of the in-memory cognitive search service + * + * @param _options - Configuration options for the search service (currently unused) + */ + constructor( + _options: { + enablePersistence?: boolean; + persistencePath?: string; + } = {}, + ) { + // Initialize focused modules + this.indexManager = new IndexManager(); + this.documentStore = new DocumentStore(); + this.searchEngine = new SearchEngineAdapter(); + } + + /** + * Initializes the search service + * + * @returns Promise that resolves with this instance when startup is complete + */ + startUp(): Promise { + if (this.isInitialized) { + return Promise.resolve(this); + } + + console.log('InMemoryCognitiveSearch: Starting up...'); + + /** + * Note: File persistence is intentionally not implemented as the in-memory + * store is sufficient for development and testing environments. + * Production deployments use Azure Cognitive Search which provides its own + * persistence. If persistence becomes necessary for development workflows, + * it can be added by implementing a DocumentStore persistence layer that + * serializes/deserializes the document store to disk. + */ + + this.isInitialized = true; + console.log('InMemoryCognitiveSearch: Started successfully'); + return Promise.resolve(this); + } + + /** + * Shuts down the search service and cleans up resources + * + * @returns Promise that resolves when shutdown is complete + */ + shutDown(): Promise { + console.log('InMemoryCognitiveSearch: Shutting down...'); + this.isInitialized = false; + console.log('InMemoryCognitiveSearch: Shutdown complete'); + return Promise.resolve(); + } + + /** + * Creates a new search index if it doesn't already exist + * + * @param indexDefinition - The definition of the index to create + * @returns Promise that resolves when the index is created or already exists + */ + createIndexIfNotExists(indexDefinition: SearchIndex): Promise { + if (this.indexManager.has(indexDefinition.name)) { + return Promise.resolve(); + } + + console.log(`Creating index: ${indexDefinition.name}`); + this.indexManager.create(indexDefinition); + this.documentStore.create(indexDefinition.name); + + // Initialize search engine index with empty documents + this.searchEngine.build(indexDefinition.name, indexDefinition.fields, []); + return Promise.resolve(); + } + + /** + * Creates or updates an existing search index definition + * + * @param indexName - The name of the index to create or update + * @param indexDefinition - The definition of the index + * @returns Promise that resolves when the index is created or updated + */ + createOrUpdateIndexDefinition( + indexName: string, + indexDefinition: SearchIndex, + ): Promise { + console.log(`Creating/updating index: ${indexName}`); + this.indexManager.create(indexDefinition); + + if (!this.documentStore.has(indexName)) { + this.documentStore.create(indexName); + } + + // Rebuild search engine index with current documents + const documents = Array.from( + this.documentStore.getDocs(indexName).values(), + ); + this.searchEngine.build(indexName, indexDefinition.fields, documents); + return Promise.resolve(); + } + + /** + * Adds or updates a document in the specified search index + * + * @param indexName - The name of the index to add the document to + * @param document - The document to index (must have an 'id' field) + * @returns Promise that resolves when the document is indexed + */ + indexDocument( + indexName: string, + document: Record, + ): Promise { + if (!this.indexManager.has(indexName)) { + return Promise.reject(new Error(`Index ${indexName} does not exist`)); + } + + if (!this.documentStore.has(indexName)) { + return Promise.reject( + new Error(`Document storage not found for index ${indexName}`), + ); + } + + const documentId = document['id'] as string; + if (!documentId) { + return Promise.reject(new Error('Document must have an id field')); + } + + console.log(`Indexing document ${documentId} in index ${indexName}`); + this.documentStore.set(indexName, documentId, { ...document }); + + // Update search engine index + this.searchEngine.add(indexName, document); + return Promise.resolve(); + } + + /** + * Removes a document from the specified search index + * + * @param indexName - The name of the index to remove the document from + * @param document - The document to remove (must have an 'id' field) + * @returns Promise that resolves when the document is removed + */ + deleteDocument( + indexName: string, + document: Record, + ): Promise { + if (!this.indexManager.has(indexName)) { + return Promise.reject(new Error(`Index ${indexName} does not exist`)); + } + + if (!this.documentStore.has(indexName)) { + return Promise.reject( + new Error(`Document storage not found for index ${indexName}`), + ); + } + + const documentId = document['id'] as string; + if (!documentId) { + return Promise.reject(new Error('Document must have an id field')); + } + + console.log(`Deleting document ${documentId} from index ${indexName}`); + this.documentStore.delete(indexName, documentId); + + // Update search engine index + this.searchEngine.remove(indexName, documentId); + return Promise.resolve(); + } + + /** + * Deletes an entire search index and all its documents + * + * @param indexName - The name of the index to delete + * @returns Promise that resolves when the index is deleted + */ + deleteIndex(indexName: string): Promise { + console.log(`Deleting index: ${indexName}`); + this.indexManager.delete(indexName); + this.documentStore.deleteStore(indexName); + return Promise.resolve(); + } + + /** + * Performs a search query on the specified index using Lunr.js + * + * @param indexName - The name of the index to search + * @param searchText - The search query text + * @param options - Optional search parameters (filters, pagination, facets, etc.) + * @returns Promise that resolves with search results including relevance scores + */ + search( + indexName: string, + searchText: string, + options?: SearchOptions, + ): Promise { + if (!this.indexManager.has(indexName)) { + return Promise.resolve({ results: [], count: 0, facets: {} }); + } + + // Use search engine adapter for enhanced search with relevance scoring + const result = this.searchEngine.search(indexName, searchText, options); + return Promise.resolve(result); + } + + /** + * Debug method to inspect current state and statistics + * + * @returns Object containing debug information about indexes, document counts, Lunr.js statistics, and LiQE capabilities + */ + getDebugInfo(): { + indexes: string[]; + documentCounts: Record; + lunrStats: Record< + string, + { documentCount: number; fieldCount: number } | null + >; + filterCapabilities: { + operators: string[]; + functions: string[]; + examples: string[]; + }; + } { + const indexes = this.indexManager.listIndexes(); + const documentCounts = this.documentStore.getAllCounts(); + const lunrStats: Record< + string, + { documentCount: number; fieldCount: number } | null + > = {}; + + for (const indexName of indexes) { + lunrStats[indexName] = this.searchEngine.getStats(indexName); + } + + return { + indexes, + documentCounts, + lunrStats, + filterCapabilities: this.searchEngine.getFilterCapabilities(), + }; + } + + /** + * Get information about supported LiQE filter capabilities + * + * @returns Object containing supported operators, functions, and examples + */ + getFilterCapabilities(): { + operators: string[]; + functions: string[]; + examples: string[]; + } { + return this.searchEngine.getFilterCapabilities(); + } + + /** + * Validate if a filter string is supported by LiQE + * + * @param filterString - Filter string to validate + * @returns True if the filter can be parsed by LiQE, false otherwise + */ + isFilterSupported(filterString: string): boolean { + return this.searchEngine.isFilterSupported(filterString); + } +} + +export { InMemoryCognitiveSearch }; diff --git a/packages/sthrift/search-service-mock/src/index-manager.test.ts b/packages/sthrift/search-service-mock/src/index-manager.test.ts new file mode 100644 index 000000000..b517ac741 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/index-manager.test.ts @@ -0,0 +1,166 @@ +/** + * Tests for IndexManager + * + * Tests index lifecycle operations including creation, retrieval, and deletion. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { IndexManager } from './index-manager'; +import type { SearchIndex } from './interfaces'; + +describe('IndexManager', () => { + let manager: IndexManager; + + const createTestIndex = (name: string): SearchIndex => ({ + name, + fields: [ + { name: 'id', type: 'Edm.String', key: true }, + { name: 'title', type: 'Edm.String', searchable: true }, + { name: 'description', type: 'Edm.String', searchable: true }, + { name: 'price', type: 'Edm.Double', sortable: true, filterable: true }, + { + name: 'category', + type: 'Edm.String', + filterable: true, + facetable: true, + }, + ], + }); + + beforeEach(() => { + manager = new IndexManager(); + }); + + describe('has', () => { + it('should return false for non-existent index', () => { + expect(manager.has('non-existent')).toBe(false); + }); + + it('should return true for existing index', () => { + manager.create(createTestIndex('test-index')); + expect(manager.has('test-index')).toBe(true); + }); + }); + + describe('create', () => { + it('should create a new index', () => { + const indexDef = createTestIndex('test-index'); + manager.create(indexDef); + expect(manager.has('test-index')).toBe(true); + }); + + it('should overwrite existing index with same name', () => { + const indexDef1 = createTestIndex('test-index'); + const indexDef2: SearchIndex = { + name: 'test-index', + fields: [{ name: 'id', type: 'Edm.String', key: true }], + }; + + manager.create(indexDef1); + manager.create(indexDef2); + + const retrieved = manager.get('test-index'); + expect(retrieved?.fields).toHaveLength(1); + }); + }); + + describe('get', () => { + it('should return undefined for non-existent index', () => { + expect(manager.get('non-existent')).toBeUndefined(); + }); + + it('should return the index definition', () => { + const indexDef = createTestIndex('test-index'); + manager.create(indexDef); + + const retrieved = manager.get('test-index'); + expect(retrieved).toEqual(indexDef); + }); + + it('should return correct fields for the index', () => { + const indexDef = createTestIndex('test-index'); + manager.create(indexDef); + + const retrieved = manager.get('test-index'); + expect(retrieved?.fields).toHaveLength(5); + expect(retrieved?.fields.find((f) => f.name === 'id')?.key).toBe(true); + expect( + retrieved?.fields.find((f) => f.name === 'title')?.searchable, + ).toBe(true); + expect(retrieved?.fields.find((f) => f.name === 'price')?.sortable).toBe( + true, + ); + }); + }); + + describe('delete', () => { + it('should delete an existing index', () => { + manager.create(createTestIndex('test-index')); + manager.delete('test-index'); + expect(manager.has('test-index')).toBe(false); + }); + + it('should do nothing for non-existent index', () => { + // Should not throw + manager.delete('non-existent'); + expect(manager.has('non-existent')).toBe(false); + }); + }); + + describe('listIndexes', () => { + it('should return empty array when no indexes exist', () => { + expect(manager.listIndexes()).toEqual([]); + }); + + it('should return all index names', () => { + manager.create(createTestIndex('index1')); + manager.create(createTestIndex('index2')); + manager.create(createTestIndex('index3')); + + const names = manager.listIndexes(); + expect(names).toHaveLength(3); + expect(names).toContain('index1'); + expect(names).toContain('index2'); + expect(names).toContain('index3'); + }); + + it('should not include deleted indexes', () => { + manager.create(createTestIndex('index1')); + manager.create(createTestIndex('index2')); + manager.delete('index1'); + + const names = manager.listIndexes(); + expect(names).toEqual(['index2']); + }); + }); + + describe('getAll', () => { + it('should return empty map when no indexes exist', () => { + const all = manager.getAll(); + expect(all.size).toBe(0); + }); + + it('should return all index definitions', () => { + const index1 = createTestIndex('index1'); + const index2 = createTestIndex('index2'); + manager.create(index1); + manager.create(index2); + + const all = manager.getAll(); + expect(all.size).toBe(2); + expect(all.get('index1')).toEqual(index1); + expect(all.get('index2')).toEqual(index2); + }); + + it('should return a copy, not the internal map', () => { + const index1 = createTestIndex('index1'); + manager.create(index1); + + const all = manager.getAll(); + all.delete('index1'); + + // Original should still have the index + expect(manager.has('index1')).toBe(true); + }); + }); +}); diff --git a/packages/sthrift/search-service-mock/src/index-manager.ts b/packages/sthrift/search-service-mock/src/index-manager.ts new file mode 100644 index 000000000..289db3ec2 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/index-manager.ts @@ -0,0 +1,68 @@ +import type { SearchIndex } from './interfaces.js'; + +/** + * Index Manager + * + * Manages search index definitions and lifecycle operations. + * Provides a clean interface for index creation, updates, and deletion. + */ +export class IndexManager { + private indexes: Map = new Map(); + + /** + * Check if an index exists + * + * @param indexName - The name of the index to check + * @returns True if the index exists, false otherwise + */ + has(indexName: string): boolean { + return this.indexes.has(indexName); + } + + /** + * Create a new index + * + * @param indexDefinition - The definition of the index to create + */ + create(indexDefinition: SearchIndex): void { + this.indexes.set(indexDefinition.name, indexDefinition); + } + + /** + * Get an index definition + * + * @param indexName - The name of the index to get + * @returns The index definition or undefined if not found + */ + get(indexName: string): SearchIndex | undefined { + return this.indexes.get(indexName); + } + + /** + * Delete an index + * + * @param indexName - The name of the index to delete + */ + delete(indexName: string): void { + this.indexes.delete(indexName); + } + + /** + * List all index names + * + * @returns Array of index names + */ + listIndexes(): string[] { + return Array.from(this.indexes.keys()); + } + + /** + * Get all index definitions + * + * @returns Map of index names to index definitions + */ + getAll(): Map { + return new Map(this.indexes); + } +} + diff --git a/packages/sthrift/search-service-mock/src/index.ts b/packages/sthrift/search-service-mock/src/index.ts new file mode 100644 index 000000000..d749b834d --- /dev/null +++ b/packages/sthrift/search-service-mock/src/index.ts @@ -0,0 +1,13 @@ +/** + * Search Service Mock Package + * + * Provides a mock implementation of SearchService for local development. + * This package allows developers to work with search functionality without requiring + * Azure credentials or external services. + */ + +export * from './in-memory-search.ts'; +// Default export for convenience +export { InMemoryCognitiveSearch as default } from './in-memory-search.ts'; +export * from './lunr-search-engine.ts'; +export * from './liqe-filter-engine.ts'; diff --git a/packages/sthrift/search-service-mock/src/interfaces.ts b/packages/sthrift/search-service-mock/src/interfaces.ts new file mode 100644 index 000000000..73571a5f4 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/interfaces.ts @@ -0,0 +1,72 @@ +/** + * Mock Cognitive Search Interfaces + * + * These interfaces match the Azure Cognitive Search SDK patterns + * to provide a drop-in replacement for development environments. + */ + +export interface SearchIndex { + name: string; + fields: SearchField[]; +} + +export interface SearchField { + name: string; + type: SearchFieldType; + key?: boolean; + searchable?: boolean; + filterable?: boolean; + sortable?: boolean; + facetable?: boolean; + retrievable?: boolean; +} + +type SearchFieldType = + | 'Edm.String' + | 'Edm.Int32' + | 'Edm.Int64' + | 'Edm.Double' + | 'Edm.Boolean' + | 'Edm.DateTimeOffset' + | 'Edm.GeographyPoint' + | 'Collection(Edm.String)' + | 'Collection(Edm.Int32)' + | 'Collection(Edm.Int64)' + | 'Collection(Edm.Double)' + | 'Collection(Edm.Boolean)' + | 'Collection(Edm.DateTimeOffset)' + | 'Collection(Edm.GeographyPoint)' + | 'Edm.ComplexType' + | 'Collection(Edm.ComplexType)'; + +export interface SearchOptions { + queryType?: 'simple' | 'full'; + searchMode?: 'any' | 'all'; + includeTotalCount?: boolean; + filter?: string; + facets?: string[]; + top?: number; + skip?: number; + orderBy?: string[]; + select?: string[]; +} + +export interface SearchDocumentsResult> { + results: Array<{ + document: T; + score?: number; + }>; + count?: number; + facets?: Record< + string, + Array<{ + value: string | number | boolean; + count: number; + }> + >; +} + +export interface SearchResult { + document: Record; + score?: number; +} diff --git a/packages/sthrift/search-service-mock/src/liqe-filter-engine.test.ts b/packages/sthrift/search-service-mock/src/liqe-filter-engine.test.ts new file mode 100644 index 000000000..af5efdfe4 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/liqe-filter-engine.test.ts @@ -0,0 +1,315 @@ +/** + * Tests for LiQEFilterEngine + * + * Tests OData to LiQE conversion, filter validation, and filtering operations. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { LiQEFilterEngine } from './liqe-filter-engine'; +import type { SearchResult } from './interfaces'; + +describe('LiQEFilterEngine', () => { + let filterEngine: LiQEFilterEngine; + + beforeEach(() => { + filterEngine = new LiQEFilterEngine(); + }); + + describe('applyAdvancedFilter', () => { + const createResult = (doc: Record): SearchResult => ({ + document: doc, + score: 1, + }); + + const testResults: SearchResult[] = [ + createResult({ id: '1', state: 'active', price: 100, title: 'Bike' }), + createResult({ + id: '2', + state: 'inactive', + price: 200, + title: 'Scooter', + }), + createResult({ + id: '3', + state: 'active', + price: 300, + title: 'Skateboard', + }), + createResult({ id: '4', state: 'pending', price: 50, title: 'Helmet' }), + ]; + + it('should return all results for empty filter', () => { + const results = filterEngine.applyAdvancedFilter(testResults, ''); + expect(results).toHaveLength(4); + }); + + it('should return all results for whitespace-only filter', () => { + const results = filterEngine.applyAdvancedFilter(testResults, ' '); + expect(results).toHaveLength(4); + }); + + it('should filter by exact equality (eq operator)', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + "state eq 'active'", + ); + expect(results).toHaveLength(2); + expect(results.every((r) => r.document.state === 'active')).toBe(true); + }); + + it('should not match substrings with eq operator (active should not match inactive)', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + "state eq 'active'", + ); + expect(results).toHaveLength(2); + // Verify none of the results have 'inactive' state + expect(results.some((r) => r.document.state === 'inactive')).toBe(false); + }); + + it('should filter by inequality (ne operator)', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + "state ne 'active'", + ); + expect(results).toHaveLength(2); + expect(results.every((r) => r.document.state !== 'active')).toBe(true); + }); + + it('should filter by greater than (gt operator)', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + 'price gt 100', + ); + expect(results).toHaveLength(2); + expect(results.every((r) => (r.document.price as number) > 100)).toBe( + true, + ); + }); + + it('should filter by less than (lt operator)', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + 'price lt 200', + ); + expect(results).toHaveLength(2); + expect(results.every((r) => (r.document.price as number) < 200)).toBe( + true, + ); + }); + + it('should filter by greater than or equal (ge operator)', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + 'price ge 200', + ); + expect(results).toHaveLength(2); + expect(results.every((r) => (r.document.price as number) >= 200)).toBe( + true, + ); + }); + + it('should filter by less than or equal (le operator)', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + 'price le 100', + ); + expect(results).toHaveLength(2); + expect(results.every((r) => (r.document.price as number) <= 100)).toBe( + true, + ); + }); + + it('should filter with AND operator', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + "state eq 'active' and price gt 100", + ); + expect(results).toHaveLength(1); + expect(results[0].document.id).toBe('3'); + }); + + it('should filter with OR operator', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + "state eq 'pending' or price gt 250", + ); + expect(results).toHaveLength(2); + }); + + it('should handle contains function', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + "contains(title, 'Bike')", + ); + expect(results).toHaveLength(1); + expect(results[0].document.title).toBe('Bike'); + }); + + it('should handle startswith function', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + "startswith(title, 'Sk')", + ); + expect(results).toHaveLength(1); + expect(results[0].document.title).toBe('Skateboard'); + }); + + it('should handle endswith function', () => { + const results = filterEngine.applyAdvancedFilter( + testResults, + "endswith(title, 'er')", + ); + expect(results).toHaveLength(1); + expect(results[0].document.title).toBe('Scooter'); + }); + + it('should handle boolean equality', () => { + const boolResults: SearchResult[] = [ + createResult({ id: '1', isActive: true }), + createResult({ id: '2', isActive: false }), + ]; + const results = filterEngine.applyAdvancedFilter( + boolResults, + 'isActive eq true', + ); + expect(results).toHaveLength(1); + expect(results[0].document.isActive).toBe(true); + }); + + it('should fallback to basic filter for malformed queries', () => { + // This malformed query should trigger basic filter fallback + const results = filterEngine.applyAdvancedFilter( + testResults, + "state eq 'active'", + ); + expect(results).toHaveLength(2); + }); + }); + + describe('isFilterSupported', () => { + it('should return true for empty filter', () => { + expect(filterEngine.isFilterSupported('')).toBe(true); + }); + + it('should return true for whitespace-only filter', () => { + expect(filterEngine.isFilterSupported(' ')).toBe(true); + }); + + it('should return true for valid eq filter', () => { + expect(filterEngine.isFilterSupported("state eq 'active'")).toBe(true); + }); + + it('should return true for valid ne filter', () => { + expect(filterEngine.isFilterSupported("state ne 'inactive'")).toBe(true); + }); + + it('should return true for valid comparison operators', () => { + expect(filterEngine.isFilterSupported('price gt 100')).toBe(true); + expect(filterEngine.isFilterSupported('price lt 100')).toBe(true); + expect(filterEngine.isFilterSupported('price ge 100')).toBe(true); + expect(filterEngine.isFilterSupported('price le 100')).toBe(true); + }); + + it('should return true for valid logical operators', () => { + expect( + filterEngine.isFilterSupported("state eq 'active' and price gt 100"), + ).toBe(true); + expect( + filterEngine.isFilterSupported("state eq 'active' or price gt 100"), + ).toBe(true); + }); + + it('should return true for valid string functions', () => { + expect(filterEngine.isFilterSupported("contains(title, 'test')")).toBe( + true, + ); + expect(filterEngine.isFilterSupported("startswith(title, 'test')")).toBe( + true, + ); + expect(filterEngine.isFilterSupported("endswith(title, 'test')")).toBe( + true, + ); + }); + + it('should return false for invalid filter without operators', () => { + expect(filterEngine.isFilterSupported('invalid query')).toBe(false); + }); + + it('should return false for completely malformed syntax', () => { + expect(filterEngine.isFilterSupported('{{{{{')).toBe(false); + }); + }); + + describe('getSupportedFeatures', () => { + it('should return supported operators', () => { + const features = filterEngine.getSupportedFeatures(); + expect(features.operators).toContain('eq'); + expect(features.operators).toContain('ne'); + expect(features.operators).toContain('gt'); + expect(features.operators).toContain('lt'); + expect(features.operators).toContain('ge'); + expect(features.operators).toContain('le'); + expect(features.operators).toContain('and'); + expect(features.operators).toContain('or'); + }); + + it('should return supported functions', () => { + const features = filterEngine.getSupportedFeatures(); + expect(features.functions).toContain('contains'); + expect(features.functions).toContain('startswith'); + expect(features.functions).toContain('endswith'); + }); + + it('should return example queries', () => { + const features = filterEngine.getSupportedFeatures(); + expect(features.examples).toBeInstanceOf(Array); + expect(features.examples.length).toBeGreaterThan(0); + }); + }); + + describe('basic filter fallback', () => { + const createResult = (doc: Record): SearchResult => ({ + document: doc, + score: 1, + }); + + it('should handle nested field access', () => { + const nestedResults: SearchResult[] = [ + createResult({ id: '1', metadata: { status: 'active' } }), + createResult({ id: '2', metadata: { status: 'inactive' } }), + ]; + // Basic filter with nested fields - falls back to basic filter + const results = filterEngine.applyAdvancedFilter( + nestedResults, + "metadata.status eq 'active'", + ); + expect(results.length).toBeGreaterThanOrEqual(0); // May or may not work depending on LiQE support + }); + + it('should skip overly long filter strings for safety', () => { + const testResults: SearchResult[] = [ + createResult({ id: '1', state: 'active' }), + ]; + // Create a filter longer than 2048 characters to trigger safety check + const longFilter = "state eq '" + 'a'.repeat(2100) + "'"; + // This should return results (safety fallback returns all) + const results = filterEngine.applyAdvancedFilter(testResults, longFilter); + expect(results.length).toBeGreaterThanOrEqual(0); + }); + + it('should handle multiple AND conditions in basic filter', () => { + const testResults: SearchResult[] = [ + createResult({ id: '1', state: 'active', category: 'sports' }), + createResult({ id: '2', state: 'active', category: 'tools' }), + createResult({ id: '3', state: 'inactive', category: 'sports' }), + ]; + const results = filterEngine.applyAdvancedFilter( + testResults, + "state eq 'active' and category eq 'sports'", + ); + expect(results).toHaveLength(1); + expect(results[0].document.id).toBe('1'); + }); + }); +}); diff --git a/packages/sthrift/search-service-mock/src/liqe-filter-engine.ts b/packages/sthrift/search-service-mock/src/liqe-filter-engine.ts new file mode 100644 index 000000000..bb45d1679 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/liqe-filter-engine.ts @@ -0,0 +1,446 @@ +/** + * LiQE Filter Engine for Advanced OData-like Filtering + * + * Provides advanced filtering capabilities using LiQE (Lucene-like Query Engine) + * to support complex OData-style filter expressions including: + * - Comparison operators (eq, ne, gt, lt, ge, le) + * - Logical operators (and, or) + * - String functions (contains, startswith, endswith) + * - Complex nested expressions + * + * This engine enhances the mock cognitive search with sophisticated filtering + * that closely matches Azure Cognitive Search OData filter capabilities. + * + * OData to LiQE syntax mapping: + * - "field eq 'value'" -> "field:value" + * - "field ne 'value'" -> "NOT field:value" + * - "field gt 100" -> "field:>100" + * - "field lt 100" -> "field:<100" + * - "field ge 100" -> "field:>=100" + * - "field le 100" -> "field:<=100" + * - "field and field2" -> "field AND field2" + * - "field or field2" -> "field OR field2" + * - "contains(field, 'text')" -> "field:*text*" + * - "startswith(field, 'text')" -> "field:text*" + * - "endswith(field, 'text')" -> "field:*text" + * + * Security Note: All regex patterns in this file are designed to be safe from + * catastrophic backtracking (ReDoS). We use: + * - Negated character classes instead of .* + * - String methods (split, indexOf) instead of complex regex where possible + * - Input length limits to prevent DoS + */ + +import { parse, test } from 'liqe'; +import type { SearchResult } from './interfaces.js'; + +/** Maximum allowed filter string length to prevent DoS */ +const MAX_FILTER_LENGTH = 2048; + +/** + * LiQE Filter Engine for advanced OData-like filtering + * + * This class provides sophisticated filtering capabilities using LiQE to parse + * and execute complex filter expressions that match Azure Cognitive Search + * OData filter syntax patterns. + */ +export class LiQEFilterEngine { + /** + * Apply advanced filtering using LiQE to parse and execute filter expressions + * + * @param results - Array of search results to filter + * @param filterString - OData-style filter string to parse and apply + * @returns Filtered array of search results + */ + applyAdvancedFilter( + results: SearchResult[], + filterString: string, + ): SearchResult[] { + if (!filterString || filterString.trim() === '') { + return results; + } + + // Safety: cap input size to avoid expensive parsing on untrusted input + if (filterString.length > MAX_FILTER_LENGTH) { + console.warn('Filter string too long; skipping filter for safety.'); + return results; + } + + try { + // Convert OData syntax to LiQE syntax + const liqeQuery = this.convertODataToLiQE(filterString); + + // Parse the converted filter string using LiQE + const parsedQuery = parse(liqeQuery); + + // Filter results using LiQE's test function + return results.filter((result) => { + return test(parsedQuery, result.document); + }); + } catch (error) { + console.warn(`LiQE filter parsing failed for "${filterString}":`, error); + // Fallback to basic filtering for malformed queries + return this.applyBasicFilter(results, filterString); + } + } + + /** + * Apply basic OData-style filtering as fallback for unsupported expressions + * + * @param results - Array of search results to filter + * @param filterString - Basic filter string to apply + * @returns Filtered array of search results + * @private + */ + private applyBasicFilter( + results: SearchResult[], + filterString: string, + ): SearchResult[] { + // Safety: cap input size to avoid expensive parsing on untrusted input + if (filterString.length > MAX_FILTER_LENGTH) { + console.warn('Filter string too long; skipping basic filter for safety.'); + return results; + } + + // Linear-time parse for basic patterns like: "field eq 'value'" joined by "and" + // Uses string methods instead of regex to avoid ReDoS + const filters: Array<{ field: string; value: string }> = []; + const parts = this.splitByKeyword(filterString.trim(), ' and '); + + for (const rawPart of parts) { + const part = rawPart.trim(); + const eqIndex = part.toLowerCase().indexOf(' eq '); + if (eqIndex === -1) continue; + + const field = part.slice(0, eqIndex).trim(); + let value = part.slice(eqIndex + 4).trim(); // after ' eq ' + if (!field || value.length === 0) continue; + + // Strip one pair of surrounding quotes if present + if ( + (value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"')) + ) { + value = value.slice(1, -1); + } + + // Skip if internal quotes remain to keep parsing simple and safe + if (value.includes("'") || value.includes('"')) continue; + + filters.push({ field, value }); + } + + return results.filter((result) => { + return filters.every((filter) => { + const fieldValue = this.getFieldValue(result.document, filter.field); + return String(fieldValue) === filter.value; + }); + }); + } + + /** + * Split string by keyword (case-insensitive) without using regex + * This is a safe alternative to split(/\s+and\s+/i) + * + * @param str - String to split + * @param keyword - Keyword to split by (e.g., ' and ') + * @returns Array of parts + * @private + */ + private splitByKeyword(str: string, keyword: string): string[] { + const result: string[] = []; + const lowerStr = str.toLowerCase(); + const lowerKeyword = keyword.toLowerCase(); + let lastIndex = 0; + + let index = lowerStr.indexOf(lowerKeyword, lastIndex); + while (index !== -1) { + result.push(str.slice(lastIndex, index)); + lastIndex = index + keyword.length; + index = lowerStr.indexOf(lowerKeyword, lastIndex); + } + + result.push(str.slice(lastIndex)); + return result; + } + + /** + * Get field value from document, supporting nested property access + * + * @param document - Document to extract field value from + * @param fieldName - Field name (supports dot notation for nested properties) + * @returns Field value or undefined if not found + * @private + */ + private getFieldValue( + document: Record, + fieldName: string, + ): unknown { + return fieldName.split('.').reduce((obj, key) => { + if (obj && typeof obj === 'object' && key in obj) { + return (obj as Record)[key]; + } + return undefined; + }, document); + } + + /** + * Convert OData filter syntax to LiQE syntax + * + * Uses safe regex patterns that avoid catastrophic backtracking: + * - Negated character classes [^\s] instead of .* + * - Bounded quantifiers where possible + * - Simple alternations without nested quantifiers + * + * @param odataFilter - OData-style filter string + * @returns LiQE-compatible filter string + * @private + */ + private convertODataToLiQE(odataFilter: string): string { + let liqeQuery = odataFilter; + + // Handle string functions first using safe regex patterns + // contains(field, 'text') -> field:text + // Pattern: contains followed by ( field , 'value' ) + // Uses [^)]+ to match non-paren chars (linear time) + liqeQuery = liqeQuery.replace( + /contains\(([^,]+),\s*'([^']+)'\)/gi, + (_, field, value) => `${field.trim()}:${value}`, + ); + liqeQuery = liqeQuery.replace( + /contains\(([^,]+),\s*"([^"]+)"\)/gi, + (_, field, value) => `${field.trim()}:${value}`, + ); + + // startswith(field, 'text') -> field:text* + liqeQuery = liqeQuery.replace( + /startswith\(([^,]+),\s*'([^']+)'\)/gi, + (_, field, value) => `${field.trim()}:${value}*`, + ); + liqeQuery = liqeQuery.replace( + /startswith\(([^,]+),\s*"([^"]+)"\)/gi, + (_, field, value) => `${field.trim()}:${value}*`, + ); + + // endswith(field, 'text') -> field:*text + liqeQuery = liqeQuery.replace( + /endswith\(([^,]+),\s*'([^']+)'\)/gi, + (_, field, value) => `${field.trim()}:*${value}`, + ); + liqeQuery = liqeQuery.replace( + /endswith\(([^,]+),\s*"([^"]+)"\)/gi, + (_, field, value) => `${field.trim()}:*${value}`, + ); + + // Handle comparison operators using safe patterns + // Pattern: word + space + operator + space + value + // Uses bounded character classes with length limits to prevent backtracking DoS + // Field names limited to 100 chars, numeric values to 20 digits (safe for all practical uses) + + // field gt value -> field:>value (numeric) + liqeQuery = liqeQuery.replace(/(\w{1,100}) gt (\d{1,20})/g, '$1:>$2'); + + // field lt value -> field: field:>=value (numeric) + liqeQuery = liqeQuery.replace(/(\w{1,100}) ge (\d{1,20})/g, '$1:>=$2'); + + // field le value -> field:<=value (numeric) + liqeQuery = liqeQuery.replace(/(\w{1,100}) le (\d{1,20})/g, '$1:<=$2'); + + // field eq 'value' -> field:/^value$/ (exact string match) + // String values limited to 500 chars for safety + liqeQuery = liqeQuery.replace( + /(\w{1,100}) eq '([^']{1,500})'/g, + '$1:/^$2$$/', + ); + liqeQuery = liqeQuery.replace( + /(\w{1,100}) eq "([^"]{1,500})"/g, + '$1:/^$2$$/', + ); + + // field ne 'value' -> NOT field:/^value$/ (not equal string) + liqeQuery = liqeQuery.replace( + /(\w{1,100}) ne '([^']{1,500})'/g, + 'NOT $1:/^$2$$/', + ); + liqeQuery = liqeQuery.replace( + /(\w{1,100}) ne "([^"]{1,500})"/g, + 'NOT $1:/^$2$$/', + ); + + // Handle boolean values (exact match) + liqeQuery = liqeQuery.replace(/(\w{1,100}) eq true\b/g, '$1:/^true$$/'); + liqeQuery = liqeQuery.replace(/(\w{1,100}) eq false\b/g, '$1:/^false$$/'); + + // Handle logical operators using string replacement (safer than regex) + liqeQuery = this.replaceLogicalOperators(liqeQuery); + + // Handle parentheses - normalize spacing + liqeQuery = this.normalizeParentheses(liqeQuery); + + // Normalize whitespace - replace multiple spaces with single space + liqeQuery = this.normalizeWhitespace(liqeQuery); + + return liqeQuery; + } + + /** + * Replace logical operators (and/or) with uppercase versions + * Uses string methods to avoid regex backtracking issues + * + * @param query - Query string to process + * @returns Query with normalized logical operators + * @private + */ + private replaceLogicalOperators(query: string): string { + // Split by spaces and replace 'and'/'or' tokens + return query + .split(' ') + .map((token) => { + const lower = token.toLowerCase(); + if (lower === 'and') return 'AND'; + if (lower === 'or') return 'OR'; + return token; + }) + .join(' '); + } + + /** + * Normalize parentheses spacing without using complex regex + * + * @param query - Query string to process + * @returns Query with normalized parentheses + * @private + */ + private normalizeParentheses(query: string): string { + let result = ''; + for (let i = 0; i < query.length; i++) { + const char = query[i]; + if (char === '(') { + // Add space before ( if needed, space after + if (result.length > 0 && result[result.length - 1] !== ' ') { + result += ' '; + } + result += '('; + } else if (char === ')') { + // Remove trailing space before ), add space after + result = result.trimEnd(); + result += ') '; + } else { + result += char; + } + } + return result.trim(); + } + + /** + * Normalize whitespace - replace multiple spaces with single space + * Uses string methods to avoid regex backtracking + * + * @param query - Query string to process + * @returns Query with normalized whitespace + * @private + */ + private normalizeWhitespace(query: string): string { + return query + .split(' ') + .filter((part) => part.length > 0) + .join(' '); + } + + /** + * Validate if a filter string is supported by LiQE + * + * @param filterString - Filter string to validate + * @returns True if the filter can be parsed by LiQE, false otherwise + */ + isFilterSupported(filterString: string): boolean { + if (!filterString || filterString.trim() === '') { + return true; + } + + // Safety check for input length + if (filterString.length > MAX_FILTER_LENGTH) { + return false; + } + + // Check for valid operators using indexOf (no regex backtracking risk) + const lowerFilter = filterString.toLowerCase(); + const hasOperator = + lowerFilter.includes(' eq ') || + lowerFilter.includes(' ne ') || + lowerFilter.includes(' gt ') || + lowerFilter.includes(' lt ') || + lowerFilter.includes(' ge ') || + lowerFilter.includes(' le ') || + lowerFilter.includes(' and ') || + lowerFilter.includes(' or ') || + lowerFilter.includes('contains(') || + lowerFilter.includes('startswith(') || + lowerFilter.includes('endswith('); + + if (!hasOperator) { + return false; + } + + try { + const liqeQuery = this.convertODataToLiQE(filterString); + const parsed = parse(liqeQuery); + + // Additional validation: ensure the parsed query has a valid structure + if (!parsed || typeof parsed !== 'object') { + return false; + } + + // Check if it's a valid LiQE query structure + if ('type' in (parsed as Record)) { + const t = (parsed as Record)['type']; + return ( + t === 'Tag' || t === 'LogicalExpression' || t === 'UnaryOperator' + ); + } + return false; + } catch { + return false; + } + } + + /** + * Get information about supported filter syntax and operators + * + * @returns Object containing supported operators and functions + */ + getSupportedFeatures(): { + operators: string[]; + functions: string[]; + examples: string[]; + } { + return { + operators: [ + 'eq', // equals + 'ne', // not equals + 'gt', // greater than + 'lt', // less than + 'ge', // greater than or equal + 'le', // less than or equal + 'and', // logical and + 'or', // logical or + ], + functions: [ + 'contains', // substring matching + 'startswith', // prefix matching + 'endswith', // suffix matching + ], + examples: [ + "title eq 'Mountain Bike'", + 'price gt 100 and price lt 500', + "contains(description, 'bike')", + "startswith(title, 'Mountain')", + "category eq 'Sports' or category eq 'Tools'", + '(price ge 100 and price le 500) and isActive eq true', + ], + }; + } +} diff --git a/packages/sthrift/search-service-mock/src/lunr-search-engine.test.ts b/packages/sthrift/search-service-mock/src/lunr-search-engine.test.ts new file mode 100644 index 000000000..1f0887cbd --- /dev/null +++ b/packages/sthrift/search-service-mock/src/lunr-search-engine.test.ts @@ -0,0 +1,428 @@ +/** + * Tests for LunrSearchEngine + * + * Tests full-text search with Lunr.js including relevance scoring, + * field boosting, facets, sorting, pagination, and advanced filtering. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { LunrSearchEngine } from './lunr-search-engine'; +import type { SearchField } from './interfaces'; + +describe('LunrSearchEngine', () => { + let engine: LunrSearchEngine; + + const testFields: SearchField[] = [ + { name: 'id', type: 'Edm.String', key: true }, + { name: 'title', type: 'Edm.String', searchable: true }, + { name: 'description', type: 'Edm.String', searchable: true }, + { name: 'price', type: 'Edm.Double', sortable: true, filterable: true }, + { name: 'category', type: 'Edm.String', filterable: true, facetable: true }, + { name: 'brand', type: 'Edm.String', filterable: true, facetable: true }, + ]; + + const testDocuments = [ + { + id: '1', + title: 'Mountain Bike', + description: 'Great for trails and off-road', + price: 500, + category: 'Sports', + brand: 'Trek', + }, + { + id: '2', + title: 'Road Bike', + description: 'Fast on pavement and racing', + price: 800, + category: 'Sports', + brand: 'Giant', + }, + { + id: '3', + title: 'Power Drill', + description: 'Cordless power tool', + price: 150, + category: 'Tools', + brand: 'DeWalt', + }, + { + id: '4', + title: 'Electric Scooter', + description: 'Eco-friendly transportation', + price: 600, + category: 'Sports', + brand: 'Razor', + }, + { + id: '5', + title: 'Hammer', + description: 'Basic hand tool', + price: 25, + category: 'Tools', + brand: 'Stanley', + }, + ]; + + beforeEach(() => { + engine = new LunrSearchEngine(); + }); + + describe('buildIndex', () => { + it('should build an index from documents', () => { + engine.buildIndex('test-index', testFields, testDocuments); + expect(engine.hasIndex('test-index')).toBe(true); + }); + + it('should store correct document count', () => { + engine.buildIndex('test-index', testFields, testDocuments); + const stats = engine.getIndexStats('test-index'); + expect(stats?.documentCount).toBe(5); + }); + + it('should store correct field count', () => { + engine.buildIndex('test-index', testFields, testDocuments); + const stats = engine.getIndexStats('test-index'); + expect(stats?.fieldCount).toBe(6); + }); + + it('should handle empty document array', () => { + engine.buildIndex('empty-index', testFields, []); + expect(engine.hasIndex('empty-index')).toBe(true); + const stats = engine.getIndexStats('empty-index'); + expect(stats?.documentCount).toBe(0); + }); + }); + + describe('rebuildIndex', () => { + it('should rebuild index with updated documents', () => { + engine.buildIndex('test-index', testFields, testDocuments); + engine.addDocument('test-index', { + id: '6', + title: 'New Item', + description: 'Test', + price: 100, + category: 'Other', + brand: 'Generic', + }); + + const stats = engine.getIndexStats('test-index'); + expect(stats?.documentCount).toBe(6); + }); + + it('should warn for non-existent index', () => { + // Should not throw, just warn + engine.rebuildIndex('non-existent'); + expect(engine.hasIndex('non-existent')).toBe(false); + }); + }); + + describe('addDocument', () => { + it('should add a document to the index', () => { + engine.buildIndex('test-index', testFields, []); + engine.addDocument('test-index', testDocuments[0]); + + const stats = engine.getIndexStats('test-index'); + expect(stats?.documentCount).toBe(1); + }); + + it('should make document searchable after adding', () => { + engine.buildIndex('test-index', testFields, []); + engine.addDocument('test-index', testDocuments[0]); + + const results = engine.search('test-index', 'Mountain'); + expect(results.results.length).toBeGreaterThanOrEqual(1); + }); + + it('should warn for non-existent index', () => { + // Should not throw, just warn + engine.addDocument('non-existent', testDocuments[0]); + }); + + it('should warn for document without id', () => { + engine.buildIndex('test-index', testFields, []); + // Should not throw, just warn + engine.addDocument('test-index', { + title: 'No ID', + description: 'Missing id field', + }); + }); + }); + + describe('removeDocument', () => { + it('should remove a document from the index', () => { + engine.buildIndex('test-index', testFields, testDocuments); + engine.removeDocument('test-index', '1'); + + const stats = engine.getIndexStats('test-index'); + expect(stats?.documentCount).toBe(4); + }); + + it('should make document unsearchable after removal', () => { + engine.buildIndex('test-index', testFields, testDocuments); + engine.removeDocument('test-index', '1'); + + const results = engine.search('test-index', 'Mountain'); + expect(results.results).toHaveLength(0); + }); + + it('should warn for non-existent index', () => { + // Should not throw, just warn + engine.removeDocument('non-existent', '1'); + }); + }); + + describe('search', () => { + beforeEach(() => { + engine.buildIndex('test-index', testFields, testDocuments); + }); + + it('should find documents by keyword', () => { + const results = engine.search('test-index', 'Bike'); + expect(results.results.length).toBeGreaterThanOrEqual(2); + }); + + it('should return relevance scores', () => { + const results = engine.search('test-index', 'Bike'); + expect(results.results[0].score).toBeGreaterThan(0); + }); + + it('should return all documents for wildcard search', () => { + const results = engine.search('test-index', '*'); + expect(results.results).toHaveLength(5); + }); + + it('should return all documents for empty search', () => { + const results = engine.search('test-index', ''); + expect(results.results).toHaveLength(5); + }); + + it('should handle partial word matches with wildcards', () => { + const results = engine.search('test-index', 'Moun*'); + expect(results.results.length).toBeGreaterThanOrEqual(1); + }); + + it('should return empty results for non-existent index', () => { + const results = engine.search('non-existent', 'test'); + expect(results.results).toHaveLength(0); + expect(results.count).toBe(0); + }); + + it('should handle malformed queries gracefully', () => { + // Should return empty results without throwing + const results = engine.search('test-index', '{{{{malformed'); + expect(results.results).toHaveLength(0); + }); + }); + + describe('search with filters', () => { + beforeEach(() => { + engine.buildIndex('test-index', testFields, testDocuments); + }); + + it('should filter by category', () => { + const results = engine.search('test-index', '*', { + filter: "category eq 'Tools'", + }); + expect(results.results).toHaveLength(2); + expect( + results.results.every((r) => r.document.category === 'Tools'), + ).toBe(true); + }); + + it('should filter by price comparison', () => { + const results = engine.search('test-index', '*', { + filter: 'price gt 500', + }); + expect(results.results).toHaveLength(2); + expect( + results.results.every((r) => (r.document.price as number) > 500), + ).toBe(true); + }); + + it('should combine search and filter', () => { + const results = engine.search('test-index', 'Bike', { + filter: 'price gt 600', + }); + expect(results.results).toHaveLength(1); + expect(results.results[0].document.title).toBe('Road Bike'); + }); + }); + + describe('search with pagination', () => { + beforeEach(() => { + engine.buildIndex('test-index', testFields, testDocuments); + }); + + it('should limit results with top', () => { + const results = engine.search('test-index', '*', { + top: 2, + }); + expect(results.results).toHaveLength(2); + }); + + it('should skip results with skip', () => { + const allResults = engine.search('test-index', '*'); + const skippedResults = engine.search('test-index', '*', { + skip: 2, + }); + + expect(skippedResults.results).toHaveLength(3); + // Skipped results should not include first two + const skippedIds = skippedResults.results.map((r) => r.document.id); + expect(skippedIds).not.toContain(allResults.results[0].document.id); + expect(skippedIds).not.toContain(allResults.results[1].document.id); + }); + + it('should combine skip and top', () => { + const results = engine.search('test-index', '*', { + skip: 1, + top: 2, + }); + expect(results.results).toHaveLength(2); + }); + + it('should include total count', () => { + const results = engine.search('test-index', '*', { + top: 2, + includeTotalCount: true, + }); + expect(results.count).toBe(5); + }); + }); + + describe('search with sorting', () => { + beforeEach(() => { + engine.buildIndex('test-index', testFields, testDocuments); + }); + + it('should sort by price ascending', () => { + const results = engine.search('test-index', '*', { + orderBy: ['price asc'], + }); + + const prices = results.results.map((r) => r.document.price as number); + expect(prices).toEqual([...prices].sort((a, b) => a - b)); + }); + + it('should sort by price descending', () => { + const results = engine.search('test-index', '*', { + orderBy: ['price desc'], + }); + + const prices = results.results.map((r) => r.document.price as number); + expect(prices).toEqual([...prices].sort((a, b) => b - a)); + }); + + it('should sort by title alphabetically', () => { + const results = engine.search('test-index', '*', { + orderBy: ['title asc'], + }); + + const titles = results.results.map((r) => r.document.title as string); + expect(titles).toEqual([...titles].sort()); + }); + + it('should default to relevance sorting for text search', () => { + const results = engine.search('test-index', 'Bike'); + // First result should have the highest score + if (results.results.length > 1) { + expect(results.results[0].score).toBeGreaterThanOrEqual( + results.results[1].score ?? 0, + ); + } + }); + }); + + describe('search with facets', () => { + beforeEach(() => { + engine.buildIndex('test-index', testFields, testDocuments); + }); + + it('should return facet counts for category', () => { + const results = engine.search('test-index', '*', { + facets: ['category'], + }); + + expect(results.facets?.category).toBeDefined(); + const sportsFacet = results.facets?.category?.find( + (f) => f.value === 'Sports', + ); + const toolsFacet = results.facets?.category?.find( + (f) => f.value === 'Tools', + ); + expect(sportsFacet?.count).toBe(3); + expect(toolsFacet?.count).toBe(2); + }); + + it('should return facet counts for multiple fields', () => { + const results = engine.search('test-index', '*', { + facets: ['category', 'brand'], + }); + + expect(results.facets?.category).toBeDefined(); + expect(results.facets?.brand).toBeDefined(); + }); + + it('should sort facets by count descending', () => { + const results = engine.search('test-index', '*', { + facets: ['category'], + }); + + const facets = results.facets?.category || []; + for (let i = 1; i < facets.length; i++) { + expect(facets[i - 1].count).toBeGreaterThanOrEqual(facets[i].count); + } + }); + }); + + describe('hasIndex', () => { + it('should return false for non-existent index', () => { + expect(engine.hasIndex('non-existent')).toBe(false); + }); + + it('should return true for existing index', () => { + engine.buildIndex('test-index', testFields, []); + expect(engine.hasIndex('test-index')).toBe(true); + }); + }); + + describe('getIndexStats', () => { + it('should return null for non-existent index', () => { + expect(engine.getIndexStats('non-existent')).toBeNull(); + }); + + it('should return stats for existing index', () => { + engine.buildIndex('test-index', testFields, testDocuments); + const stats = engine.getIndexStats('test-index'); + + expect(stats).not.toBeNull(); + expect(stats?.documentCount).toBe(5); + expect(stats?.fieldCount).toBe(6); + }); + }); + + describe('getFilterCapabilities', () => { + it('should return LiQE filter capabilities', () => { + const capabilities = engine.getFilterCapabilities(); + + expect(capabilities.operators).toContain('eq'); + expect(capabilities.operators).toContain('ne'); + expect(capabilities.operators).toContain('gt'); + expect(capabilities.operators).toContain('lt'); + expect(capabilities.functions).toContain('contains'); + }); + }); + + describe('isFilterSupported', () => { + it('should validate supported filters', () => { + expect(engine.isFilterSupported("category eq 'Sports'")).toBe(true); + expect(engine.isFilterSupported('price gt 100')).toBe(true); + expect(engine.isFilterSupported('')).toBe(true); + }); + + it('should reject unsupported filters', () => { + expect(engine.isFilterSupported('invalid query')).toBe(false); + }); + }); +}); diff --git a/packages/sthrift/search-service-mock/src/lunr-search-engine.ts b/packages/sthrift/search-service-mock/src/lunr-search-engine.ts new file mode 100644 index 000000000..103fdc595 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/lunr-search-engine.ts @@ -0,0 +1,501 @@ +import lunr from 'lunr'; +import type { + SearchField, + SearchOptions, + SearchDocumentsResult, + SearchResult, +} from './interfaces.js'; +import { LiQEFilterEngine } from './liqe-filter-engine.js'; + +/** + * Lunr.js Search Engine Wrapper with LiQE Integration + * + * Provides enhanced full-text search capabilities with: + * - Relevance scoring based on TF-IDF + * - Field boosting (title gets higher weight than description) + * - Stemming and stop word filtering + * - Fuzzy matching and wildcard support + * - Multi-field search across all searchable fields + * - Advanced OData-like filtering via LiQE integration + * + * This class encapsulates the Lunr.js functionality and provides a clean interface + * for building and querying search indexes with Azure Cognitive Search compatibility. + * Enhanced with LiQE for sophisticated filtering capabilities. + */ +export class LunrSearchEngine { + private indexes: Map = new Map(); + private documents: Map>> = + new Map(); + private indexDefinitions: Map = new Map(); + private liqeFilterEngine: LiQEFilterEngine; + + constructor() { + this.liqeFilterEngine = new LiQEFilterEngine(); + } + + /** + * Build a Lunr.js index for the given index name + * + * @param indexName - The name of the search index to build + * @param fields - Array of search field definitions with their capabilities + * @param documents - Array of documents to index initially + */ + buildIndex( + indexName: string, + fields: SearchField[], + documents: Record[], + ): void { + // Store the index definition for later reference + this.indexDefinitions.set(indexName, { fields }); + + // Store documents for retrieval + const documentMap = new Map>(); + documents.forEach((doc) => { + const docId = doc['id'] as string; + if (docId) { + documentMap.set(docId, doc); + } + }); + this.documents.set(indexName, documentMap); + + // Build Lunr index + const idx = (lunr as unknown as typeof lunr)(function (this: lunr.Builder) { + // Set the reference field (unique identifier) + this.ref('id'); + + // Add fields with boosting + fields.forEach((field) => { + if (field.searchable && field.type === 'Edm.String') { + // Boost title field significantly more than others + const boost = + field.name === 'title' ? 10 : field.name === 'description' ? 2 : 1; + this.field(field.name, { boost }); + } + }); + + // Add all documents to the index + documents.forEach((doc) => { + this.add(doc); + }); + }); + + this.indexes.set(indexName, idx); + } + + /** + * Rebuild the index for an index name (used when documents are updated) + * + * @param indexName - The name of the index to rebuild + */ + rebuildIndex(indexName: string): void { + const documentMap = this.documents.get(indexName); + const indexDef = this.indexDefinitions.get(indexName); + + if (!documentMap || !indexDef) { + console.warn( + `Cannot rebuild index ${indexName}: missing documents or definition`, + ); + return; + } + + const documents = Array.from(documentMap.values()); + this.buildIndex(indexName, indexDef.fields, documents); + } + + /** + * Add a document to an existing index + * + * @param indexName - The name of the index to add the document to + * @param document - The document to add to the index + */ + addDocument(indexName: string, document: Record): void { + const documentMap = this.documents.get(indexName); + if (!documentMap) { + console.warn(`Cannot add document to ${indexName}: index not found`); + return; + } + + const docId = document['id'] as string; + if (!docId) { + console.warn('Document must have an id field'); + return; + } + + documentMap.set(docId, document); + this.rebuildIndex(indexName); + } + + /** + * Remove a document from an index + * + * @param indexName - The name of the index to remove the document from + * @param documentId - The ID of the document to remove + */ + removeDocument(indexName: string, documentId: string): void { + const documentMap = this.documents.get(indexName); + if (!documentMap) { + console.warn(`Cannot remove document from ${indexName}: index not found`); + return; + } + + documentMap.delete(documentId); + this.rebuildIndex(indexName); + } + + /** + * Search using Lunr.js with enhanced query processing + * + * @param indexName - The name of the index to search + * @param searchText - The search query text + * @param options - Optional search parameters (filters, pagination, facets, etc.) + * @returns Search results with relevance scoring and facets + */ + search( + indexName: string, + searchText: string, + options?: SearchOptions, + ): SearchDocumentsResult { + const idx = this.indexes.get(indexName); + const documentMap = this.documents.get(indexName); + + if (!idx || !documentMap) { + return { results: [], count: 0, facets: {} }; + } + + // Handle empty search - return all documents if no search text + if (!searchText || searchText.trim() === '' || searchText === '*') { + const allDocuments = Array.from(documentMap.values()); + + // Apply LiQE filters if provided, even for empty search + let filteredDocuments = allDocuments; + if (options?.filter) { + const searchResults = allDocuments.map((doc) => ({ + document: doc, + score: 1.0, + })); + const filteredResults = this.liqeFilterEngine.applyAdvancedFilter( + searchResults, + options.filter, + ); + filteredDocuments = filteredResults.map((result) => result.document); + } + + const results = this.applyPaginationAndSorting( + filteredDocuments, + options, + ); + + // Process facets if requested + const facets = + options?.facets && options.facets.length > 0 + ? this.processFacets( + filteredDocuments.map((doc) => ({ document: doc, score: 1.0 })), + options.facets, + ) + : {}; + + const result: SearchDocumentsResult = { + results: results.map((doc) => ({ document: doc, score: 1.0 })), + facets, + count: filteredDocuments.length, // Always include count for empty searches + }; + + return result; + } + + // Process search query with enhanced features + const processedQuery = this.processSearchQuery(searchText); + + try { + // Execute Lunr search - handle both simple text and wildcard queries + let lunrResults: lunr.Index.Result[]; + if (searchText.includes('*')) { + // For wildcard queries, use the original text without processing + lunrResults = idx.search(searchText); + } else { + lunrResults = idx.search(processedQuery); + } + + // Convert Lunr results to our format + const searchResults: (SearchResult | null)[] = lunrResults.map( + (result: lunr.Index.Result) => { + const document = documentMap.get(result.ref); + return document + ? { + document, + score: result.score, + } + : null; + }, + ); + + const results: SearchResult[] = searchResults.filter( + (result): result is SearchResult => result !== null, + ); + + // Apply additional filters if provided using LiQE for advanced filtering + const filteredResults = options?.filter + ? this.liqeFilterEngine.applyAdvancedFilter(results, options.filter) + : results; + + // Apply sorting, pagination, and facets + const finalResults = this.processFacetsAndPagination( + filteredResults, + options, + ); + + return finalResults; + } catch (error) { + console.warn(`Lunr search failed for query "${searchText}":`, error); + // Fallback to empty results for malformed queries + return { results: [], count: 0, facets: {} }; + } + } + + /** + * Process search query to add fuzzy matching and wildcard support + * + * @param searchText - The original search text + * @returns Processed search text with wildcards and fuzzy matching + * @private + */ + private processSearchQuery(searchText: string): string { + // If query already contains wildcards or fuzzy operators, use as-is + if (searchText.includes('*') || searchText.includes('~')) { + return searchText; + } + + // For simple queries, add wildcard for prefix matching + // This helps with partial word matches + return `${searchText}*`; + } + + /** + * Apply facets, sorting, and pagination + */ + private processFacetsAndPagination( + results: SearchResult[], + options?: SearchOptions, + ): SearchDocumentsResult { + // Apply sorting if provided (default to relevance score descending) + let sortedResults; + if (options?.orderBy && options.orderBy.length > 0) { + sortedResults = this.applySorting(results, options.orderBy); + } else { + // Default sort by relevance score (descending) + sortedResults = results.sort((a, b) => (b.score || 0) - (a.score || 0)); + } + + // Apply pagination + const skip = options?.skip || 0; + const top = options?.top || 50; + const totalCount = sortedResults.length; + const paginatedResults = sortedResults.slice(skip, skip + top); + + // Process facets if requested + const facets = + options?.facets && options.facets.length > 0 + ? this.processFacets(sortedResults, options.facets) + : {}; + + const result: SearchDocumentsResult = { + results: paginatedResults, + facets, + count: totalCount, // Always include count for consistency + }; + + return result; + } + + /** + * Apply sorting to results + */ + private applySorting( + results: SearchResult[], + orderBy: string[], + ): SearchResult[] { + return results.sort((a, b) => { + for (const sortField of orderBy) { + const parts = sortField.split(' '); + const fieldName = parts[0]; + const direction = parts[1] || 'asc'; + + if (!fieldName) continue; + + const aValue = this.getFieldValue(a.document, fieldName); + const bValue = this.getFieldValue(b.document, fieldName); + + let comparison = 0; + if (typeof aValue === 'string' && typeof bValue === 'string') { + if (aValue < bValue) comparison = -1; + else if (aValue > bValue) comparison = 1; + } else if (typeof aValue === 'number' && typeof bValue === 'number') { + if (aValue < bValue) comparison = -1; + else if (aValue > bValue) comparison = 1; + } + + if (direction.toLowerCase() === 'desc') { + comparison = -comparison; + } + + if (comparison !== 0) { + return comparison; + } + } + return 0; + }); + } + + /** + * Process facets for the results + */ + private processFacets( + results: SearchResult[], + facetFields: string[], + ): Record< + string, + Array<{ value: string | number | boolean; count: number }> + > { + const facets: Record< + string, + Array<{ value: string | number | boolean; count: number }> + > = {}; + + facetFields.forEach((fieldName) => { + const valueCounts = new Map(); + + results.forEach((result) => { + const fieldValue = this.getFieldValue(result.document, fieldName); + if (fieldValue !== undefined && fieldValue !== null) { + const value = fieldValue as string | number | boolean; + valueCounts.set(value, (valueCounts.get(value) || 0) + 1); + } + }); + + facets[fieldName] = Array.from(valueCounts.entries()) + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.count - a.count); + }); + + return facets; + } + + /** + * Apply pagination and sorting to documents (for empty search) + */ + private applyPaginationAndSorting( + documents: Record[], + options?: SearchOptions, + ): Record[] { + let sortedDocs = documents; + + if (options?.orderBy && options.orderBy.length > 0) { + sortedDocs = documents.sort((a, b) => { + for (const sortField of options.orderBy ?? []) { + const parts = sortField.split(' '); + const fieldName = parts[0]; + const direction = parts[1] || 'asc'; + + if (!fieldName) continue; + + const aValue = this.getFieldValue(a, fieldName); + const bValue = this.getFieldValue(b, fieldName); + + let comparison = 0; + if (typeof aValue === 'string' && typeof bValue === 'string') { + if (aValue < bValue) comparison = -1; + else if (aValue > bValue) comparison = 1; + } else if (typeof aValue === 'number' && typeof bValue === 'number') { + if (aValue < bValue) comparison = -1; + else if (aValue > bValue) comparison = 1; + } + + if (direction.toLowerCase() === 'desc') { + comparison = -comparison; + } + + if (comparison !== 0) { + return comparison; + } + } + return 0; + }); + } + + // Apply pagination + const skip = options?.skip || 0; + const top = options?.top || 50; + return sortedDocs.slice(skip, skip + top); + } + + /** + * Get field value from document (supports nested field access) + */ + private getFieldValue( + document: Record, + fieldName: string, + ): unknown { + return fieldName.split('.').reduce((obj, key) => { + if (obj && typeof obj === 'object' && key in obj) { + return (obj as Record)[key]; + } + return undefined; + }, document); + } + + /** + * Check if an index exists + * + * @param indexName - The name of the index to check + * @returns True if the index exists, false otherwise + */ + hasIndex(indexName: string): boolean { + return this.indexes.has(indexName); + } + + /** + * Get index statistics for debugging and monitoring + * + * @param indexName - The name of the index to get statistics for + * @returns Statistics object with document count and field count, or null if index doesn't exist + */ + getIndexStats( + indexName: string, + ): { documentCount: number; fieldCount: number } | null { + const documentMap = this.documents.get(indexName); + const indexDef = this.indexDefinitions.get(indexName); + + if (!documentMap || !indexDef) { + return null; + } + + return { + documentCount: documentMap.size, + fieldCount: indexDef.fields.length, + }; + } + + /** + * Get information about supported LiQE filter capabilities + * + * @returns Object containing supported operators, functions, and examples + */ + getFilterCapabilities(): { + operators: string[]; + functions: string[]; + examples: string[]; + } { + return this.liqeFilterEngine.getSupportedFeatures(); + } + + /** + * Validate if a filter string is supported by LiQE + * + * @param filterString - Filter string to validate + * @returns True if the filter can be parsed by LiQE, false otherwise + */ + isFilterSupported(filterString: string): boolean { + return this.liqeFilterEngine.isFilterSupported(filterString); + } +} + diff --git a/packages/sthrift/search-service-mock/src/search-engine-adapter.test.ts b/packages/sthrift/search-service-mock/src/search-engine-adapter.test.ts new file mode 100644 index 000000000..1d0dbbd21 --- /dev/null +++ b/packages/sthrift/search-service-mock/src/search-engine-adapter.test.ts @@ -0,0 +1,196 @@ +/** + * Tests for SearchEngineAdapter + * + * Tests the adapter layer that wraps the Lunr.js search engine. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { SearchEngineAdapter } from './search-engine-adapter'; +import type { SearchField } from './interfaces'; + +describe('SearchEngineAdapter', () => { + let adapter: SearchEngineAdapter; + + const testFields: SearchField[] = [ + { name: 'id', type: 'Edm.String', key: true }, + { name: 'title', type: 'Edm.String', searchable: true }, + { name: 'description', type: 'Edm.String', searchable: true }, + { name: 'price', type: 'Edm.Double', sortable: true, filterable: true }, + { name: 'category', type: 'Edm.String', filterable: true, facetable: true }, + ]; + + const testDocuments = [ + { + id: '1', + title: 'Mountain Bike', + description: 'Great for trails', + price: 500, + category: 'Sports', + }, + { + id: '2', + title: 'Road Bike', + description: 'Fast on pavement', + price: 800, + category: 'Sports', + }, + { + id: '3', + title: 'Power Drill', + description: 'Cordless power tool', + price: 150, + category: 'Tools', + }, + ]; + + beforeEach(() => { + adapter = new SearchEngineAdapter(); + }); + + describe('build', () => { + it('should build an index from fields and documents', () => { + adapter.build('test-index', testFields, testDocuments); + expect(adapter.hasIndex('test-index')).toBe(true); + }); + + it('should allow building multiple indexes', () => { + adapter.build('index1', testFields, testDocuments); + adapter.build('index2', testFields, []); + expect(adapter.hasIndex('index1')).toBe(true); + expect(adapter.hasIndex('index2')).toBe(true); + }); + }); + + describe('add', () => { + it('should add a document to an existing index', () => { + adapter.build('test-index', testFields, []); + adapter.add('test-index', testDocuments[0]); + + const stats = adapter.getStats('test-index'); + expect(stats?.documentCount).toBe(1); + }); + + it('should add multiple documents', () => { + adapter.build('test-index', testFields, []); + adapter.add('test-index', testDocuments[0]); + adapter.add('test-index', testDocuments[1]); + adapter.add('test-index', testDocuments[2]); + + const stats = adapter.getStats('test-index'); + expect(stats?.documentCount).toBe(3); + }); + }); + + describe('remove', () => { + it('should remove a document from an index', () => { + adapter.build('test-index', testFields, testDocuments); + adapter.remove('test-index', '1'); + + const stats = adapter.getStats('test-index'); + expect(stats?.documentCount).toBe(2); + }); + }); + + describe('search', () => { + beforeEach(() => { + adapter.build('test-index', testFields, testDocuments); + }); + + it('should search by text', () => { + const results = adapter.search('test-index', 'Mountain'); + expect(results.results.length).toBeGreaterThanOrEqual(1); + expect(results.results[0].document.title).toBe('Mountain Bike'); + }); + + it('should return all documents for wildcard search', () => { + const results = adapter.search('test-index', '*'); + expect(results.results).toHaveLength(3); + }); + + it('should return all documents for empty search', () => { + const results = adapter.search('test-index', ''); + expect(results.results).toHaveLength(3); + }); + + it('should apply filters', () => { + const results = adapter.search('test-index', '*', { + filter: "category eq 'Tools'", + }); + expect(results.results).toHaveLength(1); + expect(results.results[0].document.category).toBe('Tools'); + }); + + it('should apply pagination', () => { + const results = adapter.search('test-index', '*', { + skip: 1, + top: 1, + }); + expect(results.results).toHaveLength(1); + }); + + it('should include count in results', () => { + const results = adapter.search('test-index', '*', { + includeTotalCount: true, + }); + expect(results.count).toBe(3); + }); + }); + + describe('getStats', () => { + it('should return null for non-existent index', () => { + expect(adapter.getStats('non-existent')).toBeNull(); + }); + + it('should return statistics for existing index', () => { + adapter.build('test-index', testFields, testDocuments); + const stats = adapter.getStats('test-index'); + expect(stats).not.toBeNull(); + expect(stats?.documentCount).toBe(3); + expect(stats?.fieldCount).toBe(5); + }); + }); + + describe('getFilterCapabilities', () => { + it('should return supported filter capabilities', () => { + const capabilities = adapter.getFilterCapabilities(); + expect(capabilities.operators).toContain('eq'); + expect(capabilities.operators).toContain('ne'); + expect(capabilities.operators).toContain('gt'); + expect(capabilities.operators).toContain('lt'); + expect(capabilities.functions).toContain('contains'); + expect(capabilities.functions).toContain('startswith'); + expect(capabilities.functions).toContain('endswith'); + }); + + it('should return examples', () => { + const capabilities = adapter.getFilterCapabilities(); + expect(capabilities.examples.length).toBeGreaterThan(0); + }); + }); + + describe('isFilterSupported', () => { + it('should return true for valid filters', () => { + expect(adapter.isFilterSupported("category eq 'Sports'")).toBe(true); + expect(adapter.isFilterSupported('price gt 100')).toBe(true); + }); + + it('should return true for empty filter', () => { + expect(adapter.isFilterSupported('')).toBe(true); + }); + + it('should return false for invalid filters', () => { + expect(adapter.isFilterSupported('invalid query')).toBe(false); + }); + }); + + describe('hasIndex', () => { + it('should return false for non-existent index', () => { + expect(adapter.hasIndex('non-existent')).toBe(false); + }); + + it('should return true for existing index', () => { + adapter.build('test-index', testFields, []); + expect(adapter.hasIndex('test-index')).toBe(true); + }); + }); +}); diff --git a/packages/sthrift/search-service-mock/src/search-engine-adapter.ts b/packages/sthrift/search-service-mock/src/search-engine-adapter.ts new file mode 100644 index 000000000..8c98e155d --- /dev/null +++ b/packages/sthrift/search-service-mock/src/search-engine-adapter.ts @@ -0,0 +1,118 @@ +import type { + SearchField, + SearchOptions, + SearchDocumentsResult, +} from './interfaces.js'; +import { LunrSearchEngine } from './lunr-search-engine.js'; + +/** + * Search Engine Adapter + * + * Wraps the Lunr.js search engine and provides a clean interface + * for search operations. This adapter layer allows for easy swapping + * of search engines in the future if needed. + */ +export class SearchEngineAdapter { + private engine: LunrSearchEngine; + + constructor() { + this.engine = new LunrSearchEngine(); + } + + /** + * Build an index + * + * @param indexName - The name of the index + * @param fields - Array of search field definitions + * @param documents - Array of documents to index initially + */ + build( + indexName: string, + fields: SearchField[], + documents: Record[], + ): void { + this.engine.buildIndex(indexName, fields, documents); + } + + /** + * Add a document to an index + * + * @param indexName - The name of the index + * @param document - The document to add + */ + add(indexName: string, document: Record): void { + this.engine.addDocument(indexName, document); + } + + /** + * Remove a document from an index + * + * @param indexName - The name of the index + * @param documentId - The ID of the document to remove + */ + remove(indexName: string, documentId: string): void { + this.engine.removeDocument(indexName, documentId); + } + + /** + * Search an index + * + * @param indexName - The name of the index + * @param searchText - The search query text + * @param options - Optional search parameters + * @returns Search results with relevance scores + */ + search( + indexName: string, + searchText: string, + options?: SearchOptions, + ): SearchDocumentsResult { + return this.engine.search(indexName, searchText, options); + } + + /** + * Get index statistics + * + * @param indexName - The name of the index + * @returns Statistics object or null if index doesn't exist + */ + getStats( + indexName: string, + ): { documentCount: number; fieldCount: number } | null { + return this.engine.getIndexStats(indexName); + } + + /** + * Get filter capabilities + * + * @returns Object containing supported operators, functions, and examples + */ + getFilterCapabilities(): { + operators: string[]; + functions: string[]; + examples: string[]; + } { + return this.engine.getFilterCapabilities(); + } + + /** + * Check if a filter is supported + * + * @param filterString - Filter string to validate + * @returns True if the filter is supported, false otherwise + */ + isFilterSupported(filterString: string): boolean { + return this.engine.isFilterSupported(filterString); + } + + /** + * Check if an index exists + * + * @param indexName - The name of the index + * @returns True if the index exists, false otherwise + */ + hasIndex(indexName: string): boolean { + return this.engine.hasIndex(indexName); + } +} + diff --git a/packages/sthrift/search-service-mock/src/types/liqe.d.ts b/packages/sthrift/search-service-mock/src/types/liqe.d.ts new file mode 100644 index 000000000..334162dcf --- /dev/null +++ b/packages/sthrift/search-service-mock/src/types/liqe.d.ts @@ -0,0 +1,22 @@ +/** + * Type declarations for the 'liqe' package + * + * The liqe npm package does not include TypeScript type definitions, + * so we provide minimal type declarations here for the functions we use. + * + * This is NOT a build artifact - it's a manually-maintained type definition. + */ +declare module 'liqe' { + /** + * Parses a LiQE query string into an AST-like object that can be used with `test`. + */ + export function parse(query: string): unknown; + + /** + * Evaluates a parsed LiQE query against a plain JSON document. + */ + export function test( + parsedQuery: unknown, + document: Record, + ): boolean; +} diff --git a/packages/sthrift/search-service-mock/tsconfig.json b/packages/sthrift/search-service-mock/tsconfig.json new file mode 100644 index 000000000..cba0d2fc9 --- /dev/null +++ b/packages/sthrift/search-service-mock/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@cellix/typescript-config/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/sthrift/search-service-mock/turbo.json b/packages/sthrift/search-service-mock/turbo.json new file mode 100644 index 000000000..6403b5e05 --- /dev/null +++ b/packages/sthrift/search-service-mock/turbo.json @@ -0,0 +1,4 @@ +{ + "extends": ["//"], + "tags": ["backend"] +} diff --git a/packages/sthrift/search-service-mock/vitest.config.ts b/packages/sthrift/search-service-mock/vitest.config.ts new file mode 100644 index 000000000..857b6fccc --- /dev/null +++ b/packages/sthrift/search-service-mock/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import { baseConfig } from '@cellix/vitest-config'; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + coverage: { + exclude: [ + 'node_modules/', + 'dist/', + 'examples/**', + '**/*.test.ts', + '**/__tests__/**', + '**/types/**', + ], + }, + }, + }), +); diff --git a/packages/sthrift/service-blob-storage/src/index.ts b/packages/sthrift/service-blob-storage/src/index.ts index 9bd7981b0..fad4a171b 100644 --- a/packages/sthrift/service-blob-storage/src/index.ts +++ b/packages/sthrift/service-blob-storage/src/index.ts @@ -1,9 +1,11 @@ import type { ServiceBase } from '@cellix/api-services-spec'; import type { Domain } from '@sthrift/domain'; -export class ServiceBlobStorage implements ServiceBase { +type BlobStorageService = Domain.Services.Services["BlobStorage"]; - async startUp(): Promise { +export class ServiceBlobStorage implements ServiceBase { + + async startUp(): Promise { // Use connection string from environment variable or config // biome-ignore lint:useLiteralKeys diff --git a/packages/sthrift/service-cognitive-search/.gitignore b/packages/sthrift/service-cognitive-search/.gitignore deleted file mode 100644 index 30b950720..000000000 --- a/packages/sthrift/service-cognitive-search/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist/ - diff --git a/packages/sthrift/ui-components/src/molecules/search-bar/index.tsx b/packages/sthrift/ui-components/src/molecules/search-bar/index.tsx index a4e0b6c5a..a3b09efaf 100644 --- a/packages/sthrift/ui-components/src/molecules/search-bar/index.tsx +++ b/packages/sthrift/ui-components/src/molecules/search-bar/index.tsx @@ -6,7 +6,7 @@ import styles from './index.module.css'; interface SearchBarProps { searchValue?: string; onSearchChange?: (value: string) => void; - onSearch?: (query: string) => void; + onSearch?: () => void; } export const SearchBar: React.FC = ({ @@ -15,7 +15,13 @@ export const SearchBar: React.FC = ({ onSearch, }) => { const handleSearch = () => { - onSearch?.(searchValue); + onSearch?.(); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSearch(); + } }; return ( @@ -24,6 +30,7 @@ export const SearchBar: React.FC = ({ placeholder="Search" value={searchValue} onChange={(e) => onSearchChange?.((e.target as HTMLInputElement).value)} + onKeyDown={handleKeyPress} className={styles.searchInput} />