-
Notifications
You must be signed in to change notification settings - Fork 38
fix(dev): consolidated CI hotfix — check-types + biome + unit tests #2059
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4062cac
0a36a08
3966acc
07c5030
2a191ef
82d2e73
b19fd68
c5e395b
d2e0bf5
5d2ae68
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| // Mock clientEnvs before importing the module under test so that | ||
| // buildPostImageUrl has a deterministic CDN base URL. | ||
| vi.mock('expo-app/env/clientEnvs', () => ({ | ||
| clientEnvs: { | ||
| EXPO_PUBLIC_R2_PUBLIC_URL: 'https://cdn.example.com', | ||
| }, | ||
| })); | ||
|
|
||
| // Also mock getRelativeTime so that formatRelativeDate has a predictable | ||
| // alias target that does not depend on the current clock. | ||
| vi.mock('expo-app/lib/utils/getRelativeTime', () => ({ | ||
| getRelativeTime: (input: string | Date) => `relative(${String(input)})`, | ||
| })); | ||
|
|
||
| import type { Comment, Post } from '../../types'; | ||
| import { buildPostImageUrl, formatAuthorName, formatRelativeDate } from '../index'; | ||
|
|
||
| const basePost: Post = { | ||
| id: 1, | ||
| userId: 42, | ||
| caption: 'hello', | ||
| images: [], | ||
| createdAt: '2024-01-01T00:00:00.000Z', | ||
| updatedAt: '2024-01-01T00:00:00.000Z', | ||
| likeCount: 0, | ||
| commentCount: 0, | ||
| likedByMe: false, | ||
| }; | ||
|
|
||
| const baseComment: Comment = { | ||
| id: 1, | ||
| postId: 1, | ||
| userId: 42, | ||
| content: 'nice', | ||
| parentCommentId: null, | ||
| createdAt: '2024-01-01T00:00:00.000Z', | ||
| updatedAt: '2024-01-01T00:00:00.000Z', | ||
| likeCount: 0, | ||
| likedByMe: false, | ||
| }; | ||
|
|
||
| describe('feed/utils', () => { | ||
| // --------------------------------------------------------------------------- | ||
| // buildPostImageUrl | ||
| // --------------------------------------------------------------------------- | ||
| describe('buildPostImageUrl', () => { | ||
| it('joins the CDN base URL with the image key', () => { | ||
| expect(buildPostImageUrl('posts/abc.jpg')).toBe('https://cdn.example.com/posts/abc.jpg'); | ||
| }); | ||
|
|
||
| it('does not double-encode or trim the image key', () => { | ||
| expect(buildPostImageUrl('folder/sub folder/image (1).png')).toBe( | ||
| 'https://cdn.example.com/folder/sub folder/image (1).png', | ||
| ); | ||
| }); | ||
|
|
||
| it('handles an empty image key by returning the base URL with a trailing slash', () => { | ||
| expect(buildPostImageUrl('')).toBe('https://cdn.example.com/'); | ||
| }); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // formatAuthorName | ||
| // --------------------------------------------------------------------------- | ||
| describe('formatAuthorName', () => { | ||
| it('returns "Unknown" when the author is missing', () => { | ||
| expect(formatAuthorName(basePost)).toBe('Unknown'); | ||
| expect(formatAuthorName(baseComment)).toBe('Unknown'); | ||
| }); | ||
|
|
||
| it('returns "first last" when both names are present', () => { | ||
| const post: Post = { | ||
| ...basePost, | ||
| author: { id: 1, firstName: 'Ada', lastName: 'Lovelace' }, | ||
| }; | ||
| expect(formatAuthorName(post)).toBe('Ada Lovelace'); | ||
| }); | ||
|
|
||
| it('returns just the first name when only firstName is present', () => { | ||
| const post: Post = { | ||
| ...basePost, | ||
| author: { id: 1, firstName: 'Ada', lastName: null }, | ||
| }; | ||
| expect(formatAuthorName(post)).toBe('Ada'); | ||
| }); | ||
|
|
||
| it('returns just the last name when only lastName is present', () => { | ||
| const post: Post = { | ||
| ...basePost, | ||
| author: { id: 1, firstName: null, lastName: 'Lovelace' }, | ||
| }; | ||
| expect(formatAuthorName(post)).toBe('Lovelace'); | ||
| }); | ||
|
|
||
| it('returns "User" when the author exists but both names are null', () => { | ||
| const post: Post = { | ||
| ...basePost, | ||
| author: { id: 1, firstName: null, lastName: null }, | ||
| }; | ||
| expect(formatAuthorName(post)).toBe('User'); | ||
| }); | ||
|
|
||
| it('also works for Comment entities', () => { | ||
| const comment: Comment = { | ||
| ...baseComment, | ||
| author: { id: 1, firstName: 'Grace', lastName: 'Hopper' }, | ||
| }; | ||
| expect(formatAuthorName(comment)).toBe('Grace Hopper'); | ||
| }); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // formatRelativeDate (deprecated alias of getRelativeTime) | ||
| // --------------------------------------------------------------------------- | ||
| describe('formatRelativeDate', () => { | ||
| it('delegates to getRelativeTime', () => { | ||
| expect(formatRelativeDate('2024-01-01T00:00:00.000Z')).toBe( | ||
| 'relative(2024-01-01T00:00:00.000Z)', | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import type { PackTemplate, PackTemplateItem } from '../../types'; | ||
|
|
||
| // The SUT imports convertFromGrams/convertToGrams from the packs/utils barrel, | ||
| // which also re-exports uploadImage (expo-file-system) and computeCategories | ||
| // (expo-app/features/auth/store). Replace the barrel with the real pure | ||
| // conversion helpers so the test stays in a Node environment. | ||
| vi.mock('expo-app/features/packs/utils', async () => { | ||
| const fromGrams = await import('expo-app/features/packs/utils/convertFromGrams'); | ||
| const toGrams = await import('expo-app/features/packs/utils/convertToGrams'); | ||
| return { | ||
| convertFromGrams: fromGrams.convertFromGrams, | ||
| convertToGrams: toGrams.convertToGrams, | ||
| }; | ||
| }); | ||
|
|
||
| import { computePackTemplateWeights } from '../computePacktemplateWeight'; | ||
|
|
||
| type TemplateInput = Omit<PackTemplate, 'baseWeight' | 'totalWeight'>; | ||
|
|
||
| function makeItem(overrides: Partial<PackTemplateItem> = {}): PackTemplateItem { | ||
| return { | ||
| id: 'item-1', | ||
| packTemplateId: 'tpl-1', | ||
| name: 'Item', | ||
| weight: 100, | ||
| weightUnit: 'g', | ||
| quantity: 1, | ||
| category: 'gear', | ||
| consumable: false, | ||
| worn: false, | ||
| deleted: false, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| function makeTemplate(items: PackTemplateItem[]): TemplateInput { | ||
| return { | ||
| id: 'tpl-1', | ||
| name: 'Template', | ||
| category: 'hiking', | ||
| isAppTemplate: false, | ||
| items, | ||
| deleted: false, | ||
| localCreatedAt: '2024-01-01T00:00:00.000Z', | ||
| localUpdatedAt: '2024-01-01T00:00:00.000Z', | ||
| }; | ||
| } | ||
|
|
||
| describe('computePackTemplateWeights', () => { | ||
| // --------------------------------------------------------------------------- | ||
| // Empty templates | ||
| // --------------------------------------------------------------------------- | ||
| describe('with no items', () => { | ||
| it('returns 0 for base and total weight (default unit: grams)', () => { | ||
| const result = computePackTemplateWeights(makeTemplate([])); | ||
| expect(result.baseWeight).toBe(0); | ||
| expect(result.totalWeight).toBe(0); | ||
| }); | ||
|
|
||
| it('returns 0 regardless of preferred unit', () => { | ||
| const result = computePackTemplateWeights(makeTemplate([]), 'kg'); | ||
| expect(result.baseWeight).toBe(0); | ||
| expect(result.totalWeight).toBe(0); | ||
| }); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Non-consumable / non-worn gear (counts toward base and total) | ||
| // --------------------------------------------------------------------------- | ||
| describe('with base gear only', () => { | ||
| it('sums weights into base and total in grams by default', () => { | ||
| const result = computePackTemplateWeights( | ||
| makeTemplate([ | ||
| makeItem({ id: 'a', weight: 500, weightUnit: 'g', quantity: 1 }), | ||
| makeItem({ id: 'b', weight: 1, weightUnit: 'kg', quantity: 2 }), | ||
| ]), | ||
| ); | ||
| // 500g + 2 * 1000g = 2500g | ||
| expect(result.baseWeight).toBe(2500); | ||
| expect(result.totalWeight).toBe(2500); | ||
| }); | ||
|
|
||
| it('converts to the preferred unit (kg)', () => { | ||
| const result = computePackTemplateWeights( | ||
| makeTemplate([makeItem({ weight: 2500, weightUnit: 'g', quantity: 1 })]), | ||
| 'kg', | ||
| ); | ||
| // 2500g => 2.5kg, rounded to 2 decimals | ||
| expect(result.baseWeight).toBe(2.5); | ||
| expect(result.totalWeight).toBe(2.5); | ||
| }); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Consumable items (counted in total, not base) | ||
| // --------------------------------------------------------------------------- | ||
| describe('with consumable items', () => { | ||
| it('excludes consumables from base but includes them in total', () => { | ||
| const result = computePackTemplateWeights( | ||
| makeTemplate([ | ||
| makeItem({ id: 'a', weight: 1000, weightUnit: 'g', consumable: false }), | ||
| makeItem({ id: 'b', weight: 500, weightUnit: 'g', consumable: true }), | ||
| ]), | ||
| ); | ||
| expect(result.baseWeight).toBe(1000); | ||
| expect(result.totalWeight).toBe(1500); | ||
| }); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Worn items (counted in total, not base) | ||
| // --------------------------------------------------------------------------- | ||
| describe('with worn items', () => { | ||
| it('excludes worn items from base but includes them in total', () => { | ||
| const result = computePackTemplateWeights( | ||
| makeTemplate([ | ||
| makeItem({ id: 'a', weight: 800, weightUnit: 'g', worn: false }), | ||
| makeItem({ id: 'b', weight: 200, weightUnit: 'g', worn: true }), | ||
| ]), | ||
| ); | ||
| expect(result.baseWeight).toBe(800); | ||
| expect(result.totalWeight).toBe(1000); | ||
| }); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Quantity multiplies the item weight | ||
| // --------------------------------------------------------------------------- | ||
| describe('quantity handling', () => { | ||
| it('multiplies item weight by quantity', () => { | ||
| const result = computePackTemplateWeights( | ||
| makeTemplate([makeItem({ weight: 250, weightUnit: 'g', quantity: 4 })]), | ||
| ); | ||
| expect(result.totalWeight).toBe(1000); | ||
| expect(result.baseWeight).toBe(1000); | ||
| }); | ||
| }); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Preserves template metadata | ||
| // --------------------------------------------------------------------------- | ||
| it('returns the same template metadata alongside computed weights', () => { | ||
| const template = makeTemplate([makeItem({ weight: 100, weightUnit: 'g', quantity: 1 })]); | ||
| const result = computePackTemplateWeights(template); | ||
| expect(result.id).toBe(template.id); | ||
| expect(result.name).toBe(template.name); | ||
| expect(result.items).toHaveLength(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -62,7 +62,7 @@ export const CommentSchema = z | |||||
| export const CreateCommentRequestSchema = z | ||||||
| .object({ | ||||||
| content: z.string().min(1).max(1000).openapi({ example: 'Looks amazing!' }), | ||||||
| parentCommentId: z.number().int().optional().openapi({ example: null }), | ||||||
| parentCommentId: z.number().int().optional().openapi({ example: undefined }), | ||||||
|
||||||
| parentCommentId: z.number().int().optional().openapi({ example: undefined }), | |
| parentCommentId: z.number().int().optional(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The route now documents a 400 response, but the handler’s only 400 path (
if (!newComment)) is unlikely to ever occur—Drizzle will typically throw on insert failures (e.g. FK violation whenparentCommentIddoesn’t exist), which would bubble as a 500. Consider validatingparentCommentId(exists and belongs to the samepostId) before inserting and/or catching insert errors to return a deterministic 400/404 that matches the OpenAPI contract.