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
6 changes: 4 additions & 2 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
124 changes: 124 additions & 0 deletions apps/expo/features/feed/utils/__tests__/feedUtils.test.ts
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',
Comment on lines +53 to +55
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test locks in behavior that produces URLs containing spaces/parentheses unencoded, which is not a valid/robust URL form in many clients. If buildPostImageUrl is intended to return a safe URL, update the expectation to percent-encode reserved characters (e.g., spaces as %20), or alternatively change the test input to an already-encoded key and assert it is not double-encoded (so the test still covers the intended contract reliably).

Suggested change
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('does not double-encode or trim an already-encoded image key', () => {
expect(buildPostImageUrl('folder/sub%20folder/image%20%281%29.png')).toBe(
'https://cdn.example.com/folder/sub%20folder/image%20%281%29.png',

Copilot uses AI. Check for mistakes.
);
});

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,
};
});
Comment on lines +3 to +15
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mocking the entire expo-app/features/packs/utils barrel by reaching into internal module paths makes these tests sensitive to refactors (file moves/renames) in the packs utils area. A more maintainable approach is to make the production module under test import the pure conversion helpers directly (not via the barrel), which removes the need for this barrel mock and keeps the unit pure across environments. If production changes are out of scope here, consider at least centralizing this mock into a reusable mock module to avoid repeating fragile path logic in future tests.

Suggested change
// 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 { convertFromGrams } from 'expo-app/features/packs/utils/convertFromGrams';
import { convertToGrams } from 'expo-app/features/packs/utils/convertToGrams';
// 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', () => ({
convertFromGrams,
convertToGrams,
}));

Copilot uses AI. Check for mistakes.

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);
});
});
Loading