diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index bdc3998794..a096f374b4 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -13,7 +13,8 @@ on: - "apps/expo/vitest.config.ts" - "apps/expo/utils/**" - "apps/expo/lib/utils/**" - - "apps/expo/features/packs/utils/**" + - "apps/expo/features/**/utils/**" + - ".github/workflows/unit-tests.yml" pull_request: branches: ["**"] paths: @@ -26,7 +27,8 @@ on: - "apps/expo/vitest.config.ts" - "apps/expo/utils/**" - "apps/expo/lib/utils/**" - - "apps/expo/features/packs/utils/**" + - "apps/expo/features/**/utils/**" + - ".github/workflows/unit-tests.yml" permissions: contents: read diff --git a/apps/expo/features/feed/utils/__tests__/feedUtils.test.ts b/apps/expo/features/feed/utils/__tests__/feedUtils.test.ts new file mode 100644 index 0000000000..3ddc667200 --- /dev/null +++ b/apps/expo/features/feed/utils/__tests__/feedUtils.test.ts @@ -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)', + ); + }); + }); +}); diff --git a/apps/expo/features/pack-templates/utils/__tests__/computePacktemplateWeight.test.ts b/apps/expo/features/pack-templates/utils/__tests__/computePacktemplateWeight.test.ts new file mode 100644 index 0000000000..c9f4f72480 --- /dev/null +++ b/apps/expo/features/pack-templates/utils/__tests__/computePacktemplateWeight.test.ts @@ -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; + +function makeItem(overrides: Partial = {}): 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); + }); +});