Repository navigation
feat(service-intervals): track service intervals and maintenance reminders #99
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
Merged
unclesp1d3r
merged 19 commits into
main
from
10-add-service-interval-tracking-and-in-app-maintenance-reminders-for-firearms-and-accessories
Aug 5, 2026
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
c09361b
docs(plans): add service interval tracking implementation plan
unclesp1d3r 81ba468
feat(service-intervals): add service rule and event schema (U1)
unclesp1d3r 1336fd4
feat(service-intervals): add pure derivation core (U2)
unclesp1d3r a39e6a7
feat(service-intervals): add defaults and item-rule service layer (U3)
unclesp1d3r c941c05
feat(service-intervals): add service events and due loaders (U4)
unclesp1d3r af3867c
feat(service-intervals)!: retire cleaned and lubed log events (U5)
unclesp1d3r dc55808
feat(firearms): add an acquired date (U6)
unclesp1d3r d3e1679
feat(service-intervals): add the defaults settings surface (U7)
unclesp1d3r e1fbaa7
feat(service-intervals): add item detail service surfaces (U8)
unclesp1d3r 674a5a4
feat(service-intervals): add due roll-up and list indicators (U9)
unclesp1d3r e5defcf
feat(service-intervals): seed the demo and cover the flow end to end …
unclesp1d3r ea01df0
refactor(service-intervals): remove duplicate work and duplicated code
unclesp1d3r 3c36da2
feat(service-intervals): mark many items serviced at once (R16)
unclesp1d3r 7640bb8
fix(service-intervals): close code-review findings
unclesp1d3r 58e41c2
feat(service-intervals): correct service events and date accessories
unclesp1d3r b3e73f6
fix(service-intervals): address PR review feedback (#99)
unclesp1d3r 1fe60e3
fix(service-intervals): repoint history on default rename, fix actor-…
unclesp1d3r 8ad253a
fix(service-intervals): close review findings across types, errors, a…
unclesp1d3r 4a0c28f
test(service-intervals): restore spies so they cannot leak between files
unclesp1d3r File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
app/(app)/accessories/[id]/__tests__/service-props.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; | ||
| import { NotFoundError } from "@/src/auth/errors"; | ||
| import * as dueService from "@/src/domain/service-intervals/due-service"; | ||
| import * as eventsService from "@/src/domain/service-intervals/events-service"; | ||
| import * as rulesService from "@/src/domain/service-intervals/rules-service"; | ||
| import { loadAccessoryServiceProps } from "../page"; | ||
|
|
||
| /** | ||
| * Unit tests for `loadAccessoryServiceProps` (the accessory detail page's | ||
| * owner-only service-data loader) — specifically the 404 guard added | ||
| * alongside the firearm detail page's `asNotFound` (mirrored via | ||
| * `@/src/lib/as-not-found`). | ||
| * | ||
| * Uses `spyOn` on the real `due-service`/`events-service`/`rules-service` | ||
| * module namespaces rather than `mock.module(...)` on those specifiers: | ||
| * `rules-service` is ALSO `mock.module`-replaced, with a differently-shaped | ||
| * export set, by `settings/service/__tests__/actions.test.ts` — a second, | ||
| * incompatible `mock.module` on the same path from this file would collide | ||
| * when `bun test app` runs every file in one process (mirrors the same | ||
| * tradeoff documented in `firearms/__tests__/service-actions.test.ts`). | ||
| * `spyOn` overrides only the named export on the already-loaded module | ||
| * object, so it doesn't touch that shared registry. | ||
| */ | ||
|
|
||
| const USER_ID = "user-1"; | ||
| const ACCESSORY_ID = "accessory-1"; | ||
|
|
||
| describe("loadAccessoryServiceProps", () => { | ||
| let getItemDueStateSpy: ReturnType<typeof spyOn>; | ||
| let listItemRulesSpy: ReturnType<typeof spyOn>; | ||
| let listServiceHistorySpy: ReturnType<typeof spyOn>; | ||
|
|
||
| beforeEach(() => { | ||
| getItemDueStateSpy = spyOn(dueService, "getItemDueState").mockResolvedValue( | ||
| [], | ||
| ); | ||
| listItemRulesSpy = spyOn(rulesService, "listItemRules").mockResolvedValue( | ||
| [], | ||
| ); | ||
| listServiceHistorySpy = spyOn( | ||
| eventsService, | ||
| "listServiceHistory", | ||
| ).mockResolvedValue([]); | ||
| }); | ||
|
|
||
| // `bun test app` runs every file in one process and Bun's `spyOn` | ||
| // replacements persist until restored, so an unrestored spy here would leak | ||
| // into whichever file runs next. | ||
| afterEach(() => { | ||
| getItemDueStateSpy.mockRestore(); | ||
| listItemRulesSpy.mockRestore(); | ||
| listServiceHistorySpy.mockRestore(); | ||
| }); | ||
|
|
||
| test("a non-owner viewer gets null in every field, without calling any loader", async () => { | ||
| const result = await loadAccessoryServiceProps( | ||
| USER_ID, | ||
| ACCESSORY_ID, | ||
| false, | ||
| ); | ||
|
|
||
| expect(result).toEqual({ | ||
| serviceRules: null, | ||
| suppressedServiceRuleNames: null, | ||
| serviceHistory: null, | ||
| }); | ||
| expect(getItemDueStateSpy).not.toHaveBeenCalled(); | ||
| expect(listItemRulesSpy).not.toHaveBeenCalled(); | ||
| expect(listServiceHistorySpy).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("an owner viewer gets the resolved rules, suppressed names, and history on the happy path", async () => { | ||
| const dueRuleStub = { | ||
| name: "Cleaning", | ||
| } as unknown as dueService.RuleDueState; | ||
| const keptRuleStub = { | ||
| name: "Cleaning", | ||
| suppressed: false, | ||
| } as unknown as rulesService.ServiceRuleRow; | ||
| const suppressedRuleStub = { | ||
| name: "Lube", | ||
| suppressed: true, | ||
| } as unknown as rulesService.ServiceRuleRow; | ||
| getItemDueStateSpy.mockResolvedValue([dueRuleStub]); | ||
| listItemRulesSpy.mockResolvedValue([keptRuleStub, suppressedRuleStub]); | ||
| listServiceHistorySpy.mockResolvedValue([]); | ||
|
|
||
| const result = await loadAccessoryServiceProps(USER_ID, ACCESSORY_ID, true); | ||
|
|
||
| expect(result.serviceRules).toEqual([dueRuleStub]); | ||
| expect(result.suppressedServiceRuleNames).toEqual(["Lube"]); | ||
| expect(result.serviceHistory).toEqual([]); | ||
| }); | ||
|
|
||
| // `getItemDueState`/`listItemRules`/`listServiceHistory` route through | ||
| // `requireAccessoryOwner`, which authorizes internally and can throw | ||
| // `NotFoundError` if the accessory was deleted or reassigned between the | ||
| // page's earlier `getAccessory` check and this call. Each loader is | ||
| // independently exercised so the fix applied to all three isn't only | ||
| // proven for whichever settles first in the `Promise.all`. | ||
| const guardedLoaders: Array<{ | ||
| name: string; | ||
| reject: () => void; | ||
| }> = [ | ||
| { | ||
| name: "getItemDueState", | ||
| reject: () => { | ||
| getItemDueStateSpy.mockRejectedValue(new NotFoundError()); | ||
| }, | ||
| }, | ||
| { | ||
| name: "listItemRules", | ||
| reject: () => { | ||
| listItemRulesSpy.mockRejectedValue(new NotFoundError()); | ||
| }, | ||
| }, | ||
| { | ||
| name: "listServiceHistory", | ||
| reject: () => { | ||
| listServiceHistorySpy.mockRejectedValue(new NotFoundError()); | ||
| }, | ||
| }, | ||
| ]; | ||
|
|
||
| for (const loader of guardedLoaders) { | ||
| test(`a NotFoundError from ${loader.name} surfaces as Next's clean 404, not an unhandled rejection`, async () => { | ||
| loader.reject(); | ||
|
|
||
| let caught: unknown; | ||
| try { | ||
| await loadAccessoryServiceProps(USER_ID, ACCESSORY_ID, true); | ||
| } catch (error: unknown) { | ||
| caught = error; | ||
| } | ||
|
|
||
| // `notFound()` throws a real Next.js error carrying this digest, which | ||
| // is what the framework's router boundary keys off of to render the | ||
| // clean 404 UI instead of the generic error boundary. | ||
| expect(caught).toBeInstanceOf(Error); | ||
| expect((caught as { digest?: string }).digest).toBe( | ||
| "NEXT_HTTP_ERROR_FALLBACK;404", | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| test("a non-NotFoundError from a loader propagates unchanged, not converted to a 404", async () => { | ||
| const boom = new Error("boom"); | ||
| getItemDueStateSpy.mockRejectedValue(boom); | ||
|
|
||
| let caught: unknown; | ||
| try { | ||
| await loadAccessoryServiceProps(USER_ID, ACCESSORY_ID, true); | ||
| } catch (error: unknown) { | ||
| caught = error; | ||
| } | ||
|
|
||
| expect(caught).toBe(boom); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.