Skip to content
Closed
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
12 changes: 6 additions & 6 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
interval: "weekly"
interval: 'weekly'

# Maintain dependencies for Docker
- package-ecosystem: "composer"
directory: "/"
- package-ecosystem: 'composer'
directory: '/'
schedule:
interval: "weekly"
interval: 'weekly'
4 changes: 2 additions & 2 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on: pull_request
jobs:
build:
runs-on: ubuntu-latest
environment: "test"
environment: 'test'

steps:
- uses: actions/checkout@v4
Expand All @@ -17,7 +17,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
node-version-file: '.nvmrc'
cache: pnpm

- name: Install
Expand Down
2 changes: 1 addition & 1 deletion api/database.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@
"ca": "./ca-certificate.crt"
}
}
}
}
7 changes: 3 additions & 4 deletions api/db/loadouts-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ import { DestinyVersion } from '../shapes/general.js';
import { Loadout, LoadoutItem } from '../shapes/loadouts.js';
import { isValidItemId, KeysToSnakeCase } from '../utils.js';

export interface LoadoutRow
extends KeysToSnakeCase<
Omit<Loadout, 'equipped' | 'unequipped' | 'createdAt' | 'lastUpdatedAt'>
> {
export interface LoadoutRow extends KeysToSnakeCase<
Omit<Loadout, 'equipped' | 'unequipped' | 'createdAt' | 'lastUpdatedAt'>
> {
created_at: Date;
last_updated_at: Date | null;
items: { equipped: LoadoutItem[]; unequipped: LoadoutItem[] };
Expand Down
178 changes: 178 additions & 0 deletions api/db/wishlist-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { v4 as uuid } from 'uuid';
import { statelyImport } from '../routes/import.js';
import { ExportResponse } from '../shapes/export.js';
import { closeDbPool, transaction } from './index.js';
import {
deleteWishlist,
getWishlistRollsForUser,
getWishlistsForUser,
updateWishlist,
updateWishlistRoll,
} from './wishlist-queries.js';

const bungieMembershipId = 8888;
const platformMembershipIds = ['12345', '67890']; // Must be numeric strings for BigInt conversion

beforeEach(() =>
transaction(async (client) => {
// Clean up both tables manually to ensure a clean state
await client.query(`delete from wishlist_rolls where membership_id = ${bungieMembershipId}`);
await client.query(`delete from wishlists where membership_id = ${bungieMembershipId}`);
}),
);

afterAll(async () => closeDbPool());

describe('Wishlist Design Integration', () => {
/**
* SCENARIO 1: Physical Cascade Delete
* Design: Since we use hard deletes, deleting a wishlist MUST
* physically remove all its rolls from the database via the
* FOREIGN KEY ... ON DELETE CASCADE.
*/
it('should physically delete all associated rolls when a wishlist is hard-deleted', async () => {
await transaction(async (client) => {
const wishlistId = uuid();
await updateWishlist(client, bungieMembershipId, {
id: wishlistId,
name: 'Hard Delete Test',
isPublic: false,
});

// Add a roll
await updateWishlistRoll(client, bungieMembershipId, wishlistId, {
id: uuid(),
wishlistId,
itemHash: 100,
recommendedPerks: [[1]],
isExpertMode: false,
isUndesirable: false,
});

// Verify visibility
const rollsBefore = await getWishlistRollsForUser(client, bungieMembershipId);
expect(rollsBefore.length).toBe(1);

// Hard delete the wishlist
await deleteWishlist(client, bungieMembershipId, wishlistId);

// Check visibility - wishlist should be gone
const wishlists = await getWishlistsForUser(client, bungieMembershipId);
expect(wishlists.length).toBe(0);

// Check rolls - should be physically gone due to DB CASCADE
const rollsAfter = await getWishlistRollsForUser(client, bungieMembershipId);
expect(rollsAfter.length).toBe(0);
});
});

/**
* SCENARIO 2: Import/Export Round-trip
* Tests that the entire data transport system (Export -> Import) preserves relationships.
*/
it('should perfectly reconstruct wishlists and rolls after an export/import cycle', async () => {
const wishlistId = uuid();
const rollId = uuid();

const mockExportData: ExportResponse = {
settings: {},
loadouts: [],
tags: [],
itemHashTags: [],
triumphs: [],
searches: [],
wishlists: [
{
wishlist: {
id: wishlistId,
name: 'Roundtrip List',
description: 'Testing export/import',
isPublic: true,
},
rolls: [
{
id: rollId,
wishlistId: wishlistId,
itemHash: 999,
recommendedPerks: [[1], [2]],
isExpertMode: true,
isUndesirable: false,
notes: 'Saved note',
},
],
},
],
};

// Execute the import (which uses our queries internally)
await statelyImport(
bungieMembershipId,
platformMembershipIds,
mockExportData.settings,
[], // loadouts
[], // tags
[], // triumphs
[], // searches
[], // itemHashTags
mockExportData.wishlists,
);

// Verify in the DB
await transaction(async (client) => {
const wishlists = await getWishlistsForUser(client, bungieMembershipId);
const rolls = await getWishlistRollsForUser(client, bungieMembershipId);

expect(wishlists.length).toBe(1);
expect(wishlists[0].id).toBe(wishlistId);
expect(wishlists[0].name).toBe('Roundtrip List');

expect(rolls.length).toBe(1);
expect(rolls[0].id).toBe(rollId);
expect(rolls[0].wishlistId).toBe(wishlistId);
expect(rolls[0].itemHash).toBe(999);
expect(rolls[0].isExpertMode).toBe(true);
});
});

/**
* SCENARIO 3: Cross-Wishlist Isolation
* Verify that rolls remain assigned to their respective lists.
*/
it('should maintain strict separation between rolls of different wishlists', async () => {
await transaction(async (client) => {
const w1 = uuid();
const w2 = uuid();

await updateWishlist(client, bungieMembershipId, { id: w1, name: 'List 1', isPublic: false });
await updateWishlist(client, bungieMembershipId, { id: w2, name: 'List 2', isPublic: false });

await updateWishlistRoll(client, bungieMembershipId, w1, {
id: uuid(),
wishlistId: w1,
itemHash: 1,
recommendedPerks: [[]],
isExpertMode: false,
isUndesirable: false,
});
await updateWishlistRoll(client, bungieMembershipId, w2, {
id: uuid(),
wishlistId: w2,
itemHash: 2,
recommendedPerks: [[]],
isExpertMode: false,
isUndesirable: false,
});

const allRolls = await getWishlistRollsForUser(client, bungieMembershipId);
expect(allRolls.length).toBe(2);

const rollsW1 = allRolls.filter((r) => r.wishlistId === w1);
const rollsW2 = allRolls.filter((r) => r.wishlistId === w2);

expect(rollsW1.length).toBe(1);
expect(rollsW1[0].itemHash).toBe(1);
expect(rollsW2.length).toBe(1);
expect(rollsW2[0].itemHash).toBe(2);
});
});
});
138 changes: 138 additions & 0 deletions api/db/wishlist-queries.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { v4 as uuid } from 'uuid';
import { WishlistMetadata, WishlistRoll } from '../shapes/wishlist.js';
import { closeDbPool, transaction } from './index.js';
import {
deleteWishlist,
deleteWishlistRoll,
getPublicWishlist,
getWishlistRollsForUser,
getWishlistsForUser,
updateWishlist,
updateWishlistRoll,
} from './wishlist-queries.js';

const bungieMembershipId = 9999;

beforeEach(() =>
transaction(async (client) => {
await client.query(`delete from wishlist_rolls where membership_id = ${bungieMembershipId}`);
await client.query(`delete from wishlists where membership_id = ${bungieMembershipId}`);
}),
);

afterAll(async () => closeDbPool());

const wishlist: WishlistMetadata = {
id: uuid(),
name: 'Test Wishlist',
description: 'A description',
isPublic: false,
};

const roll: WishlistRoll = {
id: uuid(),
wishlistId: wishlist.id,
itemHash: 123456789,
recommendedPerks: [[1, 2], [3]],
isExpertMode: false,
isUndesirable: false,
notes: 'Good roll',
};

it('can record and retrieve a wishlist', async () => {
await transaction(async (client) => {
await updateWishlist(client, bungieMembershipId, wishlist);

const wishlists = await getWishlistsForUser(client, bungieMembershipId);

expect(wishlists.length).toBe(1);
expect(wishlists[0].name).toBe(wishlist.name);
expect(wishlists[0].description).toBe(wishlist.description);
expect(wishlists[0].isPublic).toBe(wishlist.isPublic);
expect(wishlists[0].createdAt).toBeDefined();
expect(wishlists[0].lastUpdatedAt).toBeDefined();
});
});

it('can update a wishlist', async () => {
await transaction(async (client) => {
await updateWishlist(client, bungieMembershipId, wishlist);

const updatedWishlist = { ...wishlist, name: 'Updated Name', isPublic: true };
await updateWishlist(client, bungieMembershipId, updatedWishlist);

const wishlists = await getWishlistsForUser(client, bungieMembershipId);

expect(wishlists.length).toBe(1);
expect(wishlists[0].name).toBe('Updated Name');
expect(wishlists[0].isPublic).toBe(true);
});
});

it('can delete a wishlist', async () => {
await transaction(async (client) => {
await updateWishlist(client, bungieMembershipId, wishlist);
await deleteWishlist(client, bungieMembershipId, wishlist.id);

const wishlists = await getWishlistsForUser(client, bungieMembershipId);
expect(wishlists.length).toBe(0);

// Verify it is physically gone from the DB
const result = await client.query('SELECT * FROM wishlists WHERE id = $1', [wishlist.id]);
expect(result.rowCount).toBe(0);
});
});

it('can record and retrieve a wishlist roll', async () => {
await transaction(async (client) => {
await updateWishlist(client, bungieMembershipId, wishlist);
await updateWishlistRoll(client, bungieMembershipId, wishlist.id, roll);

const rolls = await getWishlistRollsForUser(client, bungieMembershipId);

expect(rolls.length).toBe(1);
expect(rolls[0].itemHash).toBe(roll.itemHash);
expect(rolls[0].recommendedPerks).toEqual(roll.recommendedPerks);
expect(rolls[0].notes).toBe(roll.notes);
expect(rolls[0].wishlistId).toBe(wishlist.id);
});
});

it('can delete a wishlist roll', async () => {
await transaction(async (client) => {
await updateWishlist(client, bungieMembershipId, wishlist);
await updateWishlistRoll(client, bungieMembershipId, wishlist.id, roll);

await deleteWishlistRoll(client, bungieMembershipId, roll.id);

const rolls = await getWishlistRollsForUser(client, bungieMembershipId);
expect(rolls.length).toBe(0);

// Verify it is physically gone from the DB
const result = await client.query('SELECT * FROM wishlist_rolls WHERE id = $1', [roll.id]);
expect(result.rowCount).toBe(0);
});
});

it('can retrieve a public wishlist', async () => {
await transaction(async (client) => {
const publicWishlist = { ...wishlist, isPublic: true };
await updateWishlist(client, bungieMembershipId, publicWishlist);
await updateWishlistRoll(client, bungieMembershipId, publicWishlist.id, roll);

const result = await getPublicWishlist(client, publicWishlist.id);

expect(result).toBeDefined();
expect(result?.wishlist.name).toBe(publicWishlist.name);
expect(result?.rolls.length).toBe(1);
expect(result?.rolls[0].itemHash).toBe(roll.itemHash);
});
});

it('cannot retrieve a private wishlist via getPublicWishlist', async () => {
await transaction(async (client) => {
await updateWishlist(client, bungieMembershipId, wishlist);
const result = await getPublicWishlist(client, wishlist.id);
expect(result).toBeUndefined();
});
});
Loading