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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 107 additions & 7 deletions quotevote-backend/__tests__/unit/parseSearchQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,32 @@ describe('parseSearchQuery', () => {
// ── Happy path ──────────────────────────────────────────────────────────

describe('plain text queries (no tokens)', () => {
it('returns the full string as textQuery when no tokens are present', () => {
it('returns the full string as textQuery and keywords when no tokens are present', () => {
const result: ParsedSearchQuery = parseSearchQuery('hello world');

expect(result.keywords).toEqual(['hello', 'world']);
expect(result.usernames).toEqual([]);
expect(result.hashtags).toEqual([]);
expect(result.textQuery).toBe('hello world');
expect(result.tokens).toEqual([]);
});

it('trims leading and trailing whitespace from textQuery', () => {
it('trims leading and trailing whitespace from textQuery and keywords', () => {
const result = parseSearchQuery(' spaced out ');

expect(result.keywords).toEqual(['spaced', 'out']);
expect(result.textQuery).toBe('spaced out');
expect(result.usernames).toEqual([]);
expect(result.hashtags).toEqual([]);
});

it('treats a single word as a plain keyword search', () => {
const result = parseSearchQuery('education');

expect(result.keywords).toEqual(['education']);
expect(result.usernames).toEqual([]);
expect(result.hashtags).toEqual([]);
});
});

// ── @username extraction ────────────────────────────────────────────────
Expand All @@ -30,14 +40,16 @@ describe('parseSearchQuery', () => {
const result = parseSearchQuery('@johndoe');

expect(result.usernames).toEqual(['johndoe']);
expect(result.keywords).toEqual([]);
expect(result.textQuery).toBe('');
expect(result.tokens).toEqual([{ type: 'username', value: 'johndoe' }]);
});

it('extracts @username and preserves remaining text', () => {
it('extracts @username and preserves remaining text as keywords', () => {
const result = parseSearchQuery('@alice some text here');

expect(result.usernames).toEqual(['alice']);
expect(result.keywords).toEqual(['some', 'text', 'here']);
expect(result.textQuery).toBe('some text here');
});

Expand All @@ -51,6 +63,7 @@ describe('parseSearchQuery', () => {
const result = parseSearchQuery('@alice @bob');

expect(result.usernames).toEqual(['alice', 'bob']);
expect(result.keywords).toEqual([]);
expect(result.textQuery).toBe('');
});

Expand All @@ -68,14 +81,16 @@ describe('parseSearchQuery', () => {
const result = parseSearchQuery('#typescript');

expect(result.hashtags).toEqual(['typescript']);
expect(result.keywords).toEqual([]);
expect(result.textQuery).toBe('');
expect(result.tokens).toEqual([{ type: 'hashtag', value: 'typescript' }]);
});

it('extracts #hashtag and preserves remaining text', () => {
it('extracts #hashtag and preserves remaining text as keywords', () => {
const result = parseSearchQuery('#react some text here');

expect(result.hashtags).toEqual(['react']);
expect(result.keywords).toEqual(['some', 'text', 'here']);
expect(result.textQuery).toBe('some text here');
});

Expand All @@ -89,6 +104,7 @@ describe('parseSearchQuery', () => {
const result = parseSearchQuery('#react #nextjs');

expect(result.hashtags).toEqual(['react', 'nextjs']);
expect(result.keywords).toEqual([]);
expect(result.textQuery).toBe('');
});

Expand All @@ -107,6 +123,7 @@ describe('parseSearchQuery', () => {

expect(result.usernames).toEqual(['alice']);
expect(result.hashtags).toEqual(['typescript']);
expect(result.keywords).toEqual([]);
expect(result.textQuery).toBe('');
});

Expand All @@ -115,9 +132,18 @@ describe('parseSearchQuery', () => {

expect(result.usernames).toEqual(['alice']);
expect(result.hashtags).toEqual(['react']);
expect(result.keywords).toEqual(['posts', 'by', 'about', 'development']);
expect(result.textQuery).toBe('posts by about development');
});

it('parses keywords, a username, and a hashtag all together (issue example)', () => {
const result = parseSearchQuery('school safety @marta #education');

expect(result.keywords).toEqual(['school', 'safety']);
expect(result.usernames).toEqual(['marta']);
expect(result.hashtags).toEqual(['education']);
});

it('preserves token order in the tokens array', () => {
const result = parseSearchQuery('#react @alice #nextjs');

Expand All @@ -127,6 +153,14 @@ describe('parseSearchQuery', () => {
{ type: 'hashtag', value: 'nextjs' },
]);
});

it('preserves all extracted values regardless of token order', () => {
const result = parseSearchQuery('#education @marta school safety');

expect(result.keywords).toEqual(['school', 'safety']);
expect(result.usernames).toEqual(['marta']);
expect(result.hashtags).toEqual(['education']);
});
});

// ── Edge cases ──────────────────────────────────────────────────────────
Expand All @@ -135,6 +169,7 @@ describe('parseSearchQuery', () => {
it('returns empty result for an empty string', () => {
const result = parseSearchQuery('');

expect(result.keywords).toEqual([]);
expect(result.usernames).toEqual([]);
expect(result.hashtags).toEqual([]);
expect(result.textQuery).toBe('');
Expand All @@ -144,24 +179,50 @@ describe('parseSearchQuery', () => {
it('returns empty result for whitespace-only input', () => {
const result = parseSearchQuery(' ');

expect(result.keywords).toEqual([]);
expect(result.usernames).toEqual([]);
expect(result.hashtags).toEqual([]);
expect(result.textQuery).toBe('');
});

it('ignores standalone @ without a following word', () => {
it('collapses repeated internal whitespace without creating empty keyword tokens', () => {
const result = parseSearchQuery('education reform');

expect(result.keywords).toEqual(['education', 'reform']);
expect(result.textQuery).toBe('education reform');
});

it('ignores standalone @ without a following word, and excludes it from keywords', () => {
const result = parseSearchQuery('@ hello');

expect(result.usernames).toEqual([]);
expect(result.textQuery).toBe('@ hello');
expect(result.keywords).toEqual(['hello']);
});

it('ignores standalone # without a following word', () => {
it('ignores standalone # without a following word, and excludes it from keywords', () => {
const result = parseSearchQuery('# hello');

expect(result.usernames).toEqual([]);
expect(result.hashtags).toEqual([]);
expect(result.textQuery).toBe('# hello');
expect(result.keywords).toEqual(['hello']);
});

it('does not crash when the entire query is a lone @, and excludes it from keywords', () => {
const result = parseSearchQuery('@');

expect(result.usernames).toEqual([]);
expect(result.textQuery).toBe('@');
expect(result.keywords).toEqual([]);
});

it('does not crash when the entire query is a lone #, and excludes it from keywords', () => {
const result = parseSearchQuery('#');

expect(result.hashtags).toEqual([]);
expect(result.textQuery).toBe('#');
expect(result.keywords).toEqual([]);
});

it('handles @ and # embedded in words (e.g. email addresses)', () => {
Expand All @@ -170,6 +231,7 @@ describe('parseSearchQuery', () => {
// Mid-word @ should NOT be treated as a username token
expect(result.usernames).toEqual([]);
expect(result.textQuery).toBe('user@example.com');
expect(result.keywords).toEqual(['user@example.com']);
});

it('handles tokens with underscores and digits', () => {
Expand All @@ -186,4 +248,42 @@ describe('parseSearchQuery', () => {
expect(result.usernames).toEqual(['some']);
});
});
});

// ── Punctuation-only tokens ─────────────────────────────────────────────

describe('punctuation-only tokens', () => {
it('excludes a lone @ from keywords but keeps it in textQuery', () => {
const result = parseSearchQuery('@');

expect(result.keywords).toEqual([]);
expect(result.textQuery).toBe('@');
});

it('excludes a lone # from keywords but keeps it in textQuery', () => {
const result = parseSearchQuery('#');

expect(result.keywords).toEqual([]);
expect(result.textQuery).toBe('#');
});

it('excludes multiple punctuation-only tokens from keywords', () => {
const result = parseSearchQuery('@ # !!!');

expect(result.keywords).toEqual([]);
expect(result.textQuery).toBe('@ # !!!');
});

it('keeps a word containing punctuation, such as an email address', () => {
const result = parseSearchQuery('user@example.com');

expect(result.keywords).toEqual(['user@example.com']);
expect(result.usernames).toEqual([]);
});

it('keeps punctuation-only tokens interspersed with real keywords, filtering only the former', () => {
const result = parseSearchQuery('hello @ world # !!!');

expect(result.keywords).toEqual(['hello', 'world']);
});
});
});
9 changes: 7 additions & 2 deletions quotevote-backend/app/data/utils/parseSearchQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery {
const trimmed = raw.trim();

if (!trimmed) {
return { usernames: [], hashtags: [], textQuery: '', tokens: [] };
return { keywords: [], usernames: [], hashtags: [], textQuery: '', tokens: [] };
}

const usernames = new Set<string>();
Expand Down Expand Up @@ -48,10 +48,15 @@ export function parseSearchQuery(raw: string): ParsedSearchQuery {
}
textQuery = textQuery.replace(/\s{2,}/g, ' ').trim();

const keywords = textQuery.length > 0
? textQuery.split(' ').filter((word) => /\w/.test(word))
: [];

return {
keywords,
usernames: Array.from(usernames),
hashtags: Array.from(hashtags),
textQuery,
tokens: orderedTokens.map((t) => t.token),
};
}
}
9 changes: 7 additions & 2 deletions quotevote-backend/app/types/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,19 @@ export type SearchToken = UsernameToken | HashtagToken;
/**
* The result of parsing a raw search query string.
*
* - `keywords` — plain-text keyword tokens, in the order they appeared in the query. Tokens
* made up entirely of punctuation (e.g. a lone `@` or `#`) are excluded.
* - `usernames` — extracted @username tokens (lowercased, without the @ prefix)
* - `hashtags` — extracted #hashtag tokens (lowercased, without the # prefix)
* - `textQuery` — the remaining plain-text portion after token extraction (trimmed)
* - `textQuery` — the remaining plain-text portion after token extraction (trimmed) — kept for
* backwards compatibility. Unlike `keywords`, this retains punctuation-only
* tokens verbatim.
* - `tokens` — ordered list of all extracted tokens for debugging/logging
*/
export interface ParsedSearchQuery {
readonly keywords: readonly string[];
readonly usernames: readonly string[];
readonly hashtags: readonly string[];
readonly textQuery: string;
readonly tokens: readonly SearchToken[];
}
}
Loading
Loading