|
| 1 | +import type { Class } from '@/types'; |
1 | 2 | import { AbstractError } from '@'; |
2 | 3 |
|
3 | 4 | describe('index', () => { |
@@ -55,4 +56,200 @@ describe('index', () => { |
55 | 56 | expect(e.cause).toBe(eOriginal); |
56 | 57 | } |
57 | 58 | }); |
| 59 | + test('inheritance', () => { |
| 60 | + // Propagate the cause, allow cause to be generic |
| 61 | + // and the cause is determined by the instantiator |
| 62 | + class ErrorPropagate<T> extends AbstractError<T> {} |
| 63 | + // Fix the cause, so that `SyntaxError` is always the cause |
| 64 | + class ErrorFixed extends AbstractError<SyntaxError> {} |
| 65 | + // Default the cause, but allow instantiator to override |
| 66 | + class ErrorDefault<T = TypeError> extends AbstractError<T> {} |
| 67 | + const eP = new ErrorPropagate<string>(undefined, { cause: 'string' }); |
| 68 | + const eF = new ErrorFixed(undefined, { cause: new SyntaxError() }); |
| 69 | + const eD = new ErrorDefault<number>(undefined, { cause: 123 }); |
| 70 | + expect(eP.cause).toBe('string'); |
| 71 | + expect(eF.cause).toBeInstanceOf(SyntaxError); |
| 72 | + expect(eD.cause).toBe(123); |
| 73 | + }); |
| 74 | + test('JSON encoding/decoding', () => { |
| 75 | + const e = new AbstractError(); |
| 76 | + expect(AbstractError.fromJSON(e.toJSON())).toBeInstanceOf(AbstractError); |
| 77 | + expect(AbstractError.fromJSON(e.toJSON()).toJSON()).toStrictEqual( |
| 78 | + e.toJSON(), |
| 79 | + ); |
| 80 | + const eJSON = { |
| 81 | + type: 'AbstractError', |
| 82 | + data: { |
| 83 | + message: 'some message', |
| 84 | + timestamp: '2022-05-07T09:16:06.632Z', |
| 85 | + data: {}, |
| 86 | + cause: undefined, |
| 87 | + }, |
| 88 | + }; |
| 89 | + const e2 = AbstractError.fromJSON(eJSON); |
| 90 | + expect(e2).toBeInstanceOf(AbstractError); |
| 91 | + expect(e2.message).toBe(eJSON.data.message); |
| 92 | + expect(e2.timestamp).toStrictEqual(new Date(eJSON.data.timestamp)); |
| 93 | + expect(e2.data).toStrictEqual(eJSON.data.data); |
| 94 | + expect(e2.cause).toStrictEqual(eJSON.data.cause); |
| 95 | + }); |
| 96 | + describe('JSON serialiation and deserialisation', () => { |
| 97 | + // Demonstrates an extended error with its own encoding and decoding |
| 98 | + class SpecificError<T> extends AbstractError<T> { |
| 99 | + public static fromJSON<T extends Class<any>>( |
| 100 | + this: T, |
| 101 | + json: any, |
| 102 | + ): InstanceType<T> { |
| 103 | + if ( |
| 104 | + typeof json !== 'object' || |
| 105 | + json.type !== this.name || |
| 106 | + typeof json.data !== 'object' || |
| 107 | + typeof json.data.message !== 'string' || |
| 108 | + typeof json.data.num !== 'number' || |
| 109 | + ('stack' in json.data && typeof json.data.stack !== 'string') |
| 110 | + ) { |
| 111 | + throw new TypeError(`Cannot decode JSON to ${this.name}`); |
| 112 | + } |
| 113 | + const e = new this(json.data.num, json.data.message); |
| 114 | + e.stack = json.data.stack; |
| 115 | + return e; |
| 116 | + } |
| 117 | + public num: number; |
| 118 | + public constructor(num: number, message: string) { |
| 119 | + super(message); |
| 120 | + this.num = num; |
| 121 | + } |
| 122 | + public toJSON() { |
| 123 | + const obj = super.toJSON(); |
| 124 | + obj.data.num = this.num; |
| 125 | + return obj; |
| 126 | + } |
| 127 | + } |
| 128 | + class UnknownError<T> extends AbstractError<T> {} |
| 129 | + // AbstractError classes, these should be part of our application stack |
| 130 | + const customErrors = { |
| 131 | + AbstractError, |
| 132 | + SpecificError, |
| 133 | + UnknownError, |
| 134 | + }; |
| 135 | + // Standard JS errors, these do not have fromJSON routines |
| 136 | + const standardErrors = { |
| 137 | + Error, |
| 138 | + TypeError, |
| 139 | + SyntaxError, |
| 140 | + ReferenceError, |
| 141 | + EvalError, |
| 142 | + RangeError, |
| 143 | + URIError, |
| 144 | + }; |
| 145 | + function replacer(key: string, value: any): any { |
| 146 | + if (value instanceof Error) { |
| 147 | + return { |
| 148 | + type: value.constructor.name, |
| 149 | + data: { |
| 150 | + message: value.message, |
| 151 | + stack: value.stack, |
| 152 | + }, |
| 153 | + }; |
| 154 | + } else { |
| 155 | + return value; |
| 156 | + } |
| 157 | + } |
| 158 | + // Assume that the purpose of the reviver is to deserialise JSON string |
| 159 | + // back into exceptions |
| 160 | + // The reviver has 3 choices when encountering an unknown value |
| 161 | + // 1. throw an "parse" exception |
| 162 | + // 2. return the value as it is |
| 163 | + // 3. return as special "unknown" exception that contains the unknown value as data |
| 164 | + // Choice 1. results in strict deserialisation procedure (no forwards-compatibility) |
| 165 | + // Choice 2. results in ambiguous parsed result |
| 166 | + // Choice 3. is the best option as it ensures a typed-result and debuggability of ambiguous data |
| 167 | + function reviver(key: string, value: any): any { |
| 168 | + if ( |
| 169 | + typeof value === 'object' && |
| 170 | + typeof value.type === 'string' && |
| 171 | + typeof value.data === 'object' |
| 172 | + ) { |
| 173 | + try { |
| 174 | + let eClass = customErrors[value.type]; |
| 175 | + if (eClass != null) return eClass.fromJSON(value); |
| 176 | + eClass = standardErrors[value.type]; |
| 177 | + if (eClass != null) { |
| 178 | + if ( |
| 179 | + typeof value.data.message !== 'string' || |
| 180 | + ('stack' in value.data && typeof value.data.stack !== 'string') |
| 181 | + ) { |
| 182 | + throw new TypeError(`Cannot decode JSON to ${value.type}`); |
| 183 | + } |
| 184 | + const e = new eClass(value.data.message); |
| 185 | + e.stack = value.data.stack; |
| 186 | + return e; |
| 187 | + } |
| 188 | + } catch (e) { |
| 189 | + // If `TypeError` which represents decoding failure |
| 190 | + // then return value as-is |
| 191 | + // Any other exception is a bug |
| 192 | + if (!(e instanceof TypeError)) { |
| 193 | + throw e; |
| 194 | + } |
| 195 | + } |
| 196 | + // Other values are returned as-is |
| 197 | + return value; |
| 198 | + } else if (key === '') { |
| 199 | + // Root key will be '' |
| 200 | + // Reaching here means the root JSON value is not a valid exception |
| 201 | + // Therefore UnknownError is only ever returned at the top-level |
| 202 | + return new UnknownError('Unknown error JSON', { |
| 203 | + data: { |
| 204 | + json: value, |
| 205 | + }, |
| 206 | + }); |
| 207 | + } else { |
| 208 | + // Other values will be returned as-is |
| 209 | + return value; |
| 210 | + } |
| 211 | + } |
| 212 | + test('abstract on specific', () => { |
| 213 | + const e = new AbstractError('msg1', { |
| 214 | + cause: new SpecificError(123, 'msg2'), |
| 215 | + }); |
| 216 | + const eJSONString = JSON.stringify(e, replacer); |
| 217 | + const e_ = JSON.parse(eJSONString, reviver); |
| 218 | + expect(e_).toBeInstanceOf(AbstractError); |
| 219 | + expect(e_.message).toBe(e.message); |
| 220 | + expect(e_.cause).toBeInstanceOf(SpecificError); |
| 221 | + expect(e_.cause.message).toBe(e.cause.message); |
| 222 | + }); |
| 223 | + test('abstract on abstract on range', () => { |
| 224 | + const e = new AbstractError('msg1', { |
| 225 | + cause: new AbstractError('msg2', { |
| 226 | + cause: new RangeError('msg3'), |
| 227 | + }), |
| 228 | + }); |
| 229 | + const eJSONString = JSON.stringify(e, replacer); |
| 230 | + const e_ = JSON.parse(eJSONString, reviver); |
| 231 | + expect(e_).toBeInstanceOf(AbstractError); |
| 232 | + expect(e_.message).toBe(e.message); |
| 233 | + expect(e_.cause).toBeInstanceOf(AbstractError); |
| 234 | + expect(e_.cause.message).toBe(e.cause.message); |
| 235 | + expect(e_.cause.cause).toBeInstanceOf(RangeError); |
| 236 | + expect(e_.cause.cause.message).toBe(e.cause.cause.message); |
| 237 | + }); |
| 238 | + test('abstract on something random', () => { |
| 239 | + const e = new AbstractError('msg1', { |
| 240 | + cause: 'something random', |
| 241 | + }); |
| 242 | + const eJSONString = JSON.stringify(e, replacer); |
| 243 | + const e_ = JSON.parse(eJSONString, reviver); |
| 244 | + expect(e_).toBeInstanceOf(AbstractError); |
| 245 | + expect(e_.message).toBe(e.message); |
| 246 | + expect(e_.cause).toBe('something random'); |
| 247 | + }); |
| 248 | + test('unknown at root', () => { |
| 249 | + const e = '123'; |
| 250 | + const eJSONString = JSON.stringify(e, replacer); |
| 251 | + const e_ = JSON.parse(eJSONString, reviver); |
| 252 | + expect(e_).toBeInstanceOf(UnknownError); |
| 253 | + }); |
| 254 | + }); |
58 | 255 | }); |
0 commit comments